diff --git a/.gitignore b/.gitignore index 3c5d2e1e3fe18f48ed48dcce1bbc83845d06ce39..689dac010c1b7162c7860235817f9e706b95cebd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,7 @@ -__pycache__ -*.db -*/env *.coverage */coverage -htmlcov -.pytest_cache /.idea .vs/ -/server/app/static/ + +# Documentation files +docs/build \ No newline at end of file diff --git a/.gitlab/client.gitlab-ci.yml b/.gitlab/client.gitlab-ci.yml index f35e73aa6da4b396dda7ca7b5847b83158078a41..a64f1bd85c1bba81556cc1f8fb8052cf14ac40cd 100644 --- a/.gitlab/client.gitlab-ci.yml +++ b/.gitlab/client.gitlab-ci.yml @@ -46,7 +46,7 @@ client:test: - merge_requests script: - cd client - - npm run test:coverage + - npm run unit-test:coverage coverage: /All files\s*\|\s*([\d\.]+)/ artifacts: paths: diff --git a/.vscode/settings.json b/.vscode/settings.json index b02ef900e7a5db6b3da7e7593e38600b00b950bf..d064f2c0c8cbe0db00a1acb24bb9188e1f375c5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "editor.tabCompletion": "on", "editor.codeActionsOnSave": { "source.fixAll.eslint": true, - "source.organizeImports": false + "source.organizeImports": true }, //python "python.venvPath": "${workspaceFolder}\\server", @@ -41,5 +41,6 @@ "search.exclude": { "**/env": true }, - "python.pythonPath": "server\\env\\Scripts\\python.exe" + "python.pythonPath": "server\\env\\Scripts\\python.exe", + "restructuredtext.confPath": "${workspaceFolder}\\server\\sphinx\\source" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7029dbe39d5a29ad684540e3d3eaf8272bb6bdaa..5582399b1943e8f77826edc902a4dd0da4aa2c95 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,86 +1,109 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "Start client", - "type": "npm", - "script": "start", - "path": "client/", - "group": "build", - "problemMatcher": [], - "presentation": { - "group": "Client/Server" - } - }, - { - "label": "Test client", - "type": "npm", - "script": "test:coverage:html", - "path": "client/", - "group": "build", - "problemMatcher": [], - }, - { - "label": "Open client coverage", - "type": "shell", - "group": "build", - "command": "start ./output/coverage/jest/index.html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/client" - }, - }, - { - "label": "Start server", - "type": "shell", - "group": "build", - "command": "env/Scripts/python main.py", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - "presentation": { - "group": "Client/Server" - } - }, - { - "label": "Test server", - "type": "shell", - "group": "build", - "command": "env/Scripts/pytest.exe --cov-report html --cov app tests/", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Populate server", - "type": "shell", - "group": "build", - "command": "env/Scripts/python populate.py", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Open server coverage", - "type": "shell", - "group": "build", - "command": "start ./htmlcov/index.html", - "problemMatcher": [], - "options": { - "cwd": "${workspaceFolder}/server" - }, - }, - { - "label": "Start client and server", - "group": "build", - "dependsOn": [ - "Start server", - "Start client" - ], - "problemMatcher": [] - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "Start client", + "type": "npm", + "script": "start", + "path": "client/", + "group": "build", + "problemMatcher": [], + "presentation": { + "group": "Client/Server" + } + }, + { + "label": "Unit tests", + "type": "npm", + "script": "unit-test:coverage:html", + "path": "client/", + "group": "build", + "detail": "Run unit tests on client", + "problemMatcher": [] + }, + { + "label": "Run e2e tests", + "type": "npm", + "script": "e2e-test", + "path": "client/", + "group": "build", + "problemMatcher": [], + "detail": "Make sure client and server is running before executing." + }, + { + "label": "Open client coverage", + "type": "shell", + "command": "start ./output/coverage/jest/index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/client" + } + }, + { + "label": "Start server", + "type": "shell", + "group": "build", + "command": "env/Scripts/python main.py", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + }, + "presentation": { + "group": "Client/Server" + } + }, + { + "label": "Test server", + "type": "shell", + "group": "build", + "command": "env/Scripts/pytest.exe --cov-report html --cov app tests/", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Populate database", + "type": "shell", + "group": "build", + "command": "env/Scripts/python populate.py", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Open server coverage", + "type": "shell", + "command": "start ./htmlcov/index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/server" + } + }, + { + "label": "Generate documentation", + "type": "shell", + "command": "../server/env/Scripts/activate; ./make html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/docs" + } + }, + { + "label": "Open documentation", + "type": "shell", + "command": "start index.html", + "problemMatcher": [], + "options": { + "cwd": "${workspaceFolder}/docs/build/html" + } + }, + { + "label": "Start client and server", + "group": "build", + "dependsOn": ["Start server", "Start client"], + "problemMatcher": [] + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..a5a0b1825e6665032e97606fbd0efa358e5b7a4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 Linköping University, TDDD96 Grupp 1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 1a7227fb4c7c0774ed403639f1970539a5d504f2..e0a31a75a25b28a1aca3dac9dd2e9a8b0a1f678b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,3 @@ - - - - - - - - # Scoring system for Teknikåttan This is the scoring system for Teknikåttan! @@ -17,7 +9,7 @@ To install the client and server needed to run the application, look in their re ## Using After installing both the client and the server, you are ready to run the application. -This is done in VSCode by pressing `ctrl+shift+b` and running the `Start client and server` task. +This is done in VS Code by pressing `ctrl+shift+b` and running the `Start client and server` task. The terminals for the client and server will now be seen on the right and left, respectively. After making a change to either the client or the server while they are running, simply reload the page to see the changes immediately. @@ -35,7 +27,7 @@ To begin working, you need to choose an issue and create a branch from it. 4. Choose one of these issues and click on it. 5. Add yourself as an asignee (in top right corner). 6. Press the little green downarrow on the right of the `Create merge request` button and select and press `Create branch`. -7. Open the project in VSCode. +7. Open the project in VS Code. 8. Type `git pull`. This will fetch the new branch you just created. 9. Switch to it by running `git checkout <branch>`. (Example: `git checkout 5-add-login-api`) @@ -57,7 +49,7 @@ This is done in two steps: First you need to prepare your branch to be merged and then create a merge request. First, prepare your branch to be merged. -1. Open the project in VSCode. +1. Open the project in VS Code. 2. Checkout your branch, if you are not already on it (`git checkout <branch>`). 3. Run `git pull origin dev`. This will try to merge the latest changes from `dev` into your branch. This can have a few different results: - There will be no changes, which is fine. @@ -78,12 +70,12 @@ You cannot approve your own merge requests but once it's approved anyone can mer ### Merge conflicts You will need to manually merge if there is a merge conflict between your branch and another. -This is simply done by opening the project in VSCode and going to the Git tab on the left (git symbol). +This is simply done by opening the project in VS Code and going to the Git tab on the left (git symbol). You will then see som files marked with `C`, which indicates that there are conflicts in these files. You will have to go through all of the merge conflicts and solve them in each file. -A merge typically looks like the code snippet at the bottom of this document in plain text (try opening this in VSCode and see how it looks). +A merge typically looks like the code snippet at the bottom of this document in plain text (try opening this in VS Code and see how it looks). The only thing you really need to do is removing the `<<<<<<<`, `=======` and `>>>>>>>` symbols from the document, although you don't have to do it by hand. -In VSCode, you can simply choose if you want to keep incoming changes (from the branch you merging into), current changes (from your branch) or both. +In VS Code, you can simply choose if you want to keep incoming changes (from the branch you merging into), current changes (from your branch) or both. Solve all the merge conflicts in every file and run the tests to make sure it still works. Commit and push your changes when you are done. diff --git a/client/.gitignore b/client/.gitignore index ef85236eb5861796d67cb5a15157d7e525e2989e..db6ac478b0257a9b13ba3f864404eaca323e4ac4 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -23,3 +23,6 @@ yarn-debug.log* yarn-error.log* output + +# Documentation +docs/ diff --git a/client/README.md b/client/README.md index 23cb20572c166b66b4e46f7ba58059ecab4fd66a..81cfbb936e6e7871f11e596d8a81712748745835 100644 --- a/client/README.md +++ b/client/README.md @@ -6,10 +6,10 @@ This documents describes how to install and run the client. You will need to do the following things to install the client: -1. Install [Visual Studio Code](https://code.visualstudio.com/) (VSCode). +1. Install [Visual Studio Code](https://code.visualstudio.com/) (VS Code). 2. Install [Node (LTS)](https://nodejs.org/en/). 3. Clone this repository if you haven't done so already. -4. Open the project folder in VSCode. +4. Open the project folder in VS Code. 5. Open the integrated terminal by pressing `ctrl+ö`. 6. Type the following commands (or simply paste them) into your terminal: diff --git a/client/package-lock.json b/client/package-lock.json index 667b75859fe71467505839e687c7e478b8a52834..d9504ac90fc03eb98692588cc0b73702abc2a5b1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2526,6 +2526,15 @@ "csstype": "^3.0.2" } }, + "@types/react-beautiful-dnd": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz", + "integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-dom": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", @@ -2696,6 +2705,15 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-20.2.0.tgz", "integrity": "sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA==" }, + "@types/yauzl": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", + "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.2.0.tgz", @@ -3125,6 +3143,14 @@ "regex-parser": "^2.2.11" } }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -4085,6 +4111,27 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "optional": true }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -4286,6 +4333,11 @@ "isarray": "^1.0.0" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -4812,6 +4864,11 @@ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==" }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5161,6 +5218,14 @@ "postcss": "^7.0.5" } }, + "css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "requires": { + "tiny-invariant": "^1.0.6" + } + }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -5788,6 +5853,11 @@ } } }, + "devtools-protocol": { + "version": "0.0.869402", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.869402.tgz", + "integrity": "sha512-VvlVYY+VDJe639yHs5PHISzdWTLL3Aw8rO4cvUtwvoxFd6FHbE4OpHHcde52M6096uYYazAmd4l0o5VuFRO2WA==" + }, "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", @@ -7340,6 +7410,27 @@ } } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + } + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -7408,6 +7499,14 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "requires": { + "pend": "~1.2.0" + } + }, "figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -7768,6 +7867,11 @@ } } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7994,6 +8098,25 @@ "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -8162,9 +8285,9 @@ "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==" }, "hosted-git-info": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", - "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==" + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" }, "hpack.js": { "version": "2.1.6", @@ -8480,6 +8603,15 @@ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -8656,6 +8788,11 @@ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==" + }, "ip": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", @@ -10883,9 +11020,9 @@ } }, "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash-es": { "version": "4.17.21", @@ -10988,6 +11125,11 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -11038,6 +11180,11 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-2.0.3.tgz", + "integrity": "sha512-5otztIIcJfPc2qGTN8cVtOJEjNJZ0jwa46INMagrYfk0EvqtRuEHLsEe0LrFS0/q+ZRKT0+kXK7P2T1AN5lWRA==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -11058,6 +11205,11 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -11327,6 +11479,11 @@ "minimist": "^1.2.5" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, "moo": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", @@ -11472,6 +11629,11 @@ } } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -11804,6 +11966,29 @@ "mimic-fn": "^2.1.0" } }, + "onigasm": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/onigasm/-/onigasm-2.2.5.tgz", + "integrity": "sha512-F+th54mPc0l1lp1ZcFMyL/jTs2Tlq4SqIHKIXGZOR/VkHkF9A7Fr5rRr5+ZG/lWeRsyrClLYRq7s/yFQ/XhWCA==", + "requires": { + "lru-cache": "^5.1.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "requires": { + "yallist": "^3.0.2" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + } + } + }, "open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -12111,6 +12296,11 @@ "sha.js": "^2.4.8" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -13412,6 +13602,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -13477,6 +13672,35 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "puppeteer": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", + "integrity": "sha512-W+nOulP2tYd/ZG99WuZC/I5ljjQQ7EUw/jQGcIb9eu8mDlZxNY2SgcJXTLG9h5gRvqA3uJOe4hZXYsd3EqioMw==", + "requires": { + "debug": "^4.1.0", + "devtools-protocol": "0.0.869402", + "extract-zip": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.1", + "pkg-dir": "^4.2.0", + "progress": "^2.0.1", + "proxy-from-env": "^1.1.0", + "rimraf": "^3.0.2", + "tar-fs": "^2.0.0", + "unbzip2-stream": "^1.3.3", + "ws": "^7.2.3" + }, + "dependencies": { + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "requires": { + "find-up": "^4.0.0" + } + } + } + }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -13519,6 +13743,11 @@ "performance-now": "^2.1.0" } }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "railroad-diagrams": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", @@ -13610,6 +13839,20 @@ "resolved": "https://registry.npmjs.org/react-axios/-/react-axios-2.0.4.tgz", "integrity": "sha512-QsTq7C/NwsjfrSmFVxPo29BdX6DtLpRF0fZTJv5/R4BanOm+c4639B3Xb4lF83ZfAOX5IW8XG7htz4V+WNF+WA==" }, + "react-beautiful-dnd": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", + "integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + } + }, "react-dev-utils": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.3.tgz", @@ -14113,6 +14356,14 @@ "picomatch": "^2.2.1" } }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "requires": { + "resolve": "^1.1.6" + } + }, "recursive-readdir": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", @@ -15073,12 +15324,31 @@ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==" }, + "shelljs": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", + "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, "shellwords": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "optional": true }, + "shiki": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.9.3.tgz", + "integrity": "sha512-NEjg1mVbAUrzRv2eIcUt3TG7X9svX7l3n3F5/3OdFq+/BxUdmBOeKGiH4icZJBLHy354Shnj6sfBTemea2e7XA==", + "requires": { + "onigasm": "^2.2.5", + "vscode-textmate": "^5.2.0" + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -15935,6 +16205,36 @@ } } }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + } + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -16102,6 +16402,11 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==" }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -16355,6 +16660,36 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.20.36", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.20.36.tgz", + "integrity": "sha512-qFU+DWMV/hifQ9ZAlTjdFO9wbUIHuUBpNXzv68ZyURAP9pInjZiO4+jCPeAzHVcaBCHER9WL/+YzzTt6ZlN/Nw==", + "requires": { + "colors": "^1.4.0", + "fs-extra": "^9.1.0", + "handlebars": "^4.7.7", + "lodash": "^4.17.21", + "lunr": "^2.3.9", + "marked": "^2.0.3", + "minimatch": "^3.0.0", + "progress": "^2.0.3", + "shelljs": "^0.8.4", + "shiki": "^0.9.3", + "typedoc-default-themes": "^0.12.10" + }, + "dependencies": { + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + } + } + }, + "typedoc-default-themes": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.12.10.tgz", + "integrity": "sha512-fIS001cAYHkyQPidWXmHuhs8usjP5XVJjWB8oZGqkTowZaz3v7g3KDZeeqE82FBrmkAnIBOY3jgy7lnPnqATbA==" + }, "typesafe-actions": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/typesafe-actions/-/typesafe-actions-5.1.0.tgz", @@ -16366,6 +16701,12 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==" }, + "uglify-js": { + "version": "3.13.5", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.5.tgz", + "integrity": "sha512-xtB8yEqIkn7zmOyS2zUNBsYCBRhDkvlNxMMY2smuJ/qA8NCHeQvKCF3i9Z4k8FJH4+PJvZRtMrPynfZ75+CSZw==", + "optional": true + }, "unbox-primitive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.0.tgz", @@ -16378,6 +16719,26 @@ "which-boxed-primitive": "^1.0.1" } }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -16555,9 +16916,9 @@ } }, "url-parse": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz", - "integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", + "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", "requires": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -16568,6 +16929,11 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-memo-one": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz", + "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==" + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -16727,6 +17093,11 @@ "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==", "dev": true }, + "vscode-textmate": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-5.4.0.tgz", + "integrity": "sha512-c0Q4zYZkcLizeYJ3hNyaVUM2AA8KDhNCA3JvXY8CeZSJuBdAy3bAvSbv46RClC4P3dSO9BdwhnKEx2zOo6vP/w==" + }, "vscode-uri": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", @@ -17903,6 +18274,11 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" + }, "workbox-background-sync": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-5.1.4.tgz", @@ -18234,6 +18610,15 @@ } } }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yeast": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", diff --git a/client/package.json b/client/package.json index 8733d1d45b7c2e4c2349167573e7411aa66afd45..223b350cbc0c8b16439bb4a5e282b2fa3a628e54 100644 --- a/client/package.json +++ b/client/package.json @@ -22,8 +22,10 @@ "axios": "^0.21.1", "formik": "^2.2.6", "jwt-decode": "^3.1.2", + "puppeteer": "^9.1.1", "react": "^17.0.1", "react-axios": "^2.0.4", + "react-beautiful-dnd": "^13.1.0", "react-dom": "^17.0.1", "react-redux": "^7.2.2", "react-rnd": "^10.2.4", @@ -35,12 +37,14 @@ "redux-thunk": "^2.3.0", "socket.io-client": "^4.0.1", "styled-components": "^5.2.1", + "typedoc": "^0.20.36", "typescript": "^4.1.3", "web-vitals": "^1.1.0", "yup": "^0.32.9" }, "devDependencies": { "@types/enzyme": "^3.10.8", + "@types/react-beautiful-dnd": "^13.0.0", "@types/react-redux": "^7.1.16", "@types/react-router-dom": "^5.1.7", "@types/redux-mock-store": "^1.0.2", @@ -68,8 +72,9 @@ "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint \"./src/**/*.{ts,tsx}\"", - "test:coverage": "react-scripts test --coverage --coverageDirectory=output/coverage/jest", - "test:coverage:html": "npm test -- --coverage --watchAll=false --coverageDirectory=output/coverage/jest" + "unit-test:coverage": "react-scripts test --coverage --testPathIgnorePatterns=src/e2e --coverageDirectory=output/coverage/jest", + "unit-test:coverage:html": "npm test -- --testPathIgnorePatterns=src/e2e --coverage --watchAll=false --coverageDirectory=output/coverage/jest", + "e2e-test": "npm test -- --testPathPattern=src/e2e" }, "browserslist": { "production": [ @@ -87,6 +92,7 @@ "collectCoverageFrom": [ "src/**/*.{tsx,ts}", "!src/index.tsx", + "!src/e2e/*", "!src/reportWebVitals.ts", "!src/components/TestConnection.tsx" ], diff --git a/client/public/logo192.png b/client/public/logo192.png deleted file mode 100644 index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 Binary files a/client/public/logo192.png and /dev/null differ diff --git a/client/public/logo512.png b/client/public/logo512.png deleted file mode 100644 index a4e47a6545bc15971f8f63fba70e4013df88a664..0000000000000000000000000000000000000000 Binary files a/client/public/logo512.png and /dev/null differ diff --git a/client/public/manifest.json b/client/public/manifest.json index 080d6c77ac21bb2ef88a6992b2b73ad93daaca92..73371289ff660726868cc23f5f2ab7f275bba3cc 100644 --- a/client/public/manifest.json +++ b/client/public/manifest.json @@ -1,21 +1,11 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Teknikåttan", + "name": "Teknikåttan Scoring System", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" } ], "start_url": ".", diff --git a/client/src/App.tsx b/client/src/App.tsx index fab142f7a194f228910f50af3968452b39c179b6..8ab75ad02c1c58787fb3236161695bbeeeb59c5d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import { ThemeProvider } from 'styled-components' import Main from './Main' import { Wrapper } from './styled' +/** Create a theme for the application with the colors specified by customer */ const theme = createMuiTheme({ palette: { primary: { diff --git a/client/src/Main.tsx b/client/src/Main.tsx index b7c74b556e3e072f8da96d328617d7461977b4c9..94860b1eb2b6cf31e7f06c41d10b1a4c0e6a31bd 100644 --- a/client/src/Main.tsx +++ b/client/src/Main.tsx @@ -7,11 +7,16 @@ import LoginPage from './pages/login/LoginPage' import PresentationEditorPage from './pages/presentationEditor/PresentationEditorPage' import AudienceViewPage from './pages/views/AudienceViewPage' import JudgeViewPage from './pages/views/JudgeViewPage' -import ParticipantViewPage from './pages/views/ParticipantViewPage' -import PresenterViewPage from './pages/views/PresenterViewPage' +import OperatorViewPage from './pages/views/OperatorViewPage' +import TeamViewPage from './pages/views/TeamViewPage' import ViewSelectPage from './pages/views/ViewSelectPage' import SecureRoute from './utils/SecureRoute' +/** + * This is the main function + * + * @returns jsx - All the routes + */ const Main: React.FC = () => { const dispatch = useAppDispatch() useEffect(() => { @@ -20,14 +25,18 @@ const Main: React.FC = () => { return ( <BrowserRouter> <Switch> - <SecureRoute login exact path="/" component={LoginPage} /> - <SecureRoute path="/admin" component={AdminPage} /> - <SecureRoute path="/editor/competition-id=:id" component={PresentationEditorPage} /> + <SecureRoute authLevel="login" exact path="/" component={LoginPage} /> + <SecureRoute authLevel="admin" path="/admin" component={AdminPage} /> + <SecureRoute + authLevel="admin" + path="/editor/competition-id=:competitionId" + component={PresentationEditorPage} + /> <Route exact path="/:code" component={ViewSelectPage} /> - <Route exact path="/participant/id=:id&code=:code" component={ParticipantViewPage} /> - <SecureRoute exact path="/presenter/id=:id&code=:code" component={PresenterViewPage} /> - <Route exact path="/judge/id=:id&code=:code" component={JudgeViewPage} /> - <Route exact path="/audience/id=:id&code=:code" component={AudienceViewPage} /> + <SecureRoute authLevel="Team" exact path="/view/team" component={TeamViewPage} /> + <SecureRoute authLevel="Operator" exact path="/view/operator" component={OperatorViewPage} /> + <SecureRoute authLevel="Judge" exact path="/view/judge" component={JudgeViewPage} /> + <SecureRoute authLevel="Audience" exact path="/view/audience" component={AudienceViewPage} /> </Switch> </BrowserRouter> ) diff --git a/client/src/actions/cities.ts b/client/src/actions/cities.ts index 594a43c43355f1c63d9248a2f98d28b4c243c820..c06c94a032c0e4d04f35e679ecd8869d92e76d86 100644 --- a/client/src/actions/cities.ts +++ b/client/src/actions/cities.ts @@ -1,22 +1,27 @@ +/** +This file handles actions for the cities redux state +*/ + import axios from 'axios' import { AppDispatch } from './../store' import Types from './types' +/** Action creator to get all cities from api and send appropriate actions to reducer */ export const getCities = () => async (dispatch: AppDispatch) => { await axios .get('/api/misc/cities') .then((res) => { dispatch({ type: Types.SET_CITIES, - payload: res.data.items, + payload: res.data, }) dispatch({ - type: Types.SET_CITIES_COUNT, - payload: res.data.total_count, + type: Types.SET_CITIES_TOTAL, + payload: res.data.length, }) dispatch({ - type: Types.SET_CITIES_TOTAL, - payload: res.data.count, + type: Types.SET_CITIES_COUNT, + payload: res.data.length, }) }) .catch((err) => console.log(err)) diff --git a/client/src/actions/competitionLogin.test.ts b/client/src/actions/competitionLogin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a95dd002436079daedf08ffd5d7cf480f18deee2 --- /dev/null +++ b/client/src/actions/competitionLogin.test.ts @@ -0,0 +1,76 @@ +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 { loginCompetition, logoutCompetition } from './competitionLogin' +import Types from './types' + +const middlewares = [thunk] +const mockStore = configureMockStore(middlewares) + +it('dispatches correct actions when logging into competition', async () => { + const compRes: any = { + data: { + id: 5, + slides: [], + }, + } + const compLoginDataRes: any = { + data: { + access_token: 'TEST_ACCESS_TOKEN', + competition_id: 'test_name', + team_id: 'test_team', + view: 'test_view', + }, + } + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve(compLoginDataRes) + }) + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) + const expectedActions = [ + { type: Types.LOADING_COMPETITION_LOGIN }, + { type: Types.CLEAR_COMPETITION_LOGIN_ERRORS }, + { + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: compLoginDataRes.data.competition_id, + team_id: compLoginDataRes.data.team_id, + view: compLoginDataRes.data.view, + }, + }, + { type: Types.SET_PRESENTATION_COMPETITION, payload: compRes.data }, + ] + const store = mockStore({}) + const history = createMemoryHistory() + await loginCompetition('code', history, true)(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) +}) + +it('dispatches correct action when logging out from competition', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const store = mockStore({}) + await logoutCompetition('Judge')(store.dispatch) + expect(store.getActions()).toEqual([{ type: Types.SET_COMPETITION_LOGIN_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(() => { + return Promise.reject({ response: { data: { message: errorMessage } } }) + }) + const store = mockStore({}) + const history = createMemoryHistory() + const expectedActions = [ + { type: Types.LOADING_COMPETITION_LOGIN }, + { type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: errorMessage }, + ] + await loginCompetition('code', history, true)(store.dispatch, store.getState as any) + expect(store.getActions()).toEqual(expectedActions) + expect(console.log).toHaveBeenCalled() +}) diff --git a/client/src/actions/competitionLogin.ts b/client/src/actions/competitionLogin.ts index ff177a2bc82f8d37174f4f2df4bd050ebb3822de..b2198f74698f8fb7b40acd10f7a2b2c64ebb66d7 100644 --- a/client/src/actions/competitionLogin.ts +++ b/client/src/actions/competitionLogin.ts @@ -1,23 +1,62 @@ +/** +This file handles actions for the competitionLogin redux state +*/ + import axios from 'axios' import { History } from 'history' -import { AppDispatch } from '../store' -import { AccountLoginModel } from './../interfaces/FormModels' +import { AppDispatch, RootState } from '../store' +import { getPresentationCompetition } from './presentation' import Types from './types' -export const loginCompetition = (code: string, history: History) => async (dispatch: AppDispatch) => { +/** Action creator to attempt to login with competition code */ +export const loginCompetition = (code: string, history: History, redirect: boolean) => async ( + dispatch: AppDispatch, + getState: () => RootState +) => { dispatch({ type: Types.LOADING_COMPETITION_LOGIN }) await axios - .post('/api/auth/login/code', { code }) + .post('/api/auth/code', { code }) .then((res) => { - console.log(code, res.data[0]) + const token = `Bearer ${res.data.access_token}` + localStorage.setItem(`${res.data.view}Token`, token) //setting token to local storage + axios.defaults.headers.common['Authorization'] = token //setting authorize token to header in axios dispatch({ type: Types.CLEAR_COMPETITION_LOGIN_ERRORS }) // no error - // history.push('/admin') //redirecting to admin page after login success - if (res.data && res.data[0] && res.data[0].view_type_id) { + dispatch({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: res.data.competition_id, + team_id: res.data.team_id, + view: res.data.view, + }, + }) + getPresentationCompetition(res.data.competition_id)(dispatch, getState) + if (redirect && res.data && res.data.view) { history.push(`/${code}`) } }) .catch((err) => { - dispatch({ type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: err && err.response && err.response.data }) + let errorMessage = err?.response?.data?.message + if (err?.response?.status === 401) { + errorMessage = 'Inkorrekt kod. Dubbelkolla koden och försök igen.' + } + if (err?.response?.status === 404) { + errorMessage = 'En tävling med den koden existerar inte. Dubbelkolla koden och försök igen.' + } + dispatch({ type: Types.SET_COMPETITION_LOGIN_ERRORS, payload: errorMessage }) console.log(err) }) } + +// Log out from competition and remove jwt token from local storage and axios +export const logoutCompetition = (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => async ( + dispatch: AppDispatch +) => { + localStorage.removeItem(`${role}Token`) + await axios.post('/api/auth/logout').then(() => { + delete axios.defaults.headers.common['Authorization'] + dispatch({ + type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED, + }) + window.location.href = '/' //redirect to login page + }) +} diff --git a/client/src/actions/competitions.test.ts b/client/src/actions/competitions.test.ts index 52be36acb658576e9b5c1ffc1b40394c338d0f5f..2ebb22b74f7ef7b187c11df9e347988a82e4baf5 100644 --- a/client/src/actions/competitions.test.ts +++ b/client/src/actions/competitions.test.ts @@ -11,25 +11,24 @@ 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, + data: [ + { + 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 }, + }, + ], + headers: { + pagination: '{"count": 2,"total": 3, "page_size": 5}', }, } @@ -37,9 +36,9 @@ it('dispatches correct actions when getting competitions', async () => { 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 }, + { type: Types.SET_COMPETITIONS, payload: compRes.data }, + { type: Types.SET_COMPETITIONS_TOTAL, payload: 3 }, + { type: Types.SET_COMPETITIONS_COUNT, payload: 2 }, ] const store = mockStore({ competitions: { filterParams: [] } }) await getCompetitions()(store.dispatch, store.getState as any) diff --git a/client/src/actions/competitions.ts b/client/src/actions/competitions.ts index 1a9d789c09d6646657b622de1d4c43d0c9be1dbd..e934726d8265a2ea88751137c8cc3bbaef9375b1 100644 --- a/client/src/actions/competitions.ts +++ b/client/src/actions/competitions.ts @@ -1,8 +1,13 @@ +/** +This file handles actions for the competitions redux state +*/ + import axios from 'axios' import { CompetitionFilterParams } from '../interfaces/FilterParams' import { AppDispatch, RootState } from './../store' import Types from './types' +/** Get all competitions using filterParams from current state */ export const getCompetitions = () => async (dispatch: AppDispatch, getState: () => RootState) => { const currentParams: CompetitionFilterParams = getState().competitions.filterParams // Send params in snake-case for api @@ -19,21 +24,29 @@ export const getCompetitions = () => async (dispatch: AppDispatch, getState: () .then((res) => { dispatch({ type: Types.SET_COMPETITIONS, - payload: res.data.items, + payload: res.data, }) + const pagination = JSON.parse(res.headers.pagination) dispatch({ type: Types.SET_COMPETITIONS_TOTAL, - payload: res.data.total_count, + payload: pagination.total, }) dispatch({ type: Types.SET_COMPETITIONS_COUNT, - payload: res.data.count, + payload: res.data.length, }) }) .catch((err) => { console.log(err) }) } + +/** Dispatch action to set filter params */ export const setFilterParams = (params: CompetitionFilterParams) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_COMPETITIONS_FILTER_PARAMS, payload: params }) } + +// DIspatch action to set loading +export const setEditorLoading = (loading: boolean) => (dispatch: AppDispatch) => { + dispatch({ type: Types.SET_EDITOR_LOADING, payload: loading }) +} diff --git a/client/src/actions/editor.ts b/client/src/actions/editor.ts index 45fbce986abae8df3da0c987be6faa4212cac2bf..aaee21a8a0439663583ca62ef526008d17b5b304 100644 --- a/client/src/actions/editor.ts +++ b/client/src/actions/editor.ts @@ -1,7 +1,12 @@ +/** +This file handles actions for the editor redux state +*/ + import axios from 'axios' import { AppDispatch, RootState } from './../store' import Types from './types' +/** Save competition in editor state from input id */ export const getEditorCompetition = (id: string) => async (dispatch: AppDispatch, getState: () => RootState) => { await axios .get(`/api/competitions/${id}`) @@ -13,15 +18,28 @@ export const getEditorCompetition = (id: string) => async (dispatch: AppDispatch if (getState().editor.activeSlideId === -1 && res.data.slides[0]) { setEditorSlideId(res.data.slides[0].id)(dispatch) } + const defaultViewType = getState().types.viewTypes.find((viewType) => viewType.name === 'Audience') + if (getState().editor.activeViewTypeId === -1 && defaultViewType) { + setEditorViewId(defaultViewType.id)(dispatch) + } }) .catch((err) => { console.log(err) }) } +/** Set activeSlideId in editor state */ export const setEditorSlideId = (id: number) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_EDITOR_SLIDE_ID, payload: id, }) } + +/** Set activeViewTypeId in editor state */ +export const setEditorViewId = (id: number) => (dispatch: AppDispatch) => { + dispatch({ + type: Types.SET_EDITOR_VIEW_ID, + payload: id, + }) +} diff --git a/client/src/actions/presentation.test.ts b/client/src/actions/presentation.test.ts index 53ea4847dcf1aaf3e1ba9a04c643d1d44b5f27f0..9fbc2a0ddeff68855b50a30d37a059e2edfc95a9 100644 --- a/client/src/actions/presentation.test.ts +++ b/client/src/actions/presentation.test.ts @@ -2,14 +2,8 @@ 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 { Slide } from '../interfaces/ApiModels' +import { getPresentationCompetition, setCurrentSlideByOrder } from './presentation' import Types from './types' const middlewares = [thunk] @@ -21,39 +15,22 @@ it('dispatches no actions when failing to get competitions', async () => { return Promise.reject(new Error('getting competitions failed')) }) const store = mockStore({ competitions: { filterParams: [] } }) - await getPresentationCompetition('0')(store.dispatch) + await getPresentationCompetition('0')(store.dispatch, store.getState as any) 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) + const testSlide: Slide = { + competition_id: 0, + id: 123123, + order: 43523, + timer: 20, + title: '', + background_image: undefined, + } + const expectedActions = [{ type: Types.SET_PRESENTATION_SLIDE_ID, payload: testSlide.id }] + const store = mockStore({ presentation: { competition: { id: 2, slides: [testSlide] } } }) + setCurrentSlideByOrder(testSlide.order)(store.dispatch, store.getState as any) expect(store.getActions()).toEqual(expectedActions) }) diff --git a/client/src/actions/presentation.ts b/client/src/actions/presentation.ts index 97c611dfb27015f37fa33693ee90c6f8a1159ef3..17e2e57d8529de48e183d11d38d5965eac5f8b23 100644 --- a/client/src/actions/presentation.ts +++ b/client/src/actions/presentation.ts @@ -1,10 +1,14 @@ +/** +This file handles actions for the presentation redux state +*/ + import axios from 'axios' -import { Slide } from '../interfaces/ApiModels' -import { Timer } from '../interfaces/Timer' -import store, { AppDispatch } from './../store' +import { TimerState } from '../interfaces/Timer' +import { AppDispatch, RootState } from './../store' import Types from './types' -export const getPresentationCompetition = (id: string) => async (dispatch: AppDispatch) => { +/** Save competition in presentation state from input id */ +export const getPresentationCompetition = (id: string) => async (dispatch: AppDispatch, getState: () => RootState) => { await axios .get(`/api/competitions/${id}`) .then((res) => { @@ -12,56 +16,32 @@ export const getPresentationCompetition = (id: string) => async (dispatch: AppDi type: Types.SET_PRESENTATION_COMPETITION, payload: res.data, }) + if (getState().presentation?.activeSlideId === -1 && res.data?.slides[0]) { + setCurrentSlideByOrder(0)(dispatch, getState) + } }) .catch((err) => { console.log(err) }) } -export const getPresentationTeams = (id: string) => async (dispatch: AppDispatch) => { - await axios - .get(`/api/competitions/${id}/teams`) - .then((res) => { - dispatch({ - type: Types.SET_PRESENTATION_TEAMS, - payload: res.data.items, - }) - }) - .catch((err) => { - console.log(err) - }) -} - -export const setCurrentSlide = (slide: Slide) => (dispatch: AppDispatch) => { - dispatch({ type: Types.SET_PRESENTATION_SLIDE, payload: slide }) -} - -export const setCurrentSlidePrevious = () => (dispatch: AppDispatch) => { - dispatch({ type: Types.SET_PRESENTATION_SLIDE_PREVIOUS }) -} - -export const setCurrentSlideNext = () => (dispatch: AppDispatch) => { - dispatch({ type: Types.SET_PRESENTATION_SLIDE_NEXT }) -} - -export const setCurrentSlideByOrder = (order: number) => (dispatch: AppDispatch) => { - dispatch({ type: Types.SET_PRESENTATION_SLIDE_BY_ORDER, payload: order }) +/** Set presentation slide using input order */ +export const setCurrentSlideByOrder = (order: number) => (dispatch: AppDispatch, getState: () => RootState) => { + const slideId = getState().presentation.competition.slides.find((slide) => slide.order === order)?.id + dispatch({ type: Types.SET_PRESENTATION_SLIDE_ID, payload: slideId }) } +/** Set code of presentation */ export const setPresentationCode = (code: string) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_CODE, payload: code }) } -export const setPresentationTimer = (timer: Timer) => (dispatch: AppDispatch) => { +/** Set timer to input value */ +export const setPresentationTimer = (timer: TimerState) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_PRESENTATION_TIMER, payload: timer }) } -export const setPresentationTimerDecrement = () => (dispatch: AppDispatch) => { - dispatch({ - type: Types.SET_PRESENTATION_TIMER, - payload: { - enabled: store.getState().presentation.timer.enabled, - value: store.getState().presentation.timer.value - 1, - }, - }) +/** Set show_scoreboard to input value */ +export const setPresentationShowScoreboard = (show_scoreboard: boolean) => (dispatch: AppDispatch) => { + dispatch({ type: Types.SET_PRESENTATION_SHOW_SCOREBOARD, payload: show_scoreboard }) } diff --git a/client/src/actions/roles.ts b/client/src/actions/roles.ts index 19a148c7a01d9dc4ccd6552f06dbf954509f16dc..810e13b1437ef0465039a89ea0b65dbbac3c3cb6 100644 --- a/client/src/actions/roles.ts +++ b/client/src/actions/roles.ts @@ -1,14 +1,19 @@ +/** +This file handles actions for the roles redux state +*/ + import axios from 'axios' import { AppDispatch } from './../store' import Types from './types' +/** Get all roles and dispatch action to save them to roles state */ export const getRoles = () => async (dispatch: AppDispatch) => { await axios .get('/api/misc/roles') .then((res) => { dispatch({ type: Types.SET_ROLES, - payload: res.data.items, + payload: res.data, }) }) .catch((err) => console.log(err)) diff --git a/client/src/actions/searchUser.test.ts b/client/src/actions/searchUser.test.ts index 66c155079a82d0efd21f0df949c669ab70f85c22..71d539d5dccab99c065c9c3f899f6ea8a1b61f8d 100644 --- a/client/src/actions/searchUser.test.ts +++ b/client/src/actions/searchUser.test.ts @@ -10,27 +10,26 @@ 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, + data: [ + { + 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, + }, + ], + headers: { + pagination: '{"count": 2,"total": 3, "page_size":5 }', }, } @@ -38,9 +37,9 @@ it('dispatches correct actions when getting users', async () => { 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 }, + { type: Types.SET_SEARCH_USERS, payload: userRes.data }, + { type: Types.SET_SEARCH_USERS_TOTAL_COUNT, payload: 3 }, + { type: Types.SET_SEARCH_USERS_COUNT, payload: userRes.data.length }, ] const store = mockStore({ searchUsers: { filterParams: [] } }) await getSearchUsers()(store.dispatch, store.getState as any) diff --git a/client/src/actions/searchUser.ts b/client/src/actions/searchUser.ts index a3ea55c7ce7ef5afc1468de7cc1d350303b3674a..4197ee188e51e4f2c17a207d9686a25d2cf00b63 100644 --- a/client/src/actions/searchUser.ts +++ b/client/src/actions/searchUser.ts @@ -1,8 +1,13 @@ +/** +This file handles actions for the searchUser redux state +*/ + import axios from 'axios' import { UserFilterParams } from '../interfaces/FilterParams' import { AppDispatch, RootState } from './../store' import Types from './types' +/** Get all users using current filterParams in searchUser state */ export const getSearchUsers = () => async (dispatch: AppDispatch, getState: () => RootState) => { const currentParams: UserFilterParams = getState().searchUsers.filterParams // Send params in snake-case for api @@ -19,21 +24,25 @@ export const getSearchUsers = () => async (dispatch: AppDispatch, getState: () = .then((res) => { dispatch({ type: Types.SET_SEARCH_USERS, - payload: res.data.items, + payload: res.data, }) + + const pagination = JSON.parse(res.headers.pagination) dispatch({ type: Types.SET_SEARCH_USERS_TOTAL_COUNT, - payload: res.data.total_count, + payload: pagination.total, }) dispatch({ type: Types.SET_SEARCH_USERS_COUNT, - payload: res.data.count, + payload: res.data.length, }) }) .catch((err) => { console.log(err) }) } + +/** Set filterParams in searchUser state */ export const setFilterParams = (params: UserFilterParams) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_SEARCH_USERS_FILTER_PARAMS, payload: params }) } diff --git a/client/src/actions/statistics.ts b/client/src/actions/statistics.ts index a32dce3553ba56ed47410b1499aeca57f7490f75..304099b2034ce727acff29bb3288fe3cea8e5087 100644 --- a/client/src/actions/statistics.ts +++ b/client/src/actions/statistics.ts @@ -1,7 +1,12 @@ +/** +This file handles actions for the statistics redux state +*/ + import axios from 'axios' import { AppDispatch } from './../store' import Types from './types' +/** Get all statistics and dispatch actions to save them to statistics state */ export const getStatistics = () => async (dispatch: AppDispatch) => { await axios .get('/api/misc/statistics') diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 512572bcf6f73dbbeb4c76fcdc1207b00333149a..59dd672d6ac96c210305980d67c27ddf697f3c31 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -1,40 +1,65 @@ +/** +This file includes all redux action action types +*/ + +/** Includes all actions types */ export default { + // User login action types LOADING_UI: 'LOADING_UI', LOADING_USER: 'LOADING_USER', - LOADING_COMPETITION_LOGIN: 'LOADING_COMPETITION_LOGIN', + SET_ERRORS: 'SET_ERRORS', + CLEAR_ERRORS: 'CLEAR_ERRORS', SET_ROLES: 'SET_ROLES', SET_USER: 'SET_USER', + SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', + SET_AUTHENTICATED: 'SET_AUTHENTICATED', + + // Search user action types SET_SEARCH_USERS: 'SET_SEARCH_USERS', SET_SEARCH_USERS_FILTER_PARAMS: 'SET_SEARCH_USERS_FILTER_PARAMS', SET_SEARCH_USERS_COUNT: 'SET_SEARCH_USERS_COUNT', SET_SEARCH_USERS_TOTAL_COUNT: 'SET_SEARCH_USERS_TOTAL_COUNT', - SET_ERRORS: 'SET_ERRORS', - CLEAR_ERRORS: 'CLEAR_ERRORS', + + // Competition login action types + LOADING_COMPETITION_LOGIN: 'LOADING_COMPETITION_LOGIN', + SET_COMPETITION_LOGIN_DATA: 'SET_COMPETITION_LOGIN_DATA', + SET_COMPETITION_LOGIN_UNAUTHENTICATED: 'SET_COMPETITION_LOGIN_UNAUTHENTICATED', SET_COMPETITION_LOGIN_ERRORS: 'SET_COMPETITION_LOGIN_ERRORS', CLEAR_COMPETITION_LOGIN_ERRORS: 'CLEAR_COMPETITION_LOGIN_ERRORS', - SET_UNAUTHENTICATED: 'SET_UNAUTHENTICATED', - SET_AUTHENTICATED: 'SET_AUTHENTICATED', + + // Competitions action types SET_COMPETITIONS: 'SET_COMPETITIONS', SET_COMPETITIONS_FILTER_PARAMS: 'SET_COMPETITIONS_FILTER_PARAMS', SET_COMPETITIONS_TOTAL: 'SET_COMPETITIONS_TOTAL', SET_COMPETITIONS_COUNT: 'SET_COMPETITIONS_COUNT', + + // Editor action types SET_EDITOR_COMPETITION: 'SET_EDITOR_COMPETITION', SET_EDITOR_SLIDE_ID: 'SET_EDITOR_SLIDE_ID', + SET_EDITOR_VIEW_ID: 'SET_EDITOR_VIEW_ID', + SET_EDITOR_LOADING: 'SET_EDITOR_LOADING', + + // Presentation action types SET_PRESENTATION_COMPETITION: 'SET_PRESENTATION_COMPETITION', - SET_PRESENTATION_SLIDE: 'SET_PRESENTATION_SLIDE', - SET_PRESENTATION_SLIDE_PREVIOUS: 'SET_PRESENTATION_SLIDE_PREVIOUS', - SET_PRESENTATION_SLIDE_NEXT: 'SET_PRESENTATION_SLIDE_NEXT', - SET_PRESENTATION_SLIDE_BY_ORDER: 'SET_PRESENTATION_SLIDE_BY_ORDER', - SET_PRESENTATION_TEAMS: 'SET_PRESENTATION_TEAMS', + SET_PRESENTATION_SLIDE_ID: 'SET_PRESENTATION_SLIDE_ID', SET_PRESENTATION_CODE: 'SET_PRESENTATION_CODE', SET_PRESENTATION_TIMER: 'SET_PRESENTATION_TIMER', + SET_PRESENTATION_SHOW_SCOREBOARD: 'SET_PRESENTATION_SHOW_SCOREBOARD', + + // Cities action types SET_CITIES: 'SET_CITIES', SET_CITIES_TOTAL: 'SET_CITIES_TOTAL', SET_CITIES_COUNT: 'SET_CITIES_COUNT', + + // Types action types SET_TYPES: 'SET_TYPES', + + // Media action types SET_MEDIA_ID: 'SET_MEDIA_ID', SET_MEDIA_FILENAME: 'SET_MEDIA_ID', SET_MEDIA_TYPE_ID: 'SET_MEDIA_TYPE_ID', SET_MEDIA_USER_ID: 'SET_MEDIA_USER_ID', + + // Statistics action types SET_STATISTICS: 'SET_STATISTICS', } diff --git a/client/src/actions/typesAction.ts b/client/src/actions/typesAction.ts index 32a212b0c5fc270d62dd6528efbea51824acdb8e..06519257e96c86f256bb14ec74e85d595366faf9 100644 --- a/client/src/actions/typesAction.ts +++ b/client/src/actions/typesAction.ts @@ -1,7 +1,12 @@ +/** +This file handles actions for the types redux state +*/ + import axios from 'axios' import { AppDispatch } from './../store' import Types from './types' +/** Get all types and save them to types state */ export const getTypes = () => async (dispatch: AppDispatch) => { await axios .get('/api/misc/types') diff --git a/client/src/actions/user.ts b/client/src/actions/user.ts index e2ad88acf776490ee49189236f2f921297c33d5c..a94730fa61c89ee09b483138e90f87a8669afb8a 100644 --- a/client/src/actions/user.ts +++ b/client/src/actions/user.ts @@ -1,9 +1,14 @@ +/** +This file handles actions for the user redux state +*/ + import axios from 'axios' import { History } from 'history' import { AppDispatch } from '../store' import { AccountLoginModel } from './../interfaces/FormModels' import Types from './types' +/** Attempt to log in user, dispatch correct actions and save jwt token to localStorage and axios auth header */ export const loginUser = (userData: AccountLoginModel, history: History) => async (dispatch: AppDispatch) => { dispatch({ type: Types.LOADING_UI }) await axios @@ -25,6 +30,7 @@ export const loginUser = (userData: AccountLoginModel, history: History) => asyn }) } +/** Get data for user and save to user state */ export const getUserData = () => async (dispatch: AppDispatch) => { dispatch({ type: Types.LOADING_USER }) await axios @@ -40,6 +46,7 @@ export const getUserData = () => async (dispatch: AppDispatch) => { }) } +/** Log out user and remove jwt token from local storage and axios */ export const logoutUser = () => async (dispatch: AppDispatch) => { localStorage.removeItem('token') await axios.post('/api/auth/logout').then(() => { diff --git a/client/src/components/TestConnection.tsx b/client/src/components/TestConnection.tsx deleted file mode 100644 index 7fa4aaeaab31902640232d60b25139a0c26f078e..0000000000000000000000000000000000000000 --- a/client/src/components/TestConnection.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios' -import React, { useEffect, useState } from 'react' - -interface Message { - message: string -} - -const TestConnection: React.FC = () => { - const [currentMessage, setCurrentMessage] = useState<Message>() - useEffect(() => { - axios.get<Message>('users/test').then((response) => { - setCurrentMessage(response.data) - }) - }, []) - return <p>Connection with server is: {currentMessage?.message}</p> -} - -export default TestConnection diff --git a/client/src/e2e/AdminPage.test.tsx b/client/src/e2e/AdminPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..048fc128052f5657f81563c7a3d05192014504e2 --- /dev/null +++ b/client/src/e2e/AdminPage.test.tsx @@ -0,0 +1,179 @@ +import puppeteer from 'puppeteer' +import { CLIENT_URL, DEVTOOLS_ENABLED, HEADLESS_ENABLED, SLOW_DOWN_FACTOR } from './TestingConstants' + +describe('Admin page', () => { + const userEmailSelector = '[data-testid="userEmail"]' + const buttonSelector = '[data-testid="submit"]' + const emailSelector = '[data-testid="email"]' + const passwordSelector = '[data-testid="password"]' + let browser: puppeteer.Browser + let page: puppeteer.Page + jest.setTimeout(10000) + beforeEach(async () => { + // Set up testing environment + browser = await puppeteer.launch({ + headless: HEADLESS_ENABLED, + devtools: DEVTOOLS_ENABLED, + slowMo: SLOW_DOWN_FACTOR, + }) + page = await browser.newPage() + + //Navigate to login screen and log in + await page.goto(CLIENT_URL) + await page.waitForSelector('.MuiFormControl-root') + await page.click(emailSelector) + await page.keyboard.type('admin@test.se') + await page.click(passwordSelector) + await page.keyboard.type('password') + await page.click(buttonSelector) + await page.waitForTimeout(2000) + }) + + afterEach(async () => { + await browser.close() + }) + + it('Should show correct email on welcome screen', async () => { + const AdminTitle = await page.evaluate((sel) => { + return document.querySelector(sel).innerText + }, userEmailSelector) + expect(AdminTitle).toEqual('Email: admin@test.se') + }, 9000000) + + it('Should be able to add and remove region', async () => { + const regionTabSelector = '[data-testid="Regioner"]' + const regionTextFieldSelector = '[data-testid="regionTextField"]' + const regionSubmitButton = '[data-testid="regionSubmitButton"]' + const testRegionName = 'New region test' + const testRegionSelector = `[data-testid="${testRegionName}"]` + const removeRegionButtonSelector = '[data-testid="removeRegionButton"]' + //Navigate to region tab + + await page.click(regionTabSelector) + await page.waitForSelector('.MuiFormControl-root') + + //Make sure the test region isnt already in the list + let regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).not.toContain(testRegionName) + + //Add the test region to the list and make sure it's present + await page.click(regionTextFieldSelector) + await page.keyboard.type(testRegionName) + await page.click(regionSubmitButton) + await page.waitForTimeout(1000) + regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).toContain(testRegionName) + + //Remove the test region from the list and make sure it's gone + await page.click(testRegionSelector) + await page.click(removeRegionButtonSelector) + await page.waitForTimeout(1000) + regions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(regions).not.toContain(testRegionName) + }, 9000000) + + it('Should be able to add and remove a user', async () => { + const userTabSelector = '[data-testid="Användare"]' + const addUserButtonSelector = '[data-testid="addUserButton"]' + const addUserEmailSelector = '[data-testid="addUserEmail"]' + const addUserPasswordSelector = '[data-testid="addUserPassword"]' + const addUserNameSelector = '[data-testid="addUserName"]' + const userCitySelectSelector = '[data-testid="userCitySelect"]' + const userRoleSelectSelector = '[data-testid="userRoleSelect"]' + const addUserSubmitSelector = '[data-testid="addUserSubmit"]' + const removeUserSelector = '[data-testid="removeUser"]' + const accceptRemoveUserSelector = '[data-testid="acceptRemoveUser"]' + + const testUserEmail = 'NewUser@test.test' + const testUserPassword = 'TestPassword' + const testUserName = 'TestUserName' + const testUserCity = 'Linköping' + const testUserRole = 'Admin' + + const userCitySelector = `[data-testid="${testUserCity}"]` + const userRoleSelector = `[data-testid="${testUserRole}"]` + const moreSelector = `[data-testid="more-${testUserEmail}"]` + + //Navigate to user tab + await page.click(userTabSelector) + await page.waitForSelector(addUserButtonSelector) + //Make sure the test user isnt already in the list + let emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).not.toContain(testUserEmail) + + //Add the test user to the list and make sure it's present + await page.click(addUserButtonSelector) + await page.click(addUserEmailSelector) + await page.keyboard.type(testUserEmail) + await page.click(addUserPasswordSelector) + await page.keyboard.type(testUserPassword) + await page.click(addUserNameSelector) + await page.keyboard.type(testUserName) + await page.click(userCitySelectSelector) + await page.click(userCitySelector) + await page.waitForTimeout(100) + await page.click(userRoleSelectSelector) + await page.waitForTimeout(100) + await page.click(userRoleSelector) + await page.waitForTimeout(100) + await page.click(addUserSubmitSelector) + await page.waitForTimeout(1000) + emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).toContain(testUserEmail) + + //Remove the test user from the list and make sure it's gone + await page.click(moreSelector) + await page.click(removeUserSelector) + await page.click(accceptRemoveUserSelector) + await page.waitForTimeout(1000) + emails = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(emails).not.toContain(testUserEmail) + }, 9000000) + + it('Should be able to add and remove competition', async () => { + const competitionsTabSelector = '[data-testid="Tävlingshanterare"]' + const addCompetitionsButtonSelector = '[data-testid="addCompetition"]' + const competitionNameSelector = '[data-testid="competitionName"]' + const competitionRegionSelectSelector = '[data-testid="competitionRegion"]' + const acceptAddCompetition = '[data-testid="acceptCompetition"]' + const removeCompetitionButtonSelector = '[data-testid="removeCompetitionButton"]' + const acceptRemoveCompetitionSelector = '[data-testid="acceptRemoveCompetition"]' + + const testCompetitionName = 'New test competition' + const testCompetitionRegion = 'Linköping' + + const testCompetitionRegionSelector = `[data-testid="${testCompetitionRegion}"]` + const testCompetitionSelector = `[data-testid="${testCompetitionName}"]` + //Navigate to competitionManager tab + await page.click(competitionsTabSelector) + await page.waitForSelector('.MuiFormControl-root') + + //Make sure the test region isnt already in the list + let competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).not.toContain(testCompetitionName) + + //Add the test region to the list and make sure it's present + await page.click(addCompetitionsButtonSelector) + await page.click(competitionNameSelector) + await page.keyboard.type(testCompetitionName) + await page.click(competitionRegionSelectSelector) + await page.waitForTimeout(100) + await page.click(testCompetitionRegionSelector) + await page.waitForTimeout(100) + await page.click(acceptAddCompetition) + await page.waitForTimeout(1000) + + competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).toContain(testCompetitionName) + + //Remove the test region from the list and make sure it's gone + await page.click(testCompetitionSelector) + await page.waitForTimeout(100) + await page.click(removeCompetitionButtonSelector) + await page.waitForTimeout(100) + await page.click(acceptRemoveCompetitionSelector) + await page.waitForTimeout(1000) + competitions = await page.$$eval('.MuiTableRow-root > td', (elList) => elList.map((p) => p.textContent)) + expect(competitions).not.toContain(testCompetitionName) + }, 9000000) +}) diff --git a/client/src/e2e/LoginPage.test.tsx b/client/src/e2e/LoginPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a60ff6bd50aec0965f670126437b393774da38b --- /dev/null +++ b/client/src/e2e/LoginPage.test.tsx @@ -0,0 +1,70 @@ +import puppeteer from 'puppeteer' +import { CLIENT_URL, DEVTOOLS_ENABLED, HEADLESS_ENABLED, SLOW_DOWN_FACTOR } from './TestingConstants' + +describe('Login page', () => { + const buttonSelector = '[data-testid="submit"]' + const emailSelector = '[data-testid="email"]' + const passwordSelector = '[data-testid="password"]' + let browser: puppeteer.Browser + let page: puppeteer.Page + beforeEach(async () => { + // Set up testing environment + browser = await puppeteer.launch({ + headless: HEADLESS_ENABLED, + devtools: DEVTOOLS_ENABLED, + slowMo: SLOW_DOWN_FACTOR, + }) + page = await browser.newPage() + + //Navigate to login screen + await page.goto(CLIENT_URL) + await page.waitForSelector('.MuiFormControl-root') + }) + + afterEach(async () => { + await browser.close() + }) + + it('Can submit login user with correct credentials', async () => { + await page.click(emailSelector) + await page.keyboard.type('admin@test.se') + await page.click(passwordSelector) + await page.keyboard.type('password') + await page.click(buttonSelector) + await page.waitForTimeout(4000) + const AdminTitle = await page.$eval('.MuiTypography-root', (el) => el.textContent) + expect(AdminTitle).toEqual('Startsida') + }, 9000000) + + it('Shows correct error message when logging in user with incorrect credentials', async () => { + await page.click(emailSelector) + await page.keyboard.type('wrong@wrong.se') + await page.click(passwordSelector) + await page.keyboard.type('wrongPassword') + await page.click(buttonSelector) + await page.waitForTimeout(1000) + const errorMessages = await page.$$eval('.MuiAlert-message > p', (elList) => elList.map((p) => p.textContent)) + // The error message is divided into two p elements + const errorMessageRow1 = errorMessages[0] + const errorMessageRow2 = errorMessages[1] + expect(errorMessageRow1).toEqual('Någonting gick fel. Kontrollera') + expect(errorMessageRow2).toEqual('dina användaruppgifter och försök igen') + }, 9000000) + + it('Shows correct error message when email is in incorrect format', async () => { + await page.click(emailSelector) + await page.keyboard.type('email') + await page.click(passwordSelector) + await page.waitForTimeout(1000) + const helperText = await page.$eval('.MuiFormHelperText-root', (el) => el.textContent) + expect(helperText).toEqual('Email inte giltig') + }, 9000000) + + it('Shows correct error message when password is too short (<6 chars)', async () => { + await page.click(passwordSelector) + await page.keyboard.type('short') + await page.click(emailSelector) + const helperText = await page.$eval('.MuiFormHelperText-root', (el) => el.textContent) + expect(helperText).toEqual('Lösenord måste vara minst 6 tecken') + }, 9000000) +}) diff --git a/client/src/e2e/TestingConstants.ts b/client/src/e2e/TestingConstants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3122aa2126c17021211ddd88cf0936ec0111cdf9 --- /dev/null +++ b/client/src/e2e/TestingConstants.ts @@ -0,0 +1,6 @@ +/** This file includes constants for e2e testing */ + +export const HEADLESS_ENABLED = false +export const DEVTOOLS_ENABLED = false +export const SLOW_DOWN_FACTOR = 20 +export const CLIENT_URL = 'http://localhost:3000/' diff --git a/client/src/enum/ComponentTypes.ts b/client/src/enum/ComponentTypes.ts index 8567e1c82bba8eb42e0d8a705bb6179ce56ba118..7ff75bd8d867762550dd9bcb6997038ac8d4b233 100644 --- a/client/src/enum/ComponentTypes.ts +++ b/client/src/enum/ComponentTypes.ts @@ -1,5 +1,5 @@ export enum ComponentTypes { Text = 1, Image, - Checkbox, + Question, } diff --git a/client/src/hooks.ts b/client/src/hooks.ts index 597f2813de15896121aea601caa50cc42edd0579..914e9aa81f043e785867df7a35a801a3ce60b845 100644 --- a/client/src/hooks.ts +++ b/client/src/hooks.ts @@ -1,5 +1,9 @@ +/** This file includes typed versions of redux hooks */ + import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import type { AppDispatch, RootState } from './store' +/** Typed version of useDispatch, this should be used every single time instead of useDispatch */ export const useAppDispatch = () => useDispatch<AppDispatch>() +/** Typed version of useSelector, this should be used every single time instead of useSelector */ export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector diff --git a/client/src/index.tsx b/client/src/index.tsx index fdd9cf8e2bc1d9655a060c4270091fbf5f13e236..cb658ebf015a5f33f4844d280f358d348cf24a0e 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -3,7 +3,6 @@ import ReactDOM from 'react-dom' import { Provider } from 'react-redux' import App from './App' import './index.css' -import reportWebVitals from './reportWebVitals' import store from './store' // Provider wraps the app component so that it can access store @@ -15,8 +14,3 @@ ReactDOM.render( </Provider>, document.getElementById('root') ) - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals() diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 347fdbfa514b3f2adb71c4d0c47b19a08fa99376..d32cd65ae7079282b3352cbed7551feffc8b6053 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -36,15 +36,16 @@ export interface Slide { competition_id: number id: number order: number - timer: number + timer: number | null title: string + background_image?: Media } export interface Competition extends NameID { font: string city_id: number year: number - background_image_id: number + background_image?: Media } export interface Team extends NameID { @@ -53,22 +54,31 @@ export interface Team extends NameID { export interface Question extends NameID { slide_id: number - title: string - total_score: number + total_score: number | null type_id: number + correcting_instructions: string } export interface QuestionAlternative { id: number - text: string - value: number + alternative: string + alternative_order: number + correct: string + correct_order: number question_id: number } -export interface QuestionAnswer { + +export interface QuestionAlternativeAnswer { + id: number + team_id: number + question_alternative_id: number + answer: string +} + +export interface QuestionScore { id: number - question_id: number team_id: number - data: string + question_id: number score: number } @@ -79,23 +89,29 @@ export interface Component { w: number h: number type_id: number + view_type_id: number + slide_id: number } export interface ImageComponent extends Component { - data: { - media_id: number - filename: string - } + media: Media } export interface TextComponent extends Component { - data: { - text: string - font: string - } + text: string + font: string } -export interface QuestionAlternativeComponent extends Component { - question_alternative_id: number - font: string +export interface QuestionComponent extends Component { + id: number + x: number + y: number + w: number + h: number + slide_id: number + type_id: number + view_type_id: number + text: string + media: Media + question_id: number } diff --git a/client/src/interfaces/ApiRichModels.ts b/client/src/interfaces/ApiRichModels.ts index 2388f8308ba8bfa38d4d5376040f937244f4cbd5..4251f2e644491b8c3e1a6f5fcad6fa13f42c95bb 100644 --- a/client/src/interfaces/ApiRichModels.ts +++ b/client/src/interfaces/ApiRichModels.ts @@ -1,4 +1,4 @@ -import { Component, Media, QuestionAlternative, QuestionAnswer, QuestionType } from './ApiModels' +import { Component, Media, QuestionAlternative, QuestionAlternativeAnswer, QuestionScore } from './ApiModels' export interface RichCompetition { name: string @@ -7,23 +7,25 @@ export interface RichCompetition { city_id: number slides: RichSlide[] teams: RichTeam[] + background_image?: Media } export interface RichSlide { id: number order: number - timer: number + timer: number | null title: string competition_id: number + background_image?: Media components: Component[] questions: RichQuestion[] - medias: Media[] } export interface RichTeam { id: number name: string - question_answers: QuestionAnswer[] + question_alternative_answers: QuestionAlternativeAnswer[] + question_scores: QuestionScore[] competition_id: number } @@ -33,7 +35,7 @@ export interface RichQuestion { name: string title: string total_score: number - question_type: QuestionType type_id: number + correcting_instructions: string alternatives: QuestionAlternative[] } diff --git a/client/src/interfaces/Timer.ts b/client/src/interfaces/Timer.ts index 49d1909e15692e68bb8cdef32ddd9f59d6b69409..03704c834d1b51075916d4bd4be3dcc99f9b0e53 100644 --- a/client/src/interfaces/Timer.ts +++ b/client/src/interfaces/Timer.ts @@ -1,4 +1,4 @@ -export interface Timer { +export interface TimerState { + value: number | null enabled: boolean - value: number } diff --git a/client/src/interfaces/ViewParams.ts b/client/src/interfaces/ViewParams.ts index e9aa6a5c5f81a6bf852f8caa30443a793b0dddd7..b8114216500b3295050a0b62f6188fa065fcbfdb 100644 --- a/client/src/interfaces/ViewParams.ts +++ b/client/src/interfaces/ViewParams.ts @@ -1,4 +1,3 @@ export interface ViewParams { - id: string - code: string + competitionId: string } diff --git a/client/src/logo.svg b/client/src/logo.svg deleted file mode 100644 index 9dfc1c058cebbef8b891c5062be6f31033d7d186..0000000000000000000000000000000000000000 --- a/client/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> \ No newline at end of file diff --git a/client/src/middleware/Middleware_Explanation.txt b/client/src/middleware/Middleware_Explanation.txt deleted file mode 100644 index dc4e91ab6fd33627a7b19cb06be5ba20fee4598d..0000000000000000000000000000000000000000 --- a/client/src/middleware/Middleware_Explanation.txt +++ /dev/null @@ -1,6 +0,0 @@ -Redux middleware provides a third-party extension point between dispatching an action, -and the moment it reaches the reducer. People use Redux middleware for logging, -crash reporting, talking to an asynchronous API, routing, and more. - - -https://redux.js.org/tutorials/fundamentals/part-4-store \ No newline at end of file diff --git a/client/src/pages/admin/AdminPage.test.tsx b/client/src/pages/admin/AdminPage.test.tsx index 445373a032207faa3f546a0c480fb03a093b1529..61d409733c09d3b7ff1fb2ef902eb8a10ab67e34 100644 --- a/client/src/pages/admin/AdminPage.test.tsx +++ b/client/src/pages/admin/AdminPage.test.tsx @@ -8,35 +8,33 @@ import AdminPage from './AdminPage' it('renders admin view', () => { const cityRes: any = { - data: { - items: [ - { - id: 1, - name: 'Link\u00f6ping', - }, - { - id: 2, - name: 'Stockholm', - }, - ], - count: 2, - total_count: 3, + data: [ + { + id: 1, + name: 'Link\u00f6ping', + }, + { + id: 2, + name: 'Stockholm', + }, + ], + headers: { + pagination: '{"count": 2,"total_count": 3}', }, } const rolesRes: any = { - data: { - items: [ - { - id: 1, - name: 'role1', - }, - { - id: 2, - name: 'role2', - }, - ], - count: 2, - total_count: 3, + data: [ + { + id: 1, + name: 'role1', + }, + { + id: 2, + name: 'role2', + }, + ], + headers: { + pagination: '{"count": 2,"total_count": 3}', }, } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { diff --git a/client/src/pages/admin/AdminPage.tsx b/client/src/pages/admin/AdminPage.tsx index a8b1746aa8ee4e7564a6f13dc074b11082002747..f77051b12decd0a0760d490d15ea12b1f135cdda 100644 --- a/client/src/pages/admin/AdminPage.tsx +++ b/client/src/pages/admin/AdminPage.tsx @@ -16,10 +16,11 @@ import ExitToAppIcon from '@material-ui/icons/ExitToApp' import LocationCityIcon from '@material-ui/icons/LocationCity' import PeopleIcon from '@material-ui/icons/People' import SettingsOverscanIcon from '@material-ui/icons/SettingsOverscan' -import axios from 'axios' import React, { useEffect } from 'react' -import { Link, Route, Switch, useRouteMatch } from 'react-router-dom' +import { Link, Route, Switch, useLocation, useRouteMatch } from 'react-router-dom' import { getCities } from '../../actions/cities' +import { setEditorLoading } from '../../actions/competitions' +import { setEditorSlideId } from '../../actions/editor' import { getRoles } from '../../actions/roles' import { getStatistics } from '../../actions/statistics' import { getTypes } from '../../actions/typesAction' @@ -58,6 +59,7 @@ const useStyles = makeStyles((theme: Theme) => const AdminView: React.FC = () => { const classes = useStyles() + const location = useLocation() const [openIndex, setOpenIndex] = React.useState(0) const { path, url } = useRouteMatch() const currentUser = useAppSelector((state) => state.user.userInfo) @@ -74,28 +76,41 @@ const AdminView: React.FC = () => { dispatch(getRoles()) dispatch(getTypes()) dispatch(getStatistics()) + dispatch(setEditorLoading(true)) + dispatch(setEditorSlideId(-1)) }, []) + useEffect(() => { + setActiveTabFromUrl() + }, [isAdmin]) + + const setActiveTabFromUrl = () => { + let activeIndex + if (isAdmin) activeIndex = menuAdminItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) + else activeIndex = menuEditorItems.findIndex((menuItem) => location.pathname.endsWith(menuItem.route)) + if (activeIndex !== -1) setOpenIndex(activeIndex) + } + const menuAdminItems = [ - { text: 'Startsida', icon: DashboardIcon }, - { text: 'Regioner', icon: LocationCityIcon }, - { text: 'Användare', icon: PeopleIcon }, - { text: 'Tävlingshanterare', icon: SettingsOverscanIcon }, + { text: 'Startsida', icon: DashboardIcon, route: 'dashboard' }, + { text: 'Regioner', icon: LocationCityIcon, route: 'regions' }, + { text: 'Användare', icon: PeopleIcon, route: 'users' }, + { text: 'Tävlingshanterare', icon: SettingsOverscanIcon, route: 'competition-manager' }, ] const menuEditorItems = [ - { text: 'Startsida', icon: DashboardIcon }, - { text: 'Tävlingshanterare', icon: SettingsOverscanIcon }, + { text: 'Startsida', icon: DashboardIcon, route: 'dashboard' }, + { text: 'Tävlingshanterare', icon: SettingsOverscanIcon, route: 'competition-manager' }, ] - const renderItems = () => { const menuItems = isAdmin ? menuAdminItems : menuEditorItems return menuItems.map((value, index) => ( <ListItem + data-testid={value.text} button component={Link} key={value.text} - to={`${url}/${value.text.toLowerCase()}`} + to={`${url}/${value.route}`} selected={index === openIndex} onClick={() => setOpenIndex(index)} > @@ -147,16 +162,16 @@ const AdminView: React.FC = () => { <main className={classes.content}> <div className={classes.toolbar} /> <Switch> - <Route exact path={[path, `${path}/startsida`]}> + <Route exact path={[path, `${path}/dashboard`]}> <Dashboard /> </Route> - <Route path={`${path}/regioner`}> + <Route path={`${path}/regions`}> <RegionManager /> </Route> - <Route path={`${path}/användare`}> + <Route path={`${path}/users`}> <UserManager /> </Route> - <Route path={`${path}/tävlingshanterare`}> + <Route path={`${path}/competition-manager`}> <CompetitionManager /> </Route> </Switch> diff --git a/client/src/pages/admin/competitions/AddCompetition.tsx b/client/src/pages/admin/competitions/AddCompetition.tsx index 6053f2f8de9c3d13cde3403ec1a62317e477d047..c4a0e78bd4afeb673876d1f7c361af224226762f 100644 --- a/client/src/pages/admin/competitions/AddCompetition.tsx +++ b/client/src/pages/admin/competitions/AddCompetition.tsx @@ -1,4 +1,5 @@ import { Button, FormControl, InputLabel, MenuItem, Popover, TextField } from '@material-ui/core' +import PostAddIcon from '@material-ui/icons/PostAdd' import { Alert, AlertTitle } from '@material-ui/lab' import axios from 'axios' import { Formik, FormikHelpers } from 'formik' @@ -73,9 +74,14 @@ const AddCompetition: React.FC = (props: any) => { // if the post request fails .catch(({ response }) => { console.warn(response.data) - if (response.data && response.data.message) + if (response?.status === 409) + actions.setFieldError( + 'error', + 'Denna tävling finns redan, välj ett nytt namn, region eller år och försök igen' + ) + else if (response.data && response.data.message) actions.setFieldError('error', response.data && response.data.message) - else actions.setFieldError('error', 'Something went wrong, please try again') + else actions.setFieldError('error', 'Någonting gick fel, försök igen') }) .finally(() => { actions.setSubmitting(false) @@ -88,10 +94,11 @@ const AddCompetition: React.FC = (props: any) => { return ( <div> <AddButton - style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} - color="default" + data-testid="addCompetition" + color="secondary" variant="contained" onClick={handleClick} + endIcon={<PostAddIcon />} > Ny Tävling </AddButton> @@ -124,6 +131,7 @@ const AddCompetition: React.FC = (props: any) => { {(formik) => ( <AddForm onSubmit={formik.handleSubmit}> <TextField + data-testid="competitionName" label="Namn" name="model.name" helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} @@ -137,6 +145,7 @@ const AddCompetition: React.FC = (props: any) => { Region </InputLabel> <TextField + data-testid="competitionRegion" select name="model.city" id="standard-select-currency" @@ -152,7 +161,12 @@ const AddCompetition: React.FC = (props: any) => { </MenuItem> {cities && cities.map((city) => ( - <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + <MenuItem + key={city.name} + value={city.name} + onClick={() => setSelectedCity(city)} + data-testid={city.name} + > {city.name} </MenuItem> ))} @@ -170,17 +184,18 @@ const AddCompetition: React.FC = (props: any) => { margin="normal" /> <Button + data-testid="acceptCompetition" type="submit" fullWidth variant="contained" color="secondary" - disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + disabled={!formik.isValid || !formik.values.model?.name || !selectedCity} > Skapa </Button> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/competitions/CompetitionManager.test.tsx b/client/src/pages/admin/competitions/CompetitionManager.test.tsx index 7af04abb66bba7ded7655398f288cba1c62bf78b..0b270a7df39653567333c06516090c9c0364c900 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.test.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.test.tsx @@ -8,42 +8,36 @@ import CompetitionManager from './CompetitionManager' it('renders competition manager', () => { const cityRes: any = { - data: { - items: [ - { - id: 1, - name: 'Link\u00f6ping', - }, - { - id: 2, - name: 'Stockholm', - }, - ], - count: 2, - total_count: 3, - }, + data: [ + { + id: 1, + name: 'Link\u00f6ping', + }, + { + id: 2, + name: 'Stockholm', + }, + ], + pagination: '{"count": 2,"total": 3, "page_size": 5}', } const compRes: any = { - data: { - items: [ - { - id: 21, - name: 'ggff', - year: 2021, - style_id: 1, - city: cityRes.data.items[0], - }, - { - id: 22, - name: 'sssss', - year: 2021, - style_id: 1, - city: cityRes.data.items[1], - }, - ], - count: 2, - total_count: 3, - }, + data: [ + { + id: 21, + name: 'ggff', + year: 2021, + style_id: 1, + city: cityRes.data[0], + }, + { + id: 22, + name: 'sssss', + year: 2021, + style_id: 1, + city: cityRes.data[1], + }, + ], + headers: { pagination: '{"count": 2,"total": 3, "page_size": 5}' }, } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index 83f399b95a0c083727333c2f842a1c61a8eca2d0..d0b252a3adfc88d1d4864c645be74cc27ae6709e 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -1,23 +1,44 @@ -import { Button, Menu, TablePagination, TextField, Typography } from '@material-ui/core' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + ListItem, + ListItemText, + Menu, + TablePagination, + TextField, + Tooltip, + Typography, + useMediaQuery, +} from '@material-ui/core' import FormControl from '@material-ui/core/FormControl' import InputLabel from '@material-ui/core/InputLabel' import MenuItem from '@material-ui/core/MenuItem' import Paper from '@material-ui/core/Paper' import Select from '@material-ui/core/Select' -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { createStyles, makeStyles, Theme, useTheme } from '@material-ui/core/styles' import Table from '@material-ui/core/Table' import TableBody from '@material-ui/core/TableBody' import TableCell from '@material-ui/core/TableCell' import TableContainer from '@material-ui/core/TableContainer' import TableHead from '@material-ui/core/TableHead' import TableRow from '@material-ui/core/TableRow' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import LinkIcon from '@material-ui/icons/Link' import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import RefreshIcon from '@material-ui/icons/Refresh' import axios from 'axios' import React, { useEffect } from 'react' import { Link, useHistory } from 'react-router-dom' import { getCompetitions, setFilterParams } from '../../../actions/competitions' import { useAppDispatch, useAppSelector } from '../../../hooks' +import { Team } from '../../../interfaces/ApiModels' import { CompetitionFilterParams } from '../../../interfaces/FilterParams' +import { Center } from '../../presentationEditor/components/styled' import { FilterContainer, RemoveMenuItem, TopBar, YearFilterTextField } from '../styledComp' import AddCompetition from './AddCompetition' @@ -36,26 +57,51 @@ const useStyles = makeStyles((theme: Theme) => margin: { margin: theme.spacing(1), }, + paper: { + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[5], + padding: 4, + outline: 'none', + }, }) ) +interface Code { + id: number + code: string + view_type_id: number + competition_id: number + team_id: number +} + const CompetitionManager: React.FC = (props: any) => { + // for dialog alert + const [openAlert, setOpen] = React.useState(false) + const theme = useTheme() + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) + const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) const [activeId, setActiveId] = React.useState<number | undefined>(undefined) const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const [dialogIsOpen, setDialogIsOpen] = React.useState(false) + const [codes, setCodes] = React.useState<Code[]>([]) + const [teams, setTeams] = React.useState<Team[]>([]) + const [competitionName, setCompetitionName] = React.useState<string | undefined>(undefined) const loading = useAppSelector((state) => state.user.userInfo === null) const competitions = useAppSelector((state) => state.competitions.competitions) const filterParams = useAppSelector((state) => state.competitions.filterParams) const competitionTotal = useAppSelector((state) => state.competitions.total) const cities = useAppSelector((state) => state.cities.cities) - const classes = useStyles() const noFilterText = 'Alla' const dispatch = useAppDispatch() const history = useHistory() + const handleClick = (event: React.MouseEvent<HTMLButtonElement>, id: number) => { - setAnchorEl(event.currentTarget) setActiveId(id) + getCodes(id) + getTeams(id) + setAnchorEl(event.currentTarget) } const handleClose = () => { @@ -63,6 +109,10 @@ const CompetitionManager: React.FC = (props: any) => { setActiveId(undefined) } + const handleCloseVerify = () => { + setOpen(false) + } + useEffect(() => { dispatch(getCompetitions()) }, []) @@ -78,8 +128,13 @@ const CompetitionManager: React.FC = (props: any) => { dispatch(setFilterParams({ ...filterParams, name: event.target.value })) } + const handleVerifyDelete = () => { + setOpen(true) + } + // Function to remove a competition from the systems database const handleDeleteCompetition = async () => { + setOpen(false) if (activeId) { await axios .delete(`/api/competitions/${activeId}`) @@ -93,11 +148,88 @@ const CompetitionManager: React.FC = (props: any) => { } } + /** Start the competition by redirecting with URL with Code */ const handleStartCompetition = () => { - history.push(`/presenter/id=${activeId}&code=123123`) - console.log('GLHF!') + const operatorCode = codes.find((code) => code.view_type_id === 4)?.code + if (operatorCode) { + history.push(`/${operatorCode}`) + } + } + + /** Fetch all the connection codes from the server */ + const getCodes = async (id: number) => { + await axios + .get(`/api/competitions/${id}/codes`) + .then((response) => { + setCodes(response.data) + }) + .catch(console.log) + } + + /** Fetch all the teams from the server that is connected to a specific competition*/ + const getTeams = async (id: number) => { + await axios + .get(`/api/competitions/${id}/teams`) + .then((response) => { + setTeams(response.data) + }) + .catch((err) => { + console.log(err) + }) + } + + /** Fetch the copetition name from the server */ + const getCompetitionName = async () => { + await axios + .get(`/api/competitions/${activeId}`) + .then((response) => { + // console.log(response.data.name) + setCompetitionName(response.data.name) + }) + .catch((err) => { + console.log(err) + }) } + const getTypeName = (code: Code) => { + let typeName = '' + switch (code.view_type_id) { + case 1: + const team = teams.find((team) => team.id === code.team_id) + if (team) { + typeName = team.name + } else { + typeName = 'Lagnamn hittades ej' + } + break + case 2: + typeName = 'Domare' + break + case 3: + typeName = 'Publik' + break + case 4: + typeName = 'Tävlingsoperatör' + break + default: + typeName = 'Typ hittades ej' + break + } + return typeName + } + + /** Handles the opening of the code dialog box */ + const handleOpenDialog = async () => { + await getCompetitionName() + setDialogIsOpen(true) + } + /** Handles the closing of the code dialog box */ + const handleCloseDialog = () => { + setDialogIsOpen(false) + setAnchorEl(null) + } + + /** Function that copies an existing competition */ const handleDuplicateCompetition = async () => { if (activeId) { await axios @@ -117,16 +249,25 @@ const CompetitionManager: React.FC = (props: any) => { dispatch(getCompetitions()) } + const refreshCode = async (code: Code) => { + if (activeId) { + await axios + .put(`/api/competitions/${activeId}/codes/${code.id}`) + .then(() => { + getCodes(activeId) + dispatch(getCompetitions()) + }) + .catch(({ response }) => { + console.warn(response.data) + }) + } + } + return ( <div> <TopBar> <FilterContainer> - <TextField - className={classes.margin} - value={filterParams.name || ''} - onChange={onSearchChange} - label="Sök" - ></TextField> + <TextField className={classes.margin} value={filterParams.name || ''} onChange={onSearchChange} label="Sök" /> <FormControl className={classes.margin}> <InputLabel shrink id="demo-customized-select-native"> Region @@ -175,7 +316,7 @@ const CompetitionManager: React.FC = (props: any) => { <TableBody> {competitions && competitions.map((row) => ( - <TableRow key={row.name}> + <TableRow key={row.id}> <TableCell scope="row"> <Button color="primary" component={Link} to={`/editor/competition-id=${row.id}`}> {row.name} @@ -184,7 +325,7 @@ const CompetitionManager: React.FC = (props: any) => { <TableCell align="right">{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> <TableCell align="right">{row.year}</TableCell> <TableCell align="right"> - <Button onClick={(event) => handleClick(event, row.id)}> + <Button onClick={(event) => handleClick(event, row.id)} data-testid={row.name}> <MoreHorizIcon /> </Button> </TableCell> @@ -202,14 +343,103 @@ const CompetitionManager: React.FC = (props: any) => { rowsPerPageOptions={[]} rowsPerPage={filterParams.pageSize} count={competitionTotal} - page={filterParams.page} - onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage })} + page={filterParams.page - 1} + onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage + 1 })} /> <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> <MenuItem onClick={handleStartCompetition}>Starta</MenuItem> + <MenuItem onClick={handleOpenDialog}>Visa koder</MenuItem> <MenuItem onClick={handleDuplicateCompetition}>Duplicera</MenuItem> - <RemoveMenuItem onClick={handleDeleteCompetition}>Ta bort</RemoveMenuItem> + <RemoveMenuItem onClick={handleVerifyDelete} data-testid="removeCompetitionButton"> + Ta bort + </RemoveMenuItem> </Menu> + <Dialog + fullScreen={fullScreen} + open={openAlert} + onClose={handleCloseVerify} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">{'Ta bort tävlingen?'}</DialogTitle> + <DialogContent> + <DialogContentText> + Är du säker på att du vill ta bort tävlingen och all dess information från systemet? + </DialogContentText> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleCloseVerify} color="primary"> + Avbryt + </Button> + <Button data-testid="acceptRemoveCompetition" onClick={handleDeleteCompetition} color="primary" autoFocus> + Ta bort + </Button> + </DialogActions> + </Dialog> + + <Dialog + open={dialogIsOpen} + onClose={handleCloseDialog} + aria-labelledby="max-width-dialog-title" + maxWidth="xl" + fullWidth={false} + fullScreen={false} + > + <Center> + <DialogTitle id="max-width-dialog-title" className={classes.paper} style={{ width: '100%' }}> + Koder för {competitionName} + </DialogTitle> + </Center> + <DialogContent> + {/* <DialogContentText>Här visas tävlingskoderna till den valda tävlingen.</DialogContentText> */} + {codes.map((code) => ( + <ListItem key={code.id} style={{ display: 'flex' }}> + <ListItemText primary={`${getTypeName(code)}: `} /> + <Typography component="div"> + <ListItemText style={{ textAlign: 'right', marginLeft: '10px' }}> + <Box fontFamily="Monospace" fontWeight="fontWeightBold"> + {code.code} + </Box> + </ListItemText> + </Typography> + <Tooltip title="Generera ny kod" arrow> + <Button + margin-right="0px" + onClick={() => { + refreshCode(code) + }} + > + <RefreshIcon fontSize="small" /> + </Button> + </Tooltip> + <Tooltip title="Kopiera kod" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(code.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + <Tooltip title="Kopiera länk" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(`${window.location.host}/${code.code}`) + }} + > + <LinkIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + ))} + </DialogContent> + <DialogActions> + <Button onClick={handleCloseDialog} color="primary"> + Stäng + </Button> + </DialogActions> + </Dialog> </div> ) } diff --git a/client/src/pages/admin/dashboard/Dashboard.tsx b/client/src/pages/admin/dashboard/Dashboard.tsx index a0f569b066a8073a14f9342dcb7d988be63b93b9..51aa4f4084ebf016fefbb740dcb081ac20f0e7fe 100644 --- a/client/src/pages/admin/dashboard/Dashboard.tsx +++ b/client/src/pages/admin/dashboard/Dashboard.tsx @@ -6,6 +6,11 @@ import NumberOfCompetitions from './components/NumberOfCompetitions' import NumberOfRegions from './components/NumberOfRegions' import NumberOfUsers from './components/NumberOfUsers' +/** + * This is the first page that is shown after a user logs in. It shows som statistics about the site. + * + */ + const useStyles = makeStyles((theme: Theme) => createStyles({ root: { diff --git a/client/src/pages/admin/dashboard/components/CurrentUser.tsx b/client/src/pages/admin/dashboard/components/CurrentUser.tsx index 0dfdf557bff55224f513fb742ba59bdc8c8b1f56..10b2ed94eccf875be474ac8b87468e3ca01e55b5 100644 --- a/client/src/pages/admin/dashboard/components/CurrentUser.tsx +++ b/client/src/pages/admin/dashboard/components/CurrentUser.tsx @@ -2,8 +2,40 @@ import { Box, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../../hooks' +/** This component show information about the currently logged in user */ + const CurrentUser: React.FC = () => { const currentUser = useAppSelector((state: { user: { userInfo: any } }) => state.user.userInfo) + const regions = useAppSelector((state) => state.cities.cities) + //const regionlist = regions.map((index) => index.name) + const regionlist = regions.map((index) => index) + const roles = useAppSelector((state) => state.roles.roles) + const rolelist = roles.map((index) => index) + + /** This is a temporary fix, these values "should" be stored in the state along with all the othe userinfo */ + const getRegionName = () => { + if (currentUser && regions) { + for (let i = 0; i < regionlist.length; i++) { + if (regionlist[i].id === currentUser.city_id) { + return regionlist[i].name + } + } + } + return 'N/A' + } + + /** This is a temporary fix, these values "should" be stored in the state along with all the othe userinfo */ + const getRoleName = () => { + if (currentUser && roles) { + for (let i = 0; i < rolelist.length; i++) { + if (rolelist[i].id === currentUser.role_id) { + return rolelist[i].name + } + } + } + return 'N/A' + } + return ( <div> <Box display="flex" flexDirection="column" alignContent="flex-start"> @@ -13,13 +45,15 @@ const CurrentUser: React.FC = () => { </Typography> </div> <div> - <Typography variant="h6">Email: {currentUser && currentUser.email}</Typography> + <Typography data-testid="userEmail" variant="h6"> + Email: {currentUser && currentUser.email} + </Typography> </div> <div> - <Typography variant="h6">Region: {currentUser && currentUser.city && currentUser.city.name}</Typography> + <Typography variant="h6">Region: {getRegionName()}</Typography> </div> <div> - <Typography variant="h6">Roll: {currentUser && currentUser.role && currentUser.role.name}</Typography> + <Typography variant="h6">Roll: {getRoleName()}</Typography> </div> </Box> </div> diff --git a/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx index eb667ebd690f6013c184ac13e2038ce1a7726de9..1ebe9dfba3e888e9bf07b998cff321613ba035d3 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfCompetitions.tsx @@ -2,6 +2,8 @@ import { Box, Typography } from '@material-ui/core' import React from 'react' import { useAppSelector } from '../../../../hooks' +/** Shows how many competitions is on the system */ + const NumberOfCompetitions: React.FC = () => { const competitions = useAppSelector((state) => state.statistics.competitions) diff --git a/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx index f3195f4c18969ed2ad1e4b185b2125ac2c15f782..5f9ecef5e60ff4fda27d6a42771c043b83e9dc8e 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfRegions.tsx @@ -3,6 +3,8 @@ import React, { useEffect } from 'react' import { getCities } from '../../../../actions/cities' import { useAppDispatch, useAppSelector } from '../../../../hooks' +/** Shows how many regions is on the system */ + const NumberOfRegions: React.FC = () => { const regions = useAppSelector((state) => state.statistics.regions) const dispatch = useAppDispatch() diff --git a/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx index 0e75cf1b1069814801fadc010f30ac393dfe3b25..91291e689529c8c65a54ed63c7ae77ce489ebe2f 100644 --- a/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx +++ b/client/src/pages/admin/dashboard/components/NumberOfUsers.tsx @@ -3,6 +3,8 @@ import React, { useEffect } from 'react' import { getSearchUsers } from '../../../../actions/searchUser' import { useAppDispatch, useAppSelector } from '../../../../hooks' +/** Shows how many users are on the system */ + const NumberOfUsers: React.FC = () => { const usersTotal = useAppSelector((state) => state.statistics.users) const dispatch = useAppDispatch() diff --git a/client/src/pages/admin/regions/AddRegion.tsx b/client/src/pages/admin/regions/AddRegion.tsx index b00961384f73246365e60023af04ed7567adf311..7be2edaba027f816da52463bb09f48e4781003ab 100644 --- a/client/src/pages/admin/regions/AddRegion.tsx +++ b/client/src/pages/admin/regions/AddRegion.tsx @@ -30,6 +30,7 @@ const useStyles = makeStyles((theme: Theme) => type formType = FormModel<AddCityModel> +/** add a region form with some constraints. */ const schema: Yup.SchemaOf<formType> = Yup.object({ model: Yup.object() .shape({ @@ -78,28 +79,30 @@ const AddRegion: React.FC = (props: any) => { <FormControl className={classes.margin}> <Grid container={true}> <TextField + data-testid="regionTextField" className={classes.margin} helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} error={Boolean(formik.touched.model?.name && formik.errors.model?.name)} onChange={formik.handleChange} onBlur={formik.handleBlur} + value={formik.values.model.name} name="model.name" label="Region" - ></TextField> + /> <AddButton - style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} + data-testid="regionSubmitButton" className={classes.button} - color="default" + color="secondary" variant="contained" type="submit" > - <AddIcon></AddIcon> + <AddIcon /> </AddButton> </Grid> </FormControl> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/regions/Regions.test.tsx b/client/src/pages/admin/regions/Regions.test.tsx index 7046bff04fc8fc9fc8cb3f08d0d140d0d475b5a3..864001b31dcc9cf17edbaf70af65aac240232663 100644 --- a/client/src/pages/admin/regions/Regions.test.tsx +++ b/client/src/pages/admin/regions/Regions.test.tsx @@ -8,19 +8,18 @@ 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, + data: [ + { + id: 1, + name: 'Link\u00f6ping', + }, + { + id: 2, + name: 'Stockholm', + }, + ], + headers: { + pagination: '{"count": 2,"total_count": 3}', }, } diff --git a/client/src/pages/admin/regions/Regions.tsx b/client/src/pages/admin/regions/Regions.tsx index 436da6f69429ca6711452d7babf477f8da593eee..f9a92ff73046c4d73af84d2105e8eef88038eb04 100644 --- a/client/src/pages/admin/regions/Regions.tsx +++ b/client/src/pages/admin/regions/Regions.tsx @@ -1,4 +1,4 @@ -import { Button, Menu, Typography } from '@material-ui/core' +import { Button, Menu, Snackbar, Typography } from '@material-ui/core' import Paper from '@material-ui/core/Paper' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import Table from '@material-ui/core/Table' @@ -8,12 +8,16 @@ import TableContainer from '@material-ui/core/TableContainer' import TableHead from '@material-ui/core/TableHead' import TableRow from '@material-ui/core/TableRow' import MoreHorizIcon from '@material-ui/icons/MoreHoriz' +import { Alert } from '@material-ui/lab' import axios from 'axios' import React, { useEffect } from 'react' import { getCities } from '../../../actions/cities' import { useAppDispatch, useAppSelector } from '../../../hooks' import { RemoveMenuItem, TopBar } from '../styledComp' import AddRegion from './AddRegion' + +/** shows all the regions in a list */ + const useStyles = makeStyles((theme: Theme) => createStyles({ table: { @@ -28,7 +32,7 @@ const useStyles = makeStyles((theme: Theme) => const RegionManager: React.FC = (props: any) => { const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null) const [activeId, setActiveId] = React.useState<number | undefined>(undefined) - const citiesTotal = useAppSelector((state) => state.cities.total) + const [errorActive, setErrorActive] = React.useState(false) const cities = useAppSelector((state) => state.cities.cities) const [newCity, setNewCity] = React.useState<string>() const classes = useStyles() @@ -50,8 +54,8 @@ const RegionManager: React.FC = (props: any) => { setAnchorEl(null) dispatch(getCities()) }) - .catch(({ response }) => { - console.warn(response.data) + .catch((response) => { + if (response?.response?.status === 409) setErrorActive(true) }) } } @@ -61,26 +65,10 @@ const RegionManager: React.FC = (props: any) => { setActiveId(id) } - const handleAddCity = async () => { - await axios - .post(`/api/misc/cities`, { name: newCity }) - .then(() => { - setAnchorEl(null) - dispatch(getCities()) - }) - .catch(({ response }) => { - console.warn(response.data) - }) - } - - const handleChange = (event: any) => { - setNewCity(event.target.value) - } - return ( <div> <TopBar> - <AddRegion></AddRegion> + <AddRegion /> </TopBar> <TableContainer component={Paper}> <Table className={classes.table} aria-label="simple table"> @@ -93,10 +81,10 @@ const RegionManager: React.FC = (props: any) => { <TableBody> {cities && cities.map((row) => ( - <TableRow key={row.name}> + <TableRow key={row.id}> <TableCell scope="row">{row.name}</TableCell> <TableCell align="right"> - <Button onClick={(event) => handleClick(event, row.id)}> + <Button onClick={(event) => handleClick(event, row.id)} data-testid={row.name}> <MoreHorizIcon /> </Button> </TableCell> @@ -107,8 +95,13 @@ const RegionManager: React.FC = (props: any) => { {(!cities || cities.length === 0) && <Typography>Inga regioner hittades</Typography>} </TableContainer> <Menu id="simple-menu" anchorEl={anchorEl} keepMounted open={Boolean(anchorEl)} onClose={handleClose}> - <RemoveMenuItem onClick={handleDeleteCity}>Ta bort</RemoveMenuItem> + <RemoveMenuItem onClick={handleDeleteCity} data-testid="removeRegionButton"> + Ta bort + </RemoveMenuItem> </Menu> + <Snackbar open={errorActive} autoHideDuration={4000} onClose={() => setErrorActive(false)}> + <Alert severity="error">{`Du kan inte ta bort regionen eftersom det finns användare eller tävlingar kopplade till den.`}</Alert> + </Snackbar> </div> ) } diff --git a/client/src/pages/admin/users/AddUser.tsx b/client/src/pages/admin/users/AddUser.tsx index 9f1511eac460c661ebd35dc995f6606e66080359..cde3fa322dd236edbac13b664dfdafe3985bc56f 100644 --- a/client/src/pages/admin/users/AddUser.tsx +++ b/client/src/pages/admin/users/AddUser.tsx @@ -1,3 +1,5 @@ +/** Add a user component */ + import { Button, FormControl, InputLabel, MenuItem, Popover, TextField } from '@material-ui/core' import PersonAddIcon from '@material-ui/icons/PersonAdd' import { Alert, AlertTitle } from '@material-ui/lab' @@ -16,15 +18,13 @@ type formType = FormModel<AddUserModel> const noRoleSelected = 'Välj roll' const noCitySelected = 'Välj stad' +/** Form when adding a user with some constraints */ const userSchema: Yup.SchemaOf<formType> = Yup.object({ model: Yup.object() .shape({ name: Yup.string(), email: Yup.string().email().required('Email krävs'), - password: Yup.string() - .required('Lösenord krävs.') - .min(6, 'Lösenord måste vara minst 6 tecken.') - .matches(/[a-zA-Z]/, 'Lösenord får enbart innehålla a-z, A-Z.'), + password: Yup.string().required('Lösenord krävs.').min(6, 'Lösenord måste vara minst 6 tecken.'), role: Yup.string().required('Roll krävs').notOneOf([noCitySelected], 'Välj en roll'), city: Yup.string().required('Stad krävs').notOneOf([noRoleSelected], 'Välj en stad'), }) @@ -54,12 +54,12 @@ const AddUser: React.FC = (props: any) => { const params = { email: values.model.email, password: values.model.password, - //name: values.model.name, + name: values.model.name, city_id: selectedCity?.id as number, role_id: selectedRole?.id as number, } await axios - .post('/api/auth/signup', params) + .post('/api/users', params) .then(() => { actions.resetForm() setAnchorEl(null) @@ -84,11 +84,11 @@ const AddUser: React.FC = (props: any) => { return ( <div> <AddButton - style={{ backgroundColor: '#4caf50', color: '#fcfcfc' }} - color="default" + data-testid="addUserButton" + color="secondary" variant="contained" onClick={handleClick} - endIcon={<PersonAddIcon></PersonAddIcon>} + endIcon={<PersonAddIcon />} > Ny Användare </AddButton> @@ -111,6 +111,7 @@ const AddUser: React.FC = (props: any) => { {(formik) => ( <AddForm onSubmit={formik.handleSubmit}> <TextField + data-testid="addUserEmail" label="Email" name="model.email" helperText={formik.touched.model?.email ? formik.errors.model?.email : ''} @@ -120,6 +121,7 @@ const AddUser: React.FC = (props: any) => { margin="normal" /> <TextField + data-testid="addUserPassword" label="Lösenord" name="model.password" helperText={formik.touched.model?.password ? formik.errors.model?.password : ''} @@ -129,6 +131,7 @@ const AddUser: React.FC = (props: any) => { margin="normal" /> <TextField + data-testid="addUserName" label="Namn" name="model.name" helperText={formik.touched.model?.name ? formik.errors.model?.name : ''} @@ -143,6 +146,7 @@ const AddUser: React.FC = (props: any) => { </InputLabel> <TextField select + data-testid="userCitySelect" name="model.city" id="standard-select-currency" value={selectedCity ? selectedCity.name : noCitySelected} @@ -157,7 +161,12 @@ const AddUser: React.FC = (props: any) => { </MenuItem> {cities && cities.map((city) => ( - <MenuItem key={city.name} value={city.name} onClick={() => setSelectedCity(city)}> + <MenuItem + key={city.name} + value={city.name} + onClick={() => setSelectedCity(city)} + data-testid={city.name} + > {city.name} </MenuItem> ))} @@ -170,6 +179,7 @@ const AddUser: React.FC = (props: any) => { </InputLabel> <TextField select + data-testid="userRoleSelect" name="model.role" id="standard-select-currency" value={selectedRole ? selectedRole.name : noRoleSelected} @@ -184,7 +194,12 @@ const AddUser: React.FC = (props: any) => { </MenuItem> {roles && roles.map((role) => ( - <MenuItem key={role.name} value={role.name} onClick={() => setSelectedRole(role)}> + <MenuItem + key={role.name} + value={role.name} + onClick={() => setSelectedRole(role)} + data-testid={role.name} + > {role.name} </MenuItem> ))} @@ -193,16 +208,23 @@ const AddUser: React.FC = (props: any) => { <Button type="submit" + data-testid="addUserSubmit" fullWidth variant="contained" color="secondary" - disabled={!formik.isValid || !formik.values.model?.name || !formik.values.model?.city} + disabled={ + !formik.isValid || + !formik.values.model?.email || + !formik.values.model?.password || + !selectedCity?.name || + !selectedRole?.name + } > Lägg till </Button> {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/users/EditUser.tsx b/client/src/pages/admin/users/EditUser.tsx index 9b4a5d1bd6aecd92a03f44221c2cb40d40e2ab70..9e9d5d2a1d61d235abf715f6c4075bd5f4439ffa 100644 --- a/client/src/pages/admin/users/EditUser.tsx +++ b/client/src/pages/admin/users/EditUser.tsx @@ -14,7 +14,7 @@ import { TextField, Theme, useMediaQuery, - useTheme, + useTheme } from '@material-ui/core' import MoreHorizIcon from '@material-ui/icons/MoreHoriz' import { Alert, AlertTitle } from '@material-ui/lab' @@ -110,7 +110,7 @@ const EditUser = ({ user }: UserIdProps) => { const handleDeleteUsers = async () => { setOpen(false) await axios - .delete(`/api/auth/delete/${user.id}`) + .delete(`/api/users/${user.id}`) .then(() => { setAnchorEl(null) dispatch(getSearchUsers()) @@ -142,7 +142,7 @@ const EditUser = ({ user }: UserIdProps) => { } await axios .put('/api/users/' + user.id, req) - .then((res) => { + .then(() => { setAnchorEl(null) dispatch(getSearchUsers()) }) @@ -167,7 +167,7 @@ const EditUser = ({ user }: UserIdProps) => { } return ( <div> - <Button onClick={handleClick}> + <Button onClick={handleClick} data-testid={`more-${user.email}`}> <MoreHorizIcon /> </Button> <Popover @@ -289,6 +289,7 @@ const EditUser = ({ user }: UserIdProps) => { Ändra </Button> <Button + data-testid="removeUser" onClick={handleVerifyDelete} className={classes.deleteButton} fullWidth @@ -313,7 +314,7 @@ const EditUser = ({ user }: UserIdProps) => { <Button autoFocus onClick={handleClose} color="primary"> Avbryt </Button> - <Button onClick={handleDeleteUsers} color="primary" autoFocus> + <Button data-testid="acceptRemoveUser" onClick={handleDeleteUsers} color="primary" autoFocus> Ta bort </Button> </DialogActions> @@ -321,7 +322,7 @@ const EditUser = ({ user }: UserIdProps) => { {formik.errors.error && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> {formik.errors.error} </Alert> )} diff --git a/client/src/pages/admin/users/ResponsiveDialog.tsx b/client/src/pages/admin/users/ResponsiveDialog.tsx deleted file mode 100644 index e29a6786826e854bebf888be5b7cd904e0ec1063..0000000000000000000000000000000000000000 --- a/client/src/pages/admin/users/ResponsiveDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogContentText from '@material-ui/core/DialogContentText'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import useMediaQuery from '@material-ui/core/useMediaQuery'; -import { useTheme } from '@material-ui/core/styles'; - -export default function ResponsiveDialog() { - const [open, setOpen] = React.useState(false); - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - - const handleClickOpen = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - - return ( - <div> - <Button variant="outlined" color="primary" onClick={handleClickOpen}> - Open responsive dialog - </Button> - <Dialog - fullScreen={fullScreen} - open={open} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > - <DialogTitle id="responsive-dialog-title">{"Use Google's location service?"}</DialogTitle> - <DialogContent> - <DialogContentText> - Let Google help apps determine location. This means sending anonymous location data to - Google, even when no apps are running. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Disagree - </Button> - <Button onClick={handleClose} color="primary" autoFocus> - Agree - </Button> - </DialogActions> - </Dialog> - </div> - ); -} \ No newline at end of file diff --git a/client/src/pages/admin/users/UserManager.test.tsx b/client/src/pages/admin/users/UserManager.test.tsx index f50cbed8ded2b12d3d487e96f1d185bca2a2cbf8..69117bc5829fc6fca85bc2316c1faf668112d3f7 100644 --- a/client/src/pages/admin/users/UserManager.test.tsx +++ b/client/src/pages/admin/users/UserManager.test.tsx @@ -8,25 +8,24 @@ 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, + data: [ + { + 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, + }, + ], + headers: { + pagination: '{"count": 2,"total_count": 3, "page_size": 5}', }, } diff --git a/client/src/pages/admin/users/UserManager.tsx b/client/src/pages/admin/users/UserManager.tsx index 20f5738604e48abe05d2ac280aff1ec56d896f6d..a51a472dcac25bbb958a795db0db7e0c366589d8 100644 --- a/client/src/pages/admin/users/UserManager.tsx +++ b/client/src/pages/admin/users/UserManager.tsx @@ -47,23 +47,6 @@ const UserManager: React.FC = (props: any) => { const dispatch = useAppDispatch() const open = Boolean(anchorEl) - const id = open ? 'simple-popover' : undefined - - const handleClick = (event: React.MouseEvent<HTMLButtonElement>, user: User) => { - setAnchorEl(event.currentTarget) - setSelectedUser(user) - } - - const handleClose = () => { - setAnchorEl(null) - setSelectedUser(undefined) - console.log('close') - } - - const handleEditClose = () => { - setEditAnchorEl(null) - console.log('edit close') - } useEffect(() => { dispatch(getSearchUsers()) @@ -169,7 +152,7 @@ const UserManager: React.FC = (props: any) => { <TableBody> {users && users.map((row) => ( - <TableRow key={row.email}> + <TableRow key={row.id}> <TableCell scope="row">{row.email}</TableCell> <TableCell scope="row">{row.name}</TableCell> <TableCell>{cities.find((city) => city.id === row.city_id)?.name || ''}</TableCell> @@ -187,9 +170,9 @@ const UserManager: React.FC = (props: any) => { component="div" rowsPerPageOptions={[]} rowsPerPage={filterParams.pageSize} - count={usersTotal} - page={filterParams.page} - onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage })} + count={usersTotal || 0} + page={filterParams.page - 1} + onChangePage={(event, newPage) => handleFilterChange({ ...filterParams, page: newPage + 1 })} /> </div> ) diff --git a/client/src/pages/login/LoginPage.tsx b/client/src/pages/login/LoginPage.tsx index 8f8e967c435af9c2611bc9ac3c1365b114a7666a..34c9c5ae6c5344666dc383d7f2df7586ecc13e10 100644 --- a/client/src/pages/login/LoginPage.tsx +++ b/client/src/pages/login/LoginPage.tsx @@ -1,3 +1,7 @@ +/** This is the login page, it contains two child components, one is + * to log in as an admin, the other is to connect to a competition using a code + */ + import { AppBar, Tab, Tabs } from '@material-ui/core' import React from 'react' import AdminLogin from './components/AdminLogin' diff --git a/client/src/pages/login/components/AdminLogin.test.tsx b/client/src/pages/login/components/AdminLogin.test.tsx index 3be87593f4e58698597d39fe6d3ac91a77ac133a..06d81259ae021696995c6454178cbe29dc5dec86 100644 --- a/client/src/pages/login/components/AdminLogin.test.tsx +++ b/client/src/pages/login/components/AdminLogin.test.tsx @@ -4,6 +4,8 @@ import { Provider } from 'react-redux' import store from '../../../store' import AdminLogin from './AdminLogin' +/** Test AdminLogin */ + it('renders admin login', () => { render( <Provider store={store}> diff --git a/client/src/pages/login/components/AdminLogin.tsx b/client/src/pages/login/components/AdminLogin.tsx index 964fb8abd148a9a0735206e9dbe3288cbe72ea3a..e3ec77823c30ebc9bb3f2ef5a214c6139a5c024b 100644 --- a/client/src/pages/login/components/AdminLogin.tsx +++ b/client/src/pages/login/components/AdminLogin.tsx @@ -1,3 +1,5 @@ +/** Component that handles the log in when a user is an admin */ + import { Button, TextField, Typography } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' import { Formik, FormikHelpers } from 'formik' @@ -18,6 +20,7 @@ interface formError { message: string } +/** Form logic with some requirements and constraints */ const accountSchema: Yup.SchemaOf<AccountLoginFormModel> = Yup.object({ model: Yup.object() .shape({ @@ -40,6 +43,8 @@ const AdminLogin: React.FC = () => { } setLoading(UILoading) }, [UIErrors, UILoading]) + + /** dispatch with the entered values */ const handleAccountSubmit = (values: AccountLoginFormModel, actions: FormikHelpers<AccountLoginFormModel>) => { dispatch(loginUser(values.model, history)) } @@ -48,6 +53,8 @@ const AdminLogin: React.FC = () => { const accountInitialValues: AccountLoginFormModel = { model: { email: '', password: '' }, } + + /** Render the form */ return ( <Formik initialValues={accountInitialValues} validationSchema={accountSchema} onSubmit={handleAccountSubmit}> {(formik) => ( @@ -55,6 +62,7 @@ const AdminLogin: React.FC = () => { <TextField label="Email Adress" name="model.email" + data-testid="email" helperText={formik.touched.model?.email ? formik.errors.model?.email : ''} error={Boolean(formik.touched.model?.email && formik.errors.model?.email)} onChange={formik.handleChange} @@ -65,6 +73,7 @@ const AdminLogin: React.FC = () => { label="Lösenord" name="model.password" type="password" + data-testid="password" helperText={formik.touched.model?.password ? formik.errors.model?.password : ''} error={Boolean(formik.touched.model?.password && formik.errors.model?.password)} onChange={formik.handleChange} @@ -73,6 +82,7 @@ const AdminLogin: React.FC = () => { /> <Button type="submit" + data-testid="submit" fullWidth variant="contained" color="secondary" @@ -82,7 +92,7 @@ const AdminLogin: React.FC = () => { </Button> {errors.message && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> + <AlertTitle>Något gick fel</AlertTitle> <Typography>Någonting gick fel. Kontrollera</Typography> <Typography>dina användaruppgifter och försök igen</Typography> </Alert> diff --git a/client/src/pages/login/components/CompetitionLogin.test.tsx b/client/src/pages/login/components/CompetitionLogin.test.tsx index 862880bc5006288dc76b022dc9ec171c607b13d5..5adbb7eb1f6ce179837d9f7ad3d319db47e4fd67 100644 --- a/client/src/pages/login/components/CompetitionLogin.test.tsx +++ b/client/src/pages/login/components/CompetitionLogin.test.tsx @@ -4,6 +4,8 @@ import { Provider } from 'react-redux' import store from '../../../store' import CompetitionLogin from './CompetitionLogin' +/** Test CompetitionLogin */ + it('renders competition login', () => { render( <Provider store={store}> diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index d89cafcf8216197de081d65550116e2e06b56f22..ae01ec36faa9feca3e1ea2522600674125cff47d 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -1,9 +1,10 @@ +/** Component that handles the log in when a user connects to a competition through a code */ + import { Button, TextField, Typography } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' -import axios from 'axios' -import { Formik, FormikHelpers } from 'formik' +import { Formik } from 'formik' +import React from 'react' import { useHistory } from 'react-router-dom' -import React, { useEffect, useState } from 'react' import * as Yup from 'yup' import { loginCompetition } from '../../../actions/competitionLogin' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -37,8 +38,9 @@ const CompetitionLogin: React.FC = () => { model: { code: '' }, } const handleCompetitionSubmit = async (values: CompetitionLoginFormModel) => { - dispatch(loginCompetition(values.model.code, history)) + dispatch(loginCompetition(values.model.code, history, true)) } + return ( <Formik initialValues={competitionInitialValues} @@ -59,11 +61,11 @@ const CompetitionLogin: React.FC = () => { <Button type="submit" fullWidth variant="contained" color="secondary" disabled={!formik.isValid}> Anslut till tävling </Button> - {errors && errors.message && ( + {errors && ( <Alert severity="error"> - <AlertTitle>Error</AlertTitle> - <Typography>En tävling med den koden hittades ej.</Typography> - <Typography>kontrollera koden och försök igen</Typography> + <AlertTitle>Något gick fel</AlertTitle> + <Typography>En tävling med den koden existerar ej.</Typography> + <Typography>Dubbelkolla koden och försök igen</Typography> </Alert> )} {loading && <CenteredCircularProgress color="secondary" />} diff --git a/client/src/pages/login/styled.tsx b/client/src/pages/login/styled.tsx index c071747e54a760e2972b8d36201d67780942e47a..73c75d541df22422a16853c6664434176aef3e59 100644 --- a/client/src/pages/login/styled.tsx +++ b/client/src/pages/login/styled.tsx @@ -2,6 +2,7 @@ import { Paper } from '@material-ui/core' import styled from 'styled-components' export const LoginPageContainer = styled.div` + padding-top: 5%; display: flex; justify-content: center; ` diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx index c645724e3c7fabda69a2ad2434f4bd7b587c8c2e..539d54e809926bc7a583ea06b00acddd10084f1e 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx @@ -13,22 +13,31 @@ it('renders presentation editor', () => { id: 0, year: 0, city_id: 0, - slides: [{ id: 5 }], + slides: [{ id: 5, order: 2 }], teams: [], }, } const citiesRes: any = { + data: [ + { + name: '', + city_id: 0, + }, + ], + } + const typesRes: any = { data: { - items: [ + view_types: [ { name: '', - city_id: 0, + id: 0, }, ], }, } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { if (path.startsWith('/api/competitions')) return Promise.resolve(competitionRes) + if (path.startsWith('/api/misc/types')) return Promise.resolve(typesRes) return Promise.resolve(citiesRes) }) render( diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index da18cd52fd6222b4a37c7a88bc111f0c4974ecc9..9b040b9db3a3b76e75ace7c6585c9a0c1dcca075 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -1,36 +1,38 @@ -import { Button, Checkbox, CircularProgress, Divider, Menu, MenuItem, Typography } from '@material-ui/core' -import AppBar from '@material-ui/core/AppBar' -import { CheckboxProps } from '@material-ui/core/Checkbox' +import { Button, ButtonGroup, CircularProgress, Divider, Menu, MenuItem } from '@material-ui/core' import CssBaseline from '@material-ui/core/CssBaseline' -import Drawer from '@material-ui/core/Drawer' import ListItemText from '@material-ui/core/ListItemText' -import { createStyles, makeStyles, Theme, withStyles } from '@material-ui/core/styles' import AddOutlinedIcon from '@material-ui/icons/AddOutlined' -import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' -import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' -import DnsOutlinedIcon from '@material-ui/icons/DnsOutlined' -import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' import axios from 'axios' import React, { useEffect, useState } from 'react' +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd' import { Link, useParams } from 'react-router-dom' import { getCities } from '../../actions/cities' -import { getEditorCompetition, setEditorSlideId } from '../../actions/editor' +import { getEditorCompetition, setEditorSlideId, setEditorViewId } from '../../actions/editor' import { getTypes } from '../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../hooks' import { RichSlide } from '../../interfaces/ApiRichModels' +import { renderSlideIcon } from '../../utils/renderSlideIcon' import { RemoveMenuItem } from '../admin/styledComp' -import { Content } from '../views/styled' +import { Content, InnerContent } from '../views/styled' import SettingsPanel from './components/SettingsPanel' -import SlideEditor from './components/SlideEditor' +import SlideDisplay from './components/SlideDisplay' import { + AppBarEditor, CenteredSpinnerContainer, + CompetitionName, + FillLeftContainer, + FillRightContainer, HomeIcon, + LeftDrawer, + PositionBottom, PresentationEditorContainer, + RightDrawer, + RightPanelScroll, SlideList, SlideListItem, ToolBarContainer, + ToolbarMargin, ViewButton, - ViewButtonGroup, } from './styled' const initialState = { @@ -42,70 +44,36 @@ const initialState = { const leftDrawerWidth = 150 const rightDrawerWidth = 390 -const useStyles = makeStyles((theme: Theme) => - createStyles({ - appBar: { - width: `calc(100% - ${rightDrawerWidth}px)`, - marginLeft: leftDrawerWidth, - marginRight: rightDrawerWidth, - }, - leftDrawer: { - width: leftDrawerWidth, - flexShrink: 0, - position: 'relative', - zIndex: 1, - }, - rightDrawer: { - width: rightDrawerWidth, - flexShrink: 0, - }, - leftDrawerPaper: { - width: leftDrawerWidth, - }, - rightDrawerPaper: { - width: rightDrawerWidth, - background: '#EAEAEA', - }, - // necessary for content to be below app bar - toolbar: theme.mixins.toolbar, - content: { - flexGrow: 1, - backgroundColor: theme.palette.background.default, - padding: theme.spacing(3), - }, - alignCheckboxText: { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - paddingRight: 20, - }, - }) -) - interface CompetitionParams { - id: string + competitionId: string } const PresentationEditorPage: React.FC = () => { - const classes = useStyles() - const { id }: CompetitionParams = useParams() + const { competitionId }: CompetitionParams = useParams() const dispatch = useAppDispatch() + const [sortedSlides, setSortedSlides] = useState<RichSlide[]>([]) const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const competition = useAppSelector((state) => state.editor.competition) const competitionLoading = useAppSelector((state) => state.editor.loading) useEffect(() => { - dispatch(getEditorCompetition(id)) - dispatch(getCities()) dispatch(getTypes()) + dispatch(getEditorCompetition(competitionId)) + dispatch(getCities()) }, []) + useEffect(() => { + setSortedSlides(competition.slides.sort((a, b) => (a.order > b.order ? 1 : -1))) + }, [competition]) + const setActiveSlideId = (id: number) => { dispatch(setEditorSlideId(id)) + dispatch(getEditorCompetition(competitionId)) } const createNewSlide = async () => { - await axios.post(`/api/competitions/${id}/slides`, { title: 'new slide' }) - dispatch(getEditorCompetition(id)) + await axios.post(`/api/competitions/${competitionId}/slides`, { title: 'Ny sida' }) + dispatch(getEditorCompetition(competitionId)) } const [contextState, setContextState] = React.useState<{ @@ -128,128 +96,136 @@ const PresentationEditorPage: React.FC = () => { } const handleRemoveSlide = async () => { - await axios.delete(`/api/competitions/${id}/slides/${contextState.slideId}`) - dispatch(getEditorCompetition(id)) + await axios.delete(`/api/competitions/${competitionId}/slides/${contextState.slideId}`) + dispatch(getEditorCompetition(competitionId)) setContextState(initialState) } const handleDuplicateSlide = async () => { - await axios.post(`/api/competitions/${id}/slides/${contextState.slideId}/copy`) - dispatch(getEditorCompetition(id)) + await axios.post(`/api/competitions/${competitionId}/slides/${contextState.slideId}/copy`) + dispatch(getEditorCompetition(competitionId)) setContextState(initialState) } - const renderSlideIcon = (slide: RichSlide) => { - if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { - switch (slide.questions[0].type_id) { - case 1: - return <CreateOutlinedIcon /> // text question - case 2: - return <BuildOutlinedIcon /> // practical qustion - case 3: - return <DnsOutlinedIcon /> // multiple choice question - } - } else { - return <InfoOutlinedIcon /> // information slide + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const [activeViewTypeName, setActiveViewTypeName] = useState('Audience') + const changeView = (clickedViewTypeName: string) => { + setActiveViewTypeName(clickedViewTypeName) + const clickedViewTypeId = viewTypes.find((viewType) => viewType.name === clickedViewTypeName)?.id + if (clickedViewTypeId) { + dispatch(setEditorViewId(clickedViewTypeId)) } + dispatch(getEditorCompetition(competitionId)) } - const GreenCheckbox = withStyles({ - root: { - color: '#FFFFFF', - '&$checked': { - color: '#FFFFFF', - }, - }, - checked: {}, - })((props: CheckboxProps) => <Checkbox color="default" {...props} />) - const [checkbox, setCheckbox] = useState(false) - + const onDragEnd = async (result: DropResult) => { + // dropped outside the list or same place + if (!result.destination || result.destination.index === result.source.index) { + return + } + const draggedIndex = result.source.index + const draggedSlideId = sortedSlides[draggedIndex].id + const slidesCopy = [...sortedSlides] + const [removed] = slidesCopy.splice(draggedIndex, 1) + slidesCopy.splice(result.destination.index, 0, removed) + setSortedSlides(slidesCopy) + if (draggedSlideId) { + await axios + .put(`/api/competitions/${competitionId}/slides/${draggedSlideId}`, { order: result.destination.index }) + .catch(console.log) + } + } return ( <PresentationEditorContainer> <CssBaseline /> - <AppBar position="fixed" className={classes.appBar}> + <AppBarEditor $leftDrawerWidth={leftDrawerWidth} $rightDrawerWidth={rightDrawerWidth} position="fixed"> <ToolBarContainer> - <Button component={Link} to="/admin/tävlingshanterare" style={{ padding: 0 }}> + <Button component={Link} to="/admin/competition-manager" style={{ padding: 0 }}> <HomeIcon src="/t8.png" /> </Button> - <Typography variant="h6" noWrap> + <CompetitionName variant="h5" noWrap> {competition.name} - </Typography> - - <ViewButtonGroup> - <GreenCheckbox checked={checkbox} onChange={(event) => setCheckbox(event.target.checked)} /> - <Typography className={classes.alignCheckboxText} variant="button"> - Applicera ändringar på samtliga vyer - </Typography> - <ViewButton variant="contained" color="secondary"> + </CompetitionName> + + <ButtonGroup color="secondary" variant="contained"> + <ViewButton + $activeView={activeViewTypeName === 'Audience'} + color="secondary" + onClick={() => changeView('Audience')} + > Åskådarvy </ViewButton> - <ViewButton variant="contained" color="secondary"> + <ViewButton + $activeView={activeViewTypeName === 'Team'} + color="secondary" + onClick={() => changeView('Team')} + > Deltagarvy </ViewButton> - <ViewButton variant="contained" color="secondary"> - Domarvy - </ViewButton> - </ViewButtonGroup> + </ButtonGroup> </ToolBarContainer> - </AppBar> - <Drawer - className={classes.leftDrawer} - variant="permanent" - classes={{ - paper: classes.leftDrawerPaper, - }} - anchor="left" - > - <div className={classes.toolbar} /> - <Divider /> - <SlideList> - <div> - {competition.slides && - competition.slides.map((slide) => ( - <SlideListItem - divider - button - key={slide.id} - selected={slide.id === activeSlideId} - onClick={() => setActiveSlideId(slide.id)} - onContextMenu={(event) => handleRightClick(event, slide.id)} - > - {renderSlideIcon(slide)} - <ListItemText primary={`Sida ${slide.order + 1}`} /> - </SlideListItem> - ))} - </div> - <div> + </AppBarEditor> + <LeftDrawer $leftDrawerWidth={leftDrawerWidth} $rightDrawerWidth={undefined} variant="permanent" anchor="left"> + <FillLeftContainer $leftDrawerWidth={leftDrawerWidth} $rightDrawerWidth={undefined}> + <ToolbarMargin /> + <SlideList> + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="droppable"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {sortedSlides.map((slide, index) => ( + <Draggable key={slide.id} draggableId={slide.id.toString()} index={index}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <SlideListItem + divider + key={slide.order} + button + selected={slide.id === activeSlideId} + onClick={() => setActiveSlideId(slide.id)} + onContextMenu={(event) => handleRightClick(event, slide.id)} + > + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> + </SlideListItem> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </DragDropContext> + </SlideList> + <PositionBottom> <Divider /> <SlideListItem divider button onClick={() => createNewSlide()}> <ListItemText primary="Ny sida" /> <AddOutlinedIcon /> </SlideListItem> - </div> - </SlideList> - </Drawer> - <div className={classes.toolbar} /> - <Drawer - className={classes.rightDrawer} - variant="permanent" - classes={{ - paper: classes.rightDrawerPaper, - }} - anchor="right" - > - {!competitionLoading ? ( - <SettingsPanel /> - ) : ( - <CenteredSpinnerContainer> - <CircularProgress /> - </CenteredSpinnerContainer> - )} - </Drawer> + </PositionBottom> + </FillLeftContainer> + </LeftDrawer> + <ToolbarMargin /> + <RightDrawer $leftDrawerWidth={undefined} $rightDrawerWidth={rightDrawerWidth} variant="permanent" anchor="right"> + <FillRightContainer $leftDrawerWidth={undefined} $rightDrawerWidth={rightDrawerWidth}> + <RightPanelScroll> + {!competitionLoading ? ( + <SettingsPanel /> + ) : ( + <CenteredSpinnerContainer> + <CircularProgress /> + </CenteredSpinnerContainer> + )} + </RightPanelScroll> + </FillRightContainer> + </RightDrawer> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> - <SlideEditor /> + <InnerContent> + <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} /> + </InnerContent> </Content> <Menu keepMounted diff --git a/client/src/pages/presentationEditor/components/Alternatives.tsx b/client/src/pages/presentationEditor/components/Alternatives.tsx deleted file mode 100644 index e699d003f7f3433e2caf25a26185ad5141c6f694..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/Alternatives.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Checkbox, ListItem, ListItemText, withStyles } from '@material-ui/core' -import { CheckboxProps } from '@material-ui/core/Checkbox' -import { green, grey } from '@material-ui/core/colors' -import CloseIcon from '@material-ui/icons/Close' -import axios from 'axios' -import React from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch, useAppSelector } from '../../../hooks' -import { QuestionAlternative } from '../../../interfaces/ApiModels' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { AddButton, Center, Clickable, SettingsList, TextInput, WhiteBackground } from './styled' - -type AlternativeProps = { - activeSlide: RichSlide - competitionId: string -} - -const Alternatives = ({ activeSlide, competitionId }: AlternativeProps) => { - const dispatch = useAppDispatch() - const competition = useAppSelector((state) => state.editor.competition) - const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) - const GreenCheckbox = withStyles({ - root: { - color: grey[900], - '&$checked': { - color: green[600], - }, - }, - checked: {}, - })((props: CheckboxProps) => <Checkbox color="default" {...props} />) - - const numberToBool = (num: number) => { - if (num === 0) return false - else return true - } - - const updateAlternativeValue = async (alternative: QuestionAlternative) => { - if (activeSlide && activeSlide.questions[0]) { - let newValue: number - if (alternative.value === 0) { - newValue = 1 - } else newValue = 0 - await axios - .put( - `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, - { value: newValue } - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - - const updateAlternativeText = async (alternative_id: number, newText: string) => { - if (activeSlide && activeSlide.questions[0]) { - await axios - .put( - `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, - { text: newText } - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - - const addAlternative = async () => { - if (activeSlide && activeSlide.questions[0]) { - await axios - .post( - `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, - { text: '', value: 0 } - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - - const handleCloseAnswerClick = async (alternative_id: number) => { - if (activeSlide && activeSlide.questions[0]) { - await axios - .delete( - `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - - return ( - <SettingsList> - <WhiteBackground> - <ListItem divider> - <Center> - <ListItemText - primary="Svarsalternativ" - secondary="(Fyll i rutan höger om textfältet för att markera korrekt svar)" - /> - </Center> - </ListItem> - {activeSlide && - activeSlide.questions[0] && - activeSlide.questions[0].alternatives && - activeSlide.questions[0].alternatives.map((alt) => ( - <div key={alt.id}> - <ListItem divider> - <TextInput - id="outlined-basic" - defaultValue={alt.text} - onChange={(event) => updateAlternativeText(alt.id, event.target.value)} - variant="outlined" - /> - <GreenCheckbox checked={numberToBool(alt.value)} onChange={() => updateAlternativeValue(alt)} /> - <Clickable> - <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> - </Clickable> - </ListItem> - </div> - ))} - <ListItem button onClick={addAlternative}> - <Center> - <AddButton variant="button">Lägg till svarsalternativ</AddButton> - </Center> - </ListItem> - </WhiteBackground> - </SettingsList> - ) -} - -export default Alternatives diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.test.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17705308993ad89a8f7175549e9af3edd82e8010 --- /dev/null +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.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 BackgroundImageSelect from './BackgroundImageSelect' + +it('renders background image select', () => { + render( + <BrowserRouter> + <Provider store={store}> + <BackgroundImageSelect variant="competition" /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14dbaa63ed2cc267a080264ebe3d7abd243220ba --- /dev/null +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx @@ -0,0 +1,126 @@ +import { ListItem, ListItemText, Typography } from '@material-ui/core' +import React, { useState } from 'react' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { + AddButton, + AddBackgroundButton, + Center, + HiddenInput, + ImportedImage, + SettingsList, + ImageNameText, + ImageTextContainer, +} from './styled' +import CloseIcon from '@material-ui/icons/Close' +import axios from 'axios' +import { Media } from '../../../interfaces/ApiModels' +import { getEditorCompetition } from '../../../actions/editor' +import { uploadFile } from '../../../utils/uploadImage' + +type BackgroundImageSelectProps = { + variant: 'competition' | 'slide' +} + +const BackgroundImageSelect = ({ variant }: BackgroundImageSelectProps) => { + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const backgroundImage = useAppSelector((state) => { + if (variant === 'competition') return state.editor.competition.background_image + else return state.editor.competition.slides.find((slide) => slide.id === activeSlideId)?.background_image + }) + const competitionId = useAppSelector((state) => state.editor.competition.id) + const dispatch = useAppDispatch() + + const updateBackgroundImage = async (mediaId: number) => { + // Creates a new image component on the database using API call. + if (variant === 'competition') { + await axios + .put(`/api/competitions/${competitionId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } else { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const removeBackgroundImage = async () => { + // Removes background image media and from competition using API calls. + await axios.delete(`/api/media/images/${backgroundImage?.id}`).catch(console.log) + if (variant === 'competition') { + await axios + .put(`/api/competitions/${competitionId}`, { background_image_id: null }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } else { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: null }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { + // Reads the selected image file and uploads it to the server. + // Creates a new image component containing the file. + if (e.target.files !== null && e.target.files[0]) { + const files = Array.from(e.target.files) + const file = files[0] + const formData = new FormData() + formData.append('image', file) + const media = await uploadFile(formData, competitionId.toString()) + if (media) { + updateBackgroundImage(media.id) + } + } + } + + return ( + <SettingsList> + {!backgroundImage && ( + <ListItem button style={{ padding: 0 }}> + <HiddenInput + accept="image/*" + id="background-button-file" + multiple + type="file" + onChange={handleFileSelected} + /> + <AddBackgroundButton htmlFor="background-button-file"> + <Center> + <AddButton variant="button">Välj bakgrundsbild...</AddButton> + </Center> + </AddBackgroundButton> + </ListItem> + )} + {backgroundImage && ( + <> + <ListItem divider> + <ImageTextContainer> + <ListItemText>Bakgrundsbild</ListItemText> + <Typography variant="body2">(Bilden bör ha sidförhållande 16:9)</Typography> + </ImageTextContainer> + </ListItem> + <ListItem divider button> + <ImportedImage src={`/static/images/thumbnail_${backgroundImage.filename}`} /> + <Center> + <ImageNameText primary={backgroundImage.filename} /> + </Center> + <CloseIcon onClick={removeBackgroundImage} /> + </ListItem> + </> + )} + </SettingsList> + ) +} + +export default BackgroundImageSelect diff --git a/client/src/pages/presentationEditor/components/CheckboxComponent.tsx b/client/src/pages/presentationEditor/components/CheckboxComponent.tsx deleted file mode 100644 index cb264712b0b4b57ef7938278471bc812085370cf..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/CheckboxComponent.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Checkbox } from '@material-ui/core' -import React, { useState } from 'react' -import { Rnd } from 'react-rnd' -import { Component } from '../../../interfaces/ApiModels' -import { Position } from '../../../interfaces/Components' - -type CheckboxComponentProps = { - component: Component -} - -const CheckboxComponent = ({ component }: CheckboxComponentProps) => { - const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) - return ( - <Rnd - bounds="parent" - onDragStop={(e, d) => { - setCurrentPos({ x: d.x, y: d.y }) - }} - position={{ x: currentPos.x, y: currentPos.y }} - > - <Checkbox - disableRipple - style={{ - transform: 'scale(3)', - }} - /> - </Rnd> - ) -} - -export default CheckboxComponent diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx index 53c2ecdb4dd23cc81df0140bd2bede66dfe18b88..64b4a820bec7000e9fe6f87b4c1fff5a2fd81f5f 100644 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx +++ b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx @@ -1,99 +1,47 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Divider, - FormControl, - InputLabel, - List, - ListItem, - ListItemText, - MenuItem, - Select, - TextField, - Typography, -} from '@material-ui/core' -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' -import CloseIcon from '@material-ui/icons/Close' +import { Divider, FormControl, InputLabel, ListItem, MenuItem, Select, TextField, Typography } from '@material-ui/core' import axios from 'axios' import React, { useState } from 'react' import { useParams } from 'react-router-dom' import { getEditorCompetition } from '../../../actions/editor' import { useAppDispatch, useAppSelector } from '../../../hooks' import { City } from '../../../interfaces/ApiModels' - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - textInputContainer: { - '& > *': { - margin: theme.spacing(1), - width: '100%', - background: 'white', - }, - }, - textInput: { - margin: theme.spacing(2), - width: '87%', - background: 'white', - }, - textCenter: { - textAlign: 'center', - }, - center: { - display: 'flex', - justifyContent: 'center', - background: 'white', - }, - importedImage: { - width: 70, - height: 50, - background: 'white', - }, - dropDown: { - margin: theme.spacing(2), - width: '87%', - background: 'white', - }, - addButtons: { - padding: 5, - }, - panelList: { - padding: 0, - }, - }) -) +import BackgroundImageSelect from './BackgroundImageSelect' +import { FirstItem, PanelContainer, SettingsList } from './styled' +import Teams from './Teams' interface CompetitionParams { - id: string + competitionId: string } const CompetitionSettings: React.FC = () => { - const classes = useStyles() - const { id }: CompetitionParams = useParams() + const { competitionId }: CompetitionParams = useParams() + const [nameErrorText, setNameErrorText] = useState<string | undefined>(undefined) const dispatch = useAppDispatch() const competition = useAppSelector((state) => state.editor.competition) + const cities = useAppSelector((state) => state.cities.cities) + const updateCompetitionName = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { await axios - .put(`/api/competitions/${id}`, { name: event.target.value }) + .put(`/api/competitions/${competitionId}`, { name: event.target.value }) .then(() => { - dispatch(getEditorCompetition(id)) + setNameErrorText(undefined) + dispatch(getEditorCompetition(competitionId)) + }) + .catch((response) => { + if (response?.response.status === 409) setNameErrorText('Det finns redan en tävling med det namnet.') }) - .catch(console.log) } - const cities = useAppSelector((state) => state.cities.cities) const updateCompetitionCity = async (city: City) => { await axios - .put(`/api/competitions/${id}`, { city_id: city.id }) + .put(`/api/competitions/${competitionId}`, { city_id: city.id }) .then(() => { - dispatch(getEditorCompetition(id)) + dispatch(getEditorCompetition(competitionId)) }) .catch(console.log) } + /* Finds the right city object from a city name */ const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { cities.forEach((city) => { if (event.target.value === city.name) { @@ -102,109 +50,47 @@ const CompetitionSettings: React.FC = () => { }) } - const removeTeam = async (tid: number) => { - await axios - .delete(`/api/competitions/${id}/teams/${tid}`) - .then(() => { - dispatch(getEditorCompetition(id)) - }) - .catch(console.log) - } - const addTeam = async () => { - setAddTeamOpen(false) - await axios - .post(`/api/competitions/${id}/teams`, { name: selectedTeamName }) - .then(() => { - dispatch(getEditorCompetition(id)) - }) - .catch(console.log) - } - // For "add team" dialog - const [addTeamOpen, setAddTeamOpen] = useState(false) - const openAddTeam = () => { - setAddTeamOpen(true) - } - const closeAddTeam = () => { - setAddTeamOpen(false) - } - let selectedTeamName = '' - const updateSelectedTeamName = (event: React.ChangeEvent<{ value: string }>) => { - selectedTeamName = event.target.value - } - return ( - <div className={classes.textInputContainer}> - <form noValidate autoComplete="off"> - <TextField - className={classes.textInput} - id="outlined-basic" - label={'Tävlingsnamn'} - defaultValue={competition.name} - onChange={updateCompetitionName} - variant="outlined" - /> + <PanelContainer> + <SettingsList> + <FirstItem> + <ListItem> + <TextField + error={Boolean(nameErrorText)} + helperText={nameErrorText} + id="outlined-basic" + label={'Tävlingsnamn'} + defaultValue={competition.name} + onChange={updateCompetitionName} + variant="outlined" + fullWidth={true} + /> + </ListItem> + </FirstItem> <Divider /> - <FormControl variant="outlined" className={classes.dropDown}> - <InputLabel>Region</InputLabel> - <Select - value={cities.find((city) => city.id === competition.city_id)?.name || ''} - label="Region" - onChange={handleChange} - > - {cities.map((city) => ( - <MenuItem value={city.name} key={city.name}> - <Button>{city.name}</Button> - </MenuItem> - ))} - </Select> - </FormControl> - </form> - <List className={classes.panelList}> <ListItem> - <ListItemText className={classes.textCenter} primary="Lag" /> + <FormControl fullWidth variant="outlined"> + <InputLabel>Region</InputLabel> + <Select + value={cities.find((city) => city.id === competition.city_id)?.name || ''} + label="Region" + onChange={handleChange} + > + {cities.map((city) => ( + <MenuItem value={city.name} key={city.name}> + <Typography variant="button">{city.name}</Typography> + </MenuItem> + ))} + </Select> + </FormControl> </ListItem> - {competition.teams && - competition.teams.map((team) => ( - <div key={team.id}> - <ListItem divider button> - <ListItemText primary={team.name} /> - <CloseIcon onClick={() => removeTeam(team.id)} /> - </ListItem> - </div> - ))} + </SettingsList> - <ListItem className={classes.center} button onClick={openAddTeam}> - <Typography className={classes.addButtons} variant="button"> - Lägg till lag - </Typography> - </ListItem> - <Dialog open={addTeamOpen} onClose={closeAddTeam}> - <DialogTitle className={classes.center}>Lägg till lag</DialogTitle> - <DialogContent> - <DialogContentText>Skriv namnet på laget och klicka sedan på bekräfta.</DialogContentText> - <TextField autoFocus margin="dense" label="Lagnamn" fullWidth onChange={updateSelectedTeamName} /> - </DialogContent> - <DialogActions> - <Button onClick={closeAddTeam} color="secondary"> - Avbryt - </Button> - <Button onClick={addTeam} color="primary"> - Bekräfta - </Button> - </DialogActions> - </Dialog> - </List> + <Teams competitionId={competitionId} /> - <ListItem button> - <img - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - className={classes.importedImage} - /> - <ListItemText className={classes.textCenter}>Välj bakgrundsbild ...</ListItemText> - </ListItem> - </div> + <BackgroundImageSelect variant="competition" /> + </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx index 9e78f8e13fd94cf36434ef63ed7504695208de1c..c555f7f380713132bbc5ff7d5cf1c9ef30232f2d 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.test.tsx @@ -5,7 +5,17 @@ import ImageComponentDisplay from './ImageComponentDisplay' it('renders competition settings', () => { render( <ImageComponentDisplay - component={{ id: 0, x: 0, y: 0, w: 0, h: 0, data: { media_id: 0, filename: '' }, type_id: 2 }} + component={{ + id: 0, + x: 0, + y: 0, + w: 0, + h: 0, + media: { id: 0, mediatype_id: 0, user_id: 0, filename: '' }, + type_id: 2, + view_type_id: 1, + slide_id: 2, + }} width={0} height={0} /> diff --git a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx index 7886a9b15899dfe4b790be9d3673591b4d1940d7..273748f38cd88c3302fe946f3075593b06b168d1 100644 --- a/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx +++ b/client/src/pages/presentationEditor/components/ImageComponentDisplay.tsx @@ -10,9 +10,11 @@ type ImageComponentProps = { const ImageComponentDisplay = ({ component, width, height }: ImageComponentProps) => { return ( <img - src={`http://localhost:5000/static/images/${component.data.filename}`} + src={`http://localhost:5000/static/images/${component.media?.filename}`} height={height} width={width} + // Make sure the border looks good all around the image + style={{ paddingRight: 2, paddingBottom: 2 }} draggable={false} /> ) diff --git a/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75690ea67b83e4f25663af4859ee96a6f71909a6 --- /dev/null +++ b/client/src/pages/presentationEditor/components/QuestionComponentDisplay.tsx @@ -0,0 +1,118 @@ +/** + * What it is: + * This file contains the question component function which returns the question component. + * This component is used for displaying a question component for the participants with correct type of answer alternatives to interact with + * (see the function getAlternatives). + * + * How it's used: + * This file is used when a question component is to be displayed which happens in RndComponent.tsx for rendering in the editor + * and PresentationComponent.tsx for rendering in the presentation (the actual competition). + * + * @module + */ + +import { AppBar, Card, Divider, Typography } from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import AnswerMatch from './answerComponents/AnswerMatch' +import AnswerMultiple from './answerComponents/AnswerMultiple' +import AnswerSingle from './answerComponents/AnswerSingle' +import AnswerText from './answerComponents/AnswerText' +import { Center } from './styled' + +type QuestionComponentProps = { + variant: 'editor' | 'presentation' + currentSlideId?: number +} + +const QuestionComponentDisplay = ({ variant, currentSlideId }: QuestionComponentProps) => { + const activeSlide = useAppSelector((state) => { + if (variant === 'presentation' && currentSlideId) + return state.presentation.competition.slides.find((slide) => slide.id === currentSlideId) + if (variant === 'editor') + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId) + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId) + }) + + const timer = activeSlide?.timer + const total_score = activeSlide?.questions[0].total_score + const questionName = activeSlide?.questions[0].name + + const questionTypeId = activeSlide?.questions[0].type_id + const questionTypeName = useAppSelector( + (state) => state.types.questionTypes.find((qType) => qType.id === questionTypeId)?.name + ) + + /** + * This function is used for displaying the correct answer alternatives which is the part of the question component + * which the participants will interact with to submit their answers. + */ + const getAlternatives = () => { + switch (questionTypeName) { + case 'Text': + if (activeSlide) { + return <AnswerText activeSlide={activeSlide} competitionId={activeSlide.competition_id.toString()} /> + } + return + + case 'Practical': + return + + case 'Multiple': + if (activeSlide) { + return ( + <AnswerMultiple + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + + case 'Single': + if (activeSlide) { + return ( + <AnswerSingle + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + case 'Match': + if (activeSlide) { + return ( + <AnswerMatch + variant={variant} + activeSlide={activeSlide} + competitionId={activeSlide.competition_id.toString()} + /> + ) + } + return + default: + break + } + } + + return ( + <Card elevation={4} style={{ maxHeight: '100%', overflowY: 'auto' }}> + <AppBar position="relative"> + <div style={{ display: 'flex', height: 60 }}> + <Center style={{ alignItems: 'center' }}> + <Typography variant="h5">{questionName}</Typography> + </Center> + </div> + <div style={{ position: 'fixed', right: 5, top: 14, display: 'flex', alignItems: 'center' }}> + <Typography variant="h5">{total_score}p</Typography> + </div> + </AppBar> + <Divider /> + {getAlternatives()} + </Card> + ) +} + +export default QuestionComponentDisplay diff --git a/client/src/pages/presentationEditor/components/RndComponent.test.tsx b/client/src/pages/presentationEditor/components/RndComponent.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b024ab3f14d82f8fb55bf336938a2fa17f911b15 --- /dev/null +++ b/client/src/pages/presentationEditor/components/RndComponent.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import { Component } from '../../../interfaces/ApiModels' +import store from '../../../store' +import RndComponent from './RndComponent' + +it('renders rnd component', () => { + render( + <BrowserRouter> + <Provider store={store}> + <RndComponent component={{ id: 2, x: 0, w: 15, h: 15 } as Component} width={50} height={50} scale={123} /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index 02344a36aa223b245fe7170c9186ffa8b509e6dc..3b40ad74dd654e79193cfb6a6723c416f4226048 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -1,24 +1,46 @@ +import { Card, IconButton, Menu, MenuItem, Tooltip } from '@material-ui/core' import axios from 'axios' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Rnd } from 'react-rnd' +import { getEditorCompetition } from '../../../actions/editor' import { ComponentTypes } from '../../../enum/ComponentTypes' -import { useAppSelector } from '../../../hooks' +import { useAppDispatch, useAppSelector } from '../../../hooks' import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' import { Position, Size } from '../../../interfaces/Components' -import CheckboxComponent from './CheckboxComponent' +import { RemoveMenuItem } from '../../admin/styledComp' import ImageComponentDisplay from './ImageComponentDisplay' -import { TextComponentContainer } from './styled' +import QuestionComponentDisplay from './QuestionComponentDisplay' +import { HoverContainer } from './styled' +import TextComponentDisplay from './TextComponentDisplay' +//import NestedMenuItem from 'material-ui-nested-menu-item' -type ImageComponentProps = { +type RndComponentProps = { component: Component + width: number + height: number + scale: number } -const RndComponent = ({ component }: ImageComponentProps) => { +const initialMenuState = { menuIsOpen: false, mouseX: null, mouseY: null, componentId: null } + +const RndComponent = ({ component, width, height, scale }: RndComponentProps) => { const [hover, setHover] = useState(false) const [currentPos, setCurrentPos] = useState<Position>({ x: component.x, y: component.y }) const [currentSize, setCurrentSize] = useState<Size>({ w: component.w, h: component.h }) const competitionId = useAppSelector((state) => state.editor.competition.id) const slideId = useAppSelector((state) => state.editor.activeSlideId) + const [shiftPressed, setShiftPressed] = useState(false) + const typeName = useAppSelector( + (state) => state.types.componentTypes.find((componentType) => componentType.id === component.type_id)?.name + ) + const [menuState, setMenuState] = useState<{ + menuIsOpen: boolean + mouseX: null | number + mouseY: null | number + componentId: null | number + }>(initialMenuState) + const dispatch = useAppDispatch() + const handleUpdatePos = (pos: Position) => { axios.put(`/api/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { x: pos.x, @@ -31,28 +53,85 @@ const RndComponent = ({ component }: ImageComponentProps) => { h: size.h, }) } + const handleCenterHorizontal = () => { + const centerX = width / (2 * scale) - currentSize.w / 2 + setCurrentPos({ x: centerX, y: currentPos.y }) + handleUpdatePos({ x: centerX, y: currentPos.y }) + } + const handleCenterVertical = () => { + const centerY = height / (2 * scale) - currentSize.h / 2 + setCurrentPos({ x: currentPos.x, y: centerY }) + handleUpdatePos({ x: currentPos.x, y: centerY }) + } + const handleRightClick = (event: React.MouseEvent<HTMLDivElement>, componentId: number) => { + event.preventDefault() + setMenuState({ + menuIsOpen: true, + mouseX: event.clientX - 2, + mouseY: event.clientY - 4, + componentId: componentId, + }) + } + const handleCloseMenu = () => { + setMenuState(initialMenuState) + } + const handleDuplicateComponent = async (viewTypeId: number) => { + console.log('Duplicate') + await axios + .post( + `/api/competitions/${competitionId}/slides/${slideId}/components/${menuState.componentId}/copy/${viewTypeId}` + ) + .then(() => dispatch(getEditorCompetition(competitionId.toString()))) + .catch(console.log) + setMenuState(initialMenuState) + } + const handleRemoveComponent = async () => { + console.log('Remove') + await axios + .delete(`/api/competitions/${competitionId}/slides/${slideId}/components/${menuState.componentId}`) + .then(() => dispatch(getEditorCompetition(competitionId.toString()))) + .catch(console.log) + setMenuState(initialMenuState) + } + + useEffect(() => { + const downHandler = (ev: KeyboardEvent) => { + if (ev.key === 'Shift') setShiftPressed(true) + } + const upHandler = (ev: KeyboardEvent) => { + if (ev.key === 'Shift') setShiftPressed(false) + } + window.addEventListener('keydown', downHandler) + window.addEventListener('keyup', upHandler) + return () => { + window.removeEventListener('keydown', downHandler) + window.removeEventListener('keyup', upHandler) + } + }, []) const renderInnerComponent = () => { switch (component.type_id) { - case ComponentTypes.Checkbox: - return <CheckboxComponent key={component.id} component={component} /> case ComponentTypes.Text: return ( - <TextComponentContainer - hover={hover} - dangerouslySetInnerHTML={{ - __html: `<div style="font-size: 24px;"> ${(component as TextComponent).data.text} </div>`, - }} - /> + <HoverContainer hover={hover}> + <TextComponentDisplay component={component as TextComponent} scale={scale} /> + </HoverContainer> ) case ComponentTypes.Image: return ( - <ImageComponentDisplay - key={component.id} - component={component as ImageComponent} - width={currentSize.w} - height={currentSize.h} - /> + <HoverContainer hover={hover}> + <ImageComponentDisplay + height={currentSize.h * scale} + width={currentSize.w * scale} + component={component as ImageComponent} + /> + </HoverContainer> + ) + case ComponentTypes.Question: + return ( + <HoverContainer hover={hover}> + <QuestionComponentDisplay variant="editor" /> + </HoverContainer> ) default: break @@ -61,34 +140,65 @@ const RndComponent = ({ component }: ImageComponentProps) => { return ( <Rnd - minWidth={50} - minHeight={50} + minWidth={75 * scale} + minHeight={75 * scale} bounds="parent" onDragStop={(e, d) => { - setCurrentPos({ x: d.x, y: d.y }) - handleUpdatePos(d) + //Have to divide by scale since d is position on current screen + setCurrentPos({ x: d.x / scale, y: d.y / scale }) + handleUpdatePos({ x: d.x / scale, y: d.y / scale }) }} + //Makes text appear on images + style={{ zIndex: typeName === 'Text' ? 2 : 1 }} + lockAspectRatio={shiftPressed} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} - size={{ width: currentSize.w, height: currentSize.h }} - position={{ x: currentPos.x, y: currentPos.y }} + //Right click to open menu + onContextMenu={(event: React.MouseEvent<HTMLDivElement>) => handleRightClick(event, component.id)} + //Multiply by scale to show components correctly for current screen size + size={{ width: currentSize.w * scale, height: currentSize.h * scale }} + position={{ x: currentPos.x * scale, y: currentPos.y * scale }} onResizeStop={(e, direction, ref, delta, position) => { - setCurrentSize({ - w: ref.offsetWidth, - h: ref.offsetHeight, - }) - setCurrentPos(position) - handleUpdateSize({ w: ref.offsetWidth, h: ref.offsetHeight }) - handleUpdatePos(position) + handleUpdateSize({ w: currentSize.w, h: currentSize.h }) + handleUpdatePos({ x: currentPos.x, y: currentPos.y }) }} - onResize={(e, direction, ref, delta, position) => + onResize={(e, direction, ref, delta, position) => { + //Have to divide by scale since ref has position on current screen setCurrentSize({ - w: ref.offsetWidth, - h: ref.offsetHeight, + w: ref.offsetWidth / scale, + h: ref.offsetHeight / scale, }) - } + setCurrentPos({ x: position.x / scale, y: position.y / scale }) + }} > + {hover && ( + <Card elevation={6} style={{ position: 'absolute', zIndex: 10 }}> + <Tooltip title="Centrera horisontellt"> + <IconButton onClick={handleCenterHorizontal}>X</IconButton> + </Tooltip> + <Tooltip title="Centrera Vertikalt"> + <IconButton onClick={handleCenterVertical}>Y</IconButton> + </Tooltip> + </Card> + )} {renderInnerComponent()} + <Menu + keepMounted + open={menuState.menuIsOpen} + onClose={handleCloseMenu} + anchorReference="anchorPosition" + anchorPosition={ + menuState.mouseY !== null && menuState.mouseX !== null + ? { top: menuState.mouseY, left: menuState.mouseX } + : undefined + } + > + {/* <NestedMenuItem label="Duplicera"> */} + <MenuItem onClick={() => handleDuplicateComponent(3)}>Duplicera till åskådarvy</MenuItem> + <MenuItem onClick={() => handleDuplicateComponent(1)}>Duplicera till deltagarvy</MenuItem> + {/* </NestedMenuItem> */} + <RemoveMenuItem onClick={handleRemoveComponent}>Ta bort</RemoveMenuItem> + </Menu> </Rnd> ) } diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..05c87d301047b49ac6ba715072f2f245647a3c8a --- /dev/null +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -0,0 +1,118 @@ +import { Card, Typography } from '@material-ui/core' +import TimerIcon from '@material-ui/icons/Timer' +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { getTypes } from '../../../actions/typesAction' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import PresentationComponent from '../../views/components/PresentationComponent' +import Timer from '../../views/components/Timer' +import RndComponent from './RndComponent' +import { Center, SlideDisplayText, SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' + +type SlideDisplayProps = { + //Prop to distinguish between editor and active competition + variant: 'editor' | 'presentation' + activeViewTypeId: number + //Can be used to force what slide it it's displaying (currently used in judge view) + currentSlideId?: number +} + +const SlideDisplay = ({ variant, activeViewTypeId, currentSlideId }: SlideDisplayProps) => { + const slide = useAppSelector((state) => { + if (currentSlideId && variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === currentSlideId) + if (variant === 'editor') + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId) + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId) + }) + const totalSlides = useAppSelector((state) => { + if (variant === 'presentation') return state.presentation.competition.slides.length + return state.editor.competition.slides.length + }) + const components = slide?.components + const competitionBackgroundImage = useAppSelector((state) => { + if (variant === 'editor') return state.editor.competition.background_image + return state.presentation.competition.background_image + }) + + const slideBackgroundImage = slide?.background_image + const dispatch = useAppDispatch() + const editorPaperRef = useRef<HTMLDivElement>(null) + const [width, setWidth] = useState(0) + const [height, setHeight] = useState(0) + //Makes scale close to 1, 800 height is approxemately for a 1920 by 1080 monitor + const scale = height / 800 + useEffect(() => { + dispatch(getTypes()) + }, []) + + useLayoutEffect(() => { + const updateScale = () => { + if (editorPaperRef.current) { + setWidth(editorPaperRef.current.clientWidth) + setHeight(editorPaperRef.current.clientHeight) + } + } + window.addEventListener('resize', updateScale) + updateScale() + return () => window.removeEventListener('resize', updateScale) + }, []) + return ( + <SlideEditorContainer> + <SlideEditorContainerRatio> + <SlideEditorPaper ref={editorPaperRef}> + <SlideDisplayText $scale={scale}> + {slide?.timer && ( + <Card style={{ display: 'flex', alignItems: 'center', padding: 10 }}> + <TimerIcon fontSize="large" /> + <Timer variant={variant} currentSlideId={currentSlideId} /> + </Card> + )} + </SlideDisplayText> + <SlideDisplayText $scale={scale} $right> + <Card style={{ padding: 10 }}>{slide && `${slide?.order + 1} / ${totalSlides}`}</Card> + </SlideDisplayText> + {(competitionBackgroundImage || slideBackgroundImage) && ( + <img + src={`/static/images/${ + slideBackgroundImage ? slideBackgroundImage.filename : competitionBackgroundImage?.filename + }`} + height={height} + width={width} + draggable={false} + /> + )} + {components && + components + .filter((component) => component.view_type_id === activeViewTypeId) + .map((component) => { + if (variant === 'editor') + return ( + <RndComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> + ) + return ( + <PresentationComponent + key={component.id} + component={component} + scale={scale} + currentSlideId={currentSlideId} + /> + ) + })} + {!slide && ( + <Center> + <Typography variant="body2"> Ingen sida är vald, välj en i vänstermenyn eller skapa en ny.</Typography> + </Center> + )} + </SlideEditorPaper> + </SlideEditorContainerRatio> + </SlideEditorContainer> + ) +} + +export default SlideDisplay diff --git a/client/src/pages/presentationEditor/components/SlideEditor.tsx b/client/src/pages/presentationEditor/components/SlideEditor.tsx deleted file mode 100644 index 457f903fb09ff958663daed2197516afb33841e6..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/SlideEditor.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react' -import { useAppSelector } from '../../../hooks' -import RndComponent from './RndComponent' -import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } from './styled' - -const SlideEditor: React.FC = () => { - const components = useAppSelector( - (state) => - state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId)?.components - ) - return ( - <SlideEditorContainer> - <SlideEditorContainerRatio> - <SlideEditorPaper> - {components && components.map((component) => <RndComponent key={component.id} component={component} />)} - </SlideEditorPaper> - </SlideEditorContainerRatio> - </SlideEditorContainer> - ) -} - -export default SlideEditor diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index d0b72e39b4c7b83398350ac87be391c19681460c..68abd2400125eed9864418e7d3a7a42816b4bdf9 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -1,54 +1,74 @@ /* This file compiles and renders the right hand slide settings bar, under the tab "SIDA". */ -import { Divider, List, ListItem, ListItemText, TextField, Typography } from '@material-ui/core' -import React, { useState } from 'react' +import { Divider } from '@material-ui/core' +import React from 'react' import { useParams } from 'react-router-dom' import { useAppSelector } from '../../../hooks' -import Alternatives from './Alternatives' -import SlideType from './SlideType' -import { Center, ImportedImage, SettingsList, SlidePanel } from './styled' -import Timer from './Timer' -import Images from './Images' -import Texts from './Texts' +import BackgroundImageSelect from './BackgroundImageSelect' +import Images from './slideSettingsComponents/Images' +import Instructions from './slideSettingsComponents/Instructions' +import MatchAlternatives from './slideSettingsComponents/MatchAlternatives' +import MultipleChoiceAlternatives from './slideSettingsComponents/MultipleChoiceAlternatives' +import QuestionSettings from './slideSettingsComponents/QuestionSettings' +import SingleChoiceAlternatives from './slideSettingsComponents/SingleChoiceAlternatives' +import SlideType from './slideSettingsComponents/SlideType' +import Texts from './slideSettingsComponents/Texts' +import Timer from './slideSettingsComponents/Timer' +import { PanelContainer, SettingsList } from './styled' interface CompetitionParams { - id: string + competitionId: string } const SlideSettings: React.FC = () => { - const { id }: CompetitionParams = useParams() + const { competitionId }: CompetitionParams = useParams() const activeSlide = useAppSelector((state) => // Gets the slide with id=activeSlideId from the database. state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId) ) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) return ( - <SlidePanel> + <PanelContainer> <SettingsList> - {activeSlide && <SlideType activeSlide={activeSlide} competitionId={id} />} + {activeSlide && <SlideType activeSlide={activeSlide} competitionId={competitionId} />} <Divider /> - {activeSlide && <Timer activeSlide={activeSlide} competitionId={id} />} + {activeSlide && Boolean(activeSlide.questions[0]) && ( + <Timer activeSlide={activeSlide} competitionId={competitionId} /> + )} </SettingsList> - {activeSlide && <Alternatives activeSlide={activeSlide} competitionId={id} />} + {activeSlide?.questions[0] && <QuestionSettings activeSlide={activeSlide} competitionId={competitionId} />} - {activeSlide && <Texts activeSlide={activeSlide} competitionId={id} />} + { + // Choose answer alternatives, depending on the slide type + } + {(activeSlide?.questions[0]?.type_id === 1 || activeSlide?.questions[0]?.type_id === 2) && ( + <Instructions activeSlide={activeSlide} competitionId={competitionId} /> + )} + {activeSlide?.questions[0]?.type_id === 3 && ( + <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} - {activeSlide && <Images activeSlide={activeSlide} competitionId={id} />} + {activeSlide?.questions[0]?.type_id === 4 && ( + <SingleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} - <SettingsList> - <ListItem button> - <ImportedImage - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - /> - <Center> - <ListItemText>Välj bakgrundsbild ...</ListItemText> - </Center> - </ListItem> - </SettingsList> - </SlidePanel> + {activeSlide?.questions[0]?.type_id === 5 && ( + <MatchAlternatives activeSlide={activeSlide} competitionId={competitionId} /> + )} + + {activeSlide && ( + <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} + + {activeSlide && ( + <Images activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} + + <BackgroundImageSelect variant="slide" /> + </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/SlideType.tsx b/client/src/pages/presentationEditor/components/SlideType.tsx deleted file mode 100644 index ba104fe6ce7088d6aec076359d9f3f1361de3262..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/SlideType.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - InputLabel, - ListItem, - MenuItem, - Select, - Typography, -} from '@material-ui/core' -import axios from 'axios' -import React, { useState } from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch } from '../../../hooks' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { Center, FormControlDropdown, SlideTypeInputLabel, WhiteBackground } from './styled' - -type SlideTypeProps = { - activeSlide: RichSlide - competitionId: string -} - -const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { - const dispatch = useAppDispatch() - - // For "slide type" dialog - const [selectedSlideType, setSelectedSlideType] = useState(0) - const [slideTypeDialog, setSlideTypeDialog] = useState(false) - const openSlideTypeDialog = (type_id: number) => { - setSelectedSlideType(type_id) - setSlideTypeDialog(true) - } - const closeSlideTypeDialog = () => { - setSlideTypeDialog(false) - } - - const updateSlideType = async () => { - closeSlideTypeDialog() - if (activeSlide) { - if (activeSlide.questions[0] && activeSlide.questions[0].type_id !== selectedSlideType) { - if (selectedSlideType === 0) { - // Change slide type from a question type to information - await axios - .delete( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}` - ) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } else { - // Change slide type from question type to another question type - await axios - .delete( - `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}` - ) - .catch(console.log) - await axios - .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { - name: 'Ny fråga', - total_score: 0, - type_id: selectedSlideType, - }) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } else if (selectedSlideType !== 0) { - // Change slide type from information to a question type - await axios - .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { - name: 'Ny fråga', - total_score: 0, - type_id: selectedSlideType, - }) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - } - return ( - <WhiteBackground> - <FormControlDropdown variant="outlined"> - <SlideTypeInputLabel>Sidtyp</SlideTypeInputLabel> - <Select fullWidth={true} value={activeSlide?.questions[0]?.type_id || 0} label="Sidtyp"> - <MenuItem value={0}> - <Typography variant="button" onClick={() => openSlideTypeDialog(0)}> - Informationssida - </Typography> - </MenuItem> - <MenuItem value={1}> - <Typography variant="button" onClick={() => openSlideTypeDialog(1)}> - Skriftlig fråga - </Typography> - </MenuItem> - <MenuItem value={2}> - <Typography variant="button" onClick={() => openSlideTypeDialog(2)}> - Praktisk fråga - </Typography> - </MenuItem> - <MenuItem value={3}> - <Typography variant="button" onClick={() => openSlideTypeDialog(3)}> - Flervalsfråga - </Typography> - </MenuItem> - </Select> - </FormControlDropdown> - <Dialog open={slideTypeDialog} onClose={closeSlideTypeDialog}> - <Center> - <DialogTitle color="secondary">Varning!</DialogTitle> - </Center> - <DialogContent> - <DialogContentText> - Om du ändrar sidtypen kommer eventuella frågeinställningar gå förlorade. Det inkluderar: frågans namn, poäng - och svarsalternativ.{' '} - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={closeSlideTypeDialog} color="secondary"> - Avbryt - </Button> - <Button onClick={updateSlideType} color="primary"> - Bekräfta - </Button> - </DialogActions> - </Dialog> - </WhiteBackground> - ) -} - -export default SlideType diff --git a/client/src/pages/presentationEditor/components/Teams.tsx b/client/src/pages/presentationEditor/components/Teams.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e4a47b4c2826692231194158c51af7d4093120f --- /dev/null +++ b/client/src/pages/presentationEditor/components/Teams.tsx @@ -0,0 +1,135 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + ListItem, + ListItemText, + Snackbar, + TextField, +} from '@material-ui/core' +import CloseIcon from '@material-ui/icons/Close' +import EditIcon from '@material-ui/icons/Edit' +import { Alert } from '@material-ui/lab' +import axios from 'axios' +import React, { useState } from 'react' +import { getEditorCompetition } from '../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { RichTeam } from '../../../interfaces/ApiRichModels' +import { AddButton, Center, SettingsList } from './styled' + +interface TeamEditState { + open: boolean + variant?: 'Edit' | 'Add' + team?: RichTeam +} + +type TeamsProps = { + competitionId: string +} + +const Teams = ({ competitionId }: TeamsProps) => { + const dispatch = useAppDispatch() + const competition = useAppSelector((state) => state.editor.competition) + const [errorActive, setErrorActive] = React.useState(false) + const editTeam = async () => { + if (editTeamState.variant === 'Add') { + await axios + .post(`/api/competitions/${competitionId}/teams`, { name: selectedTeamName }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } else if (editTeamState.team) { + await axios + .put(`/api/competitions/${competitionId}/teams/${editTeamState.team.id}`, { name: selectedTeamName }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch((response) => { + if (response?.response?.status === 409) setErrorActive(true) + }) + } + setEditTeamState({ open: false }) + } + // For "add team" dialog + const [editTeamState, setEditTeamState] = useState<TeamEditState>({ open: false }) + let selectedTeamName = '' + const updateSelectedTeamName = (event: React.ChangeEvent<{ value: string }>) => { + selectedTeamName = event.target.value + } + + const removeTeam = async (tid: number) => { + await axios + .delete(`/api/competitions/${competitionId}/teams/${tid}`) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Lag" /> + </Center> + </ListItem> + {competition.teams && + competition.teams.map((team) => ( + <div key={team.id}> + <ListItem divider> + <ListItemText primary={team.name} /> + <IconButton size="small" onClick={() => setEditTeamState({ variant: 'Edit', open: true, team })}> + <EditIcon /> + </IconButton> + <IconButton size="small" onClick={() => removeTeam(team.id)}> + <CloseIcon /> + </IconButton> + </ListItem> + </div> + ))} + <ListItem button onClick={() => setEditTeamState({ variant: 'Add', open: true })}> + <Center> + <AddButton variant="button">Lägg till lag</AddButton> + </Center> + </ListItem> + <Dialog open={editTeamState.open} onClose={() => setEditTeamState({ open: false })}> + <DialogTitle> + {editTeamState.variant === 'Edit' && editTeamState.team + ? `Redigera lagnamn för lag ${editTeamState.team.name}` + : 'Lägg till lag'} + </DialogTitle> + <DialogContent> + <DialogContentText> + Skriv {editTeamState.variant === 'Edit' ? 'det nya' : ''} namnet på laget och klicka sedan på bekräfta. + </DialogContentText> + <TextField + autoFocus + margin="dense" + label="Lagnamn" + fullWidth + onChange={updateSelectedTeamName} + defaultValue={editTeamState.variant === 'Edit' ? editTeamState.team?.name : ''} + /> + </DialogContent> + <DialogActions> + <Button onClick={() => setEditTeamState({ open: false })} color="secondary"> + Avbryt + </Button> + <Button onClick={editTeam} color="primary"> + Bekräfta + </Button> + </DialogActions> + </Dialog> + <Snackbar open={errorActive} autoHideDuration={4000} onClose={() => setErrorActive(false)}> + <Alert severity="error">{`Du kan inte välja det namnet eftersom ett annat lag har samma namn, välj ett annat namn och försök igen.`}</Alert> + </Snackbar> + </SettingsList> + ) +} + +export default Teams diff --git a/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5cc87ec9ee10ebc18f19398a9079aec1b40c978f --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentDisplay.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { TextComponent } from '../../../interfaces/ApiModels' + +type TextComponentDisplayProps = { + component: TextComponent + scale: number +} + +const ImageComponentDisplay = ({ component, scale }: TextComponentDisplayProps) => { + return ( + <div + dangerouslySetInnerHTML={{ + __html: `<div style="font-size: ${Math.round(24 * scale)}px;">${component.text}</div>`, + }} + /> + ) +} + +export default ImageComponentDisplay diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..655b21bfed8ac33c433bd6b152736a344aeaf02b --- /dev/null +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import { TextComponent } from '../../../interfaces/ApiModels' +import store from '../../../store' +import TextComponentEdit from './TextComponentEdit' + +it('renders text component edit', () => { + render( + <BrowserRouter> + <Provider store={store}> + <TextComponentEdit component={{ id: 2, text: 'testtext' } as TextComponent} /> + </Provider> + </BrowserRouter> + ) +}) diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index 0a5c8522447c3111629ac179ce27532048d55c7a..61dc12842674fd8af905a1a5c3a50694777d6596 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -12,23 +12,23 @@ type ImageComponentProps = { } interface CompetitionParams { - id: string + competitionId: string } const TextComponentEdit = ({ component }: ImageComponentProps) => { - const { id }: CompetitionParams = useParams() - const competitionId = useAppSelector((state) => state.editor.competition.id) + const { competitionId }: CompetitionParams = useParams() const [content, setContent] = useState('') const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const dispatch = useAppDispatch() useEffect(() => { - setContent(component.data.text) + setContent(component.text) }, []) - const handleSaveText = async (a: string) => { - setContent(a) + const handleSaveText = async (newText: string) => { + setContent(newText) if (timerHandle) { clearTimeout(timerHandle) setTimerHandle(undefined) @@ -36,45 +36,52 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { //Only updates 250ms after last input was made to not spam setTimerHandle( window.setTimeout(async () => { - console.log('Content was updated on server. id: ', component.id) await axios.put(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${component.id}`, { - data: { ...component.data, text: a }, + text: newText, }) - dispatch(getEditorCompetition(id)) + dispatch(getEditorCompetition(competitionId)) }, 250) ) } const handleDeleteText = async (componentId: number) => { - await axios.delete(`/api/competitions/${id}/slides/${activeSlideId}/components/${componentId}`) - dispatch(getEditorCompetition(id)) + await axios.delete(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${componentId}`) + dispatch(getEditorCompetition(competitionId)) } return ( - <div style={{ minHeight: '300px', height: '100%', width: '100%' }}> + <> <Editor value={content || ''} init={{ height: '300px', menubar: false, + verify_html: false, + branding: false, + entity_encoding: 'raw', + toolbar_mode: 'sliding', + icons: 'material', + font_formats: + 'Arial=arial,helvetica,sans-serif;Calibri=calibri;\ + Comic Sans MS=comic sans ms,sans-serif; Courier New=courier new,courier;\ + Georgia=georgia,palatino; Helvetica=helvetica; Impact=impact,chicago;\ + Terminal=terminal,monaco;\ + Times New Roman=times new roman,times;', fontsize_formats: '8pt 9pt 10pt 11pt 12pt 14pt 18pt 24pt 30pt 36pt 48pt 60pt 72pt 96pt 120pt 144pt', - content_style: 'body {font-size: 24pt;}', - plugins: [ - 'advlist autolink lists link image charmap print preview anchor', - 'searchreplace visualblocks code fullscreen', - 'insertdatetime media table paste code help wordcount', - ], + content_style: 'body {font-size: 24pt; font-family: Calibri;}', + plugins: ['advlist autolink lists link charmap anchor visualblocks code paste help wordcount'], toolbar: - 'fontsizeselect | bold italic backcolor | help | \ - fontselect | formatselect | undo redo | \ - alignleft aligncenter alignright alignjustify bullist numlist outdent indent | removeformat |', + 'fontsizeselect | bold italic underline backcolor | \ + fontselect | formatselect | \ + alignleft aligncenter alignright alignjustify bullist numlist outdent indent |\ + undo redo | code removeformat | help', }} onEditorChange={(a, e) => handleSaveText(a)} /> <DeleteTextButton variant="contained" color="secondary" onClick={() => handleDeleteText(component.id)}> Ta bort </DeleteTextButton> - </div> + </> ) } diff --git a/client/src/pages/presentationEditor/components/Timer.tsx b/client/src/pages/presentationEditor/components/Timer.tsx deleted file mode 100644 index 124635f423df9d0bfcca20a9a02309c646cb3dca..0000000000000000000000000000000000000000 --- a/client/src/pages/presentationEditor/components/Timer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ListItem, TextField } from '@material-ui/core' -import axios from 'axios' -import React, { useEffect, useState } from 'react' -import { getEditorCompetition } from '../../../actions/editor' -import { useAppDispatch } from '../../../hooks' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { Center, WhiteBackground } from './styled' - -type TimerProps = { - activeSlide: RichSlide - competitionId: string -} - -const Timer = ({ activeSlide, competitionId }: TimerProps) => { - const dispatch = useAppDispatch() - const updateTimer = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { - setTimer(+event.target.value) - if (activeSlide) { - await axios - .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}`, { timer: event.target.value }) - .then(() => { - dispatch(getEditorCompetition(competitionId)) - }) - .catch(console.log) - } - } - const [timer, setTimer] = useState<number | undefined>(0) - useEffect(() => { - setTimer(activeSlide?.timer) - }, [activeSlide]) - return ( - <WhiteBackground> - <ListItem> - <Center> - <TextField - id="standard-number" - fullWidth={true} - variant="outlined" - placeholder="Antal sekunder" - helperText="Lämna blank för att inte använda timerfunktionen" - label="Timer" - type="number" - defaultValue={activeSlide?.timer || 0} - onChange={updateTimer} - value={timer} - /> - </Center> - </ListItem> - </WhiteBackground> - ) -} - -export default Timer diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df79a37d8c60a10ad4ae62d31db131de5023fbc2 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMatch.tsx @@ -0,0 +1,143 @@ +/** + * What it is: + * Contains the component for the multiple choice question type ("Kryssfråga") + * which is displayed in the participant view in the editor and presentation. + * This is a part of a question component which the users will interact with to answer multiple choice questions. + * The participants get multiple alternatives and can mark multiple of these alternatives as correct. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { ListItemText, Typography } from '@material-ui/core' +import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown' +import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp' +import SyncAltIcon from '@material-ui/icons/SyncAlt' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, Clickable } from '../styled' +import { MatchButtonContainer, MatchCard, MatchContainer, MatchCorrectContainer, MatchIconContainer } from './styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMatch = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const [sortedAlternatives, setSortedAlternatives] = useState<QuestionAlternative[]>([]) + const [sortedAnswers, setSortedAnswers] = useState<QuestionAlternative[]>([]) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) + + useEffect(() => { + if (activeSlide) { + setSortedAlternatives([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setSortedAnswers([ + ...activeSlide.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + } + }, [activeSlide]) + + useEffect(() => { + // Send the standard answers ( if the team choses to not move one of the answers ) + if (teamId && team?.question_alternative_answers.length === 0) { + activeSlide?.questions[0].alternatives.forEach((alternative) => { + const answer = activeSlide?.questions[0].alternatives.find( + (alt) => alternative.alternative_order === alt.correct_order + ) + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${alternative.id}`, { + answer: `${alternative.alternative} - ${answer?.correct}`, + }) + .then(() => { + dispatch(getPresentationCompetition(competitionId)) + }) + .catch(console.log) + }) + } + }, [teamId]) + + const getButtonStyle = () => { + if (activeSlide?.timer !== null && !timer.enabled) { + return { fill: '#AAAAAA' } // Buttons are light grey if timer is not on + } + return {} + } + + const onMove = async (previousIndex: number, resultIndex: number) => { + // moved outside the list + if (resultIndex < 0 || resultIndex >= sortedAnswers.length || variant !== 'presentation') return + if (activeSlide?.timer !== null && !timer.enabled) return + const answersCopy = [...sortedAnswers] + const [removed] = answersCopy.splice(previousIndex, 1) + answersCopy.splice(resultIndex, 0, removed) + setSortedAnswers(answersCopy) + + sortedAlternatives.forEach((alternative, index) => { + const answeredText = answersCopy[index].correct + if (!activeSlide) return + axios + .put(`/api/competitions/${competitionId}/teams/${teamId}/answers/${alternative.id}`, { + answer: `${alternative.alternative} - ${answeredText}`, + }) + .catch(console.log) + }) + } + + return ( + <> + <Center> + <ListItemText secondary="Para ihop de alternativ som hör ihop:" /> + </Center> + <MatchContainer> + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <Typography id="outlined-basic">{alternative.alternative}</Typography> + </MatchCard> + ))} + </div> + + <div style={{ flexDirection: 'column', marginRight: 20 }}> + {sortedAlternatives.map((alternative, index) => ( + <MatchIconContainer key={alternative.id}> + <SyncAltIcon /> + </MatchIconContainer> + ))} + </div> + + <div style={{ flexDirection: 'column' }}> + {sortedAnswers.map((alternative, index) => ( + <MatchCard key={alternative.id} elevation={4}> + <MatchCorrectContainer> + <Typography id="outlined-basic">{alternative.correct}</Typography> + </MatchCorrectContainer> + <MatchButtonContainer> + <Clickable> + <KeyboardArrowUpIcon style={getButtonStyle()} onClick={() => onMove(index, index - 1)} /> + </Clickable> + <Clickable> + <KeyboardArrowDownIcon style={getButtonStyle()} onClick={() => onMove(index, index + 1)} /> + </Clickable> + </MatchButtonContainer> + </MatchCard> + ))} + </div> + </MatchContainer> + </> + ) +} + +export default AnswerMatch diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d95c735507090f3d88a518f26bb216f506b9f661 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerMultiple.tsx @@ -0,0 +1,106 @@ +/** + * What it is: + * Contains the component for the multiple choice question type ("Kryssfråga") + * which is displayed in the participant view in the editor and presentation. + * This is a part of a question component which the users will interact with to answer multiple choice questions. + * The participants get multiple alternatives and can mark multiple of these alternatives as correct. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { Checkbox, ListItem, ListItemText, Typography, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' + +type AnswerMultipleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerMultiple = ({ variant, activeSlide, competitionId }: AnswerMultipleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) + + const decideChecked = (alternative: QuestionAlternative) => { + const answer = team?.question_alternative_answers.find( + (questionAnswer) => questionAnswer.question_alternative_id == alternative.id + ) + if (answer) { + return answer.answer === '1' + } + return false + } + + const updateAnswer = async (alternative: QuestionAlternative, checked: boolean) => { + // TODO: fix. Make list of alternatives and delete & post instead of put to allow multiple boxes checked. + if (!activeSlide || (activeSlide?.timer !== null && !timer.enabled)) { + return + } + const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/${alternative.id}` + const payload = { + answer: checked ? 1 : 0, + } + await axios + .put(url, payload) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett eller flera svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <GreenCheckbox + disabled={activeSlide?.timer !== null && !timer.enabled} + checked={decideChecked(alt)} + onChange={(event: any) => updateAnswer(alt, event.target.checked)} + /> + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerMultiple diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..901e37c3de26930bff64d802ba685f46dfbaf112 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerSingle.tsx @@ -0,0 +1,126 @@ +/** + * What it is: + * Contains the component for the single choice question type ("Alternativfråga") + * which is displayed in the participant view in the editor and presentation. + * This is a part of a question component which the users will interact with to answer multiple choice questions. + * The participants get multiple alternatives but can only mark one of these alternatives as correct. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { ListItem, ListItemText, Typography } from '@material-ui/core' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, Clickable } from '../styled' + +type AnswerSingleProps = { + variant: 'editor' | 'presentation' + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerSingle = ({ variant, activeSlide, competitionId }: AnswerSingleProps) => { + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => { + if (variant === 'editor') return state.editor.competition.teams.find((team) => team.id === teamId) + return state.presentation.competition.teams.find((team) => team.id === teamId) + }) + const timer = useAppSelector((state) => state.presentation.timer) + + const decideChecked = (alternative: QuestionAlternative) => { + const answer = team?.question_alternative_answers.find( + (questionAnswer) => questionAnswer.question_alternative_id == alternative.id + ) + if (answer) { + return answer.answer === '1' + } + return false + } + + const updateAnswer = async (alternative: QuestionAlternative) => { + if (!activeSlide || (activeSlide?.timer !== null && !timer.enabled)) { + return + } + + // Unselect each radio button to only allow one selected alternative + const alternatives = activeSlide.questions[0].alternatives + for (const alt of alternatives) { + const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/${alt.id}` + await axios.put(url, { answer: 0 }) + } + // Update selected alternative + const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/${alternative.id}` + await axios + .put(url, { answer: 1 }) + .then(() => { + if (variant === 'editor') { + dispatch(getEditorCompetition(competitionId)) + } else { + dispatch(getPresentationCompetition(competitionId)) + } + }) + .catch(console.log) + } + + /** + * Renders the radio button which the participants will click to mark their answer. + */ + const renderRadioButton = (alt: QuestionAlternative) => { + let disabledStyle + if (activeSlide?.timer !== null && !timer.enabled) { + disabledStyle = { fill: '#AAAAAA' } // Buttons are light grey if timer is not on + } + if (variant === 'presentation') { + if (decideChecked(alt)) { + return ( + <Clickable> + <RadioButtonCheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } else { + return ( + <Clickable> + <RadioButtonUncheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> + </Clickable> + ) + } + } else { + return <RadioButtonUncheckedIcon style={disabledStyle} onClick={() => updateAnswer(alt)} /> + } + } + + return ( + <div> + <ListItem divider> + <Center> + <ListItemText primary="Välj ett svar:" /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + {renderRadioButton(alt)} + <Typography style={{ wordBreak: 'break-all' }}>{alt.alternative}</Typography> + </ListItem> + </div> + ))} + </div> + ) +} + +export default AnswerSingle diff --git a/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55fa6d8cb721e1d7ceb3b76f449cf49888e93796 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/AnswerText.tsx @@ -0,0 +1,95 @@ +/** + * What it is: + * Returns the component for the text question type ("Skriftlig fråga") + * which is a part of a question component which displayed in the participant view in the editor and presentation. + * This is the component the users will interact with to answer text questions. + * In practice the participants writes their answer in a text field. + * + * How it's used: + * This file is used when a question component is to be rendered which only happens in QuestionComponentDisplay.tsx. + * For more information read the documentation of that file. + * + * @module + */ + +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getPresentationCompetition } from '../../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center } from '../styled' +import { AnswerTextFieldContainer } from './styled' + +type AnswerTextProps = { + activeSlide: RichSlide | undefined + competitionId: string +} + +const AnswerText = ({ activeSlide, competitionId }: AnswerTextProps) => { + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + const dispatch = useAppDispatch() + const teamId = useAppSelector((state) => state.competitionLogin.data?.team_id) + const team = useAppSelector((state) => state.presentation.competition.teams.find((team) => team.id === teamId)) + const timer = useAppSelector((state) => state.presentation.timer) + + const onAnswerChange = (answer: string) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates answer if the timer is on + if (!(activeSlide?.timer !== null && !timer.enabled)) { + //Only updates answer 100ms after last input was made + setTimerHandle(window.setTimeout(() => updateAnswer(answer), 100)) + } + } + + const updateAnswer = async (answer: string) => { + if (!activeSlide) { + return + } + const alternative = activeSlide.questions[0].alternatives[0] + const url = `/api/competitions/${competitionId}/teams/${teamId}/answers/${alternative.id}` + await axios + .put(url, { answer }) + .then(() => { + dispatch(getPresentationCompetition(competitionId)) + }) + .catch(console.log) + } + + const getDefaultString = () => { + if (!team || !activeSlide) { + return + } + const activeAltId = activeSlide.questions[0]?.alternatives[0]?.id + return ( + team.question_alternative_answers.find((questionAnswer) => questionAnswer.question_alternative_id === activeAltId) + ?.answer || '' + ) + } + + return ( + <AnswerTextFieldContainer> + <ListItem divider> + <Center> + <ListItemText primary="Skriv ditt svar nedan" /> + </Center> + </ListItem> + <ListItem style={{ height: '100%' }}> + <TextField + disabled={team === undefined || (activeSlide?.timer !== null && !timer.enabled)} + defaultValue={getDefaultString()} + style={{ height: '100%' }} + variant="outlined" + fullWidth={true} + multiline + onChange={(event) => onAnswerChange(event.target.value)} + /> + </ListItem> + </AnswerTextFieldContainer> + ) +} + +export default AnswerText diff --git a/client/src/pages/presentationEditor/components/answerComponents/Description.txt b/client/src/pages/presentationEditor/components/answerComponents/Description.txt new file mode 100644 index 0000000000000000000000000000000000000000..481f0340d9d3cf867be5f9f0a8439fad3feaa7fe --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/Description.txt @@ -0,0 +1,2 @@ +The files in this directory generates the part of a question component which the participants +interacts with to change and submit their answer. \ No newline at end of file diff --git a/client/src/pages/presentationEditor/components/answerComponents/styled.tsx b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..109776febb8b4ebd9f202e425eb1757141b5fd64 --- /dev/null +++ b/client/src/pages/presentationEditor/components/answerComponents/styled.tsx @@ -0,0 +1,45 @@ +import { Card } from '@material-ui/core' +import styled from 'styled-components' + +export const AnswerTextFieldContainer = styled.div` + height: calc(100% - 90px); +` +export const MatchContainer = styled.div` + margin-bottom: 50px; + margin-top: 10px; + display: flex; + justify-content: center; +` + +export const MatchCard = styled(Card)` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + min-width: 150px; + margin-bottom: 5px; +` + +export const MatchIconContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 80px; + margin-bottom: 5px; +` + +export const MatchButtonContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + margin-top: 10px; + margin-bottom: 10px; + margin-right: 5px; +` + +export const MatchCorrectContainer = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: center; +` diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Description.txt b/client/src/pages/presentationEditor/components/slideSettingsComponents/Description.txt new file mode 100644 index 0000000000000000000000000000000000000000..1a4c4beadd2a193ea9bd576e146d28de92673109 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Description.txt @@ -0,0 +1,2 @@ +Every file in this folder is only used in SlideSettings.tsx. +These files constitutes the content of the slide settings panel to the right in the editor. \ No newline at end of file diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..917fa0c8642a308460e7fcc007c01ba1e91f5ad9 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Images from './Images' + +it('renders images', () => { + render( + <Provider store={store}> + <Images activeSlide={{ id: 5 } as RichSlide} activeViewTypeId={5} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/Images.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx similarity index 64% rename from client/src/pages/presentationEditor/components/Images.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx index 6d919b5999b680fbed864c8d956df4e9f3f6fd9e..42731c3ea45a0a51b39a051b4936dd61a4471870 100644 --- a/client/src/pages/presentationEditor/components/Images.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx @@ -1,33 +1,27 @@ -/* This file handles creating and removing image components, and uploading and removing image files from the server. +/** + * This file handles creating and removing image components, and uploading and removing image files from the server. + * + * @module */ -import { ListItem, ListItemText, Typography } from '@material-ui/core' + +import { ListItem, ListItemText } from '@material-ui/core' import CloseIcon from '@material-ui/icons/Close' -import React, { useState } from 'react' -import { useDispatch } from 'react-redux' -import { - Center, - HiddenInput, - SettingsList, - AddImageButton, - ImportedImage, - WhiteBackground, - AddButton, - Clickable, - NoPadding, -} from './styled' import axios from 'axios' -import { getEditorCompetition } from '../../../actions/editor' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { ImageComponent, Media } from '../../../interfaces/ApiModels' -import { useAppSelector } from '../../../hooks' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { ImageComponent, Media } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AddImageButton, Center, HiddenInput, ImportedImage, SettingsList } from '../styled' type ImagesProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Images = ({ activeSlide, competitionId }: ImagesProps) => { - const dispatch = useDispatch() +const Images = ({ activeViewTypeId, activeSlide, competitionId }: ImagesProps) => { + const dispatch = useAppDispatch() const uploadFile = async (formData: FormData) => { // Uploads the file to the server and creates a Media object in database. @@ -46,11 +40,9 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { const imageData = { x: 0, y: 0, - data: { - media_id: media.id, - filename: media.filename, - }, + media_id: media.id, type_id: 2, + view_type_id: activeViewTypeId, } await axios .post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, imageData) @@ -70,7 +62,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { formData.append('image', file) const response = await uploadFile(formData) if (response) { - const newComponent = createImageComponent(response) + createImageComponent(response) } } // Resets the input, so that the same file can be uploaded again. @@ -80,7 +72,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { const handleCloseimageClick = async (image: ImageComponent) => { // Removes selected image component and deletes its file from the server. await axios - .delete(`/api/media/images/${image.data.media_id}`) + .delete(`/api/media/images/${image.media?.id}`) .then(() => { dispatch(getEditorCompetition(competitionId)) }) @@ -104,21 +96,24 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { return ( <SettingsList> - <WhiteBackground> - <ListItem divider> - <Center> - <ListItemText primary="Bilder" /> - </Center> - </ListItem> - {images && - images.map((image) => ( + <ListItem divider> + <Center> + <ListItemText primary="Bilder" /> + </Center> + </ListItem> + {images && + images + .filter((image) => image.view_type_id === activeViewTypeId) + .map((image) => ( <div key={image.id}> <ListItem divider button> - <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.data.filename}`} /> + <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> <Center> <ListItemText primary={ - image.data.filename.length > 25 ? image.data.filename.substr(0, 24) + '...' : image.data.filename + image.media?.filename.length > 25 + ? image.media?.filename.substr(0, 24) + '...' + : image.media?.filename } /> </Center> @@ -127,13 +122,12 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { </div> ))} - <ListItem button> - <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> - <AddImageButton htmlFor="contained-button-file"> - <AddButton variant="button">Lägg till bild</AddButton> - </AddImageButton> - </ListItem> - </WhiteBackground> + <ListItem button style={{ padding: 0 }}> + <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> + <AddImageButton htmlFor="contained-button-file"> + <AddButton variant="button">Lägg till bild</AddButton> + </AddImageButton> + </ListItem> </SettingsList> ) } diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe745c839cc2bd9a59f07e4e97cbb85a90c19e98 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Instructions from './Instructions' + +it('renders instructions', () => { + render( + <Provider store={store}> + <Instructions activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb97e6693e4ce2f0868b3ae5503e7acdb3369d84 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx @@ -0,0 +1,77 @@ +/** + * This file returns the instructions component + * The creator of a competition can add a description of how a question will be corrected. + * This information is showed in the judge view and will help the judges decide what answer was correct. + * + * @module + */ + +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, SettingsList } from '../styled' + +type InstructionsProps = { + activeSlide: RichSlide + competitionId: string +} + +const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { + const dispatch = useAppDispatch() + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + + const updateInstructionsText = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates 250ms after last input was made to not spam + setTimerHandle( + window.setTimeout(async () => { + if (activeSlide && activeSlide.questions?.[0]) { + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, + { + correcting_instructions: event.target.value, + } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }, 250) + ) + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Rättningsinstruktioner" + secondary="Den här texten kommer endast att visas för domarna." + /> + </Center> + </ListItem> + <ListItem divider> + <Center> + <TextField + multiline + id="outlined-basic" + defaultValue={activeSlide.questions?.[0].correcting_instructions} + onChange={updateInstructionsText} + variant="outlined" + fullWidth={true} + /> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default Instructions diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a94400076d8aad091275ca456c9952abc8f4b64 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MatchAlternatives.tsx @@ -0,0 +1,316 @@ +/** + * Lets a competition creator add, remove and handle alternatives for single choice questions ("Alternativfråga") in the slide settings panel. + * + * @module + */ + +import { + AppBar, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + ListItem, + ListItemText, + Tab, + Tabs, + Typography, +} from '@material-ui/core' +import ClearIcon from '@material-ui/icons/Clear' +import DragIndicatorIcon from '@material-ui/icons/DragIndicator' +import axios from 'axios' +import React, { useEffect } from 'react' +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +interface AlternativeUpdate { + alternative?: string + alternative_order?: string + correct?: string + correct_order?: string +} + +const MatchAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const [dialogOpen, setDialogOpen] = React.useState(false) + const [selectedTab, setSelectedTab] = React.useState(0) + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) + // Locally stored sorted versions of alternatives to make the sorting smoother, and not have to wait on backend + const [alternativesSortedByAlternative, setAlternativesSortedByAlternative] = React.useState<QuestionAlternative[]>( + [] + ) + const [alternativesSortedByCorrect, setAlternativesSortedByCorrect] = React.useState<QuestionAlternative[]>([]) + useEffect(() => { + if (!activeSlide?.questions[0].alternatives) return + setAlternativesSortedByAlternative([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.alternative_order > b.alternative_order ? 1 : -1)), + ]) + setAlternativesSortedByCorrect([ + ...activeSlide?.questions[0].alternatives.sort((a, b) => (a.correct_order > b.correct_order ? 1 : -1)), + ]) + }, [activeSlide]) + + const onDragEnd = async (result: DropResult, orderType: 'alternative_order' | 'correct_order') => { + // dropped outside the list or same place + if (!result.destination || result.destination.index === result.source.index) return + + const draggedIndex = result.source.index + const draggedAlternativeId = activeSlide?.questions[0].alternatives.find((alt) => alt[orderType] === draggedIndex) + ?.id + if (orderType === 'alternative_order') { + const alternativesCopy = [...alternativesSortedByAlternative] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByAlternative(alternativesCopy) + } else { + const alternativesCopy = [...alternativesSortedByCorrect] + const [removed] = alternativesCopy.splice(draggedIndex, 1) + alternativesCopy.splice(result.destination.index, 0, removed) + setAlternativesSortedByCorrect(alternativesCopy) + } + if (!draggedAlternativeId) return + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${draggedAlternativeId}`, + { + [orderType]: result.destination.index, + } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const updateAlternative = async (alternative_id: number, alternativeUpdate: AlternativeUpdate) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates filter and api 250ms after last input was made + setTimerHandle( + window.setTimeout(() => { + if (activeSlide && activeSlide.questions[0]) { + axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + alternativeUpdate + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }, 250) + ) + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const deleteAlternative = async (alternative_id: number) => { + if (activeSlide?.questions[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Svarsalternativ" /> + </Center> + </ListItem> + {activeSlide?.questions?.[0]?.alternatives?.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <Typography> + {alt.alternative} | {alt.correct} + </Typography> + </ListItem> + </div> + ))} + <Dialog + fullWidth + open={dialogOpen} + onClose={() => console.log('close')} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">Redigera para ihop-alternativ</DialogTitle> + <DialogContent style={{ height: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <AppBar position="relative"> + <Tabs value={selectedTab} onChange={(event, selectedTab) => setSelectedTab(selectedTab)} centered> + <Tab label="Lag" /> + <Tab label="Facit" color="primary" /> + </Tabs> + </AppBar> + + {selectedTab === 0 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Para ihop alternativen som de kommer se ut för lagen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + <div style={{ display: 'flex' }}> + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'alternative_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable1"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByAlternative.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <DragIndicatorIcon elevation={3} /> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => + updateAlternative(alternative.id, { alternative: event.target.value }) + } + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + + <DragDropContext onDragEnd={(result) => onDragEnd(result, 'correct_order')}> + <div style={{ flexDirection: 'column' }}> + <Droppable droppableId="droppable2"> + {(provided) => ( + <div ref={provided.innerRef} {...provided.droppableProps}> + {alternativesSortedByCorrect.map((alternative, index) => ( + <Draggable draggableId={alternative.id.toString()} index={index} key={alternative.id}> + {(provided, snapshot) => ( + <div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => + updateAlternative(alternative.id, { correct: event.target.value }) + } + variant="outlined" + /> + <DragIndicatorIcon elevation={3} /> + </Card> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </div> + </DragDropContext> + </div> + </div> + )} + + {selectedTab === 1 && ( + <div style={{ marginBottom: 50, marginTop: 10 }}> + <div style={{ display: 'flex', justifyContent: 'center' }}> + {activeSlide?.questions[0].alternatives.length !== 0 && ( + <DialogContentText>Editera svarsalternativen.</DialogContentText> + )} + {activeSlide?.questions[0].alternatives.length === 0 && ( + <DialogContentText> + Det finns inga alternativ, lägg till alternativ med knappen nedan. + </DialogContentText> + )} + </div> + {activeSlide?.questions?.[0]?.alternatives?.map((alternative, index) => ( + <div style={{ display: 'flex' }} key={alternative.id}> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <IconButton size="small" onClick={() => deleteAlternative(alternative.id)}> + <ClearIcon color="error" /> + </IconButton> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.alternative} + onChange={(event) => updateAlternative(alternative.id, { alternative: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + <Card elevation={4} style={{ display: 'flex', alignItems: 'center' }}> + <AlternativeTextField + id="outlined-basic" + defaultValue={alternative.correct} + onChange={(event) => updateAlternative(alternative.id, { correct: event.target.value })} + variant="outlined" + style={{ width: 200 }} + /> + </Card> + </div> + ))} + </div> + )} + </DialogContent> + <DialogActions> + <Button variant="contained" autoFocus onClick={addAlternative} color="primary"> + Lägg till alternativ + </Button> + <Button variant="contained" autoFocus onClick={() => setDialogOpen(false)} color="secondary"> + Stäng + </Button> + </DialogActions> + </Dialog> + <ListItem button onClick={() => setDialogOpen(true)}> + <Center> + <AddButton variant="button">Redigera alternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default MatchAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75131257642df47d260ec795025e1c8bee759a6a --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import MultipleChoiceAlternatives from './MultipleChoiceAlternatives' + +it('renders multiple choice alternatives', () => { + render( + <Provider store={store}> + <MultipleChoiceAlternatives activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17f09647aafc21540b5fd2f7bdce4583a8048570 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/MultipleChoiceAlternatives.tsx @@ -0,0 +1,139 @@ +/** + * Lets a competition creator add, remove and handle alternatives for multiple choice questions ("Kryssfråga") in the slide settings panel. + * + * @module + */ + +import { Checkbox, ListItem, ListItemText, withStyles } from '@material-ui/core' +import { CheckboxProps } from '@material-ui/core/Checkbox' +import { green, grey } from '@material-ui/core/colors' +import CloseIcon from '@material-ui/icons/Close' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, Clickable, SettingsList } from '../styled' + +type MultipleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +const MultipleChoiceAlternatives = ({ activeSlide, competitionId }: MultipleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + + const GreenCheckbox = withStyles({ + root: { + color: grey[900], + '&$checked': { + color: green[600], + }, + }, + checked: {}, + })((props: CheckboxProps) => <Checkbox color="default" {...props} />) + + /** + * A checked checkbox is represented with 1 and an unchecked with 0. + */ + const stringToBool = (num: string) => { + if (num === '0') return false + else return true + } + + const updateAlternativeValue = async (alternative: QuestionAlternative) => { + if (activeSlide && activeSlide.questions?.[0]) { + let newValue: string + if (alternative.correct === '0') { + newValue = '1' + } else newValue = '0' + await axios .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, + { correct: newValue } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const updateAlternativeText = async (alternative_id: number, newText: string) => { + if (activeSlide && activeSlide.questions?.[0]) { + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + { alternative: newText } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions?.[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, + { correct: '0' } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const handleCloseAnswerClick = async (alternative_id: number) => { + if (activeSlide && activeSlide.questions?.[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Svarsalternativ" + secondary="(Fyll i rutan höger om textfältet för att markera korrekt svar)" + /> + </Center> + </ListItem> + {activeSlide?.questions?.[0]?.alternatives?.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.alternative} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <GreenCheckbox checked={stringToBool(alt.correct)} onChange={() => updateAlternativeValue(alt)} /> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={addAlternative}> + <Center> + <AddButton variant="button">Lägg till svarsalternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default MultipleChoiceAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9f13bcdd7e7b76d0e90de61cfc2a86c52dca194 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import QuestionSettings from './QuestionSettings' + +it('renders question settings', () => { + render( + <Provider store={store}> + <QuestionSettings activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c2ea6e8ee73b5a3af1e9c0f845a8930a24d2c9e2 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -0,0 +1,117 @@ +/** + * This file contatins the question settings component, which in turn lets the competition editor + * change the name of the question and how many points the participants can get when submittning the correct answer. + * + * @module + */ + +import { ListItem, ListItemText, TextField } from '@material-ui/core' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, SettingsItemContainer, SettingsList } from '../styled' + +type QuestionSettingsProps = { + activeSlide: RichSlide + competitionId: string +} + +const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) => { + const dispatch = useAppDispatch() + const [timerHandle, setTimerHandle] = useState<number | undefined>(undefined) + + const handleChangeQuestion = ( + updateTitle: boolean, + event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> + ) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates question and api 500ms after last input was made + setTimerHandle(window.setTimeout(() => updateQuestion(updateTitle, event), 300)) + if (updateTitle) { + setName(event.target.value) + } else setScore(+event.target.value) + } + + // Set to not let the editor set a bigger number than this to affect the server in a bad way. + const maxScore = 1000000 + + const updateQuestion = async ( + updateTitle: boolean, + event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> + ) => { + if (activeSlide && activeSlide.questions?.[0]) { + if (updateTitle) { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { + name: event.target.value, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } else { + // Sets score to event.target.value if it's between 0 and max + const score = Math.max(0, Math.min(+event.target.value, maxScore)) + setScore(score) + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}`, { + total_score: score, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + const [score, setScore] = useState<number | undefined>(0) + const [name, setName] = useState<string | undefined>('') + useEffect(() => { + setName(activeSlide?.questions?.[0]?.name) + setScore(activeSlide?.questions?.[0]?.total_score) + }, [activeSlide]) + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText primary="Frågeinställningar" secondary="" /> + </Center> + </ListItem> + <ListItem divider> + <TextField + id="outlined-basic" + label="Frågans titel" + onChange={(event) => handleChangeQuestion(true, event)} + variant="outlined" + fullWidth={true} + value={name || ''} + /> + </ListItem> + <ListItem> + <Center> + <SettingsItemContainer> + <TextField + fullWidth + variant="outlined" + placeholder="Antal poäng" + helperText="Välj hur många poäng frågan ska ge för rätt svar. Lämna blank för att inte använda poängfunktionen" + label="Poäng" + type="number" + InputProps={{ inputProps: { min: 0, max: maxScore } }} + value={score || ''} + onChange={(event) => handleChangeQuestion(false, event)} + /> + </SettingsItemContainer> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default QuestionSettings diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx new file mode 100644 index 0000000000000000000000000000000000000000..51de69dab1022149e953280e0a1dfaecf2e7645a --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SingleChoiceAlternatives.tsx @@ -0,0 +1,137 @@ +/** + * Lets a competition creator add, remove and handle alternatives for single choice questions ("Alternativfråga") in the slide settings panel. + * + * @module + */ + +import { ListItem, ListItemText } from '@material-ui/core' +import CloseIcon from '@material-ui/icons/Close' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonCheckedOutlined' +import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' +import axios from 'axios' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { QuestionAlternative } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, AlternativeTextField, Center, Clickable, SettingsList } from '../styled' + +type SingleChoiceAlternativeProps = { + activeSlide: RichSlide + competitionId: string +} + +const SingleChoiceAlternatives = ({ activeSlide, competitionId }: SingleChoiceAlternativeProps) => { + const dispatch = useAppDispatch() + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + + const updateAlternativeValue = async (alternative: QuestionAlternative) => { + if (activeSlide && activeSlide.questions[0]) { + // Remove check from previously checked alternative + const previousCheckedAltId = activeSlide.questions[0].alternatives.find((alt) => alt.correct === '1')?.id + if (previousCheckedAltId !== alternative.id) { + if (previousCheckedAltId) { + axios.put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${previousCheckedAltId}`, + { correct: '0' } + ) + } + // Set new checked alternative + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative.id}`, + { correct: '1' } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } + + const updateAlternativeText = async (alternative_id: number, newText: string) => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .put( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}`, + { alternative: newText } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const addAlternative = async () => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide?.id}/questions/${activeSlide?.questions[0].id}/alternatives`, + { correct: '0' } + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const handleCloseAnswerClick = async (alternative_id: number) => { + if (activeSlide && activeSlide.questions[0]) { + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlideId}/questions/${activeSlide?.questions[0].id}/alternatives/${alternative_id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const renderRadioButton = (alt: QuestionAlternative) => { + if (alt.correct === '1') return <RadioButtonCheckedIcon onClick={() => updateAlternativeValue(alt)} /> + else return <RadioButtonUncheckedIcon onClick={() => updateAlternativeValue(alt)} /> + } + + return ( + <SettingsList> + <ListItem divider> + <Center> + <ListItemText + primary="Svarsalternativ" + secondary="(Fyll i cirkeln höger om textfältet för att markera korrekt svar)" + /> + </Center> + </ListItem> + {activeSlide && + activeSlide.questions[0] && + activeSlide.questions[0].alternatives && + activeSlide.questions[0].alternatives.map((alt) => ( + <div key={alt.id}> + <ListItem divider> + <AlternativeTextField + id="outlined-basic" + defaultValue={alt.alternative} + onChange={(event) => updateAlternativeText(alt.id, event.target.value)} + variant="outlined" + /> + <Clickable>{renderRadioButton(alt)}</Clickable> + <Clickable> + <CloseIcon onClick={() => handleCloseAnswerClick(alt.id)} /> + </Clickable> + </ListItem> + </div> + ))} + <ListItem button onClick={addAlternative}> + <Center> + <AddButton variant="button">Lägg till svarsalternativ</AddButton> + </Center> + </ListItem> + </SettingsList> + ) +} + +export default SingleChoiceAlternatives diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0b44c6439cf8d2f2aa3d4dd8eb94deb0ed3ef0e --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import SlideType from './SlideType' + +it('renders slidetype', () => { + render( + <Provider store={store}> + <SlideType activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fdecde8ed07c8b7cf1ae7945d90ffc398f870f08 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/SlideType.tsx @@ -0,0 +1,203 @@ +/** + * Lets a competition creator set the slide type for a slide such as "Informationssida" or "Skriftlig fråga" etc. + * + * @module + */ + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + InputLabel, + ListItem, + MenuItem, + Select, + Typography, +} from '@material-ui/core' +import axios from 'axios' +import React, { useState } from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, FirstItem } from '../styled' + +type SlideTypeProps = { + activeSlide: RichSlide + competitionId: string +} + +const SlideType = ({ activeSlide, competitionId }: SlideTypeProps) => { + const dispatch = useAppDispatch() + + // For "slide type" dialog + const [selectedSlideType, setSelectedSlideType] = useState(0) + const [slideTypeDialog, setSlideTypeDialog] = useState(false) + const components = useAppSelector( + (state) => state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.components + ) + + const openSlideTypeDialog = (type_id: number) => { + setSelectedSlideType(type_id) + setSlideTypeDialog(true) + } + const closeSlideTypeDialog = () => { + setSlideTypeDialog(false) + } + + const updateSlideType = async () => { + closeSlideTypeDialog() + if (activeSlide) { + if (activeSlide.questions?.[0] && activeSlide.questions[0].type_id !== selectedSlideType) { + if (selectedSlideType === 0) { + // Change slide type from a question type to information + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}` + ) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + removeQuestionComponent() + }) + .catch(console.log) + } else { + // Change slide type from question type to another question type + await axios + .delete( + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}` + ) + .catch(console.log) + await axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { + name: 'Ny fråga', + total_score: 0, + type_id: selectedSlideType, + }) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + removeQuestionComponent().then(() => { + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + }) + }) + .catch(console.log) + if (selectedSlideType === 1) { + // Add an alternative to text questions to allow giving answers. + await axios + .post( + `/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${activeSlide.questions[0].id}/alternatives` + ) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + } else if (!activeSlide.questions[0] && selectedSlideType !== 0) { + // Change slide type from information to a question type + await axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions`, { + name: 'Ny fråga', + total_score: 0, + type_id: selectedSlideType, + }) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + //No question component for practical questions + if (selectedSlideType !== 2) createQuestionComponent(data.id) + if (selectedSlideType === 1) { + // Add an alternative to text questions to allow giving answers. + axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/questions/${data.id}/alternatives`) + .then(({ data }) => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + }) + .catch(console.log) + } + } + } + + const createQuestionComponent = async (question_id: number) => { + await axios + .post(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components`, { + x: 0, + y: 0, + w: 400, + h: 250, + type_id: 3, + view_type_id: 1, + question_id, + }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + + const removeQuestionComponent = async () => { + const questionComponentId = activeSlide.components.find((component) => component.type_id === 3)?.id + if (questionComponentId) { + await axios + .delete(`/api/competitions/${competitionId}/slides/${activeSlide.id}/components/${questionComponentId}`) + .catch(console.log) + } + } + + return ( + <FirstItem> + <ListItem> + <FormControl fullWidth variant="outlined"> + <InputLabel>Sidtyp</InputLabel> + <Select fullWidth={true} value={activeSlide?.questions?.[0]?.type_id || 0} label="Sidtyp"> + <MenuItem value={0} button onClick={() => openSlideTypeDialog(0)}> + <Typography>Informationssida</Typography> + </MenuItem> + <MenuItem value={1} button onClick={() => openSlideTypeDialog(1)}> + <Typography>Skriftlig fråga</Typography> + </MenuItem> + <MenuItem value={2} button onClick={() => openSlideTypeDialog(2)}> + <Typography>Praktisk fråga</Typography> + </MenuItem> + <MenuItem value={3} button onClick={() => openSlideTypeDialog(3)}> + <Typography>Kryssfråga</Typography> + </MenuItem> + <MenuItem value={4} button onClick={() => openSlideTypeDialog(4)}> + <Typography>Alternativfråga</Typography> + </MenuItem> + <MenuItem value={5} button onClick={() => openSlideTypeDialog(5)}> + <Typography>Para ihop-fråga</Typography> + </MenuItem> + </Select> + </FormControl> + + <Dialog open={slideTypeDialog} onClose={closeSlideTypeDialog}> + <Center> + <DialogTitle color="secondary">Varning!</DialogTitle> + </Center> + <DialogContent> + <DialogContentText> + Om du ändrar sidtypen kommer eventuella frågeinställningar gå förlorade. Det inkluderar: frågans namn, + poäng, svarsalternativ och svar från lagen.{' '} + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={closeSlideTypeDialog} color="secondary"> + Avbryt + </Button> + <Button onClick={updateSlideType} color="primary"> + Bekräfta + </Button> + </DialogActions> + </Dialog> + </ListItem> + </FirstItem> + ) +} + +export default SlideType diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edd4a89f28c440abb20e406de84fb0121647c718 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Texts from './Texts' + +it('renders texts', () => { + render( + <Provider store={store}> + <Texts activeSlide={{ id: 5 } as RichSlide} activeViewTypeId={5} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/Texts.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx similarity index 51% rename from client/src/pages/presentationEditor/components/Texts.tsx rename to client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx index 22cde214c92bb64788b4f69d9993977c85fe981d..dd934e60319ab50419ea68ec0df3d1b8382dcdb5 100644 --- a/client/src/pages/presentationEditor/components/Texts.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx @@ -1,20 +1,20 @@ -import { Divider, ListItem, ListItemText, Typography } from '@material-ui/core' -import React from 'react' -import { useAppSelector } from '../../../hooks' -import { TextComponent } from '../../../interfaces/ApiModels' -import { RichSlide } from '../../../interfaces/ApiRichModels' -import { AddButton, Center, SettingsList, TextCard } from './styled' -import TextComponentEdit from './TextComponentEdit' +import { Divider, ListItem, ListItemText } from '@material-ui/core' import axios from 'axios' -import { getEditorCompetition } from '../../../actions/editor' -import { useDispatch } from 'react-redux' +import React from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch, useAppSelector } from '../../../../hooks' +import { TextComponent } from '../../../../interfaces/ApiModels' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { AddButton, Center, SettingsList, TextCard } from '../styled' +import TextComponentEdit from '../TextComponentEdit' type TextsProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Texts = ({ activeSlide, competitionId }: TextsProps) => { +const Texts = ({ activeViewTypeId, activeSlide, competitionId }: TextsProps) => { const texts = useAppSelector( (state) => state.editor.competition.slides @@ -22,14 +22,15 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { ?.components.filter((component) => component.type_id === 1) as TextComponent[] ) - const dispatch = useDispatch() + const dispatch = useAppDispatch() const handleAddText = async () => { if (activeSlide) { await axios.post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, { type_id: 1, - data: { text: 'Ny text' }, + text: '<p><span style="font-size: 24pt;">Ny text</span></p>', w: 315, h: 50, + view_type_id: activeViewTypeId, }) dispatch(getEditorCompetition(competitionId)) } @@ -43,12 +44,14 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { </Center> </ListItem> {texts && - texts.map((text) => ( - <TextCard elevation={4} key={text.id}> - <TextComponentEdit component={text} /> - <Divider /> - </TextCard> - ))} + texts + .filter((text) => text.view_type_id === activeViewTypeId) + .map((text) => ( + <TextCard elevation={4} key={text.id}> + <TextComponentEdit component={text} /> + <Divider /> + </TextCard> + ))} <ListItem button onClick={handleAddText}> <Center> <AddButton variant="button">Lägg till text</AddButton> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ef0d2ed43af3fae53ad94816027d97091340a83 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import store from '../../../../store' +import Timer from './Timer' + +it('renders timer', () => { + render( + <Provider store={store}> + <Timer activeSlide={{ id: 5 } as RichSlide} competitionId="1" /> + </Provider> + ) +}) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1cc012b8febf61346cd0e7c6e62679d4a3207f99 --- /dev/null +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Timer.tsx @@ -0,0 +1,69 @@ +import { ListItem, TextField } from '@material-ui/core' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { getEditorCompetition } from '../../../../actions/editor' +import { useAppDispatch } from '../../../../hooks' +import { RichSlide } from '../../../../interfaces/ApiRichModels' +import { Center, SettingsItemContainer } from '../styled' + +type TimerProps = { + activeSlide: RichSlide + competitionId: string +} + +const Timer = ({ activeSlide, competitionId }: TimerProps) => { + const maxTime = 1000000 // ms + const dispatch = useAppDispatch() + const [timerHandle, setTimerHandle] = useState<number | undefined>(undefined) + const handleChangeTimer = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + if (timerHandle) { + clearTimeout(timerHandle) + setTimerHandle(undefined) + } + //Only updates slide and api 300s after last input was made + setTimerHandle(window.setTimeout(() => updateTimer(event), 300)) + setTimer(+event.target.value) + } + + const updateTimer = async (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { + /** If timer value is above the max value, set the timer value to max value to not overflow the server */ + // Sets score to event.target.value if it's between 0 and max + const timerValue = Math.max(0, Math.min(+event.target.value, maxTime)) + if (activeSlide) { + setTimer(timerValue) + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlide.id}`, { timer: timerValue || null }) + .then(() => { + dispatch(getEditorCompetition(competitionId)) + }) + .catch(console.log) + } + } + + const [timer, setTimer] = useState<number | null>(activeSlide?.timer) + useEffect(() => { + setTimer(activeSlide?.timer) + }, [activeSlide]) + return ( + <ListItem> + <Center> + <SettingsItemContainer> + <TextField + id="standard-number" + fullWidth={true} + variant="outlined" + placeholder="Antal sekunder" + helperText="Lämna blank för att inte använda timerfunktionen" + label="Timer" + type="number" + onChange={handleChangeTimer} + InputProps={{ inputProps: { min: 0, max: 1000000 } }} + value={timer || ''} + /> + </SettingsItemContainer> + </Center> + </ListItem> + ) +} + +export default Timer diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index a636d9d2d4d3f7faac3eef6166e5c07544c5a596..106e360efe10519a8818efab416f1d3ae7e84513 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -1,15 +1,4 @@ -import { - FormControl, - List, - Tab, - TextField, - Typography, - Button, - Card, - ListItem, - Select, - InputLabel, -} from '@material-ui/core' +import { Button, Card, List, ListItemText, Tab, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SettingsTab = styled(Tab)` @@ -18,17 +7,15 @@ export const SettingsTab = styled(Tab)` ` export const SlideEditorContainer = styled.div` + overflow: hidden; height: 100%; display: flex; align-items: center; justify-content: center; - background-color: rgba(0, 0, 0, 0.08); ` export const SlideEditorContainerRatio = styled.div` - padding-top: 56.25%; width: 100%; - height: 0; overflow: hidden; padding-top: 56.25%; position: relative; @@ -56,52 +43,39 @@ export const ToolbarPadding = styled.div` padding-top: 55px; ` -export const FormControlDropdown = styled(FormControl)` +export const FirstItem = styled.div` width: 100%; - margin-top: 10px; - padding: 8px; - padding-left: 16px; - padding-right: 16px; + padding-top: 10px; ` -export const SlideTypeInputLabel = styled(InputLabel)` - width: 100%; - padding: 10px; - padding-left: 22px; -` - -export const TextInput = styled(TextField)` +export const AlternativeTextField = styled(TextField)` width: 87%; ` -export const NoPadding = styled.div` - padding: 0; - height: 100%; - width: 100%; -` - export const Center = styled.div` display: flex; justify-content: center; text-align: center; height: 100%; width: 100%; + overflow-x: hidden; ` -export const SlidePanel = styled.div` - padding: 10px; +export const ImageTextContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; width: 100%; ` -export const WhiteBackground = styled.div` - background: white; +export const PanelContainer = styled.div` + padding: 10px; + width: 100%; ` export const AddButton = styled(Typography)` - padding-left: 8px; - padding-right: 8px; - padding-top: 7px; - padding-bottom: 7px; + padding: 7px 8px 7px 8px; ` export const ImportedImage = styled.img` @@ -114,8 +88,17 @@ export const Clickable = styled.div` ` export const AddImageButton = styled.label` - padding: 0; - cursor: 'pointer'; + padding: 8px 13px 8px 13px; + display: flex; + justify-content: center; + text-align: center; + height: 100%; + width: 100%; + cursor: pointer; +` + +export const AddBackgroundButton = styled.label` + padding: 16px 29px 16px 29px; display: flex; justify-content: center; text-align: center; @@ -140,13 +123,38 @@ export const DeleteTextButton = styled(Button)` margin-bottom: 7px; ` -interface TextComponentContainerProps { +interface HoverContainerProps { hover: boolean } -export const TextComponentContainer = styled.div<TextComponentContainerProps>` +export const HoverContainer = styled.div<HoverContainerProps>` height: 100%; width: 100%; padding: ${(props) => (props.hover ? 0 : 1)}px; border: solid ${(props) => (props.hover ? 1 : 0)}px; ` + +export const ImageNameText = styled(ListItemText)` + word-break: break-all; +` + +export const QuestionComponent = styled.div` + outline-style: double; +` + +export const SettingsItemContainer = styled.div` + padding: 5px; +` + +interface SlideDisplayTextProps { + $scale: number + $right?: boolean +} + +export const SlideDisplayText = styled(Typography)<SlideDisplayTextProps>` + position: absolute; + top: 0px; + left: ${(props) => (props.$right ? undefined : 0)}px; + right: ${(props) => (props.$right ? 0 : undefined)}px; + font-size: ${(props) => 24 * props.$scale}px; +` diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index d1f05d6bf18de2eccc6b486fb6e94701acce3b5c..4b6e7a0cbae81dbf5c09f4c7eaf0cb02c300c854 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -1,32 +1,42 @@ -import { Button, List, ListItem, Toolbar } from '@material-ui/core' +import { AppBar, Button, Drawer, List, ListItem, Toolbar, Typography } from '@material-ui/core' import styled from 'styled-components' +interface ViewButtonProps { + $activeView: boolean +} + +interface DrawerSizeProps { + $leftDrawerWidth: number | undefined + $rightDrawerWidth: number | undefined +} + +const AppBarHeight = 64 +const SlideListHeight = 60 + export const ToolBarContainer = styled(Toolbar)` display: flex; justify-content: space-between; padding-left: 0; ` -export const ViewButton = styled(Button)` - margin-right: 8px; +export const ViewButton = styled(Button)<ViewButtonProps>` + background: ${(props) => (!props.$activeView ? '#5a0017' : undefined)}; ` -export const ViewButtonGroup = styled.div` - display: flex; - flex-direction: row; +export const SlideList = styled(List)` + height: calc(100% - ${SlideListHeight}px); + padding: 0px; + overflow-y: auto; ` -export const SlideList = styled(List)` - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; +export const RightPanelScroll = styled(List)` padding: 0px; + overflow-y: auto; ` export const SlideListItem = styled(ListItem)` text-align: center; - height: 60px; + height: ${SlideListHeight}px; ` export const PresentationEditorContainer = styled.div` @@ -41,5 +51,55 @@ export const CenteredSpinnerContainer = styled.div` ` export const HomeIcon = styled.img` - height: 64px; + height: ${AppBarHeight}px; +` + +export const LeftDrawer = styled(Drawer)<DrawerSizeProps>` + width: ${(props) => (props ? props.$leftDrawerWidth : 0)}px; + flex-shrink: 0; + position: relative; + z-index: 1; + overflow: hidden; +` + +export const RightDrawer = styled(Drawer)<DrawerSizeProps>` + width: ${(props) => (props ? props.$rightDrawerWidth : 0)}px; + flex-shrink: 0; +` + +export const AppBarEditor = styled(AppBar)<DrawerSizeProps>` + width: calc(100% - ${(props) => (props ? props.$rightDrawerWidth : 0)}px); + left: 0; + margin-left: $leftDrawerWidth; + margin-right: $rightDrawerWidth; +` + +// Necessary for content to be below app bar +export const ToolbarMargin = styled.div` + padding-top: ${AppBarHeight}px; +` + +export const FillLeftContainer = styled.div<DrawerSizeProps>` + width: ${(props) => (props ? props.$leftDrawerWidth : 0)}px; + height: calc(100% - ${SlideListHeight}px); + overflow: hidden; +` + +export const FillRightContainer = styled.div<DrawerSizeProps>` + width: ${(props) => (props ? props.$rightDrawerWidth : 0)}px; + height: 100%; + overflow-y: auto; + background: #e9e9e9; +` + +export const PositionBottom = styled.div` + position: absolute; + bottom: 0; + width: 100%; +` + +export const CompetitionName = styled(Typography)` + text-decoration: none; + position: absolute; + left: 180px; ` diff --git a/client/src/pages/views/AudienceViewPage.test.tsx b/client/src/pages/views/AudienceViewPage.test.tsx index d00d4277e8b3a5021088b02a13947fb41f28b9f1..be17ab7428591823718ce2a5451ab594c517c54d 100644 --- a/client/src/pages/views/AudienceViewPage.test.tsx +++ b/client/src/pages/views/AudienceViewPage.test.tsx @@ -1,13 +1,23 @@ 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 AudienceViewPage from './AudienceViewPage' it('renders audience view page', () => { + const typeRes: any = { + data: { id: 5, slides: [{ id: 2 }] }, + } + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(typeRes) + }) render( - <Provider store={store}> - <AudienceViewPage /> - </Provider> + <BrowserRouter> + <Provider store={store}> + <AudienceViewPage /> + </Provider> + </BrowserRouter> ) }) diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index 00a821f35a4ad05354954fdb50694012cb2bda46..0024e701d8dea1f14baa2fe88484837744a5f9ee 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -1,8 +1,39 @@ -import React from 'react' -import SlideDisplay from './components/SlideDisplay' +import { Snackbar, Typography } from '@material-ui/core' +import { Alert } from '@material-ui/lab' +import React, { useEffect, useState } from 'react' +import { useAppSelector } from '../../hooks' +import { socketConnect } from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import Scoreboard from './components/Scoreboard' +import { PresentationBackground, PresentationContainer } from './styled' const AudienceViewPage: React.FC = () => { - return <SlideDisplay /> + const code = useAppSelector((state) => state.presentation.code) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + const [successMessageOpen, setSuccessMessageOpen] = useState(true) + const competitionName = useAppSelector((state) => state.presentation.competition.name) + const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard) + + useEffect(() => { + if (code && code !== '') { + socketConnect('Audience') + } + }, []) + if (activeViewTypeId) { + return ( + <PresentationBackground> + <PresentationContainer> + <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} /> + </PresentationContainer> + <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}> + <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som åskådare`}</Alert> + </Snackbar> + {showScoreboard && <Scoreboard />} + </PresentationBackground> + ) + } + return <Typography>Error: Åskådarvyn kunde inte laddas</Typography> } export default AudienceViewPage diff --git a/client/src/pages/views/JudgeViewPage.test.tsx b/client/src/pages/views/JudgeViewPage.test.tsx index 537dae4c570f3b7ed50dadaa9f60bae1be8823a2..86176923391e661b6ef8e2032d4d13c1b46bc580 100644 --- a/client/src/pages/views/JudgeViewPage.test.tsx +++ b/client/src/pages/views/JudgeViewPage.test.tsx @@ -9,7 +9,7 @@ import JudgeViewPage from './JudgeViewPage' it('renders judge view page', () => { const compRes: any = { data: { - slides: [{ id: 0, title: '' }], + slides: [{ id: 0, title: '', questions: [{ id: 0 }] }], }, } const teamsRes: any = { diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index 66450f3a1f8ac98f14423d7040e215c0c96881c6..ad328fa018ea9d1430c9d454e64eea9073479a1a 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -1,32 +1,32 @@ -import { Divider, List, ListItemText, Typography } from '@material-ui/core' +import { Divider, List, ListItemText, Snackbar, Typography } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { Alert } from '@material-ui/lab' import React, { useEffect, useState } from 'react' -import { useParams } from 'react-router-dom' -import { - getPresentationCompetition, - getPresentationTeams, - setCurrentSlide, - setPresentationCode, -} from '../../actions/presentation' +import { getPresentationCompetition, setPresentationTimer } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' -import { socket_connect } from '../../sockets' +import { RichSlide } from '../../interfaces/ApiRichModels' +import { socketConnect } from '../../sockets' +import { renderSlideIcon } from '../../utils/renderSlideIcon' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { SlideListItem } from '../presentationEditor/styled' import JudgeScoreDisplay from './components/JudgeScoreDisplay' -import SlideDisplay from './components/SlideDisplay' -import { useHistory } from 'react-router-dom' +import JudgeScoringInstructions from './components/JudgeScoringInstructions' import { Content, + InnerContent, JudgeAnswersLabel, JudgeAppBar, JudgeQuestionsLabel, JudgeToolbar, LeftDrawer, RightDrawer, + ScoreFooterPadding, + ScoreHeaderPadding, + ScoreHeaderPaper, } from './styled' const leftDrawerWidth = 150 -const rightDrawerWidth = 390 +const rightDrawerWidth = 700 const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -42,30 +42,74 @@ const useStyles = makeStyles((theme: Theme) => const JudgeViewPage: React.FC = () => { const classes = useStyles() - const history = useHistory() - const { id, code }: ViewParams = useParams() const dispatch = useAppDispatch() - const [activeSlideIndex, setActiveSlideIndex] = useState<number>(0) - const teams = useAppSelector((state) => state.presentation.teams) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const code = useAppSelector((state) => state.presentation.code) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Team')?.id + const teams = useAppSelector((state) => state.presentation.competition.teams) const slides = useAppSelector((state) => state.presentation.competition.slides) + const [successMessageOpen, setSuccessMessageOpen] = useState(true) + const competitionName = useAppSelector((state) => state.presentation.competition.name) + const [currentSlide, setCurrentSlide] = useState<RichSlide | undefined>(undefined) + const currentQuestion = currentSlide?.questions[0] + const operatorActiveSlideId = useAppSelector((state) => state.presentation.activeSlideId) + const operatorActiveSlideOrder = useAppSelector( + (state) => state.presentation.competition.slides.find((slide) => slide.id === operatorActiveSlideId)?.order + ) + const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) const handleSelectSlide = (index: number) => { - setActiveSlideIndex(index) - dispatch(setCurrentSlide(slides[index])) + setCurrentSlide(slides[index]) } useEffect(() => { - socket_connect() - dispatch(getPresentationCompetition(id)) - dispatch(getPresentationTeams(id)) - dispatch(setPresentationCode(code)) - //hides the url so people can't sneak peak - history.push('judge') + if (code && code !== '') { + socketConnect('Judge') + } }, []) + useEffect(() => { + if (!currentSlide) setCurrentSlide(slides?.[0]) + }, [slides]) + useEffect(() => { + if (competitionId) { + dispatch(getPresentationCompetition(competitionId.toString())) + } + }, [operatorActiveSlideId]) + + const timer = useAppSelector((state) => state.presentation.timer) + const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) + useEffect(() => { + if (!timer.enabled) { + if (timerIntervalId !== null && competitionId) { + clearInterval(timerIntervalId) + dispatch(getPresentationCompetition(competitionId.toString())) + } + return + } + setTimerIntervalId( + setInterval(() => { + if (timer.value === null) return + + if (timer.value - Date.now() < 0) { + if (competitionId) { + dispatch(getPresentationCompetition(competitionId.toString())) + } + dispatch(setPresentationTimer({ ...timer, enabled: false })) + return + } + if (competitionId) { + dispatch(getPresentationCompetition(competitionId.toString())) + } + }, 1000) + ) + }, [timer.enabled]) return ( - <div> + <div style={{ height: '100%' }}> <JudgeAppBar position="fixed"> <JudgeToolbar> <JudgeQuestionsLabel variant="h5">Frågor</JudgeQuestionsLabel> + {operatorActiveSlideOrder !== undefined && ( + <Typography variant="h5">Operatör är på sida: {operatorActiveSlideOrder + 1}</Typography> + )} <JudgeAnswersLabel variant="h5">Svar</JudgeAnswersLabel> </JudgeToolbar> </JudgeAppBar> @@ -80,16 +124,18 @@ const JudgeViewPage: React.FC = () => { <div className={classes.toolbar} /> <List> {slides.map((slide, index) => ( - <SlideListItem - selected={index === activeSlideIndex} - onClick={() => handleSelectSlide(index)} - divider - button - key={slide.id} - > - <Typography variant="h6">Slide ID: {slide.id} </Typography> - <ListItemText primary={slide.title} /> - </SlideListItem> + <div key={slide.id}> + <SlideListItem + selected={slide.order === currentSlide?.order} + onClick={() => handleSelectSlide(index)} + button + style={{ border: 2, borderStyle: slide.id === operatorActiveSlideId ? 'dashed' : 'none' }} + > + {renderSlideIcon(slide)} + <ListItemText primary={`Sida ${slide.order + 1}`} /> + </SlideListItem> + <Divider /> + </div> ))} </List> </LeftDrawer> @@ -102,19 +148,35 @@ const JudgeViewPage: React.FC = () => { anchor="right" > <div className={classes.toolbar} /> - <List> - {teams.map((answer, index) => ( - <div key={answer.name}> - <JudgeScoreDisplay teamIndex={index} /> - <Divider /> - </div> - ))} + {currentQuestion && ( + <ScoreHeaderPaper $rightDrawerWidth={rightDrawerWidth} elevation={4}> + <Typography variant="h4">{`${currentQuestion.name} (${currentQuestion.total_score}p)`}</Typography> + </ScoreHeaderPaper> + )} + <ScoreHeaderPadding /> + <List style={{ overflowY: 'auto', overflowX: 'hidden' }}> + {teams && + teams.map((answer, index) => ( + <div key={answer.name}> + {currentSlide && <JudgeScoreDisplay teamIndex={index} activeSlide={currentSlide} />} + <Divider /> + </div> + ))} </List> + <ScoreFooterPadding /> + {currentQuestion && <JudgeScoringInstructions question={currentQuestion} />} </RightDrawer> + <div className={classes.toolbar} /> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> - <div className={classes.toolbar} /> - <SlideDisplay /> + <InnerContent> + {activeViewTypeId && currentSlide && ( + <SlideDisplay variant="presentation" currentSlideId={currentSlide.id} activeViewTypeId={activeViewTypeId} /> + )} + </InnerContent> </Content> + <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}> + <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som domare`}</Alert> + </Snackbar> </div> ) } diff --git a/client/src/pages/views/OperatorViewPage.test.tsx b/client/src/pages/views/OperatorViewPage.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f20cb695a695f94ccc519fe1f149ad5d957a86d2 --- /dev/null +++ b/client/src/pages/views/OperatorViewPage.test.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react' +import mockedAxios from 'axios' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { BrowserRouter } from 'react-router-dom' +import store from '../../store' +import OperatorViewPage from './OperatorViewPage' + +it('renders operator view page', async () => { + await act(async () => { + const compRes: any = { + data: { + slides: [{ id: 0, title: '' }], + }, + } + const teamsRes: any = { + data: [ + { + id: 1, + name: 'team1', + }, + { + id: 2, + name: 'team2', + }, + ], + headers: { + pagination: '{"count": 2,"total_count": 3}', + }, + } + + ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { + if (path.endsWith('/teams')) return Promise.resolve(teamsRes) + else return Promise.resolve(compRes) + }) + render( + <BrowserRouter> + <Provider store={store}> + <OperatorViewPage /> + </Provider> + </BrowserRouter> + ) + }) +}) diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55946eec366106c42886ea5f0cb9fb69de225eb3 --- /dev/null +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -0,0 +1,360 @@ +import { + Box, + Button, + createStyles, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + ListItem, + ListItemText, + makeStyles, + Snackbar, + Theme, + Tooltip, + Typography, +} from '@material-ui/core' +import AssignmentIcon from '@material-ui/icons/Assignment' +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft' +import ChevronRightIcon from '@material-ui/icons/ChevronRight' +import CloseIcon from '@material-ui/icons/Close' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import LinkIcon from '@material-ui/icons/Link' +import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount' +import TimerIcon from '@material-ui/icons/Timer' +import { Alert } from '@material-ui/lab' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { logoutCompetition } from '../../actions/competitionLogin' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { socketConnect, socketEndPresentation, socketSync } from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import { Center } from '../presentationEditor/components/styled' +import Scoreboard from './components/Scoreboard' +import Timer from './components/Timer' +import { + OperatorButton, + OperatorContainer, + OperatorContent, + OperatorFooter, + OperatorHeader, + OperatorHeaderItem, + OperatorInnerContent, + OperatorQuitButton, +} from './styled' + +/** + * Description: + * + * Presentation is an active competition + * + * + * =========================================== + * TODO: + * - When two userers are connected to the same Localhost:5000 and updates/starts/end competition it + * creates a bug where the competition can't be started. + * =========================================== + */ +const useStyles = makeStyles((theme: Theme) => + createStyles({ + table: { + width: '100%', + }, + margin: { + margin: theme.spacing(1), + }, + paper: { + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[5], + padding: 4, + outline: 'none', + }, + }) +) + +interface Code { + id: number + code: string + view_type_id: number + competition_id: number + team_id: number +} + +const OperatorViewPage: React.FC = () => { + // for dialog alert + const [openAlert, setOpen] = React.useState(false) + const [openAlertCode, setOpenCode] = React.useState(false) + const [codes, setCodes] = React.useState<Code[]>([]) + const competitionName = useAppSelector((state) => state.presentation.competition.name) + + //const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) + const dispatch = useAppDispatch() + const classes = useStyles() + const teams = useAppSelector((state) => state.presentation.competition.teams) + const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) + const presentation = useAppSelector((state) => state.presentation) + const activeId = useAppSelector((state) => state.presentation.competition.id) + const timer = useAppSelector((state) => state.presentation.timer) + const history = useHistory() + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + const [successMessageOpen, setSuccessMessageOpen] = useState(true) + const activeSlideOrder = useAppSelector( + (state) => + state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.order + ) + const slideTimer = useAppSelector((state) => + activeSlideOrder !== undefined ? state.presentation.competition.slides[activeSlideOrder].timer : null + ) + const isFirstSlide = activeSlideOrder === 0 + const isLastSlide = useAppSelector((state) => activeSlideOrder === state.presentation.competition.slides.length - 1) + const showScoreboard = useAppSelector((state) => state.presentation.show_scoreboard) + + useEffect(() => { + socketConnect('Operator') + }, []) + + /** Handles the browsers back button and if pressed cancels the ongoing competition */ + window.onpopstate = () => { + alert('Tävlingen avslutas för alla') + endCompetition() + } + + /** Handles the closing of popup*/ + const handleClose = () => { + setOpen(false) + setOpenCode(false) + setAnchorEl(null) + } + + /** Making sure the user wants to exit the competition by displaying a dialog box */ + const handleVerifyExit = () => { + setOpen(true) + } + + /** Handles opening the code popup */ + const handleOpenCodes = async () => { + await getCodes() + setOpenCode(true) + } + + /** Function that runs when the operator closes the competition */ + const endCompetition = () => { + setOpen(false) + socketEndPresentation() + dispatch(logoutCompetition('Operator')) + } + + /** Retrives the codes from the server */ + const getCodes = async () => { + await axios + .get(`/api/competitions/${activeId}/codes`) + .then((response) => { + setCodes(response.data) + }) + .catch(console.log) + } + const getTypeName = (code: Code) => { + let typeName = '' + switch (code.view_type_id) { + case 1: + const team = teams.find((team) => team.id === code.team_id) + if (team) { + typeName = team.name + } else { + typeName = 'Lagnamn hittades ej' + } + break + case 2: + typeName = 'Domare' + break + case 3: + typeName = 'Publik' + break + case 4: + typeName = 'Tävlingsoperatör' + break + default: + typeName = 'Typ hittades ej' + break + } + return typeName + } + + /** Starting the timer */ + const handleStartTimer = () => { + if (!slideTimer) return + + if (!timer.enabled) socketSync({ timer: { value: Date.now() + 1000 * slideTimer, enabled: true } }) + else socketSync({ timer: { ...timer, enabled: false } }) + } + + /** Function that runs when operator presses the next slide button */ + const handleSetNextSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder + 1, timer: { value: null, enabled: false } }) + } + + /** Function that runs when operator presses the previous slide button */ + const handleSetPrevSlide = () => { + if (activeSlideOrder !== undefined) + socketSync({ slide_order: activeSlideOrder - 1, timer: { value: null, enabled: false } }) + } + + return ( + <OperatorContainer> + <Dialog open={openAlertCode} onClose={handleClose} aria-labelledby="max-width-dialog-title" maxWidth="xl"> + <Center> + <DialogTitle id="max-width-dialog-title" className={classes.paper} style={{ width: '100%' }}> + Koder för {competitionName} + </DialogTitle> + </Center> + <DialogContent> + {/** competition codes popup */} + {codes && + codes.map((code) => ( + <ListItem key={code.id} style={{ display: 'flex' }}> + <ListItemText primary={`${getTypeName(code)}: `} /> + <Typography component="div"> + <ListItemText style={{ textAlign: 'right', marginLeft: '10px' }}> + <Box fontFamily="Monospace" fontWeight="fontWeightBold"> + {code.code} + </Box> + </ListItemText> + </Typography> + <Tooltip title="Kopiera kod" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(code.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + <Tooltip title="Kopiera länk" arrow> + <Button + margin-right="0px" + onClick={() => { + navigator.clipboard.writeText(`${window.location.host}/${code.code}`) + }} + > + <LinkIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + ))} + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary"> + Stäng + </Button> + </DialogActions> + </Dialog> + + {/* Verify exit popup */} + <OperatorHeader color="primary" position="fixed"> + <Tooltip title="Avsluta tävling" arrow> + <OperatorQuitButton onClick={handleVerifyExit} variant="contained" color="secondary"> + <CloseIcon fontSize="large" /> + </OperatorQuitButton> + </Tooltip> + + <Dialog open={openAlert} onClose={handleClose} aria-labelledby="responsive-dialog-title"> + <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> + <DialogContent> + <DialogContentText> + Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. + </DialogContentText> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleClose} color="primary"> + Avbryt + </Button> + <Button onClick={endCompetition} color="primary" autoFocus> + Avsluta tävling + </Button> + </DialogActions> + </Dialog> + <OperatorHeaderItem> + <Typography variant="h4">{presentation.competition.name}</Typography> + </OperatorHeaderItem> + <OperatorHeaderItem> + <Typography variant="h4"> + {activeSlideOrder !== undefined && activeSlideOrder + 1} / {presentation.competition.slides.length} + </Typography> + </OperatorHeaderItem> + </OperatorHeader> + {<div style={{ minHeight: 64 }} />} + + {/* Show the correct slide */} + <OperatorContent> + <OperatorInnerContent> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </OperatorInnerContent> + </OperatorContent> + {<div style={{ minHeight: 128 }} />} + + {/* Show the operator buttons */} + <OperatorFooter position="fixed"> + <Tooltip title="Föregående sida" arrow> + <div> + <OperatorButton onClick={handleSetPrevSlide} variant="contained" disabled={isFirstSlide} color="primary"> + <ChevronLeftIcon fontSize="large" /> + </OperatorButton> + </div> + </Tooltip> + + {slideTimer !== null && ( + <Tooltip title="Starta timer" arrow> + <div> + <OperatorButton + onClick={handleStartTimer} + variant="contained" + disabled={timer.value !== null && !timer.enabled} + color="primary" + > + <TimerIcon fontSize="large" /> + <Timer variant="presentation" /> + </OperatorButton> + </div> + </Tooltip> + )} + + <Tooltip title="Visa ställning för publik" arrow> + <OperatorButton onClick={() => socketSync({ show_scoreboard: true })} variant="contained" color="primary"> + <AssignmentIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + {showScoreboard && <Scoreboard isOperator />} + + <Tooltip title="Visa koder" arrow> + <OperatorButton onClick={handleOpenCodes} variant="contained" color="primary"> + <SupervisorAccountIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Tooltip title="Nästa sida" arrow> + <div> + <OperatorButton onClick={handleSetNextSlide} variant="contained" disabled={isLastSlide} color="primary"> + <ChevronRightIcon fontSize="large" /> + </OperatorButton> + </div> + </Tooltip> + </OperatorFooter> + + {/* Show the user that they have joined the competition */} + <Snackbar + open={successMessageOpen && Boolean(competitionName)} + autoHideDuration={4000} + onClose={() => setSuccessMessageOpen(false)} + > + <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som operatör`}</Alert> + </Snackbar> + </OperatorContainer> + ) +} + +export default OperatorViewPage diff --git a/client/src/pages/views/ParticipantViewPage.tsx b/client/src/pages/views/ParticipantViewPage.tsx deleted file mode 100644 index f531ad76db15bc5ba8578b4015e2d5feed8ea1e8..0000000000000000000000000000000000000000 --- a/client/src/pages/views/ParticipantViewPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { useEffect } from 'react' -import SlideDisplay from './components/SlideDisplay' -import { useHistory } from 'react-router-dom' - -const ParticipantViewPage: React.FC = () => { - const history = useHistory() - useEffect(() => { - //hides the url so people can't sneak peak - history.push('participant') - }, []) - return <SlideDisplay /> -} - -export default ParticipantViewPage diff --git a/client/src/pages/views/PresenterViewPage.test.tsx b/client/src/pages/views/PresenterViewPage.test.tsx deleted file mode 100644 index fd7b0a9692e08354330b3e4db98c046649c0d10a..0000000000000000000000000000000000000000 --- a/client/src/pages/views/PresenterViewPage.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 PresenterViewPage from './PresenterViewPage' - -it('renders presenter view page', () => { - const compRes: any = { - data: { - slides: [{ id: 0, title: '' }], - }, - } - const teamsRes: any = { - data: { - items: [ - { - id: 1, - name: 'team1', - }, - { - id: 2, - name: 'team2', - }, - ], - count: 2, - total_count: 3, - }, - } - - ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { - if (path.endsWith('/teams')) return Promise.resolve(teamsRes) - else return Promise.resolve(compRes) - }) - render( - <BrowserRouter> - <Provider store={store}> - <PresenterViewPage /> - </Provider> - </BrowserRouter> - ) -}) diff --git a/client/src/pages/views/PresenterViewPage.tsx b/client/src/pages/views/PresenterViewPage.tsx deleted file mode 100644 index 1abeee92c519c3aae977d667f68470ca78cafe2d..0000000000000000000000000000000000000000 --- a/client/src/pages/views/PresenterViewPage.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - List, - ListItem, - Popover, - Tooltip, - Typography, - useMediaQuery, - useTheme, -} from '@material-ui/core' -import AssignmentIcon from '@material-ui/icons/Assignment' -import BackspaceIcon from '@material-ui/icons/Backspace' -import ChevronLeftIcon from '@material-ui/icons/ChevronLeft' -import ChevronRightIcon from '@material-ui/icons/ChevronRight' -import TimerIcon from '@material-ui/icons/Timer' -import React, { useEffect } from 'react' -import { useHistory, useParams } from 'react-router-dom' -import { getPresentationCompetition, getPresentationTeams, setPresentationCode } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' -import { - socketEndPresentation, - socketSetSlide, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, - socket_connect, -} from '../../sockets' -import SlideDisplay from './components/SlideDisplay' -import Timer from './components/Timer' -import { - PresenterButton, - PresenterContainer, - PresenterFooter, - PresenterHeader, - SlideCounter, - ToolBarContainer, -} from './styled' - -/** - * Presentation is an active competition - */ - -const PresenterViewPage: React.FC = () => { - // for dialog alert - const [openAlert, setOpen] = React.useState(false) - const theme = useTheme() - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) - const teams = useAppSelector((state) => state.presentation.teams) - const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const { id, code }: ViewParams = useParams() - const presentation = useAppSelector((state) => state.presentation) - const history = useHistory() - const dispatch = useAppDispatch() - - useEffect(() => { - dispatch(getPresentationCompetition(id)) - dispatch(getPresentationTeams(id)) - dispatch(setPresentationCode(code)) - socket_connect() - socketSetSlide // Behövs denna? - setTimeout(startCompetition, 500) // Ghetto, wait for everything to load - // console.log(id) - }, []) - - const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setOpen(false) - setAnchorEl(null) - } - - const startCompetition = () => { - socketStartPresentation() - console.log('started competition for') - console.log(id) - } - - const handleVerifyExit = () => { - setOpen(true) - } - - const endCompetition = () => { - setOpen(false) - socketEndPresentation() - history.push('/admin/tävlingshanterare') - window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again - } - - return ( - <PresenterContainer> - <PresenterHeader> - <Tooltip title="Avsluta tävling" arrow> - <PresenterButton onClick={handleVerifyExit} variant="contained" color="secondary"> - <BackspaceIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Dialog - fullScreen={fullScreen} - open={openAlert} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > - <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> - <DialogContent> - <DialogContentText> - Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Avbryt - </Button> - <Button onClick={endCompetition} color="primary" autoFocus> - Avsluta tävling - </Button> - </DialogActions> - </Dialog> - <Typography variant="h3">{presentation.competition.name}</Typography> - <SlideCounter> - <Typography variant="h3"> - {presentation.slide.order + 1} / {presentation.competition.slides.length} - </Typography> - </SlideCounter> - </PresenterHeader> - <SlideDisplay /> - <PresenterFooter> - <ToolBarContainer> - <Tooltip title="Previous Slide" arrow> - <PresenterButton onClick={socketSetSlidePrev} variant="contained"> - <ChevronLeftIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - {/* - // Manual start button - <Tooltip title="Start Presentation" arrow> - <PresenterButton onClick={startCompetition} variant="contained"> - start - </PresenterButton> - </Tooltip> - - - // This creates a join button, but presenter should not join others, others should join presenter - <Tooltip title="Join Presentation" arrow> - <PresenterButton onClick={socketJoinPresentation} variant="contained"> - <GroupAddIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - - // This creates another end button, it might not be needed since we already have one - <Tooltip title="End Presentation" arrow> - <PresenterButton onClick={socketEndPresentation} variant="contained"> - <CancelIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - */} - - <Tooltip title="Start Timer" arrow> - <PresenterButton onClick={socketStartTimer} variant="contained"> - <TimerIcon fontSize="large" /> - <Timer></Timer> - </PresenterButton> - </Tooltip> - - <Tooltip title="Scoreboard" arrow> - <PresenterButton onClick={handleOpenPopover} variant="contained"> - <AssignmentIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Tooltip title="Next Slide" arrow> - <PresenterButton onClick={socketSetSlideNext} variant="contained"> - <ChevronRightIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - </ToolBarContainer> - </PresenterFooter> - <Popover - open={Boolean(anchorEl)} - anchorEl={anchorEl} - onClose={handleClose} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - > - <List> - {/** TODO: - * Fix scoreboard - */} - {teams.map((team) => ( - <ListItem key={team.id}>{team.name} score: 20</ListItem> - ))} - </List> - </Popover> - </PresenterContainer> - ) -} - -export default PresenterViewPage -function componentDidMount() { - throw new Error('Function not implemented.') -} diff --git a/client/src/pages/views/ParticipantViewPage.test.tsx b/client/src/pages/views/TeamViewPage.test.tsx similarity index 57% rename from client/src/pages/views/ParticipantViewPage.test.tsx rename to client/src/pages/views/TeamViewPage.test.tsx index c0950b3c6d3dfeaf1b1ce2d1293829c10651fe33..33f7014df5d9974f141dd65273af3050062766ed 100644 --- a/client/src/pages/views/ParticipantViewPage.test.tsx +++ b/client/src/pages/views/TeamViewPage.test.tsx @@ -1,15 +1,22 @@ 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 ParticipantViewPage from './ParticipantViewPage' +import TeamViewPage from './TeamViewPage' it('renders participant view page', () => { + const res = { + data: { slides: [{ id: 5 }] }, + } + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(res) + }) render( <BrowserRouter> <Provider store={store}> - <ParticipantViewPage /> + <TeamViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..540bc33c0dd1b344bf4ee841633bb417da50af55 --- /dev/null +++ b/client/src/pages/views/TeamViewPage.tsx @@ -0,0 +1,43 @@ +import { Snackbar } from '@material-ui/core' +import { Alert } from '@material-ui/lab' +import React, { useEffect, useState } from 'react' +import { useAppSelector } from '../../hooks' +import { socketConnect } from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import { OperatorContainer, PresentationBackground, PresentationContainer } from './styled' + +const TeamViewPage: React.FC = () => { + const code = useAppSelector((state) => state.presentation.code) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Team')?.id + const [successMessageOpen, setSuccessMessageOpen] = useState(true) + const competitionName = useAppSelector((state) => state.presentation.competition.name) + const presentation = useAppSelector((state) => state.presentation) + const activeSlideOrder = useAppSelector( + (state) => + state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.order + ) + const teamName = useAppSelector( + (state) => + state.presentation.competition.teams.find((team) => team.id === state.competitionLogin.data?.team_id)?.name + ) + useEffect(() => { + if (code && code !== '') { + socketConnect('Team') + } + }, []) + return ( + <OperatorContainer> + <PresentationBackground> + <PresentationContainer> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </PresentationContainer> + <Snackbar open={successMessageOpen} autoHideDuration={4000} onClose={() => setSuccessMessageOpen(false)}> + <Alert severity="success">{`Du har gått med i tävlingen "${competitionName}" som lag ${teamName}`}</Alert> + </Snackbar> + </PresentationBackground> + </OperatorContainer> + ) +} + +export default TeamViewPage diff --git a/client/src/pages/views/ViewSelectPage.test.tsx b/client/src/pages/views/ViewSelectPage.test.tsx index 83b71db05a13abc23b629003877c5698d99b4481..1843a1863990fe7e9fc54dd77971ed6dc7ef08ae 100644 --- a/client/src/pages/views/ViewSelectPage.test.tsx +++ b/client/src/pages/views/ViewSelectPage.test.tsx @@ -12,9 +12,18 @@ it('renders view select page', async () => { const res = { data: {}, } + const compRes = { + data: { + id: 2, + slides: [{ id: 4 }], + }, + } ;(mockedAxios.post as jest.Mock).mockImplementation(() => { return Promise.resolve(res) }) + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) render( <BrowserRouter> <Provider store={store}> diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index 3c3599edeaf3d9d46ff6c462506d196d79d1a9f7..9bd8d2ae4e4ec01249bd25b26be1927b9b3a1efc 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -1,65 +1,61 @@ -import Button from '@material-ui/core/Button' -import React, { useEffect, useState } from 'react' -import { Link, useRouteMatch } from 'react-router-dom' -import { ViewSelectButtonGroup, ViewSelectContainer } from './styled' -import { useParams } from 'react-router-dom' import { CircularProgress, Typography } from '@material-ui/core' -import ParticipantViewPage from './ParticipantViewPage' -import axios from 'axios' -import PresenterViewPage from './PresenterViewPage' -import JudgeViewPage from './JudgeViewPage' -import AudienceViewPage from './AudienceViewPage' -import { useAppSelector } from '../../hooks' +import React, { useEffect } from 'react' +import { Redirect, useHistory, useParams } from 'react-router-dom' +import { loginCompetition } from '../../actions/competitionLogin' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { ViewSelectButtonGroup, ViewSelectContainer } from './styled' interface ViewSelectParams { code: string } const ViewSelectPage: React.FC = () => { - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [viewTypeId, setViewTypeId] = useState(undefined) - const [competitionId, setCompetitionId] = useState<number | undefined>(undefined) + const dispatch = useAppDispatch() + const history = useHistory() + const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) + const errorMessage = useAppSelector((state) => state.competitionLogin.errors) + const loading = useAppSelector((state) => state.competitionLogin.loading) const { code }: ViewSelectParams = useParams() - const viewType = useAppSelector((state) => state.types.viewTypes.find((viewType) => viewType.id === viewTypeId)?.name) + const viewType = useAppSelector((state) => state.competitionLogin.data?.view) - const renderView = (viewTypeId: number | undefined) => { + const renderView = () => { //Renders the correct view depending on view type - if (competitionId) { + if (competitionId && !errorMessage) { switch (viewType) { case 'Team': - return <ParticipantViewPage /> + return <Redirect to={`/view/team`} /> case 'Judge': - return <JudgeViewPage /> + return <Redirect to={`/view/judge`} /> case 'Audience': - return <AudienceViewPage /> + return <Redirect to={`/view/audience`} /> + case 'Operator': + return <Redirect to={`/view/operator`} /> default: - return <Typography>Inkorrekt vy</Typography> + return ( + <ViewSelectContainer> + <ViewSelectButtonGroup> + <Typography variant="h4">Inkorrekt vy</Typography> + </ViewSelectButtonGroup> + </ViewSelectContainer> + ) } } } - useEffect(() => { - axios - .post('/api/auth/login/code', { code }) - .then((response) => { - setLoading(false) - setViewTypeId(response.data[0].view_type_id) - setCompetitionId(response.data[0].competition_id) - }) - .catch(() => { - setLoading(false) - setError(true) - }) + dispatch(loginCompetition(code, history, false)) }, []) return ( - <ViewSelectContainer> - <ViewSelectButtonGroup> - {loading && <CircularProgress />} - {!loading && renderView(viewTypeId)} - {error && <Typography>Något gick fel, dubbelkolla koden och försök igen</Typography>} - </ViewSelectButtonGroup> - </ViewSelectContainer> + <> + {renderView()} + {(loading || errorMessage) && ( + <ViewSelectContainer> + <ViewSelectButtonGroup> + {loading && <CircularProgress />} + {errorMessage && <Typography variant="h4">{errorMessage}</Typography>} + </ViewSelectButtonGroup> + </ViewSelectContainer> + )} + </> ) } diff --git a/client/src/pages/views/components/JudgeScoreDisplay.tsx b/client/src/pages/views/components/JudgeScoreDisplay.tsx index ae4d8ab3e7e95a44357565093711d9f4722be0a3..7deb0f348e0857bfc581f25ecd9274f38cc25815 100644 --- a/client/src/pages/views/components/JudgeScoreDisplay.tsx +++ b/client/src/pages/views/components/JudgeScoreDisplay.tsx @@ -1,15 +1,94 @@ -import { Box, Typography } from '@material-ui/core' +import { Box, Card, Divider, Typography } from '@material-ui/core' +import axios from 'axios' import React from 'react' -import { useAppSelector } from '../../../hooks' -import { AnswerContainer, ScoreDisplayContainer, ScoreDisplayHeader, ScoreInput } from './styled' +import { getPresentationCompetition } from '../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { RichSlide } from '../../../interfaces/ApiRichModels' +import { + AnswerContainer, + Answers, + AnswersDisplay, + ScoreDisplayContainer, + ScoreDisplayHeader, + ScoreInput, + UnderlinedTypography, +} from './styled' type ScoreDisplayProps = { teamIndex: number + activeSlide: RichSlide } -const questionMaxScore = 5 -const JudgeScoreDisplay = ({ teamIndex }: ScoreDisplayProps) => { - const currentTeam = useAppSelector((state) => state.presentation.teams[teamIndex]) +const JudgeScoreDisplay = ({ teamIndex, activeSlide }: ScoreDisplayProps) => { + const dispatch = useAppDispatch() + const currentTeam = useAppSelector((state) => state.presentation.competition.teams[teamIndex]) + const currentCompetititonId = useAppSelector((state) => state.presentation.competition.id) + + const activeQuestion = activeSlide.questions[0] + const activeScore = currentTeam.question_scores.find((x) => x.question_id === activeQuestion?.id) + + const questions = useAppSelector((state) => state.presentation.competition.slides.map((slide) => slide.questions[0])) + const teamScores = [...currentTeam.question_scores.map((score) => score)] + const scores: (number | undefined)[] = [] + for (const question of questions) { + const correctTeamScore = teamScores.find((score) => question && score.question_id === question.id) + if (correctTeamScore !== undefined) { + scores.push(correctTeamScore.score) + } else scores.push(undefined) + } + const handleEditScore = async (newScore: number, questionId: number) => { + await axios + .put(`/api/competitions/${currentCompetititonId}/teams/${currentTeam.id}/answers/question_scores/${questionId}`, { + score: newScore, + }) + .then(() => dispatch(getPresentationCompetition(currentCompetititonId.toString()))) + } + + const sumTwoScores = (a: number | undefined, b: number | undefined) => { + let aValue = 0 + let bValue = 0 + aValue = a ? a : 0 + bValue = b ? b : 0 + return aValue + bValue + } + + const getAnswers = () => { + const result: string[] = [] + if (!activeQuestion) { + return result + } + for (const alt of activeQuestion.alternatives) { + const ans = currentTeam.question_alternative_answers.find((x) => x.question_alternative_id === alt.id) + if (!ans) { + continue + } + if (activeQuestion.type_id === 1 || activeQuestion.type_id === 5) { + // Text question or match question + result.push(ans.answer) + } else if (+ans.answer > 0) { + result.push(alt.alternative) + } + } + return result + } + + const getAlternatives = () => { + const result: string[] = [] + if (!activeQuestion) { + return result + } + for (const alt of activeQuestion.alternatives) { + // Match question + if (activeQuestion.type_id === 5) { + result.push(`${alt.alternative} - ${alt.correct}`) + } else if (activeQuestion.type_id !== 1 && +alt.correct > 0) { + // Not text question and correct answer + result.push(alt.alternative) + } + } + return result + } + return ( <ScoreDisplayContainer> <ScoreDisplayHeader> @@ -17,21 +96,91 @@ const JudgeScoreDisplay = ({ teamIndex }: ScoreDisplayProps) => { <Box fontWeight="fontWeightBold">{currentTeam.name}</Box> </Typography> - <ScoreInput - label="Poäng" - defaultValue={0} - inputProps={{ style: { fontSize: 20 } }} - InputProps={{ disableUnderline: true, inputProps: { min: 0, max: questionMaxScore } }} - type="number" - ></ScoreInput> + {activeQuestion && ( + <ScoreInput + label="Poäng" + defaultValue={0} + value={activeScore ? activeScore.score : 0} + inputProps={{ style: { fontSize: 20 } }} + InputProps={{ disableUnderline: true, inputProps: { min: 0 } }} + type="number" + onChange={(event) => handleEditScore(+event.target.value, activeQuestion.id)} + /> + )} </ScoreDisplayHeader> - <Typography variant="h6">Alla poäng: 2 0 0 0 0 0 0 0 0</Typography> - <Typography variant="h6">Total poäng: 9</Typography> - <AnswerContainer> - <Typography variant="body1"> - Svar: blablablablablablablablablabla blablablablabla blablablablabla blablablablablablablablablabla{' '} - </Typography> - </AnswerContainer> + <Typography variant="h6"> + Sidor: + <div style={{ display: 'flex' }}> + {questions.map((question, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {index + 1} + </Card> + ))} + </div> + Poäng: + <div style={{ display: 'flex' }}> + {scores.map((score, index) => ( + <Card + key={index} + elevation={2} + style={{ + width: 25, + height: 25, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }} + > + {questions[index] ? score : '-'} + </Card> + ))} + </div> + Totala poäng: {scores.reduce((a, b) => sumTwoScores(a, b), 0)} + </Typography> + <AnswersDisplay> + <Answers> + <Divider /> + <UnderlinedTypography variant="body1">Lagets svar:</UnderlinedTypography> + {activeQuestion && ( + <AnswerContainer> + {getAnswers().map((v, k) => ( + <Typography variant="body1" key={k}> + <span>•</span> {v} + </Typography> + ))} + </AnswerContainer> + )} + </Answers> + + <Answers> + <Divider /> + {activeQuestion && activeQuestion.type_id !== 1 && ( + <UnderlinedTypography variant="body1">Korrekta svar:</UnderlinedTypography> + )} + {activeQuestion && ( + <AnswerContainer> + {getAlternatives().map((v, k) => ( + <Typography variant="body1" key={k}> + <span>•</span> {v} + </Typography> + ))} + </AnswerContainer> + )} + </Answers> + </AnswersDisplay> + {!activeQuestion && <Typography variant="body1">Inget svar</Typography>} </ScoreDisplayContainer> ) } diff --git a/client/src/pages/views/components/JudgeScoringInstructions.tsx b/client/src/pages/views/components/JudgeScoringInstructions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f8c21667504524720523004ce9e853616c84e41f --- /dev/null +++ b/client/src/pages/views/components/JudgeScoringInstructions.tsx @@ -0,0 +1,25 @@ +import { Typography } from '@material-ui/core' +import React from 'react' +import { RichQuestion } from '../../../interfaces/ApiRichModels' +import { JudgeScoringInstructionsContainer, ScoringInstructionsInner } from './styled' + +type JudgeScoringInstructionsProps = { + question: RichQuestion +} + +const JudgeScoringInstructions = ({ question }: JudgeScoringInstructionsProps) => { + return ( + <JudgeScoringInstructionsContainer elevation={3}> + <ScoringInstructionsInner> + <Typography variant="h4">Rättningsinstruktioner</Typography> + <Typography variant="body1"> + {question?.correcting_instructions !== null + ? question?.correcting_instructions + : 'Det finns inga rättningsinstruktioner för denna fråga'} + </Typography> + </ScoringInstructionsInner> + </JudgeScoringInstructionsContainer> + ) +} + +export default JudgeScoringInstructions diff --git a/client/src/pages/views/components/PresentationComponent.tsx b/client/src/pages/views/components/PresentationComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71783f6a15921136e1c56e85d29b8f63b8d2aae8 --- /dev/null +++ b/client/src/pages/views/components/PresentationComponent.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { Rnd } from 'react-rnd' +import { ComponentTypes } from '../../../enum/ComponentTypes' +import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' +import ImageComponentDisplay from '../../presentationEditor/components/ImageComponentDisplay' +import QuestionComponentDisplay from '../../presentationEditor/components/QuestionComponentDisplay' +import TextComponentDisplay from '../../presentationEditor/components/TextComponentDisplay' + +type PresentationComponentProps = { + component: Component + scale: number + currentSlideId?: number +} + +const PresentationComponent = ({ component, scale, currentSlideId }: PresentationComponentProps) => { + const renderInnerComponent = () => { + switch (component.type_id) { + case ComponentTypes.Text: + return <TextComponentDisplay component={component as TextComponent} scale={scale} /> + case ComponentTypes.Image: + return ( + <ImageComponentDisplay + height={component.h * scale} + width={component.w * scale} + component={component as ImageComponent} + /> + ) + case ComponentTypes.Question: + return <QuestionComponentDisplay variant="presentation" currentSlideId={currentSlideId} /> + default: + break + } + } + return ( + <Rnd + minWidth={75 * scale} + minHeight={75 * scale} + disableDragging={true} + enableResizing={false} + bounds="parent" + //Multiply by scale to show components correctly for current screen size + size={{ width: component.w * scale, height: component.h * scale }} + position={{ x: component.x * scale, y: component.y * scale }} + > + {renderInnerComponent()} + </Rnd> + ) +} + +export default PresentationComponent diff --git a/client/src/pages/views/components/Scoreboard.tsx b/client/src/pages/views/components/Scoreboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4cbc0c36605716b3edc7ab3439c2cd75c4008464 --- /dev/null +++ b/client/src/pages/views/components/Scoreboard.tsx @@ -0,0 +1,69 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemText, +} from '@material-ui/core' +import React from 'react' +import { useAppSelector } from '../../../hooks' +import { RichTeam } from '../../../interfaces/ApiRichModels' +import { socketSync } from '../../../sockets' +import { Center } from '../../presentationEditor/components/styled' + +type ScoreboardProps = { + isOperator?: boolean +} + +const Scoreboard = ({ isOperator }: ScoreboardProps) => { + const teams = useAppSelector((state) => state.presentation.competition.teams) + + /** Sums the scores for the teams. */ + const addScore = (team: RichTeam) => { + let totalScore = 0 + for (let j = 0; j < team.question_scores.length; j++) { + totalScore = totalScore + team.question_scores[j].score + } + return totalScore + } + + return ( + <Dialog open aria-labelledby="max-width-dialog-title" maxWidth="xl"> + <Center> + <DialogTitle id="max-width-dialog-title" style={{ width: '100%' }}> + <h1>Ställning</h1> + </DialogTitle> + </Center> + <DialogContent> + {(!teams || teams.length === 0) && 'Det finns inga lag i denna tävling'} + <List> + {teams && + teams + .sort((a, b) => (addScore(a) < addScore(b) ? 1 : 0)) + .map((team) => ( + <ListItem key={team.id}> + <ListItemText primary={team.name} /> + <ListItemText + primary={`${addScore(team)} poäng`} + style={{ textAlign: 'right', marginLeft: '25px' }} + /> + </ListItem> + ))} + </List> + </DialogContent> + + {isOperator && ( + <DialogActions> + <Button onClick={() => socketSync({ show_scoreboard: false })} color="primary"> + Stäng + </Button> + </DialogActions> + )} + </Dialog> + ) +} + +export default Scoreboard diff --git a/client/src/pages/views/components/SlideDisplay.test.tsx b/client/src/pages/views/components/SlideDisplay.test.tsx deleted file mode 100644 index 1a661d3340d503c71e149393db3cb00f1e2406c4..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SlideDisplay.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { render } from '@testing-library/react' -import React from 'react' -import { Provider } from 'react-redux' -import store from '../../../store' -import SlideDisplay from './SlideDisplay' - -it('renders slide display', () => { - render( - <Provider store={store}> - <SlideDisplay /> - </Provider> - ) -}) diff --git a/client/src/pages/views/components/SlideDisplay.tsx b/client/src/pages/views/components/SlideDisplay.tsx deleted file mode 100644 index 7ecffac50a735eade79ebcad046040045e37dd93..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SlideDisplay.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Typography } from '@material-ui/core' -import React from 'react' -import { useAppSelector } from '../../../hooks' -import { SlideContainer } from './styled' - -const SlideDisplay: React.FC = () => { - const currentSlide = useAppSelector((state) => state.presentation.slide) - - return ( - <div> - <SlideContainer> - <Typography variant="h3">Slide Title: {currentSlide.title} </Typography> - <Typography variant="h3">Timer: {currentSlide.timer} </Typography> - <Typography variant="h3">Slide ID: {currentSlide.id} </Typography> - </SlideContainer> - </div> - ) -} - -export default SlideDisplay diff --git a/client/src/pages/views/components/SocketTest.tsx b/client/src/pages/views/components/SocketTest.tsx deleted file mode 100644 index d99f5b2aa740d9a2690b3aadeae6e1050b92d089..0000000000000000000000000000000000000000 --- a/client/src/pages/views/components/SocketTest.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect } from 'react' -import { connect } from 'react-redux' -import { useAppDispatch } from '../../../hooks' -import { - socketEndPresentation, - socketJoinPresentation, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, - socket_connect, -} from '../../../sockets' - -const mapStateToProps = (state: any) => { - return { - slide_order: state.presentation.slide.order, - } -} - -const mapDispatchToProps = (dispatch: any) => { - return { - // tickTimer: () => dispatch(tickTimer(1)), - } -} - -const SocketTest: React.FC = (props: any) => { - const dispatch = useAppDispatch() - - useEffect(() => { - socket_connect() - // dispatch(getPresentationCompetition('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call - // dispatch(getPresentationTeams('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call - }, []) - - return ( - <> - <button onClick={socketStartPresentation}>Start presentation</button> - <button onClick={socketJoinPresentation}>Join presentation</button> - <button onClick={socketEndPresentation}>End presentation</button> - <button onClick={socketSetSlidePrev}>Prev slide</button> - <button onClick={socketSetSlideNext}>Next slide</button> - <button onClick={socketStartTimer}>Start timer</button> - <div>Current slide: {props.slide_order}</div> - {/* <div>Timer: {props.timer.value}</div> - <div>Enabled: {props.timer.enabled.toString()}</div> - <button onClick={syncTimer}>Sync</button> - <button onClick={() => dispatch(setTimer(5))}>5 Sec</button> - <button - onClick={() => { - dispatch(setTimer(5)) - dispatch(setTimerEnabled(true)) - syncTimer() - }} - > - Sync and 5 sec - </button> */} - </> - ) -} - -export default connect(mapStateToProps, mapDispatchToProps)(SocketTest) diff --git a/client/src/pages/views/components/Timer.tsx b/client/src/pages/views/components/Timer.tsx index 0cbd1fdfdbf0d63dc6ef5e687539400510af770e..5b496325803d8f092178ef39506b704f11ab0443 100644 --- a/client/src/pages/views/components/Timer.tsx +++ b/client/src/pages/views/components/Timer.tsx @@ -1,46 +1,71 @@ -import React, { useEffect } from 'react' -import { connect } from 'react-redux' -import { setPresentationTimer, setPresentationTimerDecrement } from '../../../actions/presentation' -import { useAppDispatch } from '../../../hooks' -import store from '../../../store' - -const mapStateToProps = (state: any) => { - return { - timer: state.presentation.timer, - timer_start_value: state.presentation.slide.timer, - } -} +import React, { useEffect, useState } from 'react' +import { setPresentationTimer } from '../../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../../hooks' -const mapDispatchToProps = (dispatch: any) => { - return { - // tickTimer: () => dispatch(tickTimer(1)), - } +type TimerProps = { + variant: 'editor' | 'presentation' + currentSlideId?: number } -let timerIntervalId: NodeJS.Timeout - -const Timer: React.FC = (props: any) => { +const Timer = ({ variant, currentSlideId }: TimerProps) => { const dispatch = useAppDispatch() + const timer = useAppSelector((state) => state.presentation.timer) + const [remainingTimer, setRemainingTimer] = useState<number>(0) + const remainingSeconds = remainingTimer / 1000 + const remainingWholeSeconds = Math.floor(remainingSeconds % 60) + // Add a 0 before the seconds if it's lower than 10 + const remainingDisplaySeconds = `${remainingWholeSeconds < 10 ? '0' : ''}${remainingWholeSeconds}` + + const remainingMinutes = Math.floor(remainingSeconds / 60) % 60 + // Add a 0 before the minutes if it's lower than 10 + const remainingDisplayMinutes = `${remainingMinutes < 10 ? '0' : ''}${remainingMinutes}` + + const displayTime = `${remainingDisplayMinutes}:${remainingDisplaySeconds}` + const [timerIntervalId, setTimerIntervalId] = useState<NodeJS.Timeout | null>(null) + const slideTimer = useAppSelector((state) => { + if (currentSlideId && variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === currentSlideId)?.timer + if (variant === 'presentation') + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.activeSlideId)?.timer + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.timer + }) useEffect(() => { - dispatch(setPresentationTimer({ enabled: false, value: store.getState().presentation.slide.timer })) - }, [props.timer_start_value]) + if (slideTimer) setRemainingTimer(slideTimer * 1000) + }, [slideTimer]) useEffect(() => { - if (props.timer.enabled) { - timerIntervalId = setInterval(() => { - dispatch(setPresentationTimerDecrement()) - }, 1000) - } else { - clearInterval(timerIntervalId) + if (variant === 'editor') return + if (!timer.enabled) { + if (timerIntervalId !== null) clearInterval(timerIntervalId) + + if (timer.value !== null) { + setRemainingTimer(0) + } else if (slideTimer) { + setRemainingTimer(slideTimer * 1000) + } + + return } - }, [props.timer.enabled]) - return ( - <> - <div>{props.timer.value}</div> - </> - ) + setTimerIntervalId( + setInterval(() => { + if (timer.value === null) return + if (timer.enabled === false && timerIntervalId !== null) clearInterval(timerIntervalId) + + if (timer.value - Date.now() < 0) { + setRemainingTimer(0) + dispatch(setPresentationTimer({ ...timer, enabled: false })) + if (timerIntervalId !== null) clearInterval(timerIntervalId) + return + } + + setRemainingTimer(timer.value - Date.now()) + }, 500) + ) + }, [timer.enabled, slideTimer]) + + return <>{slideTimer && displayTime}</> } -export default connect(mapStateToProps, mapDispatchToProps)(Timer) +export default Timer diff --git a/client/src/pages/views/components/styled.tsx b/client/src/pages/views/components/styled.tsx index b522b20d3548b4f11864373d5a91088424c45a58..602011d43ca1c8e9d7ea50fc5e8537f34a080b19 100644 --- a/client/src/pages/views/components/styled.tsx +++ b/client/src/pages/views/components/styled.tsx @@ -1,4 +1,4 @@ -import { TextField } from '@material-ui/core' +import { Paper, TextField, Typography } from '@material-ui/core' import styled from 'styled-components' export const SlideContainer = styled.div` @@ -31,4 +31,37 @@ export const ScoreInput = styled(TextField)` export const AnswerContainer = styled.div` display: flex; flex-wrap: wrap; + flex-direction: column; +` + +export const JudgeScoringInstructionsContainer = styled(Paper)` + position: absolute; + bottom: 0; + height: 250px; + width: 100%; +` + +export const ScoringInstructionsInner = styled.div` + margin-left: 15px; + margin-right: 15px; + display: flex; + align-items: center; + flex-direction: column; +` + +export const AnswersDisplay = styled.div` + display: flex; + flex-direction: row; +` + +export const Answers = styled.div` + margin-left: 15px; + margin-right: 15px; + display: flex; + align-items: center; + flex-direction: column; +` + +export const UnderlinedTypography = styled(Typography)` + text-decoration: underline; ` diff --git a/client/src/pages/views/styled.tsx b/client/src/pages/views/styled.tsx index 1f3a61c61789964b96346f8734422ca8c83518b7..9a2dfada326ef0c045428de6f88e3fae4c36b837 100644 --- a/client/src/pages/views/styled.tsx +++ b/client/src/pages/views/styled.tsx @@ -1,4 +1,4 @@ -import { AppBar, Button, Drawer, Toolbar, Typography } from '@material-ui/core' +import { AppBar, Button, Card, Drawer, Toolbar, Typography } from '@material-ui/core' import styled from 'styled-components' export const JudgeAppBar = styled(AppBar)` @@ -15,14 +15,14 @@ export const JudgeQuestionsLabel = styled(Typography)` ` export const JudgeAnswersLabel = styled(Typography)` - margin-right: 160px; + margin-right: 304px; ` export const ViewSelectContainer = styled.div` display: flex; justify-content: center; - margin-top: 12%; - height: 100%; + padding-top: 12%; + height: calc(100%-12%); ` export const ViewSelectButtonGroup = styled.div` @@ -35,36 +35,49 @@ export const ViewSelectButtonGroup = styled.div` margin-right: auto; ` -export const PresenterHeader = styled.div` +export const OperatorHeader = styled(AppBar)` display: flex; + flex-direction: row; justify-content: space-between; - position: fixed; + align-items: center; + height: 64px; width: 100%; ` -export const PresenterFooter = styled.div` +export const OperatorFooter = styled(AppBar)` + background: white; display: flex; - justify-content: space-between; + flex-direction: row; + justify-content: center; + align-items: center; + height: 128px; + top: auto; + bottom: 0; + width: 100%; ` -export const PresenterButton = styled(Button)` - width: 100px; - height: 100px; +export const OperatorQuitButton = styled(Button)` + height: 100%; + padding: 0; +` + +export const OperatorButton = styled(Button)` + min-width: 90px; + min-height: 90px; margin-left: 16px; margin-right: 16px; - margin-top: 16px; ` -export const SlideCounter = styled(Button)` +export const OperatorHeaderItem = styled.div` margin-left: 16px; margin-right: 16px; - margin-top: 16px; ` -export const PresenterContainer = styled.div` +export const OperatorContainer = styled.div` display: flex; flex-direction: column; justify-content: space-between; + align-items: center; height: 100%; ` @@ -103,8 +116,73 @@ interface ContentProps { } export const Content = styled.div<ContentProps>` + width: 100%; + height: 100%; + max-width: calc(100% - ${(props) => (props ? props.leftDrawerWidth + props.rightDrawerWidth : 0)}px); + max-height: calc(100% - 64px); margin-left: ${(props) => (props ? props.leftDrawerWidth : 0)}px; margin-right: ${(props) => (props ? props.rightDrawerWidth : 0)}px; - width: calc(100% - ${(props) => (props ? props.leftDrawerWidth + props.rightDrawerWidth : 0)}px); - height: calc(100% - 64px); + display: flex; + justify-content: center; + background-color: rgba(0, 0, 0, 0.08); +` + +export const InnerContent = styled.div` + width: 100%; + /* Makes sure width is not bigger than where a 16:9 display can fit + without overlapping with header */ + max-width: calc(((100vh - 64px) / 9) * 16); +` + +export const OperatorContent = styled.div` + height: 100%; + width: 100%; + display: flex; + justify-content: center; + background-color: rgba(0, 0, 0, 0.08); +` + +export const OperatorInnerContent = styled.div` + height: 100%; + width: 100%; + /* Makes sure width is not bigger than where a 16:9 display can fit + without overlapping with header and footer */ + max-width: calc(((100vh - 192px) / 9) * 16); +` + +export const PresentationContainer = styled.div` + height: 100%; + width: 100%; + max-width: calc((100vh / 9) * 16); +` + +export const PresentationBackground = styled.div` + height: 100%; + width: 100%; + background-color: rgba(0, 0, 0, 0.08); + display: flex; + justify-content: center; +` + +interface ScoreHeaderPaperProps { + $rightDrawerWidth: number +} + +export const ScoreHeaderPaper = styled(Card)<ScoreHeaderPaperProps>` + position: absolute; + top: 66px; + width: ${(props) => (props ? props.$rightDrawerWidth : 0)}px; + height: 71px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +` + +export const ScoreHeaderPadding = styled.div` + min-height: 71px; +` + +export const ScoreFooterPadding = styled.div` + min-height: 250px; ` diff --git a/client/src/reducers/allReducers.ts b/client/src/reducers/allReducers.ts index 90cb24144612c5a45f611ad99871c54c51969bbd..29b8d2c4bcf9cd71eaa0cdbff988530c35976c47 100644 --- a/client/src/reducers/allReducers.ts +++ b/client/src/reducers/allReducers.ts @@ -1,11 +1,10 @@ -// Combines all the reducers so that we only have to pass "one" reducer to the store in src/index.tsx +/** Combines all the reducers so that we only have to pass "one" reducer to the store in src/index.tsx */ import { combineReducers } from 'redux' import citiesReducer from './citiesReducer' import competitionLoginReducer from './competitionLoginReducer' import competitionsReducer from './competitionsReducer' import editorReducer from './editorReducer' -import mediaReducer from './mediaReducer' import presentationReducer from './presentationReducer' import rolesReducer from './rolesReducer' import searchUserReducer from './searchUserReducer' @@ -14,8 +13,8 @@ import typesReducer from './typesReducer' import uiReducer from './uiReducer' import userReducer from './userReducer' +/** All reducers combined */ const allReducers = combineReducers({ - // name: state user: userReducer, UI: uiReducer, competitions: competitionsReducer, @@ -25,7 +24,6 @@ const allReducers = combineReducers({ roles: rolesReducer, searchUsers: searchUserReducer, types: typesReducer, - media: mediaReducer, statistics: statisticsReducer, competitionLogin: competitionLoginReducer, }) diff --git a/client/src/reducers/citiesReducer.ts b/client/src/reducers/citiesReducer.ts index 7f5555b3eb002559df3952dfbac31d178ffd6271..22cf54f8749c98e3970f36d8642a5fb20c8eb653 100644 --- a/client/src/reducers/citiesReducer.ts +++ b/client/src/reducers/citiesReducer.ts @@ -2,17 +2,21 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { City } from '../interfaces/ApiModels' +/** Define a type for the city state */ interface CityState { cities: City[] total: number count: number } + +/** Define initial values for the city state */ const initialState: CityState = { cities: [], total: 0, count: 0, } +/** Intercept actions for cities state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_CITIES: diff --git a/client/src/reducers/competitionLoginReducer.ts b/client/src/reducers/competitionLoginReducer.ts index 81c426a4a297f138fdfa6350e17a3cd4fc72d3fd..b95efdf53f908728b5138dd8d5df806868a3e602 100644 --- a/client/src/reducers/competitionLoginReducer.ts +++ b/client/src/reducers/competitionLoginReducer.ts @@ -1,29 +1,50 @@ import { AnyAction } from 'redux' import Types from '../actions/types' -interface UIError { - message: string +/** Define a type for the competition login data */ +interface CompetitionLoginData { + competition_id: number + team_id: number | null + view: string } -interface UserState { +/** Define a type for the competition login state */ +interface CompetitionLoginState { loading: boolean - errors: null | UIError + errors: null | string + authenticated: boolean + data: CompetitionLoginData | null + initialized: boolean } -const initialState: UserState = { +/** Define the initial values for the competition login state */ +const initialState: CompetitionLoginState = { loading: false, errors: null, + authenticated: false, + data: null, + initialized: false, } +/** Intercept actions for competitionLogin state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { + case Types.SET_COMPETITION_LOGIN_DATA: + return { + ...state, + data: action.payload as CompetitionLoginData, + authenticated: true, + initialized: true, + } case Types.SET_COMPETITION_LOGIN_ERRORS: return { - errors: action.payload as UIError, + ...state, + errors: action.payload as string, loading: false, } case Types.CLEAR_COMPETITION_LOGIN_ERRORS: return { + ...state, loading: false, errors: null, } diff --git a/client/src/reducers/competitionsReducer.ts b/client/src/reducers/competitionsReducer.ts index bb788da5439874f8f9963dfbb756a222012697e6..e7cab2058b2b903eb7c8f4e02904050701deff95 100644 --- a/client/src/reducers/competitionsReducer.ts +++ b/client/src/reducers/competitionsReducer.ts @@ -3,6 +3,7 @@ import Types from '../actions/types' import { Competition } from '../interfaces/ApiModels' import { CompetitionFilterParams } from './../interfaces/FilterParams' +/** Define a type for competitions state */ interface CompetitionState { competitions: Competition[] total: number @@ -10,13 +11,15 @@ interface CompetitionState { filterParams: CompetitionFilterParams } +/** Define the initial values for the competition state */ const initialState: CompetitionState = { competitions: [], total: 0, count: 0, - filterParams: { pageSize: 10, page: 0 }, + filterParams: { pageSize: 10, page: 1 }, } +/** Intercept actions for competitions state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_COMPETITIONS: diff --git a/client/src/reducers/editorReducer.ts b/client/src/reducers/editorReducer.ts index 20d0b42885b4d3ebeffbca9ead97b1eb0ddc2fad..01a3fbeaa4248099b882f20940f841fb008a3c7b 100644 --- a/client/src/reducers/editorReducer.ts +++ b/client/src/reducers/editorReducer.ts @@ -2,12 +2,15 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { RichCompetition } from '../interfaces/ApiRichModels' +/** Define a type for editor state */ interface EditorState { competition: RichCompetition activeSlideId: number + activeViewTypeId: number loading: boolean } +/** Define initial values for the editor state */ const initialState: EditorState = { competition: { name: '', @@ -16,11 +19,14 @@ const initialState: EditorState = { city_id: 1, slides: [], teams: [], + background_image: undefined, }, activeSlideId: -1, + activeViewTypeId: -1, loading: true, } +/** Intercept actions for editor state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_EDITOR_COMPETITION: @@ -34,6 +40,16 @@ export default function (state = initialState, action: AnyAction) { ...state, activeSlideId: action.payload as number, } + case Types.SET_EDITOR_VIEW_ID: + return { + ...state, + activeViewTypeId: action.payload as number, + } + case Types.SET_EDITOR_LOADING: + return { + ...state, + loading: action.payload as boolean, + } default: return state } diff --git a/client/src/reducers/mediaReducer.ts b/client/src/reducers/mediaReducer.ts deleted file mode 100644 index ad5f3b46547f2e9a20135537ff814b196ca22331..0000000000000000000000000000000000000000 --- a/client/src/reducers/mediaReducer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AnyAction } from 'redux' -import Types from '../actions/types' - -interface MediaState { - id: number - filename: string - mediatype_id: number - user_id: number -} -const initialState: MediaState = { - id: 0, - filename: '', - mediatype_id: 1, - user_id: 0, -} - -export default function (state = initialState, action: AnyAction) { - switch (action.type) { - case Types.SET_MEDIA_ID: - return { ...state, id: action.payload as number } - case Types.SET_MEDIA_FILENAME: - return { - ...state, - filename: action.payload as string, - } - case Types.SET_MEDIA_TYPE_ID: - return { - ...state, - mediatype_id: action.payload as number, - } - case Types.SET_MEDIA_USER_ID: - return { - ...state, - user_id: action.payload as number, - } - default: - return state - } -} diff --git a/client/src/reducers/presentationReducer.test.ts b/client/src/reducers/presentationReducer.test.ts index a155eab5c915e82c64f16c0093dd2fc296b10d7a..638d98d924dc14d7b6a2e4fc4c6a81d7db37cab9 100644 --- a/client/src/reducers/presentationReducer.test.ts +++ b/client/src/reducers/presentationReducer.test.ts @@ -1,30 +1,23 @@ 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, + id: 1, + background_image: undefined, city_id: 0, slides: [], year: 0, teams: [], }, - slide: { - competition_id: 0, - id: 0, - order: 0, - timer: 0, - title: '', - }, - teams: [], + activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, + show_scoreboard: false, } it('should return the initial state', () => { @@ -41,7 +34,6 @@ it('should handle SET_PRESENTATION_COMPETITION', () => { }, slides: [{ id: 20 }], year: 1999, - teams: [], } expect( presentationReducer(initialState, { @@ -50,160 +42,25 @@ it('should handle SET_PRESENTATION_COMPETITION', () => { }) ).toEqual({ competition: testCompetition, - slide: testCompetition.slides[0], - teams: initialState.teams, - code: initialState.code, - timer: initialState.timer, - }) -}) - -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, + activeSlideId: initialState.activeSlideId, code: initialState.code, timer: initialState.timer, + show_scoreboard: initialState.show_scoreboard, }) }) -it('should handle SET_PRESENTATION_SLIDE', () => { - const testSlide = [ - { - competition_id: 20, - id: 4, - order: 3, - timer: 123, - title: 'testSlideTitle', - }, - ] +it('should handle SET_PRESENTATION_SLIDE_ID', () => { + const testSlideId = 123123123 expect( presentationReducer(initialState, { - type: Types.SET_PRESENTATION_SLIDE, - payload: testSlide, + type: Types.SET_PRESENTATION_SLIDE_ID, + payload: testSlideId, }) ).toEqual({ competition: initialState.competition, - slide: testSlide, - teams: initialState.teams, + activeSlideId: testSlideId, code: initialState.code, timer: initialState.timer, - }) -}) - -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, - code: initialState.code, - timer: initialState.timer, - } - expect( - presentationReducer(testPresentationState, { - type: Types.SET_PRESENTATION_SLIDE_PREVIOUS, - }) - ).toEqual({ - competition: testPresentationState.competition, - slide: testPresentationState.competition.slides[0], - teams: testPresentationState.teams, - code: initialState.code, - timer: initialState.timer, - }) - }) - 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, - code: initialState.code, - timer: initialState.timer, - } - expect( - presentationReducer(testPresentationState, { - type: Types.SET_PRESENTATION_SLIDE_PREVIOUS, - }) - ).toEqual({ - competition: testPresentationState.competition, - slide: testPresentationState.competition.slides[0], - teams: testPresentationState.teams, - code: initialState.code, - timer: initialState.timer, - }) - }) -}) - -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, - }) + show_scoreboard: initialState.show_scoreboard, }) }) diff --git a/client/src/reducers/presentationReducer.ts b/client/src/reducers/presentationReducer.ts index 5ba3ac5b196cb65fc3209b87fcb4636e22cac2ba..06baa7d972052ed898f5bcf9f120974739a59d5b 100644 --- a/client/src/reducers/presentationReducer.ts +++ b/client/src/reducers/presentationReducer.ts @@ -1,94 +1,64 @@ import { AnyAction } from 'redux' import Types from '../actions/types' -import { Slide, Team } from '../interfaces/ApiModels' -import { Timer } from '../interfaces/Timer' +import { TimerState } from '../interfaces/Timer' import { RichCompetition } from './../interfaces/ApiRichModels' +/** Define a type for the presentation state */ interface PresentationState { competition: RichCompetition - slide: Slide - teams: Team[] + activeSlideId: number code: string - timer: Timer + timer: TimerState + show_scoreboard: boolean } +/** Define the initial values for the presentation state */ const initialState: PresentationState = { competition: { name: '', - id: 0, + id: 1, city_id: 0, slides: [], year: 0, teams: [], + background_image: undefined, }, - slide: { - competition_id: 0, - id: 0, - order: 0, - timer: 0, - title: '', - }, - teams: [], + activeSlideId: -1, code: '', timer: { + value: null, enabled: false, - value: 0, }, + show_scoreboard: false, } +/** Intercept actions for presentation state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_PRESENTATION_COMPETITION: return { ...state, - slide: action.payload.slides[0] as Slide, competition: action.payload as RichCompetition, } - case Types.SET_PRESENTATION_TEAMS: - return { - ...state, - teams: action.payload as Team[], - } case Types.SET_PRESENTATION_CODE: return { ...state, - code: action.payload, + code: action.payload as string, } - case Types.SET_PRESENTATION_SLIDE: + case Types.SET_PRESENTATION_SLIDE_ID: return { ...state, - slide: action.payload as Slide, - } - case Types.SET_PRESENTATION_SLIDE_PREVIOUS: - if (state.slide.order - 1 >= 0) { - return { - ...state, - slide: state.competition.slides[state.slide.order - 1], - } + activeSlideId: action.payload as number, } - return state - case Types.SET_PRESENTATION_SLIDE_NEXT: - if (state.slide.order + 1 < state.competition.slides.length) { - return { - ...state, - slide: state.competition.slides[state.slide.order + 1], - } - } - return state - case Types.SET_PRESENTATION_SLIDE_BY_ORDER: - if (0 <= action.payload && action.payload < state.competition.slides.length) - return { - ...state, - slide: state.competition.slides[action.payload], - } - return state case Types.SET_PRESENTATION_TIMER: - if (action.payload.value == 0) { - action.payload.enabled = false + return { + ...state, + timer: action.payload as TimerState, } + case Types.SET_PRESENTATION_SHOW_SCOREBOARD: return { ...state, - timer: action.payload, + show_scoreboard: action.payload as boolean, } default: return state diff --git a/client/src/reducers/rolesReducer.ts b/client/src/reducers/rolesReducer.ts index 5028ae04cb13a4b1bf44536cd42bf3f8935b268c..994ebb1e18c3f855bb3b6fd5a9bc98a54d4ee004 100644 --- a/client/src/reducers/rolesReducer.ts +++ b/client/src/reducers/rolesReducer.ts @@ -2,13 +2,17 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { Role } from '../interfaces/ApiModels' +/** Define a type for the role state */ interface RoleState { roles: Role[] } + +/** Define the initial values for the role state */ const initialState: RoleState = { roles: [], } +/** Intercept actions for roles state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_ROLES: diff --git a/client/src/reducers/searchUserReducer.ts b/client/src/reducers/searchUserReducer.ts index e0c1250683ae273318a4bcd6e4a3f5ae5f6324bd..4fea6ef9c12cdcce2d05c5a197ff046a25fc904d 100644 --- a/client/src/reducers/searchUserReducer.ts +++ b/client/src/reducers/searchUserReducer.ts @@ -3,6 +3,7 @@ import Types from '../actions/types' import { User } from '../interfaces/ApiModels' import { UserFilterParams } from '../interfaces/FilterParams' +/** Define a type for the search user state */ interface SearchUserState { users: User[] total: number @@ -10,13 +11,15 @@ interface SearchUserState { filterParams: UserFilterParams } +/** Define the initial values for the search user state */ const initialState: SearchUserState = { users: [], total: 0, count: 0, - filterParams: { pageSize: 10, page: 0 }, + filterParams: { pageSize: 10, page: 1 }, } +/** Intercept actions for searchUser state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_SEARCH_USERS: diff --git a/client/src/reducers/statisticsReducer.ts b/client/src/reducers/statisticsReducer.ts index 78a06e1157f6428c10ea17073afbdf46cf8609e5..4975bd7f381e728ac6d05237b66d050f8369b50d 100644 --- a/client/src/reducers/statisticsReducer.ts +++ b/client/src/reducers/statisticsReducer.ts @@ -1,18 +1,21 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +/** Define a type for the statistics state */ interface StatisticsState { users: number competitions: number regions: number } +/** Define the initial values for the statistics state */ const initialState: StatisticsState = { users: 0, competitions: 0, regions: 0, } +/** Intercept actions for statistics state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_STATISTICS: diff --git a/client/src/reducers/typesReducer.ts b/client/src/reducers/typesReducer.ts index 3540ef86fbd4a921738d896b2b0bebb14b3216e0..927077435f27573aea52c71c809102fed325cb94 100644 --- a/client/src/reducers/typesReducer.ts +++ b/client/src/reducers/typesReducer.ts @@ -2,12 +2,14 @@ import { AnyAction } from 'redux' import Types from '../actions/types' import { ComponentType, MediaType, QuestionType, ViewType } from '../interfaces/ApiModels' +/** Define a type for the Types state */ interface TypesState { componentTypes: ComponentType[] viewTypes: ViewType[] questionTypes: QuestionType[] mediaTypes: MediaType[] } +/** Define the initial values for the types state */ const initialState: TypesState = { componentTypes: [], viewTypes: [], @@ -15,6 +17,7 @@ const initialState: TypesState = { mediaTypes: [], } +/** Intercept actions for types state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_TYPES: diff --git a/client/src/reducers/uiReducer.ts b/client/src/reducers/uiReducer.ts index 4d06d1e298bab88cbfe2f41a54284a2557660a33..0b650683b8b90d10dc3c7567b771c4b9b24f13f4 100644 --- a/client/src/reducers/uiReducer.ts +++ b/client/src/reducers/uiReducer.ts @@ -1,20 +1,24 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +/** Define a type for the UI error */ interface UIError { message: string } +/** Define a type for the UI state */ interface UIState { loading: boolean errors: null | UIError } +/** Define the initial values for the UI state */ const initialState: UIState = { loading: false, errors: null, } +/** Intercept actions for ui state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_ERRORS: diff --git a/client/src/reducers/userReducer.ts b/client/src/reducers/userReducer.ts index 91c056d3f0a55383fed0a0e40d0e94240d14a947..155cf2a804ed175407eccd2b8a937b99d4b7246b 100644 --- a/client/src/reducers/userReducer.ts +++ b/client/src/reducers/userReducer.ts @@ -1,6 +1,7 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +/** Define a type for users info */ interface UserInfo { name: string email: string @@ -9,18 +10,21 @@ interface UserInfo { id: number } +/** Define a type for the users state */ interface UserState { authenticated: boolean userInfo: UserInfo | null loading: boolean } +/** Define the initial values for the users state */ const initialState: UserState = { authenticated: false, loading: false, userInfo: null, } +/** Intercept actions for user state and update the state */ export default function (state = initialState, action: AnyAction) { switch (action.type) { case Types.SET_AUTHENTICATED: diff --git a/client/src/reportWebVitals.ts b/client/src/reportWebVitals.ts deleted file mode 100644 index a832dfa7c24162e95463f27f3687de39a3c311bb..0000000000000000000000000000000000000000 --- a/client/src/reportWebVitals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReportHandler } from 'web-vitals' - -const reportWebVitals: () => void = (onPerfEntry?: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry) - getFID(onPerfEntry) - getFCP(onPerfEntry) - getLCP(onPerfEntry) - getTTFB(onPerfEntry) - }) - } -} - -export default reportWebVitals diff --git a/client/src/sockets.ts b/client/src/sockets.ts index 54f84c39840acec184fa023545c1a15332eecdbc..d7795f843ccc70ef1d6784b8d8c8f4c72d17c5f8 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -1,87 +1,69 @@ +/** + * Handles everything that has to do with syncing active competitions. + * + * @module + */ + import io from 'socket.io-client' -import { setCurrentSlideByOrder, setPresentationTimer } from './actions/presentation' -import { Timer } from './interfaces/Timer' +import { setCurrentSlideByOrder, setPresentationShowScoreboard, setPresentationTimer } from './actions/presentation' +import { TimerState } from './interfaces/Timer' import store from './store' -interface SetSlideInterface { - slide_order: number -} - -interface TimerInterface { - value: number - enabled: boolean -} - -interface SetTimerInterface { - timer: TimerInterface +/** + * The values that can be synced between clients connected to the same presentation. + */ +interface SyncInterface { + slide_order?: number + timer?: TimerState + show_scoreboard?: boolean } let socket: SocketIOClient.Socket -export const socket_connect = () => { - if (!socket) { - socket = io('localhost:5000') - - socket.on('set_slide', (data: SetSlideInterface) => { - setCurrentSlideByOrder(data.slide_order)(store.dispatch) - }) - - socket.on('set_timer', (data: SetTimerInterface) => { - setPresentationTimer(data.timer)(store.dispatch) - }) - - socket.on('end_presentation', () => { - socket.disconnect() - }) - } -} - -export const socketStartPresentation = () => { - socket.emit('start_presentation', { competition_id: store.getState().presentation.competition.id }) - console.log('START PRESENTATION') -} - -export const socketJoinPresentation = () => { - socket.emit('join_presentation', { code: 'CO0ART' }) // TODO: Send code gotten from auth/login/<code> api call - console.log('JOIN PRESENTATION') -} - -export const socketEndPresentation = () => { - socket.emit('end_presentation', { competition_id: store.getState().presentation.competition.id }) - console.log('END PRESENTATION') -} - -export const socketSetSlideNext = () => { - socketSetSlide(store.getState().presentation.slide.order + 1) // TODO: Check that this slide exists - console.log('NEXT SLIDE +1') -} - -export const socketSetSlidePrev = () => { - socketSetSlide(store.getState().presentation.slide.order - 1) // TODO: Check that this slide exists - console.log('PREVIOUS SLIDE -1') -} +/** + * Connect to server, setup authorization header and listen to some events. + * + * @param role The role the connecting client has + */ +export const socketConnect = (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => { + if (socket) return + + // The token is the JWT returned from the login/code API call. + const token = localStorage[`${role}Token`] + socket = io('localhost:5000', { + transportOptions: { + polling: { + extraHeaders: { + Authorization: token, + }, + }, + }, + }) -export const socketSetSlide = (slide_order: number) => { - if (slide_order < 0 || store.getState().presentation.competition.slides.length <= slide_order) { - console.log('CANT CHANGE TO NON EXISTENT SLIDE') - return - } + socket.on('sync', (data: SyncInterface) => { + // The order of these is important, for some reason, so dont change it + if (data.timer !== undefined) setPresentationTimer(data.timer)(store.dispatch) + if (data.slide_order !== undefined) setCurrentSlideByOrder(data.slide_order)(store.dispatch, store.getState) + if (data.show_scoreboard !== undefined) setPresentationShowScoreboard(data.show_scoreboard)(store.dispatch) + }) - socket.emit('set_slide', { - competition_id: store.getState().presentation.competition.id, - slide_order: slide_order, + socket.on('end_presentation', () => { + socket.disconnect() }) } -export const socketSetTimer = (timer: Timer) => { - console.log('SET TIMER') - socket.emit('set_timer', { - competition_id: store.getState().presentation.competition.id, - timer: timer, - }) +/** + * Disconnect all clients. + */ +export const socketEndPresentation = () => { + socket.emit('end_presentation') } -export const socketStartTimer = () => { - console.log('START TIMER') - socketSetTimer({ enabled: true, value: store.getState().presentation.timer.value }) +/** + * Sync data between all connected clients. + * + * @param syncData The data to sync between all clients connected to the same presentation + */ +export const socketSync = (syncData: SyncInterface) => { + socket.emit('sync', syncData) } diff --git a/client/src/store.ts b/client/src/store.ts index 8eec0a48d943be786ec85b4bd7ac06b16e0989e2..66e384d33b364864175b8ecd52e9e417d106dfda 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -2,7 +2,7 @@ import { AnyAction, applyMiddleware, compose, createStore } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' import thunk, { ThunkAction, ThunkDispatch } from 'redux-thunk' import allReducers from './reducers/allReducers' -/* +/** TypeScript does not know the type of the property. Therefore, you will get the error; Property ‘__REDUX_DEVTOOLS_EXTENSION_COMPOSE__’ does not exist on type ‘Window’. Hence, you need to add the property to the global window as below. @@ -19,10 +19,13 @@ const middleware = [thunk] // const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // allows Mozilla plugin to view state in a GUI, https://github.com/zalmoxisus/redux-devtools-extension#13-use-redux-devtools-extension-package-from-npm // const store = createStore(allReducers, composeEnhancers(applyMiddleware())) -// simple store with plugin +/** Simple store with plugins and middleware */ const store = createStore(allReducers, initialState, composeWithDevTools(applyMiddleware(...middleware))) +/** Type of thunk */ export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, AnyAction> +/** Type of state */ export type RootState = ReturnType<typeof store.getState> +/** Type of dispatch */ export type AppDispatch = ThunkDispatch<RootState, void, AnyAction> export default store diff --git a/client/src/utils/SecureRoute.tsx b/client/src/utils/SecureRoute.tsx index c8c238dffc4015a887fef707cf40b3eba5d6c6e5..64cb437afe0b0968a8054de523bbd3465a2a2085 100644 --- a/client/src/utils/SecureRoute.tsx +++ b/client/src/utils/SecureRoute.tsx @@ -1,30 +1,51 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Redirect, Route, RouteProps } from 'react-router-dom' import { useAppSelector } from '../hooks' -import { CheckAuthentication } from './checkAuthentication' +import { CheckAuthenticationAdmin } from './checkAuthenticationAdmin' +import { CheckAuthenticationCompetition } from './checkAuthenticationCompetition' interface SecureRouteProps extends RouteProps { - login?: boolean component: React.ComponentType<any> rest?: any + authLevel: 'admin' | 'login' | 'Operator' | 'Team' | 'Judge' | 'Audience' } + /** Utility component to use for authentication, replace all routes that should be private with secure routes*/ -const SecureRoute: React.FC<SecureRouteProps> = ({ login, component: Component, ...rest }: SecureRouteProps) => { - const authenticated = useAppSelector((state) => state.user.authenticated) +const SecureRoute: React.FC<SecureRouteProps> = ({ component: Component, authLevel, ...rest }: SecureRouteProps) => { + const userAuthenticated = useAppSelector((state) => state.user.authenticated) + const compAuthenticated = useAppSelector((state) => state.competitionLogin.authenticated) const [initialized, setInitialized] = React.useState(false) - useEffect(() => { - const waitForAuthentication = async () => { - await CheckAuthentication() - setInitialized(true) + const compInitialized = useAppSelector((state) => state.competitionLogin.initialized) + const viewType = useAppSelector((state) => state.competitionLogin.data?.view) + React.useEffect(() => { + if (authLevel === 'admin' || authLevel === 'login') { + CheckAuthenticationAdmin().then(() => setInitialized(true)) + } else { + CheckAuthenticationCompetition(authLevel).then(() => setInitialized(true)) } - waitForAuthentication() }, []) + if (initialized) { - if (login) + if (authLevel === 'login') + return ( + <Route + {...rest} + render={(props) => (userAuthenticated ? <Redirect to="/admin" /> : <Component {...props} />)} + /> + ) + else if (compInitialized && viewType && authLevel !== 'admin') { + return ( + <Route + {...rest} + render={(props) => + compAuthenticated && viewType === authLevel ? <Component {...props} /> : <Redirect to="/" /> + } + /> + ) + } else return ( - <Route {...rest} render={(props) => (authenticated ? <Redirect to="/admin" /> : <Component {...props} />)} /> + <Route {...rest} render={(props) => (userAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} /> ) - else return <Route {...rest} render={(props) => (authenticated ? <Component {...props} /> : <Redirect to="/" />)} /> } else return null } export default SecureRoute diff --git a/client/src/utils/checkAuthentication.test.ts b/client/src/utils/checkAuthenticationAdmin.test.ts similarity index 97% rename from client/src/utils/checkAuthentication.test.ts rename to client/src/utils/checkAuthenticationAdmin.test.ts index 6d12e1fc77cab5e2575af85e0b91c22ae29ad731..b2f033859c408c2baf5544f7bf88b2f5942b943e 100644 --- a/client/src/utils/checkAuthentication.test.ts +++ b/client/src/utils/checkAuthenticationAdmin.test.ts @@ -1,7 +1,7 @@ import mockedAxios from 'axios' import Types from '../actions/types' import store from '../store' -import { CheckAuthentication } from './checkAuthentication' +import { CheckAuthenticationAdmin } from './checkAuthenticationAdmin' it('dispatches correct actions when auth token is ok', async () => { const userRes: any = { @@ -20,7 +20,7 @@ it('dispatches correct actions when auth token is ok', async () => { 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() + await CheckAuthenticationAdmin() expect(spy).toBeCalledWith({ type: Types.LOADING_USER }) expect(spy).toBeCalledWith({ type: Types.SET_AUTHENTICATED }) expect(spy).toBeCalledWith({ type: Types.SET_USER, payload: userRes.data }) @@ -39,7 +39,7 @@ it('dispatches correct actions when getting user data fails', async () => { 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() + await CheckAuthenticationAdmin() expect(spy).toBeCalledWith({ type: Types.LOADING_USER }) expect(spy).toBeCalledWith({ type: Types.SET_UNAUTHENTICATED }) expect(spy).toBeCalledTimes(2) @@ -51,7 +51,7 @@ it('dispatches no actions when no token exists', async () => { return Promise.resolve({ data: {} }) }) const spy = jest.spyOn(store, 'dispatch') - await CheckAuthentication() + await CheckAuthenticationAdmin() expect(spy).not.toBeCalled() }) @@ -63,7 +63,7 @@ it('dispatches correct actions when token is expired', async () => { 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTgzMDY1MTUsImV4cCI6MTU4Njc3MDUxNSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.R5-oWGGumd-YWPoKyziJmVB8SdX6B9SsV6m7novIfgg' localStorage.setItem('token', testToken) const spy = jest.spyOn(store, 'dispatch') - await CheckAuthentication() + await CheckAuthenticationAdmin() expect(spy).toBeCalledWith({ type: Types.SET_UNAUTHENTICATED }) expect(spy).toBeCalledTimes(1) }) diff --git a/client/src/utils/checkAuthentication.ts b/client/src/utils/checkAuthenticationAdmin.ts similarity index 94% rename from client/src/utils/checkAuthentication.ts rename to client/src/utils/checkAuthenticationAdmin.ts index 9225aa29858d2f9ea58f8ac118dd2feed01d1cc5..3565f8e3b93bb8ff83114e166618a22f823d335e 100644 --- a/client/src/utils/checkAuthentication.ts +++ b/client/src/utils/checkAuthenticationAdmin.ts @@ -8,7 +8,7 @@ const UnAuthorized = async () => { await logoutUser()(store.dispatch) } -export const CheckAuthentication = async () => { +export const CheckAuthenticationAdmin = async () => { const authToken = localStorage.token if (authToken) { const decodedToken: any = jwtDecode(authToken) diff --git a/client/src/utils/checkAuthenticationCompetition.test.ts b/client/src/utils/checkAuthenticationCompetition.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecd8e9beb4d2541746c1d0d879e7e7b26aefa421 --- /dev/null +++ b/client/src/utils/checkAuthenticationCompetition.test.ts @@ -0,0 +1,82 @@ +import mockedAxios from 'axios' +import Types from '../actions/types' +import store from '../store' +import { CheckAuthenticationCompetition } from './checkAuthenticationCompetition' + +it('dispatches correct actions when auth token is ok', async () => { + const compRes = { data: { id: 3, slides: [{ id: 2 }] } } + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.resolve(compRes) + }) + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + const decodedToken = { + iat: 1620216181, + exp: 32514436993, + competition_id: 123123, + team_id: 321321, + view: 'Participant', + code: 'ABCDEF', + } + + const testToken = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MjAyMTYxODEsImV4cCI6MzI1MTQ0MzY5OTMsImNvbXBldGl0aW9uX2lkIjoxMjMxMjMsInRlYW1faWQiOjMyMTMyMSwidmlldyI6IlBhcnRpY2lwYW50IiwiY29kZSI6IkFCQ0RFRiJ9.fNrU8s-ZHPFCLYqtD2nogmSy31sBtX-8KWu911xNC8I' + localStorage.setItem('JudgeToken', testToken) + await CheckAuthenticationCompetition('Judge') + expect(spy).toBeCalledWith({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: decodedToken.competition_id, + team_id: decodedToken.team_id, + view: decodedToken.view, + }, + }) + expect(spy).toBeCalledWith({ type: Types.SET_PRESENTATION_CODE, payload: decodedToken.code }) + expect(spy).toBeCalledWith({ + type: Types.SET_PRESENTATION_COMPETITION, + payload: compRes.data, + }) + expect(spy).toBeCalledTimes(4) +}) + +it('dispatches correct actions when getting user data fails', async () => { + console.log = jest.fn() + ;(mockedAxios.get as jest.Mock).mockImplementation(() => { + return Promise.reject(new Error('failed getting user data')) + }) + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + const testToken = + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MjAyMTYxODEsImV4cCI6MzI1MTQ0MzY5OTMsImNvbXBldGl0aW9uX2lkIjoxMjMxMjMsInRlYW1faWQiOjMyMTMyMSwidmlldyI6IlBhcnRpY2lwYW50IiwiY29kZSI6IkFCQ0RFRiJ9.fNrU8s-ZHPFCLYqtD2nogmSy31sBtX-8KWu911xNC8I' + localStorage.setItem('AudienceToken', testToken) + await CheckAuthenticationCompetition('Audience') + expect(spy).toBeCalledWith({ type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(1) + expect(console.log).toHaveBeenCalled() +}) + +it('dispatches no actions when no token exists', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthenticationCompetition('Operator') + expect(spy).not.toBeCalled() +}) + +it('dispatches correct actions when token is expired', async () => { + ;(mockedAxios.post as jest.Mock).mockImplementation(() => { + return Promise.resolve({ data: {} }) + }) + const testToken = + 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MjAyMjE1OTgsImV4cCI6OTU3NTMzNTk4LCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIn0.uFXtkAsf-cTlKrTIdZ3E-gXnHkzS08iPrhS8iNCGV2E' + localStorage.setItem('TeamToken', testToken) + const spy = jest.spyOn(store, 'dispatch') + await CheckAuthenticationCompetition('Team') + expect(spy).toBeCalledWith({ type: Types.SET_COMPETITION_LOGIN_UNAUTHENTICATED }) + expect(spy).toBeCalledTimes(1) +}) diff --git a/client/src/utils/checkAuthenticationCompetition.ts b/client/src/utils/checkAuthenticationCompetition.ts new file mode 100644 index 0000000000000000000000000000000000000000..5da2eb3dac6f6d33bc0fce3b79d8272cfe4ded71 --- /dev/null +++ b/client/src/utils/checkAuthenticationCompetition.ts @@ -0,0 +1,40 @@ +import axios from 'axios' +import jwtDecode from 'jwt-decode' +import { logoutCompetition } from '../actions/competitionLogin' +import { getPresentationCompetition, setPresentationCode } from '../actions/presentation' +import Types from '../actions/types' +import store from '../store' + +const UnAuthorized = async (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => { + await logoutCompetition(role)(store.dispatch) +} + +export const CheckAuthenticationCompetition = async (role: 'Judge' | 'Operator' | 'Team' | 'Audience') => { + const authToken = localStorage[`${role}Token`] + if (authToken) { + const decodedToken: any = jwtDecode(authToken) + if (decodedToken.exp * 1000 >= Date.now()) { + axios.defaults.headers.common['Authorization'] = authToken + await axios + .get('/api/auth/test') + .then(() => { + store.dispatch({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: decodedToken.competition_id, + team_id: decodedToken.team_id, + view: decodedToken.view, + }, + }) + getPresentationCompetition(decodedToken.competition_id)(store.dispatch, store.getState) + setPresentationCode(decodedToken.code)(store.dispatch) + }) + .catch((error) => { + console.log(error) + UnAuthorized(role) + }) + } else { + await UnAuthorized(role) + } + } +} diff --git a/client/src/utils/renderSlideIcon.test.tsx b/client/src/utils/renderSlideIcon.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed82c2efcd8b880538a98176acead1b7221104b5 --- /dev/null +++ b/client/src/utils/renderSlideIcon.test.tsx @@ -0,0 +1,63 @@ +import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' +import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import { shallow } from 'enzyme' +import { RichSlide } from '../interfaces/ApiRichModels' +import { renderSlideIcon } from './renderSlideIcon' + +it('returns CreateOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 1 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<CreateOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns BuildOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 2 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<BuildOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns DnsOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 3 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<CheckBoxOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('returns DnsOutlinedIcon correctly ', async () => { + const testSlide = { questions: [{ id: 5, type_id: 4 }] } as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<RadioButtonCheckedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) + +it('defaults to InfoOutlinedIcon', async () => { + const testSlide = {} as RichSlide + const icon = renderSlideIcon(testSlide) + expect(icon).toBeDefined() + if (icon) { + const actualResult = shallow(icon) + const expectedResult = shallow(<InfoOutlinedIcon />) + expect(actualResult).toEqual(expectedResult) + } +}) diff --git a/client/src/utils/renderSlideIcon.tsx b/client/src/utils/renderSlideIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe1d0040b6fa2818e25a47db10f0695d32558fd6 --- /dev/null +++ b/client/src/utils/renderSlideIcon.tsx @@ -0,0 +1,27 @@ +import BuildOutlinedIcon from '@material-ui/icons/BuildOutlined' +import CheckBoxOutlinedIcon from '@material-ui/icons/CheckBoxOutlined' +import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined' +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined' +import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked' +import UnfoldMoreOutlinedIcon from '@material-ui/icons/UnfoldMoreOutlined' +import React from 'react' +import { RichSlide } from '../interfaces/ApiRichModels' + +export const renderSlideIcon = (slide: RichSlide) => { + if (slide.questions && slide.questions[0] && slide.questions[0].type_id) { + switch (slide.questions[0].type_id) { + case 1: + return <CreateOutlinedIcon /> // text question + case 2: + return <BuildOutlinedIcon /> // practical qustion + case 3: + return <CheckBoxOutlinedIcon /> // multiple choice question + case 4: + return <RadioButtonCheckedIcon /> // single choice question + case 5: + return <UnfoldMoreOutlinedIcon /> // Match question + } + } else { + return <InfoOutlinedIcon /> // information slide + } +} diff --git a/client/src/utils/uploadImage.ts b/client/src/utils/uploadImage.ts new file mode 100644 index 0000000000000000000000000000000000000000..151a34a400f2d2e632b1f18b8f249206c043a02f --- /dev/null +++ b/client/src/utils/uploadImage.ts @@ -0,0 +1,16 @@ +import axios from 'axios' +import { getEditorCompetition } from '../actions/editor' +import { Media } from '../interfaces/ApiModels' +import store from '../store' + +export const uploadFile = async (formData: FormData, competitionId: string) => { + // Uploads the file to the server and creates a Media object in database. + // Returns media object data. + return await axios + .post(`/api/media/images`, formData) + .then((response) => { + getEditorCompetition(competitionId)(store.dispatch, store.getState) + return response.data as Media + }) + .catch(console.log) +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 7f162ff3f6dbd3b8e60e6de8503fac0937d8f423..0682b11d2c1881c9a229f62382ad6c147daac32f 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -27,5 +27,9 @@ ], "exclude": [ "build" - ] + ], + "typedocOptions": { + "entryPoints": "./src/", + "exclude": "**/*.test.*", + } } diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..d0c3cbf1020d5c292abdedf27627c6abe25e2293 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..9534b018135ed7d5caed6298980c55e8b1d2ec82 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/admin.png b/docs/source/_static/admin.png new file mode 100644 index 0000000000000000000000000000000000000000..4ddd377c0be0e57b7953fcef27a0f2c76a0e4614 Binary files /dev/null and b/docs/source/_static/admin.png differ diff --git a/docs/source/_static/audience.jpg b/docs/source/_static/audience.jpg new file mode 100644 index 0000000000000000000000000000000000000000..db10fc311cc971947f69f0dd6e9fad8894a33d0a Binary files /dev/null and b/docs/source/_static/audience.jpg differ diff --git a/docs/source/_static/competitions.png b/docs/source/_static/competitions.png new file mode 100644 index 0000000000000000000000000000000000000000..443b5349dfb7d64251fe15fc98cdf67410f2f801 Binary files /dev/null and b/docs/source/_static/competitions.png differ diff --git a/docs/source/_static/competitions_codes.png b/docs/source/_static/competitions_codes.png new file mode 100644 index 0000000000000000000000000000000000000000..4b3b9a40dd6ba6cb5adbcf8e324b0701f3ea9591 Binary files /dev/null and b/docs/source/_static/competitions_codes.png differ diff --git a/docs/source/_static/editor_competition.png b/docs/source/_static/editor_competition.png new file mode 100644 index 0000000000000000000000000000000000000000..707a0d61d677f7e0ee726679fba4155a8f3b4afc Binary files /dev/null and b/docs/source/_static/editor_competition.png differ diff --git a/docs/source/_static/editor_slide.jpg b/docs/source/_static/editor_slide.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d89d9380e9df525ec05fd934be97561cf355c040 Binary files /dev/null and b/docs/source/_static/editor_slide.jpg differ diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f3dde5b1d1464d1c26312a8f28bd829a2604b731 Binary files /dev/null and b/docs/source/_static/favicon.ico differ diff --git a/docs/source/_static/judge.png b/docs/source/_static/judge.png new file mode 100644 index 0000000000000000000000000000000000000000..ddffcd5b1685d187426e38aac924e21ecc002a58 Binary files /dev/null and b/docs/source/_static/judge.png differ diff --git a/docs/source/_static/login.png b/docs/source/_static/login.png new file mode 100644 index 0000000000000000000000000000000000000000..d378907ea26706c6319b97d8a077d3e5babcd6f4 Binary files /dev/null and b/docs/source/_static/login.png differ diff --git a/docs/source/_static/logincode.png b/docs/source/_static/logincode.png new file mode 100644 index 0000000000000000000000000000000000000000..9d86b937c3e32bc6b4d84f877c3b910def8553dc Binary files /dev/null and b/docs/source/_static/logincode.png differ diff --git a/docs/source/_static/operator.jpg b/docs/source/_static/operator.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5db86250b25971924044bf32c0f184a81ee2cbad Binary files /dev/null and b/docs/source/_static/operator.jpg differ diff --git a/docs/source/_static/regions.png b/docs/source/_static/regions.png new file mode 100644 index 0000000000000000000000000000000000000000..40a8c39353cb93c275a53897e24c6d0c6e17302a Binary files /dev/null and b/docs/source/_static/regions.png differ diff --git a/docs/source/_static/system_overview.svg b/docs/source/_static/system_overview.svg new file mode 100644 index 0000000000000000000000000000000000000000..aaf6ddd6f83c3c9b6cb27dce6e1fac77b43e88e0 --- /dev/null +++ b/docs/source/_static/system_overview.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="241px" height="181px" viewBox="-0.5 -0.5 241 181" content="<mxfile host="app.diagrams.net" modified="2021-05-03T17:52:01.107Z" agent="5.0 (Windows)" etag="Rg_EECxCY_-eWCFxB_2B" version="14.6.0" type="google"><diagram id="fYUsZG3gZXCst6AfRoV6" name="Page-1">1VhdU6MwFP01POoQkLY+2lbXndndcezOaH2LJIWsgXRC2lJ//V4kQCCtVm39eOlwT5ILnHtybqjjj5L8h8Tz+LcglDueS3LHHzue1+v34bcA1iXgD7wSiCQjJYQaYMIeqQZdjS4YoVlrohKCKzZvg6FIUxqqFoalFKv2tJng7bvOcUQtYBJibqM3jKi4RAeB2+CXlEVxdWfk6pEEV5M1kMWYiJUB+eeOP5JCqPIqyUeUF9xVvJTrLraM1g8maap2WcBHaPb3kl9k5Prm7s67o96/6yOdZYn5Qr+w4/U45BsStoTLqLj8A0WtYMhvjOg3U+uKLikWKaHFHV0YXsVM0ckch8XoCvQBWKwSDhGqVy+pVDTf+lKopgokRkVClVzDFL3AO9Hs1vLS8cooloZio07VMqzlEdWZGwbhQpP4CkI9i9ALjrOHTyeqJuarEOXvorxrimFPfw/peZ/NaGBLT4pU0ZS8jyuCs/hpbhHMGOcjwYWEOBUprBgWLDJwzDPOohRgJeZ7YtjvMBzYDNd+a1I8OBTFPYviIQ4fPpzhe6GUSA5Dst+3Sa41a5LcOxTJgw3O0GEXGD8rmjtEIdhrxkIgI1NYKhs2eAZK5PpW1+QpmBbBcVCF49wcHK91VN6fEuuo0CEZziZYRlS91B/sYhhkBxu4rjBJOVZs2X6MTQXQd7gSDB6waQJeu9YnbqeGmVjIkOpV5lmimwh1RHPaSVTyYCWC6uC1MW1eTMieeeCTzQ/cyKvM2Iit5vTt+kP2Lr+SIl/bKgRB/ML3cOBtqQzrXRqCOKjcsH0TRkiRYyhpxh7x/VO+QmeaDkgeDJ1g/NwG1+ddvdipT5mmJrfvrq1ucOQeuwihFunofdqrpojZLKPK6brDHgp2atXr7Orn3jwjZ+q2cQmIppVHwHVjGEVQ+UXjM421TE0PerfPlBv1ORHv6Ef+l/Ij761+1N/SxF7wo71Zhnu4nqX1h0z11Vp8nf7cj9Of/x36Ieq2l24b21l/3a+r7uHo0Pqzv6YmAg6mZW/9/k0LuZtl0nQtaFpeqwRHn9i2nOLLq/pfp5ze/Dnmn/8H</diagram></mxfile>"><defs/><g><rect x="10" y="130" width="80" height="40" fill="#ffffff" stroke="#000000" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 150px; margin-left: 11px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Node</div></div></div></div></foreignObject><text x="50" y="154" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Node</text></switch></g><rect x="150" y="130" width="80" height="40" fill="#ffffff" stroke="#000000" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 150px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Flask</div></div></div></foreignObject><text x="190" y="154" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Flask</text></switch></g><rect x="10" y="30" width="80" height="40" fill="#ffffff" stroke="#000000" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 50px; margin-left: 11px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>React</div></div></div></div></foreignObject><text x="50" y="54" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">React</text></switch></g><rect x="0" y="0" width="100" height="80" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 7px; margin-left: 1px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Frontend</div></div></div></foreignObject><text x="50" y="19" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Frontend</text></switch></g><rect x="0" y="120" width="240" height="60" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe center; width: 238px; height: 1px; padding-top: 177px; margin-left: 1px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Backend</div></div></div></foreignObject><text x="120" y="177" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">Backend</text></switch></g><path d="M 96.37 150 L 103.18 150 Q 110 150 120 150 L 143.63 150" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 91.12 150 L 98.12 146.5 L 96.37 150 L 98.12 153.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 148.88 150 L 141.88 153.5 L 143.63 150 L 141.88 146.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 149px; margin-left: 120px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; background-color: #ffffff; white-space: nowrap; ">Proxy</div></div></div></foreignObject><text x="120" y="153" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">Proxy</text></switch></g><path d="M 50 123.63 L 50 76.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 50 128.88 L 46.5 121.88 L 50 123.63 L 53.5 121.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 50 71.12 L 53.5 78.12 L 50 76.37 L 46.5 78.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 100px; margin-left: 50px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; background-color: #ffffff; white-space: nowrap; ">API</div></div></div></foreignObject><text x="50" y="103" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">API</text></switch></g><path d="M 94.97 53.98 L 185.03 126.02" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 90.87 50.7 L 98.53 52.34 L 94.97 53.98 L 94.15 57.8 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 189.13 129.3 L 181.47 127.66 L 185.03 126.02 L 185.85 122.2 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 87px; margin-left: 134px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; background-color: #ffffff; white-space: nowrap; ">Sockets</div></div></div></foreignObject><text x="134" y="90" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">Sockets</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Viewer does not support full SVG 1.1</text></a></switch></svg> \ No newline at end of file diff --git a/docs/source/_static/t8.jpg b/docs/source/_static/t8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ca5d8246c6dea02a762401943551c3394ad241bd Binary files /dev/null and b/docs/source/_static/t8.jpg differ diff --git a/docs/source/_static/team.jpg b/docs/source/_static/team.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c1b35cacf650065715c5b07d89a7035ce27331fd Binary files /dev/null and b/docs/source/_static/team.jpg differ diff --git a/docs/source/_static/users.png b/docs/source/_static/users.png new file mode 100644 index 0000000000000000000000000000000000000000..e6cb70fab3376f17f67dd20c5d29df1353701503 Binary files /dev/null and b/docs/source/_static/users.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..d1b60818f90339dfe05d2e3a586bf551cd5c5cff --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,68 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys + +import sphinx_rtd_theme + +basepath = os.path.dirname(__file__) +# filepath = os.path.abspath(os.path.join(basepath, "../../server")) +# sys.path.insert(0, filepath) + + +# -- Project information ----------------------------------------------------- + +project = "Teknikattan scoring system" +copyright = "2021, Albin Henriksson, Sebastian Karlsson, Victor Löfgren, Björn Modée, Josef Olsson, Max Rüdiger, Carl Schönfelder, Emil Wahlqvist" +author = "Albin Henriksson, Sebastian Karlsson, Victor Löfgren, Björn Modée, Josef Olsson, Max Rüdiger, Carl Schönfelder, Emil Wahlqvist" +version = "0.1" + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc", "myst_parser", "sphinx_rtd_theme"] + +# Add any paths that contain templates here, relative to this directory. +# templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +# exclude_patterns = ["test/, *.test*"] + +# autodoc_member_order = "bysource" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +add_module_names = False + + +logo_path = os.path.abspath(os.path.join(basepath, "./_static/t8.jpg")) +html_logo = logo_path + +favicon_path = "./_static/favicon.ico" +html_favicon = favicon_path diff --git a/docs/source/contact.md b/docs/source/contact.md new file mode 100644 index 0000000000000000000000000000000000000000..eebe583d4b8df2d21638761816c05dfe83795e6e --- /dev/null +++ b/docs/source/contact.md @@ -0,0 +1,17 @@ +# Contact + +The people involved in the project, their email, their role in the project, what they worked on generally and, if anything, they worked on most will be described below. +Please feel free to contact us if you have any questions. + +| Namn | Email | Roll | Generellt | Speciellt | +| ------------------ | ----------------------- | ---------------------- | --------- | ---------------------- | +| Albin Henriksson | albhe428@student.liu.se | Testledare | Frontend | | +| Sebastian Karlsson | sebka991@student.liu.se | Arkitekt | Frontend | | +| Victor Löfgren | viclo211@student.liu.se | Konfigurationsansvarig | Backend | Dokumentation, Sockets | +| Björn Modée | bjomo323@student.liu.se | Kvalitetsamordnare | Frontend | Redux | +| Josef Olsson | josol381@student.liu.se | Teamledare | Backend | | +| Max Rüdiger | maxru105@student.liu.se | Dokumentansvarig | Frontend | | +| Carl Schönfelder | carsc272@student.liu.se | Utvecklingsledare | Backend | Databas | +| Emil Wahlqvist | emiwa210@student.liu.se | Analysansvarig | Frontend | Presentationseditor | + +[comment]: # (Should this really be in swedish?) \ No newline at end of file diff --git a/docs/source/development.rst b/docs/source/development.rst new file mode 100644 index 0000000000000000000000000000000000000000..89085b0c568d6bb91b3eadda11f8e242891f23e8 --- /dev/null +++ b/docs/source/development.rst @@ -0,0 +1,14 @@ +Development +=========== + +This section will give all the instructions necessary to continue the development of this project. +Some recommendations for how to go about it will also be given. + +.. toctree:: + :maxdepth: 2 + + development/client + development/server + development/vscode + development/external + development/further diff --git a/docs/source/development/client.md b/docs/source/development/client.md new file mode 100644 index 0000000000000000000000000000000000000000..c868be7420b0a94cfa9cb6e2379cf9789d4cb18a --- /dev/null +++ b/docs/source/development/client.md @@ -0,0 +1,21 @@ +# Frontend + +[comment]: # (TODO) + +## Working with TypeScript + +[comment]: # (TODO) + +### npm + +`npm` is the node package manager. +Below we briefly describe how to use it. +All of the following snippets assume you are in the `client` folder. + +To install a module, run `npm install <module>`. + +To uninstall a module, run `npm uninstall <module>`. + +To install all project dependencies, run `npm install`. + +It is important to remember to install the project dependencies whenever someone else has added new ones to the project. diff --git a/docs/source/development/external.md b/docs/source/development/external.md new file mode 100644 index 0000000000000000000000000000000000000000..ee8c3b358bf0c8bea21575bf161d4450ae05f39e --- /dev/null +++ b/docs/source/development/external.md @@ -0,0 +1,16 @@ +# External programs + +These are some useful programs that can help with the development. + +## Postman + +[Postman](https://www.postman.com/) is a program used to test API calls. +You can create and edit API calls, change the body, headers and even share what you have saved. +It's very helpful when developing APIs. + +## DB Browser for SQlite + +[DB Browser for SQlite](https://sqlitebrowser.org/) is used to see what is currently stored in the database. +You can even edit values. + +[comment]: # (Add VS CODE?) \ No newline at end of file diff --git a/docs/source/development/further.md b/docs/source/development/further.md new file mode 100644 index 0000000000000000000000000000000000000000..543de426d0f437292596246684b5aff10ca3ceb3 --- /dev/null +++ b/docs/source/development/further.md @@ -0,0 +1,34 @@ +# Further development + +Because the project was time limited a lot is left to be done. +Below we will give two different types of things to improve. +The first type is functionality, bugs and aesthetics which improves the usability of the system. +The second type is refactoring which is basically just things related to the source code. +This won't effect the end user but will certainly improve the system as a whole. + +## Functionality, bugs and aesthetics + +Most of the basic functionality of the system is already completed. +There are however a few major things left to be done. + +### Different question types + +The system needs to support a lot of different types of questions. +A list of all the questions that needs to be supported (and more) can be found on [Teknikattan scoring system](https://github.com/TechnoX/teknikattan-scoring-system/blob/master/kandidatarbete_teknikattan.md). + +## Refactoring + +Here we will give a list of things we think will improve the system. +It is not certain that they are a better solutions but definitely something to look into. + +### Replace Flask-RESTX with flask-smorest + +[comment]: # (This is already implemented) + +We currently use [Flask-RESTX](https://flask-restx.readthedocs.io/en/latest/) to define our endpoints and parse the arguments they take, either as a query string or in the body. +But when responding we use [Marshmallow](https://flask-smorest.readthedocs.io/en/latest/) to generate the JSON objects to return. +We believe that [flask-smorest](https://flask-smorest.readthedocs.io/en/latest/) would integrate a lot better with Marshmallow. +This would give us the ability to more easily show the expected arguments and the return values for our endpoints using Swagger (when visiting `localhost:5000`). +Currently we only show the route. +The work required also seems to be rather small because they look quite similar. +This would also remove the deprecated [reqparse](https://flask-restx.readthedocs.io/en/latest/parsing.html) part from Flask-RESTX, which is desirable. diff --git a/docs/source/development/server.md b/docs/source/development/server.md new file mode 100644 index 0000000000000000000000000000000000000000..ef234d03d81754c8e68bf86ee0936c367d4c6738 --- /dev/null +++ b/docs/source/development/server.md @@ -0,0 +1,26 @@ +# Backend + +## Working with Python + +In this section we briefly describe how to work with Python. + +### Virtual environments + +Python virtual environments are used to isolate packages for each project from each other. +When [installing the server](../installation/server.md) you installed `virtualenv` and created and activated a virtual environment. + +### Pip + +Python uses `pip` to manage it's packages. +Here we briefly describe to use it. +All of the following instructions assume you have created and activated a virtual environment and are located in the server folder. + +To install a package, run `pip install <package>`. + +To uninstall a package, run `pip uninstall <package>`. + +To save a package as a dependency to the project, run `pip freeze > requirements.txt`. + +To install all project dependencies, run `pip install -r requirements.txt`. + +Remember to install the project dependencies whenever you or someone else has added new ones to the project. diff --git a/docs/source/development/vscode.md b/docs/source/development/vscode.md new file mode 100644 index 0000000000000000000000000000000000000000..86c4a97fd13e0b0320a0cecb7894ff77c078feda --- /dev/null +++ b/docs/source/development/vscode.md @@ -0,0 +1,48 @@ +# Visual Studio Code + +The development of this project was mainly done using Visual Studio Code (VS Code). +It is not that surprising, then, that we recommend you use it. + +## Extensions + +When you first open the repository in Visual Studio Code it will ask you to install all recommended extensions, which you should do. +We used a few extensions to help with the development of this project. + +The Python and Pylance extensions help with linting Python code, auto imports, syntax highlighting and much more. + +Prettier is an extension used to format JavaScript and TypeScript. +ESLint is used to lint JavaScript and TypeScript code. + +[comment]: # ("is used to lint JavaScript" what is lint? It's not explained) + +Live Share is an extension that is used to write code together at the same time, much like a Google Docs document. +There were however a few issues with the Python extension that made Live Share hard to work with. + +## Tasks + +A task in VS Code is a simple action that can be run by pressing `ctrl+shift+p`, searching for and selecting `Tasks: Run Task`. +These tasks are configured in the `.vscode/tasks.json` file. +Tasks that are marked as build tasks (starting and testing tasks as well as populate) can also be run with `ctrl+shift+b`. +A few such tasks has been setup in this project and will be described below. + +The `Start server` task will start the server. + +The `Start client` task will start the client. + +The `Start client and server` task will start both the client and the server. + +The `Populate database` task will populate the database with a few competitions, teams, users and such. Look in the `populate.py` to see exactly what it adds. Remember to always run this after changing the structure of the database. + +The `Test server` task will run the server tests located in the `server/tests/` folder. + +The `Open server coverage` task can only be run after running the server tests (`Test server` task) and will open the coverage report generated by those tests in a web browser. + +The `Unit tests` task will run the unit tests for the client. + +The `Run e2e tests` task will run the end-to-end tests. + +The `Open client coverage` task can only be run after running the client tests (`Unit tests` task) and will open the coverage report generated by those tests in a web browser. + +The `Generate documentation` task will generate the project documentation, i.e. this document, in the `docs/build/html/` folder. + +The `Open documentation` task can only be run after generating the documentation and will open it in a web browser. diff --git a/docs/source/documentation.rst b/docs/source/documentation.rst new file mode 100644 index 0000000000000000000000000000000000000000..72553205a30cb93c6d5053729f4fe281c2fc1c74 --- /dev/null +++ b/docs/source/documentation.rst @@ -0,0 +1,13 @@ +Documentation +============= + +Here we describe how to generate this entire web page. +We also describe how to generate documentation for the client and server modules. + + +.. toctree:: + :maxdepth: 2 + + documentation/general + documentation/client + documentation/server diff --git a/docs/source/documentation/client.md b/docs/source/documentation/client.md new file mode 100644 index 0000000000000000000000000000000000000000..fe424fe59115b82fd2ac7ed865a7958f226e48d9 --- /dev/null +++ b/docs/source/documentation/client.md @@ -0,0 +1,20 @@ +# Generating documentation for the client + +To generate documentation for the client you first need to [install the client](../installation/client.md). + +After that you will be able to generate the documentation by running: + +```bash +cd client/ +typedoc +``` + +You will then able to open it by running: + +```bash +start ./docs/index.html +``` + +If you want to include the documentation from the tests, go to the file `client/tsconfig.json` and comment out the line `"exlude": "**/*.test.*"`. + +[comment]: # (There should be a task for this, or does one exist already?) \ No newline at end of file diff --git a/docs/source/documentation/general.md b/docs/source/documentation/general.md new file mode 100644 index 0000000000000000000000000000000000000000..d75f2cc4cad952a2dc409c7f1eb7c039fb962e3d --- /dev/null +++ b/docs/source/documentation/general.md @@ -0,0 +1,21 @@ +# Generating this document + +To generate this document you need to do a few things. + +You will need to install `make`. +If you are on Linux you probably already have it installed and can skip the two following steps. +If you are on Windows you need to do the following: + +Download and install [Chocolatey](https://chocolatey.org/install). + +Install `make` using Chocolatey (open PowerShell as administrator): + +```bash +choco install make +``` + +You also need to [install the server](../installation/server.md). + +You should now be able to generate the documentation by activating the Python virtual environment, navigating to `docs/` and running `make html`. +Alternatively you can also run the [VS Code task](../development/vscode.html#tasks) `Generate documentation`, which will do the same thing. +If everything went well you should be able to open it by running (from the `docs/` folder) `start ./build/html/index.html` or running the task `Open documentation`, which does the same thing. diff --git a/docs/source/documentation/server.md b/docs/source/documentation/server.md new file mode 100644 index 0000000000000000000000000000000000000000..e3c9a7e21e4e84936be469a4756ec499a0b92954 --- /dev/null +++ b/docs/source/documentation/server.md @@ -0,0 +1,63 @@ +# Generating documentation for the server + +Generating the server documentation involves a few more steps. + +You need to follow the same preparatory steps as you did to [generate this document](./general.md). +That is installing make and installing the server. + +Run the following: + +```bash +cd server/ +mkdir docs +cd docs/ +sphinx-quickstart +``` + +You will be asked a few questions about how to configure Sphinx. +Just press enter on all, which will use the default. +You can enter the correct project name and/or author if you want, but it's not necessary, no one but you will see it anyway. + +Then will need to modify a few files. +First add the following code snippet after the first block of comments, above the "project information" comment, in the file `./server/docs/conf.py`: + +```py +import os +import sys + +basepath = os.path.dirname(__file__) +filepath = os.path.abspath(os.path.join(basepath, "../")) +sys.path.insert(0, filepath) +``` + +Then in the same file add an extension to the list of extensions, like so: + +```py +extensions = ["sphinx.ext.autodoc"] +``` + +Then just write the word app on line 13 in the file `server/docs/index.rst`. +The file will then look something like: + +``` +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + app +``` + +Then the documentation can be generated by running (still in the docs/ folder): + +```bash +sphinx-apidoc -o ./ ../app --no-toc -f --separate --module-first +make html +``` + +You can then open it by typing: + +```bash +start ./_build/html/index.html +``` + +You could add all of the files we just added, except `_build/`, to Git if you want. diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..6cb2e479f24868a5d346ebc18eee9e15063b4723 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,18 @@ +Welcome to Teknikattan scoring system's documentation! +====================================================== + +This project was developed during the period January-May 2021 as part of the course `TDDD96 Kandidatprojekt i programvaruutveckling <https://liu.se/studieinfo/kurs/tddd96/vt-2021>`_ at Linköping University. +It was developed for `Teknikåttan <https://www.teknikattan.se/>`_. + +.. toctree:: + :maxdepth: 1 + + introduction + user_manual + overview + installation + development + testing + documentation + contact + license diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000000000000000000000000000000000000..7152a72cca2950845560a45e1961b8d14be73f7f --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,11 @@ +Installation +============ + +This section will describe how to install the application. +You will need to install both the client and the server. + +.. toctree:: + :maxdepth: 2 + + installation/client + installation/server diff --git a/docs/source/installation/client.md b/docs/source/installation/client.md new file mode 100644 index 0000000000000000000000000000000000000000..e5768e9c7a1eb0c658b3ecc4e66d2f5122ec776e --- /dev/null +++ b/docs/source/installation/client.md @@ -0,0 +1,23 @@ +# Installing the client + +It is recommended to use [Visual Studio Code](https://code.visualstudio.com/) to install and use the client, but it is not necessary. +In order to install the client, you will need to do the following: + +Install [Node (LTS)](https://nodejs.org/en/). + +Clone the git repository [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). + +Open a terminal and navigate to the root of the cloned project. + +Install all client dependencies: + +```bash +cd client +npm install +``` + +You should now be ready to start the client. +Try it by running `npm run start`. +A web page should open where you can see the [login page](../user_manual/login.md). + +[comment]: # (Should we mention the task for starting the client?) diff --git a/docs/source/installation/server.md b/docs/source/installation/server.md new file mode 100644 index 0000000000000000000000000000000000000000..3c867f43d7a90f49ea21720df224770d2372c27c --- /dev/null +++ b/docs/source/installation/server.md @@ -0,0 +1,45 @@ +# Installing the server + +It is recommended to use [Visual Studio Code](https://code.visualstudio.com/) to install and use the server, but it is not necessary. +In order to install the server, you will need to do the following: + +Install [Python](https://www.python.org/downloads/). + +Clone the git repository [teknikattan-scoring-system](https://gitlab.liu.se/tddd96-grupp11/teknikattan-scoring-system). + +Open a terminal and navigate to the root of the cloned project. + +Install virtualenv and create a virtual environment: + +```bash +pip install virtualenv +cd server +py -m venv env +``` + +Activate the virtual environment (which is done slightly differently on Windows and Linux/Mac): + +On Windows: + +```bash +Set-ExecutionPolicy Unrestricted -Scope Process +./env/Scripts/activate +``` + +On Linux/Mac: + +```bash +source env/bin/activate +``` + +Lastly, install all project dependencies: + +```bash +pip install -r requirements.txt +``` + +You should now be ready to start the server. +Try it by running `python main.py` and navigate to `localhost:5000`. +If everything worked as it should you should see a list of all available API calls. + +[comment]: # (Should we mention the task for starting the server?) diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000000000000000000000000000000000000..4af754753f05af1b680642e1ae4c2500a6735437 --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,10 @@ +Introduction +============ + +This is a short introduction to both the project as a whole and our system. + +.. toctree:: + :maxdepth: 2 + + introduction/project + introduction/system diff --git a/docs/source/introduction/project.md b/docs/source/introduction/project.md new file mode 100644 index 0000000000000000000000000000000000000000..efe0cee655550d61ba0d86d1aaaa56ed5244c751 --- /dev/null +++ b/docs/source/introduction/project.md @@ -0,0 +1,32 @@ +# Introduction to the project + +This is a short introduction to the project. +There are several links to other relevant things to read before choosing this project. + +## Before choosing this project + +There are a lot of things this system needs to do. +To get a complete description, see the [original repository](https://github.com/TechnoX/teknikattan-scoring-system#beskrivning-av-hur-man-anv%C3%A4nder-systemet) from Teknikåttan. +There you will see exactly what is expected of the system (click on each picture to see a video that will give a more in-depth explanation). +You may also what to look at the [description of the project](https://github.com/TechnoX/teknikattan-scoring-system/blob/master/kandidatarbete_teknikattan.md), if you have not already done so. +There is a lot to read (and watch) on these two links, but in doing so you will get a complete picture of the requirements. +Make sure you understand what this project entails before continuing with it, it is not as "simple" as it might first seem. + +## Our perspective + +This was a fun project. +In contrast to some other previous projects the purpose of this one, what it's requirements are and why it's useful, is clear. +It is really fun developing a product you know (if it turns out well) many people will appreciate, use, and see. + +But on the other hand the project is large. +There was a group that worked on this project before us. +We could have continued their project when we began, but we decided not to. +This was in part due to it not really working and in part due to lack of documentation. +We hope to have learned from that mistake. +That is why we have made proper documentation (the one you are reading right now!) and a decent, working foundation of the system. +We have also made an effort to document the code as much as possible. +We hope you continue on our efforts if you choose this project. + +## Contact us + +If you have any questions about the project, our system or anything, feel free to [contact](../contact.md) any of us. diff --git a/docs/source/introduction/system.md b/docs/source/introduction/system.md new file mode 100644 index 0000000000000000000000000000000000000000..5163562bb6d984529b9bd8bcb90a20761a0e5566 --- /dev/null +++ b/docs/source/introduction/system.md @@ -0,0 +1,35 @@ +# Introduction to our system + +This system allows a user to create, edit and host competitions. +Below it is in short described what the system allows you to do. +If you want a more exact description (with pictures!), see the [user manual](../user_manual.rst) + +## Login + +After logging in you will be able to see all competitions and edit them. +If you're an admin you will also be able to see all users and edit them. +You will also be able to connect to an active competition from the same screen you used to login. + +## Editor + +The editor allows you to edit competitions. +You can add, remove and reorder slides. +You can add, delete and edit: + +- teams +- text and image components +- questions +- question types +- correcting instructions +- background image. + +## Active competitions + +You can also start a competition. +This will let other people join it with codes that can be seen either before or after starting a presentation. +Then when you switch slides, start the timer or show the current score, it will also happen for every other person connected to the same competition. + +Depending on which code someone uses to join an active competition they will see different things, which we call different _views_. +The _team view_ will allow the user to answer the questions. +The _judge view_ will allow the user to see correct answers and give a score to the questions answered by a team. +The _audience view_ will show the current slide. diff --git a/docs/source/license.md b/docs/source/license.md new file mode 100644 index 0000000000000000000000000000000000000000..bbfc640b47821227d8b3efb5398dc87cc7e57500 --- /dev/null +++ b/docs/source/license.md @@ -0,0 +1,23 @@ +# License + +MIT License + +Copyright (c) 2021 Linköping University, TDDD96 Grupp 1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/source/overview.rst b/docs/source/overview.rst new file mode 100644 index 0000000000000000000000000000000000000000..fe607291db9e1fbed7c55dea84a85ce7e308ccf6 --- /dev/null +++ b/docs/source/overview.rst @@ -0,0 +1,12 @@ +System overview +=============== + +This is a brief overview of how the entire system works. +There is then more detail about the client and the server. + +.. toctree:: + :maxdepth: 2 + + overview/overview + overview/client + overview/server diff --git a/docs/source/overview/client.md b/docs/source/overview/client.md new file mode 100644 index 0000000000000000000000000000000000000000..0187b5e916093a661048468268726578b2a7da0f --- /dev/null +++ b/docs/source/overview/client.md @@ -0,0 +1,37 @@ +# Client overview + +The client is the main part of the system. +It is divided into 4 pages: login, admin, presentation editor and active competitions (presentations). +The presentations is also further divided into four different views: operator view, audience view, team view and judge view. + +## Competitions and Presentations + +In this project competitions are often refered to when meaning un-active competitions while presentations are refered to when meaning active competitions involving multiple users and sockets connecting them. + +## File structure + +All of the source code for the pages in the system is stored in the `client/src/pages/` folder. +For each of the different parts there is a corresponding file that ends in Page, for example `JudgeViewPage.tsx` or `LoginPage.tsx`. +This is the main file for that page. +All of these pages also has their own and shared components, in the folder relative to the page `./components/`. +Every React component should also have their responding test file. + +## Routes + +All pages have their own route which is handled in `client/src/Main.tsx`. Futhermore the admin page has one route for each of the tabs which helps when reloading the site to select the previously selected tab. There is also a route for logging in with code which makes it possible to go to for example `localhost:3000/CODE123` to automatically join a competition with that code. + +## Authentication + +Authentication is managed by using JWT from the API. The JWT for logging in is stored in local storage under `token`. The JWT for active presentations are stored in local storage `RoleToken` so for example the token for Operator is stored in local storage under `OperatorToken`. + +## Prettier and Eslint + +[Eslint](https://eslint.org/) is used to set rules for syntax, [prettier](https://prettier.io/) is then used to enforce these rules when saving a file. Eslint is set to only warn about linting warnings. These libraries have their own config files which can be used to change their behavior: `client/.eslintrc` and `client/.prettierrc` + +## Redux + +[Redux](https://eslint.org/) is used for state management along with the [thunk](https://github.com/reduxjs/redux-thunk) middleware which helps with asynchronous actions. Action creators are under `client/src/actions.ts`, these dispatch actions to the reducers under `client/src/reducers.ts` that update the state. The interfaces for the states is saved in each reducer along with their initial state. When updating the state in the reducers the action payload is casted to the correct type to make the store correctly typed. + +## Interfaces + +In `client/src/interfaces` all interfaces that are shared in the client is located. `client/src/interfaces/ApiModels.ts` and `client/src/interfaces/ApiRichModels.ts` includes all models from the api and should always be updated when editing models on the back-end. This folder also includes some more specific interfaces that are re-used in the client. diff --git a/docs/source/overview/overview.md b/docs/source/overview/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..5a46ed1975d8835380c5dc403edefe0bd6caf9c1 --- /dev/null +++ b/docs/source/overview/overview.md @@ -0,0 +1,30 @@ +# System overview + +The system has a fairly simple design, as depicted in the image below. +The terms frontend and client as well as backend and server will be used interchangeably. + + + +First there is the main server which is written in Python using the micro-framework Flask. +Then there is a fairly small Node server with only one function, to serve the React frontend pages. +Lastly there is the frontend which is written in TypeScript using React and Redux. + +## Communication + +The frontend communicates with the backend in two ways, both of which are authorized on the server. +This is to make sure that whoever tries to communicate has the correct level of access. + +### API + +[comment]: # (What does "that will proxy the request to the main Python server" mean?) + +API calls are used for simple functions that the client wants to perform, such as getting, editing, and saving data. +These are sent from the client to the backend Node server that will proxy the request to the main Python server. +The request will then be handled there and the response will be sent back. +The Node server will then send them back to the client. + +### Sockets + +The client can also communicate directly with the server via sockets. +These are suited for fast real time communication. +Thus they are used during an active presentation to sync things between different views such as current slide and timer. diff --git a/docs/source/overview/server.md b/docs/source/overview/server.md new file mode 100644 index 0000000000000000000000000000000000000000..9f322c4e2d43843f8126ac9539a5ef2a88d9e0ff --- /dev/null +++ b/docs/source/overview/server.md @@ -0,0 +1,93 @@ +# Server overview + +The server has two main responsibilities. +The first is to handle API calls from the client to store, update and delete information, such as competitions or users. +It also needs to make sure that only authorized people can access these. +The other responsibility is to sync slides, timer and answers between clients in an active competition. +Both of these will be described in more detail below. + +## Receiving API calls + +An API call is a way for the client to communicate with the server. +When a request is received the server begins by authorizing it (making sure the person sending the request is allowed to access the route). +After that it confirms that it got all information in the request that it needed. +The server will then process the client request. +Finally it generates a response, usually in the form of an object from the database. +All of these steps are described in more detail below. + +### Routes + +Each existing route that can be called is specified in the files in the `app/apis/` folder. +All available routes can also be seen by navigating to `localhost:5000` after starting the server. + +### Authorization + +When the server receives an API call it will first check that the call is authorized. +The authorization is done using JSON Web Tokens (JWT) by comparing the contents of them with what is expected. +Whenever a client logs into an account or joins a competition, it is given a JWT generated by the server, and the client will need to use this token in every subsequent request sent to the server in order to authenticate itself. + +The needed authorization is specified by the `@protect_route()` decorator. +This decorator specifies who is allowed to access this route, which can either be users with specific roles, or people that have joined competitions with specific views. +If the route is not decorated everyone is allowed to access it, and the only routes currently like that is, by necessity, logging in as a user and joining a competition. + +#### JSON Web Tokens (JWT) + +JSON Web Tokens (JWT) are used for authentication, both for API and socket events. +A JWT is created on the server when a user logs in or connects to a competition. +Some information is stored in the JWT, which can be seen in the file `server/app/apis/auth.py`. +The JWT is also encrypted using the secret key defined in `server/configmodule.py`. +(NOTE: Change this key before running the server in production). +The client can read the contents of the JWT but cannot modify them because it doesn't have access to the secret key. +This is why the server can simply read the contents of the JWT to be sure that the client is who it says it is. + +### Parsing request + +After the request is authorized the server will need to parse the contents of the request. +The parsing is done with [reqparse](https://flask-restx.readthedocs.io/en/latest/parsing.html) from RestX (this module is deprecated and should be replaced). +Each API call expects different parameters in different places and this is specified in each of the files in `app/apis/` folder, together with the route. + +### Handling request + +After the request has been authorized and parsed the server will process the request. +What it does depends on the route and the given arguments, but it usually gets, edits or deletes something from the database. +The server uses an SQL database and interfaces to it via SQLAlchemy. +Everything related to the database is located in the `app/database/` folder. + +### Responding + +When the server har processed the request it usually responds with an item from the database. +Converting a database object to json is done with [Marsmallow](https://marshmallow.readthedocs.io/en/stable/). +This conversion is specified in two files in the folder `app/core/`. +The file `schemas.py` converts a record in the database field by field. +The file `rich_schemas.py` on the other hand converts an `id` in one table to an entire object in the another table, thus the name rich. +In this way, for example, an entire competition with its teams, codes, slides and the slides' questions and components can be returned in a single API call. + +## Active competitions + +Slides, timers, and answers needs to be synced during an active presentation. +This is done using SocketIO together with flask_socketio. +Sent events are also authorized via JWT, basically the same way as the for the API calls. +But for socket events, the decorator that is used to authenticate them is `@authorize_user()`. +Whenever a client joins a competition they will connect via sockets. +A single competition cannot be active more than once at the same time. +This means that you will need to make a copy of a competition if you want to run the same competition at several locations at the same time. +All of the functionality related to an active competition and sockets can be found in the file `app/core/sockets.py`. +The terms *active competition* and *presentation* are equivalent. + +### Starting and joing presentations + +Whenever a code is typed in to the client it will be checked via the `api/auth/login/code` API call. +If there is such a code and it was an operator code, the client will receive the JWT it will need to use to authenticate itself. +If there is such a code and the associated competition is active, the client will also receive a JWT for its corresponding role. +Both of these cases will be handled by the default `connect` event, using the JWT received from the API call. +The server can see what is stored in the JWT and do different things depending on its contents. + +### Syncing between clients + +[comment]: # (What does `sync` mean? It isn't explained) + +The operator will emit the `sync` event and provide either a slide or a timer to update it on the server. +The server will then send `sync` to all connected clients with the updated values, regardless of what was actually updated. +The server will also store the timer and active slide in order to `sync` clients when they join. +The operator can also emit `end_presentation` to disconnect all clients from its competitions. +This will also end the presentation. diff --git a/docs/source/testing.rst b/docs/source/testing.rst new file mode 100644 index 0000000000000000000000000000000000000000..d154d2268db1209a556fda67e8cb7d6b3def90da --- /dev/null +++ b/docs/source/testing.rst @@ -0,0 +1,13 @@ +Testing +======= + +Here we briefly describe how we have tested the system. +Both unit tests for the client and server has been made. +Some end-to-end tests have also been made that tests both the server and client at the same time. + +.. toctree:: + :maxdepth: 2 + + testing/client + testing/server + testing/e2e diff --git a/docs/source/testing/client.md b/docs/source/testing/client.md new file mode 100644 index 0000000000000000000000000000000000000000..b0201a485b067be1dd57a7ca0fd48712d543cf31 --- /dev/null +++ b/docs/source/testing/client.md @@ -0,0 +1,3 @@ +# Testing the client + +[comment]: # (TODO) \ No newline at end of file diff --git a/docs/source/testing/e2e.md b/docs/source/testing/e2e.md new file mode 100644 index 0000000000000000000000000000000000000000..62d7038664f24151e4fcc1a45849f337e9636770 --- /dev/null +++ b/docs/source/testing/e2e.md @@ -0,0 +1,3 @@ +# End to end tests + +[comment]: # (TODO) \ No newline at end of file diff --git a/docs/source/testing/server.md b/docs/source/testing/server.md new file mode 100644 index 0000000000000000000000000000000000000000..e113982b2f503c97fefac60570f42598d2ae17d6 --- /dev/null +++ b/docs/source/testing/server.md @@ -0,0 +1,12 @@ +# Testing the server + +The Python testing framework used to test the server is [pytest](https://docs.pytest.org/). + +The server tests are located in the folder `./server/tests`. +The tests are further divided into files that test the database (`test_db.py`) and test the api (`test_api.py`). + +The file `test_helpers.py` is used to store some common functionality between the tests, such as adding default values to the database. +There are also some functions that makes using the api easier, such as the `get`, `post` and `delete` functions. + +Run the tests by running the [VS Code task](../development/vscode.html#tasks) `Test server`. +After that you can see what has been tested by opening the server coverage using the task `Open server coverage`. diff --git a/docs/source/user_manual.rst b/docs/source/user_manual.rst new file mode 100644 index 0000000000000000000000000000000000000000..8aa0d00fe067c1b23aece3d36f90884eb04f25a9 --- /dev/null +++ b/docs/source/user_manual.rst @@ -0,0 +1,12 @@ +User manual +=========== + +The user manual will describe how to login, navigate the admin page, create and edit a competition and host and participate in a presentation. + +.. toctree:: + :maxdepth: 2 + + user_manual/login + user_manual/admin + user_manual/editor + user_manual/presentation \ No newline at end of file diff --git a/docs/source/user_manual/admin.md b/docs/source/user_manual/admin.md new file mode 100644 index 0000000000000000000000000000000000000000..10ee20fd8b4d3a6cf5fe68026b35444bb0899e73 --- /dev/null +++ b/docs/source/user_manual/admin.md @@ -0,0 +1,40 @@ +# Admin + +After logging in you will see the admin page. +To the left you will see the start page and the competitions tab. +If you are an admin you will also see regions and users. +In the bottom left you will be able to logout by pressing the "Logga ut" button. + + + +## Regions + +The regions tab will show all regions. +To create a new region, enter its name at the top and then click the "+" button. + + + +## Users + +The users tab will allow you to see all users, their name, region and role. +You will also be able to create new users by clicking the "Ny användare" button. +By clicking the three dots "..." you will be able to edit or delete that user. +You will also be able to search for and filter users by their region or role. + + + +## Competitions + +The competitions tab will allow you to see all competitions, their name, region and year. +You will also be able to create a new competition by clicking the "Ny tävling" button or edit existing ones by clicking on their name. +By click on the three dots "..." you will be able to start, show the codes for, copy or delete that competition. + + + +### Competition codes + +By pressing the three dots "..." for a competition and then pressing "Visa koder", all the codes for that competition will be shown. +Here you will see what view each code is associated with and what the code is. +You will also be able to generate a new code, copy the code or copy a link to the code that will let others join, or even host, a competition directly. + + diff --git a/docs/source/user_manual/editor.md b/docs/source/user_manual/editor.md new file mode 100644 index 0000000000000000000000000000000000000000..7091cce61b74daabd5bbdc476d3dce7fc6186aa2 --- /dev/null +++ b/docs/source/user_manual/editor.md @@ -0,0 +1,35 @@ +# Editor + +[comment]: # 'Explain where to find the competition name. Perhaps an image or link to Admin?' + +After clicking on a competition name you will enter the editor and will be able to edit it. +The Teknikåttan logo in the top left corner will take you back to the Admin page and right under that all slides are shown. +A newly created competition will have one empty default slide. +Switch to a different slide by clicking on it. +In the bottom left corner you will be able to add a new slide using the "Ny sida" button. +Delete or copy a slide simply by right clicking on it and choosing the appropriate option. +In the top right corner you will be able to change which view you see and edit. + + + +## Competition settings + +To the right you will see the active tab "Tävling", which will show and let you edit everything about the entire competition. +There you will be able to edit the competition name, add a new team and a background image. +The background image for the competition will be used for all slides in the competition. + +## Slide settings + +If you choose the "Sida" tab, you will be able to edit the current slide. +In the top right you can change the question type of the current slide. +For all question types you will be able to add a timer for how long the teams have to answer that question. +Depending on which type you choose, you will have different options below. +For this example we will choose multiple choice ("Kryssfråga"). +For this question type you will have the option to add a title to the question and how much many points a correct answer yields. +For this question type you will also be able to add alternatives, which the teams will be able to choose between during a competition. +Below that you will be able to add and remove text and image components as well as a background image. +The background image for the competition can be overridden by explicitly setting it on a specific page. + + + +[comment]: # 'Perhaps mention right clicking a component to make a copy to another view?' diff --git a/docs/source/user_manual/login.md b/docs/source/user_manual/login.md new file mode 100644 index 0000000000000000000000000000000000000000..ec0424b20d9884651e7cde044d29442245bd0105 --- /dev/null +++ b/docs/source/user_manual/login.md @@ -0,0 +1,19 @@ +# Login + +The login page will let you either login as a user or join a competition with a code. + +## User + +The first page you will be presented with when accessing the site is the login page. +From here you can login with your account by typing your email and password in their respective fields and pressing the "Logga in" button. + + + +## Competition code + +You can also choose the "Tävling" tab. +Here you can enter your six character long code and by pressing the "Anslut till tävling" button you will be able to join a competition. + + + +These codes can be accessed from [Admin](admin.md). diff --git a/docs/source/user_manual/presentation.md b/docs/source/user_manual/presentation.md new file mode 100644 index 0000000000000000000000000000000000000000..2bb125a59fafad25d5787524a4212e4377b0e789 --- /dev/null +++ b/docs/source/user_manual/presentation.md @@ -0,0 +1,48 @@ +[comment]: # "Why is this file named 'presentation' but the main headline is 'Active competitions'?" + +# Active competitions + +There are many different views during a competition. +Below it is described how to start a competition, how to join a competition, and how the different kinds of views work. + +## Competition codes + +You can join a competition with codes. +This can either be done by pasting the link that can be copied when listing the codes or can be typed by hand in the login page. +All the views have different purposes and therefore looks a little bit different from one another. + +## Operator + +There are two ways to start a competition. +The first way is to navigate to the competition manager, press the three dots "..." and press "Starta". +You will then enter the operator view. +From there you will be able to go between slides with the "<" and ">" buttons or start the timer, both will be synced between all clients connected to that competition. +You will also be able to view the scores for the teams and view all codes to the competition. + + + +## Team + +[comment]: # 'What is meant with "(or the code for one of the teams)"? Doesnt a team have to log in using a code?' + +The team view (or the code for one of the teams) will be used by teams. +It shows the current slide (that the operator has decided) and allows the user to answer questions on the slide that will be saved. + + + +## Audience + +The audience view will look like the operator view but without the buttons. + + + +## Judge + +[comment]: # 'Update image to show that the current slide is highlighted.' + +The judge view will show show the same slide as team view. +To the left you will be able to move between different slides without affecting the other clients and will be shown och which slide the operator currently is. +To the right you will see what the teams have answered on every question, what score each team got on each question, their total score and be able to set the score of a team on any and all questions. +In the bottom right you will see instructions for how to grade the current question. + + diff --git a/metrics/README.md b/metrics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8686aaab743940fbfcae7f7152cca2d8684342ea --- /dev/null +++ b/metrics/README.md @@ -0,0 +1,80 @@ +# Code Metrics + +This documents describes how to install and run the rust-code-analysis on a unix machine, some alteration needed for a Windows system. + +## Installing + +You will need to do the following things to install the client: + +1. Install Rust on your system by visiting https://www.rust-lang.org/tools/install or typing following in the terminal + +```bash +curl https://sh.rustup.rs -sSf | sh +``` + +2. Clone the rust-code-analysis repository from https://github.com/mozilla/rust-code-analysis + +```bash +git clone https://github.com/mozilla/rust-code-analysis.git +``` + +3. Enter the rust-code-analysis folder + +```bash +cargo build --workspace + +cargo install rust-code-analysis-cli +``` + +## Using + +For each function space, rust-code-analysis computes the list of metrics described above. At the end of this process, rust-code-analysis-cli dumps the result formatted in a certain way on the screen. + +1. Create a folder for the the outputs, for this guide it will be called "metrics-client" and "metrics-server" + +# Frontend + +2. Run the analysis tool for the frontend + +```bash + ./rust-code-analysis-cli -m --pr -p /path/to/teknikattan-scoring-system/client/src -o /path/to/metrics-client -O json +``` + +# Backend + +3. Run the analysis tool for the backend + +```bash + ./rust-code-analysis-cli -m --pr -p /path/to/teknikattan-scoring-system/server/app -o /path/to/metrics-server -O json +``` + +# Metrics + +Now the tool has analyzed the project and has outputted json files which the script will extract the metrics from. +Don't forget to change the path in the script. + +```bash +python3 metrics-script.py +``` + +## rust-code-analysis documentation + +To read more about the tool, see https://mozilla.github.io/rust-code-analysis/index.html + +# Citation + +``` +@article{ARDITO2020100635, + title = {rust-code-analysis: A Rust library to analyze and extract maintainability information from source codes}, + journal = {SoftwareX}, + volume = {12}, + pages = {100635}, + year = {2020}, + issn = {2352-7110}, + doi = {https://doi.org/10.1016/j.softx.2020.100635}, + url = {https://www.sciencedirect.com/science/article/pii/S2352711020303484}, + author = {Luca Ardito and Luca Barbato and Marco Castelluccio and Riccardo Coppola and Calixte Denizet and Sylvestre Ledru and Michele Valsesia}, + keywords = {Algorithm, Software metrics, Software maintainability, Software quality}, + abstract = {The literature proposes many software metrics for evaluating the source code non-functional properties, such as its complexity and maintainability. The literature also proposes several tools to compute those properties on source codes developed with many different software languages. However, the Rust language emergence has not been paired by the community’s effort in developing parsers and tools able to compute metrics for the Rust source code. Also, metrics tools often fall short in providing immediate means of comparing maintainability metrics between different algorithms or coding languages. We hence introduce rust-code-analysis, a Rust library that allows the extraction of a set of eleven maintainability metrics for ten different languages, including Rust. rust-code-analysis, through the Abstract Syntax Tree (AST) of a source file, allows the inspection of the code structure, analyzing source code metrics at different levels of granularity, and finding code syntax errors before compiling time. The tool also offers a command-line interface that allows exporting the results in different formats. The possibility of analyzing source codes written in different programming languages enables simple and systematic comparisons between the metrics produced from different empirical and large-scale analysis sources.} +} +``` diff --git a/metrics/metrics-script.py b/metrics/metrics-script.py new file mode 100644 index 0000000000000000000000000000000000000000..d2979fda8ccdb88e369486900d40be1b2f0580d9 --- /dev/null +++ b/metrics/metrics-script.py @@ -0,0 +1,44 @@ +# Change these path to the output folder in your system: + +client_path = "/Path/to/metrics-client" +server_path = "/Path/to/metrics-server" + +import json +import os + +mi_list = [] +cyc_list = [] +lloc_list = [] + + +def get_metrics_from_file(filename): + with open(filename) as json_file: + data = json.load(json_file) + mi_list.append(data["metrics"]["mi"]["mi_visual_studio"]) + cyc_list.append(data["metrics"]["cyclomatic"]["sum"]) + lloc_list.append(data["metrics"]["loc"]["lloc"]) + + +def main(filepath): + for root, dirs, files in os.walk(filepath): + for filename in files: + get_metrics_from_file(root + "/" + filename) + + print("Maintanability Index:", sum(mi_list) / len(mi_list)) + print() + print("Cyclomatic complexity:", sum(cyc_list) / len(cyc_list)) + print() + print("Lines of code (lloc):", sum(lloc_list)) + mi_list.clear() + cyc_list.clear() + lloc_list.clear() + + +print(" ") +print("========== Metrics for client ==========") +main(client_path) +print(" ") +print("========== Metrics for server ==========") +main(server_path) +print("===================================") +print(" ") diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..95e6d8fcdb95c67124bf5ffc4c9fe2f88a810054 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +env +htmlcov +.pytest_cache +app/*.db +app/static/ + +# Documentation +docs \ No newline at end of file diff --git a/server/README.md b/server/README.md index 84d994d85e475930cd49d6a8adb0a6372289d302..f03f602134fd4db905a7ec7256ead676adf94e10 100644 --- a/server/README.md +++ b/server/README.md @@ -6,10 +6,10 @@ This document describes how to install and run the server. You will need to do the following things to install the server: -1. Install [Visual Studio Code](https://code.visualstudio.com/) (VSCode). +1. Install [Visual Studio Code](https://code.visualstudio.com/) (VS Code). 2. Install [Python](https://www.python.org/downloads/). 3. Clone this repository if you haven't done so already. -4. Open the project folder in VSCode. +4. Open the project folder in VS Code. 5. Open the integrated terminal by pressing `ctrl+ö`. 6. Type the following commands into your terminal: diff --git a/server/app/__init__.py b/server/app/__init__.py index 2add65446ea6ca429a204a1564813d9fe19e25d0..e1512dd590b55127e9aa54f2d9925aaa816a09f3 100644 --- a/server/app/__init__.py +++ b/server/app/__init__.py @@ -1,31 +1,46 @@ from flask import Flask, redirect, request from flask_uploads import configure_uploads +from flask_uploads.extensions import IMAGES +from flask_uploads.flask_uploads import UploadSet import app.database.models as models +from app.apis import init_api from app.core import bcrypt, db, jwt, ma -from app.core.dto import MediaDTO def create_app(config_name="configmodule.DevelopmentConfig"): + """ + Creates Flask app, returns it and a SocketIO instance. Call run on the + SocketIO instance and pass in the Flask app to start the server. + """ + + # Init flask app = Flask(__name__, static_url_path="/static", static_folder="static") app.config.from_object(config_name) app.url_map.strict_slashes = False + with app.app_context(): + # Init flask apps bcrypt.init_app(app) jwt.init_app(app) db.init_app(app) db.create_all() ma.init_app(app) - configure_uploads(app, (MediaDTO.image_set,)) + configure_uploads(app, (UploadSet("photos", IMAGES),)) + # Init socket from app.core.sockets import sio sio.init_app(app) + # Init api from app.apis import flask_api flask_api.init_app(app) + init_api() + + # Flask helpers methods @app.before_request def clear_trailing(): @@ -40,15 +55,3 @@ def create_app(config_name="configmodule.DevelopmentConfig"): return response return app, sio - - -def identity(payload): - user_id = payload["identity"] - return models.User.query.filter_by(id=user_id) - - -@jwt.token_in_blacklist_loader -def check_if_token_in_blacklist(decrypted_token): - jti = decrypted_token["jti"] - - return models.Blacklist.query.filter_by(jti=jti).first() is not None diff --git a/server/app/apis/__init__.py b/server/app/apis/__init__.py index 5eab3f829ae81e17ecc269d98f568eeacc45a68c..5bb204b3de372e93a4182943013f20a3b7d5b3f9 100644 --- a/server/app/apis/__init__.py +++ b/server/app/apis/__init__.py @@ -1,85 +1,140 @@ from functools import wraps -import app.core.http_codes as http_codes from flask_jwt_extended import verify_jwt_in_request -from flask_jwt_extended.utils import get_jwt_claims -from flask_restx.errors import abort - - -def validate_editor(db_item, *views): - claims = get_jwt_claims() - city_id = int(claims.get("city_id")) - if db_item.city_id != city_id: - abort(http_codes.UNAUTHORIZED) - - -def check_jwt(editor=False, *views): - def wrapper(fn): - @wraps(fn) - def decorator(*args, **kwargs): - verify_jwt_in_request() - claims = get_jwt_claims() - role = claims.get("role") - view = claims.get("view") - if role == "Admin": - return fn(*args, **kwargs) - elif editor and role == "Editor": - return fn(*args, **kwargs) - elif view in views: - return fn(*args, **kwargs) - else: - abort(http_codes.UNAUTHORIZED) +from flask_jwt_extended.utils import get_jwt +from flask_smorest import Blueprint, abort +from flask_smorest.error_handler import ErrorSchema - return decorator +Blueprint.PAGINATION_HEADER_FIELD_NAME = "pagination" + + +ALL = ["*"] + + +class http_codes: + OK = 200 + NO_CONTENT = 204 + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + CONFLICT = 409 + GONE = 410 + INTERNAL_SERVER_ERROR = 500 + SERVICE_UNAVAILABLE = 503 + + +def _is_allowed(allowed, actual): + return actual and allowed == ALL or actual in allowed + + +def _has_access(in_claim, in_route): + in_route = int(in_route) if in_route else None + return not in_route or in_claim and in_claim == in_route + + +# class AuthorizationHeadersSchema(Schema): - return wrapper +# Authorization = fields.String(required=True) -def text_response(message, code=http_codes.OK): - return {"message": message}, code +class ExtendedBlueprint(Blueprint): + def authorization(self, allowed_roles=None, allowed_views=None): + def decorator(func): + # func = self.arguments(AuthorizationHeadersSchema, location="headers")(func) + func = self.alt_response(http_codes.UNAUTHORIZED, ErrorSchema, description="Unauthorized")(func) -def list_response(items, total=None, code=http_codes.OK): - if type(items) is not list: - abort(http_codes.INTERNAL_SERVER_ERROR) - if not total: - total = len(items) - return {"items": items, "count": len(items), "total_count": total}, code + @wraps(func) + def wrapper(*args, **kwargs): + # Check that allowed_roles and allowed_views have correct type + nonlocal allowed_roles + nonlocal allowed_views + allowed_roles = allowed_roles or [] + allowed_views = allowed_views or [] + assert ( + isinstance(allowed_roles, list) or allowed_roles == "*" + ), f"Allowed roles must be a list or '*', not '{allowed_roles}'" + assert ( + isinstance(allowed_views, list) or allowed_views == "*" + ), f"Allowed views must be a list or '*', not '{allowed_views}'" -def item_response(item, code=http_codes.OK): - if isinstance(item, list): - abort(http_codes.INTERNAL_SERVER_ERROR) - return item, code + verify_jwt_in_request() + jwt = get_jwt() + # Authorize request if roles has access to the route # -from flask_restx import Api + role = jwt.get("role") + if _is_allowed(allowed_roles, role): + return func(*args, **kwargs) -from .alternatives import api as alternative_ns -from .answers import api as answer_ns -from .auth import api as auth_ns -from .codes import api as code_ns -from .competitions import api as comp_ns -from .components import api as component_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 -from .teams import api as team_ns -from .users import api as user_ns + # Authorize request if view has access and is trying to access the + # competition its in. Also check team if client is a team. + # Allow request if route doesn't belong to any competition. + + view = jwt.get("view") + if not _is_allowed(allowed_views, view): + abort( + http_codes.UNAUTHORIZED, + f"Client with view '{view}' is not allowed to access route with allowed views {allowed_views}.", + ) + + claim_competition_id = jwt.get("competition_id") + route_competition_id = kwargs.get("competition_id") + if not _has_access(claim_competition_id, route_competition_id): + abort( + http_codes.UNAUTHORIZED, + f"Client in competition '{claim_competition_id}' is not allowed to access competition '{route_competition_id}'.", + ) + + if view == "Team": + claim_team_id = jwt.get("team_id") + route_team_id = kwargs.get("team_id") + if not _has_access(claim_team_id, route_team_id): + abort( + http_codes.UNAUTHORIZED, + f"Client in team '{claim_team_id}' is not allowed to access team '{route_team_id}'.", + ) + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +from flask_smorest import Api 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") -flask_api.add_namespace(comp_ns, path="/api/competitions") -flask_api.add_namespace(slide_ns, path="/api/competitions/<competition_id>/slides") -flask_api.add_namespace( - alternative_ns, path="/api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives" -) -flask_api.add_namespace(answer_ns, path="/api/competitions/<competition_id>/teams/<team_id>/answers") -flask_api.add_namespace(team_ns, path="/api/competitions/<competition_id>/teams") -flask_api.add_namespace(code_ns, path="/api/competitions/<competition_id>/codes") -flask_api.add_namespace(question_ns, path="/api/competitions/<competition_id>") -flask_api.add_namespace(component_ns, path="/api/competitions/<competition_id>/slides/<slide_id>/components") + + +def init_api(): + + from .alternatives import blp as alternative_blp + from .answers import blp as answer_blp + from .auth import blp as auth_blp + from .codes import blp as code_blp + from .competitions import blp as competition_blp + from .components import blp as component_blp + from .media import blp as media_blp + from .misc import blp as misc_blp + from .questions import blp as question_blp + from .scores import blp as score_blp + from .slides import blp as slide_blp + from .teams import blp as team_blp + from .users import blp as user_blp + + flask_api.register_blueprint(user_blp) + flask_api.register_blueprint(auth_blp) + flask_api.register_blueprint(competition_blp) + flask_api.register_blueprint(misc_blp) + flask_api.register_blueprint(media_blp) + flask_api.register_blueprint(slide_blp) + flask_api.register_blueprint(question_blp) + flask_api.register_blueprint(team_blp) + flask_api.register_blueprint(code_blp) + flask_api.register_blueprint(alternative_blp) + flask_api.register_blueprint(component_blp) + flask_api.register_blueprint(answer_blp) + flask_api.register_blueprint(score_blp) diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index ce7b4e7d3bb8a550aa52cbc0b2492c1118999876..d4db80fc3405374f10b53665544714d9f97ad41e 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -1,50 +1,123 @@ -import app.core.http_codes as codes +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives +""" + + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import QuestionAlternativeDTO, QuestionDTO -from app.core.parsers import question_alternative_parser -from app.core.schemas import QuestionAlternativeSchema +from app.core import ma +from app.core.schemas import BaseSchema, QuestionAlternativeSchema +from app.database import models from app.database.models import Question, QuestionAlternative -from flask_jwt_extended import jwt_required -from flask_restx import Resource +from flask.views import MethodView +from flask_smorest import abort +from flask_smorest.error_handler import ErrorSchema + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "alternative", + "alternative", + url_prefix="/api/competitions/<competition_id>/slides/<slide_id>/questions/<question_id>/alternatives", + description="Adding, updating, deleting and copy alternatives", +) + + +class AlternativeAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionAlternative + + alternative = ma.auto_field(required=False, missing="") + correct = ma.auto_field(required=False, missing="") + -api = QuestionAlternativeDTO.api -schema = QuestionAlternativeDTO.schema -list_schema = QuestionAlternativeDTO.list_schema +class AlternativeEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionAlternative + alternative = ma.auto_field(required=False) + alternative_order = ma.auto_field(required=False, missing=None) + correct = ma.auto_field(required=False) + correct_order = ma.auto_field(required=False, missing=None) -@api.route("") -@api.param("competition_id, slide_id, question_id") -class QuestionAlternativeList(Resource): - @check_jwt(editor=True) + +@blp.route("") +class Alternatives(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionAlternativeSchema(many=True)) def get(self, competition_id, slide_id, question_id): - items = dbc.get.question_alternative_list(competition_id, slide_id, question_id) - return list_response(list_schema.dump(items)) + """ Gets the all question alternatives to the specified question. """ + return dbc.get.question_alternative_list(competition_id, slide_id, question_id) - @check_jwt(editor=True) - def post(self, competition_id, slide_id, question_id): - args = question_alternative_parser.parse_args(strict=True) - item = dbc.add.question_alternative(**args, question_id=question_id) - return item_response(schema.dump(item)) + @blp.authorization(allowed_roles=ALL) + @blp.arguments(AlternativeAddArgsSchema) + @blp.response(http_codes.OK, QuestionAlternativeSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not add alternative") + def post(self, args, competition_id, slide_id, question_id): + """ + Posts a new question alternative to the specified + question using the provided arguments. + """ + return dbc.add.question_alternative(**args, question_id=question_id) -@api.route("/<alternative_id>") -@api.param("competition_id, slide_id, question_id, alternative_id") -class QuestionAlternatives(Resource): - @check_jwt(editor=True) +@blp.route("/<alternative_id>") +class QuestionAlternatives(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionAlternativeSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find alternative") def get(self, competition_id, slide_id, question_id, alternative_id): - items = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) - return item_response(schema.dump(items)) + """ Gets the specified question alternative. """ + return dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(AlternativeEditArgsSchema) + @blp.response(http_codes.OK, QuestionAlternativeSchema) + @blp.alt_response( + http_codes.BAD_REQUEST, ErrorSchema, description="Paramters to edit alternative with is incorrect" + ) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find alternative") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not edit alternative with the given values") + def put(self, args, competition_id, slide_id, question_id, alternative_id): + """ + Edits the specified question alternative using the provided arguments. + """ + + item = dbc.get.question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, + ) + + new_alternative_order = args.pop("alternative_order") + if new_alternative_order is not None and item.alternative_order != new_alternative_order: + if not (0 <= new_alternative_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort( + http_codes.BAD_REQUEST, + message=f"Kan inte ändra till ogiltigt sidordning '{new_alternative_order}'", + ) + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order( + item_question.alternatives, "alternative_order", item.alternative_order, new_alternative_order + ) + + new_correct_order = args.pop("correct_order") + if new_correct_order is not None and item.correct_order != new_correct_order: + if not (0 <= new_correct_order < dbc.utils.count(QuestionAlternative, {"question_id": question_id})): + abort(http_codes.BAD_REQUEST, message=f"Kan inte ändra till ogiltigt sidordning '{new_correct_order}'") + + item_question = dbc.get.one(Question, question_id) + dbc.utils.move_order(item_question.alternatives, "correct_order", item.correct_order, new_correct_order) - @check_jwt(editor=True) - def put(self, competition_id, slide_id, question_id, alternative_id): - args = question_alternative_parser.parse_args(strict=True) - item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) - item = dbc.edit.default(item, **args) - return item_response(schema.dump(item)) + return dbc.edit.default(item, **args) - @check_jwt(editor=True) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find alternative") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not delete alternative") def delete(self, competition_id, slide_id, question_id, alternative_id): - item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) - dbc.delete.default(item) - return {}, codes.NO_CONTENT + """ Deletes the specified question alternative. """ + dbc.delete.default(dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id)) + return None diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index 0ef3003931d52d304d66cff1cedb36b0c8cfa777..15f99a1c2b7ce569938ee7794ec4679e52bfbe6c 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -1,44 +1,58 @@ -import app.core.http_codes as codes +""" +All API calls concerning question answers. +Default route: /api/competitions/<competition_id>/teams/<team_id>/answers +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import QuestionAnswerDTO -from app.core.parsers import question_answer_edit_parser, question_answer_parser -from app.core.schemas import QuestionAlternativeSchema -from app.database.models import Question, QuestionAlternative, QuestionAnswer -from flask_jwt_extended import jwt_required -from flask_restx import Resource - -api = QuestionAnswerDTO.api -schema = QuestionAnswerDTO.schema -list_schema = QuestionAnswerDTO.list_schema - - -@api.route("") -@api.param("competition_id, team_id") -class QuestionAnswerList(Resource): - @check_jwt(editor=True) - def get(self, competition_id, team_id): - items = dbc.get.question_answer_list(competition_id, team_id) - return list_response(list_schema.dump(items)) +from app.core import ma +from app.core.schemas import BaseSchema, QuestionAlternativeAnswerSchema +from app.database import models +from flask.views import MethodView + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "answer", + "answer", + url_prefix="/api/competitions/<competition_id>/teams/<team_id>/answers", + description="Adding, updating, deleting and copy answer", +) + + +class AnswerAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionAlternativeAnswer - @check_jwt(editor=True) - def post(self, competition_id, team_id): - args = question_answer_parser.parse_args(strict=True) - item = dbc.add.question_answer(**args, team_id=team_id) - return item_response(schema.dump(item)) + answer = ma.auto_field(required=False) -@api.route("/<answer_id>") -@api.param("competition_id, team_id, answer_id") -class QuestionAnswers(Resource): - @check_jwt(editor=True) +@blp.route("") +class QuestionAlternativeList(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionAlternativeAnswerSchema) + def get(self, competition_id, team_id): + """ Gets all question answers that the specified team has given. """ + return dbc.get.question_alternative_answer_list(competition_id, team_id) + + +@blp.route("/<answer_id>") +class QuestionAlternativeAnswers(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionAlternativeAnswerSchema) def get(self, competition_id, team_id, answer_id): - item = dbc.get.question_answer(competition_id, team_id, answer_id) - return item_response(schema.dump(item)) - - @check_jwt(editor=True) - def put(self, competition_id, team_id, answer_id): - args = question_answer_edit_parser.parse_args(strict=True) - item = dbc.get.question_answer(competition_id, team_id, answer_id) - item = dbc.edit.default(item, **args) - return item_response(schema.dump(item)) + """ Gets the specified question answer. """ + return dbc.get.question_alternative_answer(competition_id, team_id, answer_id) + + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.arguments(AnswerAddArgsSchema) + @blp.response(http_codes.OK, QuestionAlternativeAnswerSchema) + def put(self, args, competition_id, team_id, answer_id): + """ Add or edit specified quesiton_answer. """ + + item = dbc.get.question_alternative_answer(competition_id, team_id, answer_id, required=False) + if item is None: + item = dbc.add.question_alternative_answer(args.get("answer"), answer_id, team_id) + else: + item = dbc.edit.default(item, **args) + + return item diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 87d7f1d19041760131560db52de5dada55dc34fe..18fb5f5d716af23a08e0a126c1cd2ae3e5394da5 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -1,106 +1,172 @@ -import app.core.http_codes as codes +""" +All API calls concerning question answers. +Default route: /api/auth +""" + +from datetime import datetime, timedelta + import app.database.controller as dbc -from app.apis import check_jwt, item_response, text_response +import marshmallow as ma from app.core.codes import verify_code -from app.core.dto import AuthDTO, CodeDTO -from app.core.parsers import create_user_parser, login_code_parser, login_parser -from app.database.models import User -from flask_jwt_extended import ( - create_access_token, - create_refresh_token, - get_jwt_identity, - get_raw_jwt, - jwt_refresh_token_required, - jwt_required, +from app.core.sockets import is_active_competition +from app.database.controller.delete import whitelist_to_blacklist +from app.database.models import Whitelist +from flask import current_app, has_app_context +from flask.views import MethodView +from flask_jwt_extended import create_access_token, get_jti +from flask_jwt_extended.utils import get_jti, get_jwt +from flask_smorest import abort +from flask_smorest.error_handler import ErrorSchema + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "auth", "auth", url_prefix="/api/auth", description="Logging in as a user or with a code, and logging out" ) -from flask_restx import Namespace, Resource, cors -api = AuthDTO.api -schema = AuthDTO.schema -list_schema = AuthDTO.list_schema + +class UserLoginArgsSchema(ma.Schema): + email = ma.fields.Email(required=True) + password = ma.fields.String(required=True) + + +class UserLoginResponseSchema(ma.Schema): + id = ma.fields.Int() + access_token = ma.fields.String() + + +if has_app_context(): + USER_LOGIN_LOCKED_ATTEMPTS = current_app.config["USER_LOGIN_LOCKED_ATTEMPTS"] + USER_LOGIN_LOCKED_EXPIRES = current_app.config["USER_LOGIN_LOCKED_EXPIRES"] def get_user_claims(item_user): - return {"role": item_user.role.name, "city_id": item_user.city_id} + """ Gets user details for jwt. """ + return {"role": item_user.role.name, "city_id": item_user.city_id} -@api.route("/signup") -class AuthSignup(Resource): - @check_jwt(editor=False) - def post(self): - args = create_user_parser.parse_args(strict=True) - email = args.get("email") - if dbc.get.user_exists(email): - api.abort(codes.BAD_REQUEST, "User already exists") +def get_code_claims(item_code): + """ Gets code details for jwt. """ - item_user = dbc.add.user(**args) - return item_response(schema.dump(item_user)) + return { + "view": item_code.view_type.name, + "competition_id": item_code.competition_id, + "team_id": item_code.team_id, + "code": item_code.code, + } -@api.route("/delete/<ID>") -@api.param("ID") -class AuthDelete(Resource): - @check_jwt(editor=False) - def delete(self, ID): - item_user = dbc.get.user(ID) +@blp.route("/test") +class AuthSignup(MethodView): + @blp.authorization(allowed_roles=["Admin"], allowed_views=["*"]) + @blp.response(http_codes.NO_CONTENT, None) + def get(self): + """ Tests that the user is admin or is in a competition. """ + return None - dbc.delete.default(item_user) - if int(ID) == get_jwt_identity(): - jti = get_raw_jwt()["jti"] - dbc.add.blacklist(jti) - return text_response(f"User {ID} deleted") +@blp.route("/login") +class AuthLogin(MethodView): + @blp.arguments(UserLoginArgsSchema) + @blp.response(http_codes.OK, UserLoginResponseSchema) + def post(self, args): + """ Logs in the specified user and creates a jwt. """ -@api.route("/login") -class AuthLogin(Resource): - def post(self): - args = login_parser.parse_args(strict=True) email = args.get("email") password = args.get("password") item_user = dbc.get.user_by_email(email) - if not item_user or not item_user.is_correct_password(password): - api.abort(codes.UNAUTHORIZED, "Invalid email or password") + # Login with unknown email + if not item_user: + abort(http_codes.UNAUTHORIZED, "Ogiltigt användarnamn eller lösenord") - access_token = create_access_token(item_user.id, user_claims=get_user_claims(item_user)) - refresh_token = create_refresh_token(item_user.id) + now = datetime.now() - response = {"id": item_user.id, "access_token": access_token, "refresh_token": refresh_token} - return response + # Login with existing email but with wrong password + if not item_user.is_correct_password(password): + # Increase the login attempts every time the user tries to login with wrong password + item_user.login_attempts += 1 + # Lock the user out for some time + if item_user.login_attempts >= USER_LOGIN_LOCKED_ATTEMPTS: + item_user.locked = now + USER_LOGIN_LOCKED_EXPIRES -@api.route("/login/code") -class AuthLoginCode(Resource): - def post(self): - args = login_code_parser.parse_args() - code = args["code"] + dbc.utils.commit() + abort(http_codes.UNAUTHORIZED, "Ogiltigt användarnamn eller lösenord") - if not verify_code(code): - api.abort(codes.BAD_REQUEST, "Invalid code") + # Otherwise if login was successful but the user is locked + if item_user.locked: + # Check if locked is greater than now + if item_user.locked.timestamp() > now.timestamp(): + abort(http_codes.UNAUTHORIZED, f"Kontot låst, försök igen om {item_user.locked} timmar") + else: + item_user.locked = None - item_code = dbc.get.code_by_code(code) - return item_response(CodeDTO.schema.dump(item_code)), codes.OK + # If everything else was successful, set login_attempts to 0 + item_user.login_attempts = 0 + dbc.utils.commit() + # Create the jwt with user.id as the identifier + access_token = create_access_token(item_user.id, additional_claims=get_user_claims(item_user)) -@api.route("/logout") -class AuthLogout(Resource): - @check_jwt(editor=True) - def post(self): - jti = get_raw_jwt()["jti"] - dbc.add.blacklist(jti) - return text_response("User logout") + # Whitelist the created jwt + dbc.add.whitelist(get_jti(access_token), item_user.id) + # Login response includes the id and jwt for the user + return {"id": item_user.id, "access_token": access_token} -@api.route("/refresh") -class AuthRefresh(Resource): - @check_jwt(editor=True) - @jwt_refresh_token_required + +@blp.route("/logout") +class AuthLogout(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.NO_CONTENT, None) def post(self): - old_jti = get_raw_jwt()["jti"] + """ Logs out. """ + whitelist_to_blacklist(Whitelist.jti == get_jwt()["jti"]) + return None + + +class CodeArgsSchema(ma.Schema): + code = ma.fields.String(required=True) + + +class CodeResponseSchema(ma.Schema): + competition_id = ma.fields.Int() + view = ma.fields.String() + team_id = ma.fields.Int() + access_token = ma.fields.String() + + +@blp.route("/code") +class AuthLoginCode(MethodView): + @blp.arguments(CodeArgsSchema) + @blp.response(http_codes.OK, CodeResponseSchema) + @blp.alt_response(http_codes.UNAUTHORIZED, ErrorSchema, description="Incorrect code or competition is not active") + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="The code doesn't exist") + def post(self, args): + """ Logs in using the provided competition code. """ + + code = args["code"] + + if not verify_code(code): # Check that code string is valid + abort(http_codes.UNAUTHORIZED, message="Felaktigt kod") + + item_code = dbc.get.code_by_code(code) - 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} - return response + # If joining client is not operator and competition is not active + if item_code.view_type_id != 4 and not is_active_competition(item_code.competition_id): + abort(http_codes.UNAUTHORIZED, message="Tävlingen är ej aktiv") + + # Create jwt that is only valid for 8 hours + access_token = create_access_token( + item_code.id, additional_claims=get_code_claims(item_code), expires_delta=timedelta(hours=8) + ) + dbc.add.whitelist(get_jti(access_token), competition_id=item_code.competition_id) # Whitelist the created jwt + + return { + "competition_id": item_code.competition_id, + "view": item_code.view_type.name, + "team_id": item_code.team_id, + "access_token": access_token, + } diff --git a/server/app/apis/codes.py b/server/app/apis/codes.py index 2a2eea5c55c6e46cf516ff35dd79ed3d96f58679..8ea7901763bf85e01417ecabadeb843c3b7d1dcc 100644 --- a/server/app/apis/codes.py +++ b/server/app/apis/codes.py @@ -1,32 +1,35 @@ +""" +All API calls concerning competition codes. +Default route: /api/competitions/<competition_id>/codes +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core import http_codes as codes -from app.core.dto import CodeDTO -from app.core.parsers import code_parser -from app.database.models import Code, Competition -from flask_jwt_extended import jwt_required -from flask_restx import Resource +from app.core.schemas import CodeSchema +from app.database.models import Code +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema + +from . import ALL, ExtendedBlueprint, http_codes -api = CodeDTO.api -schema = CodeDTO.schema -list_schema = CodeDTO.list_schema +blp = ExtendedBlueprint( + "code", "code", url_prefix="/api/competitions/<competition_id>/codes", description="Operations on codes" +) -@api.route("") -@api.param("competition_id") -class CodesList(Resource): - @check_jwt(editor=True) +@blp.route("") +class CodesList(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=["Operator"]) + @blp.response(http_codes.OK, CodeSchema(many=True)) def get(self, competition_id): - items = dbc.get.code_list(competition_id) - return list_response(list_schema.dump(items), len(items)), codes.OK + """ Gets the all competition codes. """ + return dbc.get.code_list(competition_id) -@api.route("/<code_id>") -@api.param("competition_id, code_id") -class CodesById(Resource): - @check_jwt(editor=False) +@blp.route("/<code_id>") +class CodesById(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, CodeSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Code not found") def put(self, competition_id, code_id): - item = dbc.get.one(Code, code_id) - item.code = dbc.utils.generate_unique_code() - dbc.utils.commit_and_refresh(item) - return item_response(schema.dump(item)), codes.OK + """ Generates a new competition code. """ + return dbc.edit.default(dbc.get.one(Code, code_id), code=dbc.utils.generate_unique_code()) diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index 4a6bf79d6bf17d132e3a5fed6a403b21762bbc13..4f31c8b6338d5335fc85e6b091694d08ba59fc4d 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -1,75 +1,109 @@ -import time +""" +All API calls concerning competitions. +Default route: /api/competitions +""" import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core import rich_schemas -from app.core.dto import CompetitionDTO -from app.core.parsers import competition_parser, competition_search_parser +from app.core import ma +from app.core.rich_schemas import CompetitionSchemaRich +from app.core.schemas import BaseSchema, CompetitionSchema +from app.database import models from app.database.models import Competition -from flask_jwt_extended import jwt_required -from flask_restx import Resource +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema -api = CompetitionDTO.api -schema = CompetitionDTO.schema -rich_schema = CompetitionDTO.rich_schema -list_schema = CompetitionDTO.list_schema +from . import ALL, ExtendedBlueprint, http_codes +blp = ExtendedBlueprint( + "competitions", "competitions", url_prefix="/api/competitions", description="Operations competitions" +) -@api.route("") -class CompetitionsList(Resource): - @check_jwt(editor=True) - def post(self): - args = competition_parser.parse_args(strict=True) - # Add competition - item = dbc.add.competition(**args) +class CompetitionAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Competition - # Add default slide - # dbc.add.slide(item.id) - return item_response(schema.dump(item)) + name = ma.auto_field() + year = ma.auto_field() + city_id = ma.auto_field() -@api.route("/<competition_id>") -@api.param("competition_id") -class Competitions(Resource): - @check_jwt(editor=True) - def get(self, competition_id): - item = dbc.get.competition(competition_id) - - return item_response(rich_schema.dump(item)) +class CompetitionEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Competition - @check_jwt(editor=True) - def put(self, competition_id): - args = competition_parser.parse_args(strict=True) - item = dbc.get.one(Competition, competition_id) - item = dbc.edit.default(item, **args) + name = ma.auto_field(required=False) + year = ma.auto_field(required=False) + city_id = ma.auto_field(required=False) + background_image_id = ma.auto_field(required=False) - return item_response(schema.dump(item)) - @check_jwt(editor=True) - def delete(self, competition_id): - item = dbc.get.one(Competition, competition_id) - dbc.delete.competition(item) +class CompetitionSearchArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Competition - return "deleted" + name = ma.auto_field(required=False) + year = ma.auto_field(required=False) + city_id = ma.auto_field(required=False) + background_image_id = ma.auto_field(required=False) -@api.route("/search") -class CompetitionSearch(Resource): - @check_jwt(editor=True) - def get(self): - args = competition_search_parser.parse_args(strict=True) - items, total = dbc.search.competition(**args) - return list_response(list_schema.dump(items), total) +@blp.route("") +class Competitions(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.arguments(CompetitionAddArgsSchema) + @blp.response(http_codes.OK, CompetitionSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Competition could not be added") + def post(self, args): + """ Adds a new competition. """ + return dbc.add.competition(**args) -@api.route("/<competition_id>/copy") -@api.param("competition_id") -class SlidesOrder(Resource): - @check_jwt(editor=True) +@blp.route("/<competition_id>") +class CompetitionById(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, CompetitionSchemaRich) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Competition not found") + def get(self, competition_id): + """ Gets the specified competition. """ + return dbc.get.competition(competition_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(CompetitionEditArgsSchema) + @blp.response(http_codes.OK, CompetitionSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Competition not found") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Competition could not be updated") + def put(self, args, competition_id): + """ Edits the specified competition with the specified arguments. """ + return dbc.edit.default(dbc.get.one(Competition, competition_id), **args) + + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Competition not found") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Competition could not be deleted") + def delete(self, competition_id): + """ Deletes the specified competition. """ + dbc.delete.competition(dbc.get.one(Competition, competition_id)) + return None + + +@blp.route("/search") +class CompetitionSearch(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.arguments(CompetitionSearchArgsSchema, location="query") + @blp.paginate() + @blp.response(http_codes.OK, CompetitionSchema(many=True)) + def get(self, args, pagination_parameters): + """ Finds a specific competition based on the provided arguments. """ + return dbc.search.competition(pagination_parameters, **args) + + +@blp.route("/<competition_id>/copy") +class SlidesOrder(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, CompetitionSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Competition not found") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Competition could not be copied") def post(self, competition_id): - item_competition = dbc.get.competition(competition_id) - - item_competition_copy = dbc.copy.competition(item_competition) - - return item_response(schema.dump(item_competition_copy)) + """ Creates a deep copy of the specified competition. """ + return dbc.copy.competition(dbc.get.competition(competition_id)) diff --git a/server/app/apis/components.py b/server/app/apis/components.py index 23d250256120f4bf600b10cd010b5de9aa67e0dc..b72053fa8b861dec25c658295f43de4fc862ad49 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -1,50 +1,102 @@ -import app.core.http_codes as codes +""" +All API calls concerning competitions. +Default route: /api/competitions/<competition_id>/slides/<slide_id>/components +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import ComponentDTO -from app.core.parsers import component_create_parser, component_parser -from app.database.models import Competition, Component -from flask.globals import request -from flask_jwt_extended import jwt_required -from flask_restx import Resource - -api = ComponentDTO.api -schema = ComponentDTO.schema -list_schema = ComponentDTO.list_schema - - -@api.route("/<component_id>") -@api.param("competition_id, slide_id, component_id") -class ComponentByID(Resource): - @check_jwt(editor=True) +from app.core.schemas import BaseSchema, ComponentSchema +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema +from marshmallow import fields + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "component", + "component", + url_prefix="/api/competitions/<competition_id>/slides/<slide_id>/components", + description="Adding, updating, deleting and copy components", +) + + +class ComponentAddArgsSchema(BaseSchema): + + x = fields.Integer(required=False, missing=0) + y = fields.Integer(required=False, missing=0) + w = fields.Integer(required=False, missing=1) + h = fields.Integer(required=False, missing=1) + + type_id = fields.Integer(required=True) + view_type_id = fields.Integer(required=True) + + text = fields.String(required=False) + media_id = fields.Integer(required=False) + question_id = fields.Integer(required=False) + + +class ComponentEditArgsSchema(BaseSchema): + + x = fields.Integer(required=False) + y = fields.Integer(required=False) + w = fields.Integer(required=False) + h = fields.Integer(required=False) + + type_id = fields.Integer(required=False) + view_type_id = fields.Integer(required=False) + + text = fields.String(required=False) + media_id = fields.Integer(required=False) + question_id = fields.Integer(required=False) + + +@blp.route("") +class Components(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, ComponentSchema(many=True)) + def get(self, competition_id, slide_id): + """ Gets all components in the specified slide and competition. """ + return dbc.get.component_list(competition_id, slide_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(ComponentAddArgsSchema) + @blp.response(http_codes.OK, ComponentSchema) + def post(self, args, competition_id, slide_id): + """ Posts a new component to the specified slide. """ + return dbc.add.component(slide_id=slide_id, **args) + + +@blp.route("/<component_id>") +class ComponentById(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, ComponentSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find component") def get(self, competition_id, slide_id, component_id): - item = dbc.get.component(competition_id, slide_id, component_id) - return item_response(schema.dump(item)) + """ Gets the specified component. """ + return dbc.get.component(competition_id, slide_id, component_id) - @check_jwt(editor=True) - def put(self, competition_id, slide_id, component_id): - args = component_parser.parse_args() - item = dbc.get.component(competition_id, slide_id, component_id) - item = dbc.edit.default(item, **args) - return item_response(schema.dump(item)) + @blp.authorization(allowed_roles=ALL) + @blp.arguments(ComponentEditArgsSchema) + @blp.response(http_codes.OK, ComponentSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find component") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not update component with given values") + def put(self, args, competition_id, slide_id, component_id): + """ Edits the specified component using the provided arguments. """ + return dbc.edit.default(dbc.get.component(competition_id, slide_id, component_id), **args) - @check_jwt(editor=True) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find component") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not delete component") def delete(self, competition_id, slide_id, component_id): - item = dbc.get.component(competition_id, slide_id, component_id) - dbc.delete.component(item) - return {}, codes.NO_CONTENT + """ Deletes the specified component. """ + dbc.delete.component(dbc.get.component(competition_id, slide_id, component_id)) + return None -@api.route("") -@api.param("competition_id, slide_id") -class ComponentList(Resource): - @check_jwt(editor=True) - def get(self, competition_id, slide_id): - items = dbc.get.component_list(competition_id, slide_id) - return list_response(list_schema.dump(items)) - - @check_jwt(editor=True) - def post(self, competition_id, slide_id): - args = component_create_parser.parse_args() - item = dbc.add.component(slide_id=slide_id, **args) - return item_response(schema.dump(item)) +@blp.route("/<component_id>/copy/<view_type_id>") +class ComponentCopy(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, ComponentSchema) + def post(self, competition_id, slide_id, component_id, view_type_id): + """ Creates a deep copy of the specified component. """ + return dbc.copy.component(dbc.get.component(competition_id, slide_id, component_id), slide_id, view_type_id) diff --git a/server/app/apis/media.py b/server/app/apis/media.py index 830b16de0a6125dde7c95406c8b7bc928c2bc105..a20cd1b0bdba88518be7a430f5ea71f910d71adc 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -1,84 +1,97 @@ -import os +""" +All API calls concerning media. +Default route: /api/media +""" -import app.core.http_codes as codes +import app.core.files as files import app.database.controller as dbc -from app.apis import check_jwt, 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 current_app, request -from flask_jwt_extended import get_jwt_identity, jwt_required -from flask_restx import Resource, reqparse +from app.core import ma +from app.core.schemas import BaseSchema, MediaSchema +from app.database import models +from flask import request +from flask.views import MethodView +from flask_jwt_extended import get_jwt_identity +from flask_smorest import abort +from flask_smorest.error_handler import ErrorSchema from flask_uploads import UploadNotAllowed -from PIL import Image from sqlalchemy import exc -api = MediaDTO.api -image_set = MediaDTO.image_set -schema = MediaDTO.schema -list_schema = MediaDTO.list_schema +from . import ALL, ExtendedBlueprint, http_codes -PHOTO_PATH = current_app.config["UPLOADED_PHOTOS_DEST"] +class ImageSearchArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Media -def generate_thumbnail(filename): - thumbnail_size = current_app.config["THUMBNAIL_SIZE"] - path = os.path.join(PHOTO_PATH, filename) - thumb_path = os.path.join(PHOTO_PATH, f"thumbnail_{filename}") - with Image.open(path) as im: - im.thumbnail(thumbnail_size) - im.save(thumb_path) + filename = ma.auto_field(required=False) -def delete_image(filename): - path = os.path.join(PHOTO_PATH, filename) - thumb_path = os.path.join(PHOTO_PATH, f"thumbnail_{filename}") - os.remove(path) - os.remove(thumb_path) +blp = ExtendedBlueprint("media", "media", url_prefix="/api/media", description="Operations on media") -@api.route("/images") -class ImageList(Resource): - @check_jwt(editor=True) - 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) +@blp.route("/images") +class Images(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.arguments(ImageSearchArgsSchema, location="query") + @blp.paginate() + @blp.response(http_codes.OK, MediaSchema(many=True)) + def get(self, args, pagination_parameters): + """ Gets a list of all images with the specified filename. """ + return dbc.search.image(pagination_parameters, **args) - @check_jwt(editor=True) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, MediaSchema) + @blp.alt_response(http_codes.BAD_REQUEST, ErrorSchema, description="Could not save image") + @blp.alt_response(http_codes.INTERNAL_SERVER_ERROR, ErrorSchema, description="Could not save image") def post(self): + """ Posts the specified image. """ + if "image" not in request.files: - api.abort(codes.BAD_REQUEST, "Missing image in request.files") + abort(http_codes.BAD_REQUEST, message="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()) + filename = files.save_image_with_thumbnail(request.files["image"]) + item = models.Media.query.filter(models.Media.filename == filename).first() + if not item: + item = dbc.add.image(filename, get_jwt_identity()) + + return item except UploadNotAllowed: - api.abort(codes.BAD_REQUEST, "Could not save the image") + abort(http_codes.BAD_REQUEST, message="Could not save the image") except: - api.abort(codes.INTERNAL_SERVER_ERROR, "Something went wrong when trying to save image") - finally: - return item_response(schema.dump(item)) - - -@api.route("/images/<ID>") -@api.param("ID") -class ImageList(Resource): - @check_jwt(editor=True) - def get(self, ID): - item = dbc.get.one(Media, ID) - return item_response(schema.dump(item)) - - @check_jwt(editor=True) - def delete(self, ID): - item = dbc.get.one(Media, ID) + abort(http_codes.INTERNAL_SERVER_ERROR, message="Something went wrong when trying to save image") + + +@blp.route("/images/<media_id>") +class ImageById(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, MediaSchema) + @blp.alt_response(http_codes.NOT_FOUND, MediaSchema, description="Could not find image") + def get(self, media_id): + """ Gets the specified image. """ + return dbc.get.one(models.Media, media_id) + + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.response(http_codes.CONFLICT, None, description="Could not delete image it is used by something") + @blp.response(http_codes.BAD_REQUEST, None, description="Failed to delete image") + @blp.response(http_codes.INTERNAL_SERVER_ERROR, ErrorSchema, description="Somehting very serious went wrong") + def delete(self, media_id): + """ Deletes the specified image. """ + item = dbc.get.one(models.Media, media_id) + if len(item.image_components) > 0: + abort(http_codes.CONFLICT, "Component depends on this Image") + + if len(item.competition_background_images) > 0: + abort(http_codes.CONFLICT, "Competition background image depends on this Image") + + if len(item.slide_background_images) > 0: + abort(http_codes.CONFLICT, "Slide background image depends on this Image") + try: - delete_image(item.filename) + files.delete_image_and_thumbnail(item.filename) dbc.delete.default(item) + return None except OSError: - api.abort(codes.BAD_REQUEST, "Could not delete the file image") + abort(http_codes.BAD_REQUEST, "Could not delete the file image") except exc.SQLAlchemyError: - api.abort(codes.INTERNAL_SERVER_ERROR, "Something went wrong when trying to delete image") - finally: - return {}, codes.NO_CONTENT + abort(http_codes.INTERNAL_SERVER_ERROR, "Something went wrong when trying to delete image") diff --git a/server/app/apis/misc.py b/server/app/apis/misc.py index 8b3bd5e57e5b877fdaed85f3e6a372b4984d8ef8..713a707db73f56cf5d8e332f615c4a3c558ba52d 100644 --- a/server/app/apis/misc.py +++ b/server/app/apis/misc.py @@ -1,85 +1,119 @@ +""" +All misc API calls. +Default route: /api/misc +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core import http_codes -from app.core.dto import MiscDTO +import marshmallow as ma +from app.core.schemas import ( + BaseSchema, + CitySchema, + ComponentTypeSchema, + MediaTypeSchema, + QuestionTypeSchema, + RoleSchema, + ViewTypeSchema, +) +from app.database import models from app.database.models import City, Competition, ComponentType, MediaType, QuestionType, Role, User, ViewType -from flask_jwt_extended import jwt_required -from flask_restx import Resource, reqparse - -api = MiscDTO.api +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema +from marshmallow_sqlalchemy import auto_field -question_type_schema = MiscDTO.question_type_schema -media_type_schema = MiscDTO.media_type_schema -component_type_schema = MiscDTO.component_type_schema -view_type_schema = MiscDTO.view_type_schema +from . import ALL, ExtendedBlueprint, http_codes -role_schema = MiscDTO.role_schema -city_schema = MiscDTO.city_schema +blp = ExtendedBlueprint("misc", "misc", url_prefix="/api/misc", description="Roles, regions, types and statistics") -name_parser = reqparse.RequestParser() -name_parser.add_argument("name", type=str, required=True, location="json") +class TypesResponseSchema(BaseSchema): + media_types = ma.fields.Nested(MediaTypeSchema, many=True) + component_types = ma.fields.Nested(ComponentTypeSchema, many=True) + question_types = ma.fields.Nested(QuestionTypeSchema, many=True) + view_types = ma.fields.Nested(ViewTypeSchema, many=True) -@api.route("/types") -class TypesList(Resource): +@blp.route("/types") +class Types(MethodView): + @blp.response(http_codes.OK, TypesResponseSchema) + def get(self): + """ Gets a list of all types """ + return dict( + media_types=dbc.get.all(MediaType), + component_types=dbc.get.all(ComponentType), + question_types=dbc.get.all(QuestionType), + view_types=dbc.get.all(ViewType), + ) + + +@blp.route("/roles") +class RoleList(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, RoleSchema(many=True)) def get(self): - result = {} - result["media_types"] = media_type_schema.dump(dbc.get.all(MediaType)) - result["component_types"] = component_type_schema.dump(dbc.get.all(ComponentType)) - result["question_types"] = question_type_schema.dump(dbc.get.all(QuestionType)) - result["view_types"] = view_type_schema.dump(dbc.get.all(ViewType)) - return result + """ Gets a list of all roles. """ + return dbc.get.all(Role) -@api.route("/roles") -class RoleList(Resource): - @check_jwt(editor=True) - def get(self): - items = dbc.get.all(Role) - return list_response(role_schema.dump(items)) +class CityAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.City + + name = auto_field() -@api.route("/cities") -class CitiesList(Resource): - @check_jwt(editor=True) +@blp.route("/cities") +class CitiesList(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, CitySchema(many=True)) def get(self): - items = dbc.get.all(City) - return list_response(city_schema.dump(items)) - - @check_jwt(editor=False) - def post(self): - args = name_parser.parse_args(strict=True) - dbc.add.city(args["name"]) - items = dbc.get.all(City) - return list_response(city_schema.dump(items)) - - -@api.route("/cities/<ID>") -@api.param("ID") -class Cities(Resource): - @check_jwt(editor=False) - def put(self, ID): - item = dbc.get.one(City, ID) - args = name_parser.parse_args(strict=True) - item.name = args["name"] - dbc.utils.commit_and_refresh(item) - items = dbc.get.all(City) - return list_response(city_schema.dump(items)) - - @check_jwt(editor=False) - def delete(self, ID): - item = dbc.get.one(City, ID) - dbc.delete.default(item) - items = dbc.get.all(City) - return list_response(city_schema.dump(items)) - - -@api.route("/statistics") -class Statistics(Resource): - @check_jwt(editor=True) + """ Gets a list of all cities. """ + return dbc.get.all(City) + + @blp.authorization(allowed_roles=["Admin"]) + @blp.arguments(CitySchema) + @blp.response(http_codes.OK, CitySchema(many=True)) + def post(self, args): + """ Posts the specified city. """ + dbc.add.city(**args) + return dbc.get.all(City) + + +@blp.route("/cities/<city_id>") +class Cities(MethodView): + @blp.authorization(allowed_roles=["Admin"]) + @blp.arguments(CitySchema) + @blp.response(http_codes.OK, CitySchema(many=True)) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="City not found") + @blp.alt_response( + http_codes.CONFLICT, ErrorSchema, description="The city can't be updated with the provided values" + ) + def put(self, args, city_id): + """ Edits the specified city with the provided arguments. """ + dbc.edit.default(dbc.get.one(City, city_id), **args) + return dbc.get.all(City) + + @blp.authorization(allowed_roles=["Admin"]) + @blp.response(http_codes.OK, CitySchema(many=True)) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="City not found") + @blp.alt_response( + http_codes.CONFLICT, ErrorSchema, description="The city can't be updated with the provided values" + ) + def delete(self, city_id): + """ Deletes the specified city. """ + dbc.delete.default(dbc.get.one(City, city_id)) + return dbc.get.all(City) + + +class StatisticsResponseSchema(BaseSchema): + users = ma.fields.Int() + competitions = ma.fields.Int() + regions = ma.fields.Int() + + +@blp.route("/statistics") +class Statistics(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, StatisticsResponseSchema) def get(self): - user_count = User.query.count() - competition_count = Competition.query.count() - region_count = City.query.count() - return {"users": user_count, "competitions": competition_count, "regions": region_count}, http_codes.OK + """ Gets statistics. """ + return {"users": User.query.count(), "competitions": Competition.query.count(), "regions": City.query.count()} diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index 5797872a9865bd693989eb0322d8a9818796e86b..092c91d5460fbc877d05d2f67bf00130151eb4e3 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -1,60 +1,87 @@ -import app.core.http_codes as codes +""" +All API calls concerning question answers. +Default route: /api/competitions/<competition_id> +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import QuestionDTO -from app.core.parsers import question_parser -from app.database.models import Question -from flask_jwt_extended import jwt_required -from flask_restx import Resource - -api = QuestionDTO.api -schema = QuestionDTO.schema -list_schema = QuestionDTO.list_schema - - -@api.route("/questions") -@api.param("competition_id") -class QuestionList(Resource): - @check_jwt(editor=True) - def get(self, competition_id): - items = dbc.get.question_list_for_competition(competition_id) - return list_response(list_schema.dump(items)) - - -@api.route("/slides/<slide_id>/questions") -@api.param("competition_id, slide_id") -class QuestionListForSlide(Resource): - @check_jwt(editor=True) - def get(self, competition_id, slide_id): - items = dbc.get.question_list(competition_id, slide_id) - return list_response(list_schema.dump(items)) +from app.core import ma +from app.core.schemas import BaseSchema, QuestionSchema +from app.database import models +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema - @check_jwt(editor=True) - def post(self, competition_id, slide_id): - args = question_parser.parse_args(strict=True) - item = dbc.add.question(slide_id=slide_id, **args) - return item_response(schema.dump(item)) +from . import ALL, ExtendedBlueprint, http_codes +blp = ExtendedBlueprint( + "question", + "question", + url_prefix="/api/competitions/<competition_id>/slides/<slide_id>/questions", + description="Adding, updating and deleting questions", +) -@api.route("/slides/<slide_id>/questions/<question_id>") -@api.param("competition_id, slide_id, question_id") -class QuestionById(Resource): - @check_jwt(editor=True) - def get(self, competition_id, slide_id, question_id): - item_question = dbc.get.question(competition_id, slide_id, question_id) - return item_response(schema.dump(item_question)) - @check_jwt(editor=True) - def put(self, competition_id, slide_id, question_id): - args = question_parser.parse_args(strict=True) +class QuestionAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Question + + name = ma.auto_field(required=False, missing="") + total_score = ma.auto_field(required=False, missing=None) + type_id = ma.auto_field(required=True) + correcting_instructions = ma.auto_field(required=False, missing=None) + + +class QuestionEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Question + + name = ma.auto_field(required=False) + total_score = ma.auto_field(required=False) + type_id = ma.auto_field(required=False) + correcting_instructions = ma.auto_field(required=False) - item_question = dbc.get.question(competition_id, slide_id, question_id) - item_question = dbc.edit.default(item_question, **args) - return item_response(schema.dump(item_question)) +@blp.route("") +class Questions(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, QuestionSchema(many=True)) + def get(self, competition_id, slide_id): + """ Gets all questions in the specified competition and slide. """ + return dbc.get.question_list(competition_id, slide_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(QuestionAddArgsSchema) + @blp.response(http_codes.OK, QuestionSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not add question") + def post(self, args, competition_id, slide_id): + """ Posts a new question to the specified slide using the provided arguments. """ + return dbc.add.question(slide_id=slide_id, **args) + + +@blp.route("/<question_id>") +class QuestionById(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, QuestionSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find question") + def get(self, competition_id, slide_id, question_id): + """ + Gets the specified question using the specified competition and slide. + """ + return dbc.get.question(competition_id, slide_id, question_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(QuestionEditArgsSchema) + @blp.response(http_codes.OK, QuestionSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find question") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not edit question") + def put(self, args, competition_id, slide_id, question_id): + """ Edits the specified question with the provided arguments. """ + return dbc.edit.default(dbc.get.question(competition_id, slide_id, question_id), **args) - @check_jwt(editor=True) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find question") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not delete question") def delete(self, competition_id, slide_id, question_id): - item_question = dbc.get.question(competition_id, slide_id, question_id) - dbc.delete.question(item_question) - return {}, codes.NO_CONTENT + """ Deletes the specified question. """ + dbc.delete.question(dbc.get.question(competition_id, slide_id, question_id)) + return None diff --git a/server/app/apis/scores.py b/server/app/apis/scores.py new file mode 100644 index 0000000000000000000000000000000000000000..8e7208d685538b0e83970cb2accff3f5caa93938 --- /dev/null +++ b/server/app/apis/scores.py @@ -0,0 +1,62 @@ +""" +All API calls concerning question score. +Default route: /api/competitions/<competition_id>/teams/<team_id>/answers/quesiton_scores +""" + +import app.database.controller as dbc +from app.core import ma +from app.core.schemas import BaseSchema, QuestionScoreSchema +from app.database import models +from flask.views import MethodView +from flask_smorest.error_handler import ErrorSchema + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "score", + "score", + url_prefix="/api/competitions/<competition_id>/teams/<team_id>/scores", + description="Operations on scores", +) + + +class ScoreAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionScore + + score = ma.auto_field(required=False) + + +@blp.route("") +class QuestionScoreList(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionScoreSchema(many=True)) + def get(self, competition_id, team_id): + """ Gets all question answers that the specified team has given. """ + return dbc.get.question_score_list(competition_id, team_id) + + +@blp.route("/<question_id>") +class QuestionScores(MethodView): + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.response(http_codes.OK, QuestionScoreSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Cant find answer") + def get(self, competition_id, team_id, question_id): + """ Gets the score for the provided team on the provided question. """ + return dbc.get.question_score(competition_id, team_id, question_id) + + @blp.authorization(allowed_roles=ALL, allowed_views=ALL) + @blp.arguments(ScoreAddArgsSchema) + @blp.response(http_codes.OK, QuestionScoreSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Cant find score") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Can't add or edit score with provided values") + def put(self, args, competition_id, team_id, question_id): + """ Add or edit specified quesiton_answer. """ + + item = dbc.get.question_score(competition_id, team_id, question_id, required=False) + if item is None: + item = dbc.add.question_score(args.get("score"), question_id, team_id) + else: + item = dbc.edit.default(item, **args) + + return item diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 9ca4a5f229488e03931ec544dd3ccf43406e5b9e..86854efc9cf764250bc34d7791503c2362de8a5f 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -1,92 +1,97 @@ -import app.core.http_codes as codes +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/slides +""" + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import SlideDTO -from app.core.parsers import slide_parser +from app.core import ma +from app.core.schemas import BaseSchema, SlideSchema +from app.database import models from app.database.models import Competition, Slide -from flask_jwt_extended import jwt_required -from flask_restx import Resource +from flask.views import MethodView +from flask_smorest import abort +from flask_smorest.error_handler import ErrorSchema -api = SlideDTO.api -schema = SlideDTO.schema -list_schema = SlideDTO.list_schema +from . import ALL, ExtendedBlueprint, http_codes +blp = ExtendedBlueprint( + "slide", + "slide", + url_prefix="/api/competitions/<competition_id>/slides", + description="Adding, updating, deleting and copy slide", +) -@api.route("") -@api.param("competition_id") -class SlidesList(Resource): - @check_jwt(editor=True) - def get(self, competition_id): - items = dbc.get.slide_list(competition_id) - return list_response(list_schema.dump(items)) - @check_jwt(editor=True) - def post(self, competition_id): - item_slide = dbc.add.slide(competition_id) - return item_response(schema.dump(item_slide)) +class SlideEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Slide + title = ma.auto_field(required=False) + timer = ma.auto_field(required=False) + order = ma.auto_field(required=False, missing=None) + background_image_id = ma.auto_field(required=False) -@api.route("/<slide_id>") -@api.param("competition_id,slide_id") -class Slides(Resource): - @check_jwt(editor=True) - def get(self, competition_id, slide_id): - item_slide = dbc.get.slide(competition_id, slide_id) - return item_response(schema.dump(item_slide)) - @check_jwt(editor=True) - def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) - - item_slide = dbc.get.slide(competition_id, slide_id) - item_slide = dbc.edit.default(item_slide, **args) - - return item_response(schema.dump(item_slide)) +@blp.route("") +class Slides(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, SlideSchema(many=True)) + def get(self, competition_id): + """ Gets all slides from the specified competition. """ + return dbc.get.slide_list(competition_id) - @check_jwt(editor=True) - def delete(self, competition_id, slide_id): - item_slide = dbc.get.slide(competition_id, slide_id) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, SlideSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Can't add slide") + def post(self, competition_id): + """ Posts a new slide to the specified competition. """ + return dbc.add.slide(competition_id) - dbc.delete.slide(item_slide) - return {}, codes.NO_CONTENT +@blp.route("/<slide_id>") +class Slides(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, SlideSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema) + def get(self, competition_id, slide_id): + """ Gets the specified slide. """ + return dbc.get.slide(competition_id, slide_id) -@api.route("/<slide_id>/order") -@api.param("competition_id,slide_id") -class SlideOrder(Resource): - @check_jwt(editor=True) - def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) - order = args.get("order") + @blp.authorization(allowed_roles=ALL) + @blp.arguments(SlideEditArgsSchema) + @blp.response(http_codes.OK, SlideSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Can't edit slide") + @blp.alt_response(http_codes.BAD_REQUEST, ErrorSchema, description="Can't edit slide with the provided arguments") + def put(self, args, competition_id, slide_id): + """ Edits the specified slide using the provided arguments. """ item_slide = dbc.get.slide(competition_id, slide_id) - if order == item_slide.order: - return item_response(schema.dump(item_slide)) - - # clamp order between 0 and max - order_count = dbc.get.slide_count(competition_id) - if order < 0: - order = 0 - elif order >= order_count - 1: - order = order_count - 1 + new_order = args.pop("order") + if new_order is not None and item_slide.order != new_order: + if not (0 <= new_order < dbc.utils.count(Slide, {"competition_id": competition_id})): + abort(http_codes.BAD_REQUEST, f"Cant change to invalid slide order '{new_order}'") - # get slide at the requested order - item_slide_id = dbc.get.slide(competition_id, order) + item_competition = dbc.get.one(Competition, competition_id) + dbc.utils.move_order(item_competition.slides, "order", item_slide.order, new_order) - # switch place between them - item_slide = dbc.edit.switch_order(item_slide, item_slide_id) + return dbc.edit.default(item_slide, **args) - return item_response(schema.dump(item_slide)) + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Slide not found") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Can't delete slide") + def delete(self, competition_id, slide_id): + """ Deletes the specified slide. """ + dbc.delete.slide(dbc.get.slide(competition_id, slide_id)) + return None -@api.route("/<slide_id>/copy") -@api.param("competition_id,slide_id") -class SlideCopy(Resource): - @check_jwt(editor=True) +@blp.route("/<slide_id>/copy") +class SlideCopy(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, SlideSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Can't find slide") def post(self, competition_id, slide_id): - item_slide = dbc.get.slide(competition_id, slide_id) - - item_slide_copy = dbc.copy.slide(item_slide) - - return item_response(schema.dump(item_slide_copy)) + """ Creates a deep copy of the specified slide. """ + return dbc.copy.slide(dbc.get.slide(competition_id, slide_id)) diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index 514748ae27c7ff685d2b1099d1bf710610928860..7a1b4841f1a327f006abf5675a326b13f12f4028 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -1,53 +1,77 @@ -import app.core.http_codes as codes +""" +All API calls concerning question alternatives. +Default route: /api/competitions/<competition_id>/teams +""" + +from flask_smorest.error_handler import ErrorSchema import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import TeamDTO -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 - -api = TeamDTO.api -schema = TeamDTO.schema -list_schema = TeamDTO.list_schema - - -@api.route("") -@api.param("competition_id") -class TeamsList(Resource): - @check_jwt(editor=True) - def get(self, competition_id): - items = dbc.get.team_list(competition_id) - return list_response(list_schema.dump(items)) +from app.core import ma +from app.core.schemas import BaseSchema, TeamSchema +from app.database import models +from flask.views import MethodView - @check_jwt(editor=True) - def post(self, competition_id): - args = team_parser.parse_args(strict=True) - item_team = dbc.add.team(args["name"], competition_id) - return item_response(schema.dump(item_team)) +from . import ALL, ExtendedBlueprint, http_codes +blp = ExtendedBlueprint( + "team", + "team", + url_prefix="/api/competitions/<competition_id>/teams", + description="Operations on teams", +) -@api.route("/<team_id>") -@api.param("competition_id,team_id") -class Teams(Resource): - @check_jwt(editor=True) - def get(self, competition_id, team_id): - item = dbc.get.team(competition_id, team_id) - return item_response(schema.dump(item)) - @check_jwt(editor=True) - def delete(self, competition_id, team_id): - item_team = dbc.get.team(competition_id, team_id) +class TeamAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Team + + name = ma.auto_field(required=True) + + +class TeamEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.Team - dbc.delete.team(item_team) - return {}, codes.NO_CONTENT + name = ma.auto_field(required=False) - @check_jwt(editor=True) - def put(self, competition_id, team_id): - args = team_parser.parse_args(strict=True) - name = args.get("name") - item_team = dbc.get.team(competition_id, team_id) +@blp.route("") +class Teams(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, TeamSchema(many=True)) + def get(self, competition_id): + """ Gets all teams to the specified competition. """ + return dbc.get.team_list(competition_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(TeamAddArgsSchema) + @blp.response(http_codes.OK, TeamSchema) + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="Could not add team") + def post(self, args, competition_id): + """ Posts a new team to the specified competition. """ + return dbc.add.team(args["name"], competition_id) - item_team = dbc.edit.default(item_team, name=name, competition_id=competition_id) - return item_response(schema.dump(item_team)) + +@blp.route("/<team_id>") +class TeamsById(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, TeamSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find team") + def get(self, competition_id, team_id): + """ Gets the specified team. """ + return dbc.get.team(competition_id, team_id) + + @blp.authorization(allowed_roles=ALL) + @blp.arguments(TeamEditArgsSchema) + @blp.response(http_codes.OK, TeamSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find team") + def put(self, args, competition_id, team_id): + """ Edits the specified team using the provided arguments. """ + return dbc.edit.default(dbc.get.team(competition_id, team_id), **args) + + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="Could not find team") + def delete(self, competition_id, team_id): + """ Deletes the specified team. """ + dbc.delete.team(dbc.get.team(competition_id, team_id)) + return None diff --git a/server/app/apis/users.py b/server/app/apis/users.py index 1bae000bc6821ff7c7ac3dde82474bd351c2fe4d..9617cd4543bbe0ff97eead8d0a34d9f2bfed86fe 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -1,67 +1,133 @@ -import app.core.http_codes as codes +""" +All API calls concerning question alternatives. +Default route: /api/users +""" + + import app.database.controller as dbc -from app.apis import check_jwt, item_response, list_response -from app.core.dto import UserDTO -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 +from app.core import ma +from app.core.schemas import BaseSchema, UserSchema +from app.database import models +from app.database.models import User, Whitelist +from flask.views import MethodView +from flask_jwt_extended.utils import get_jwt_identity +from flask_smorest import abort +from flask_smorest.error_handler import ErrorSchema +from marshmallow import fields + +from . import ALL, ExtendedBlueprint, http_codes + +blp = ExtendedBlueprint( + "users", "users", url_prefix="/api/users", description="Adding, updating, deleting and searching for users" +) + + +class UserAddArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.User + + name = ma.auto_field() + password = fields.String(required=True) + email = ma.auto_field(required=True) + role_id = ma.auto_field(required=True) + city_id = ma.auto_field(required=True) + -api = UserDTO.api -schema = UserDTO.schema -list_schema = UserDTO.list_schema +class UserEditArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.User + + name = ma.auto_field(required=False) + email = ma.auto_field(required=False) + role_id = ma.auto_field(required=False) + city_id = ma.auto_field(required=False) + + +class UserSearchArgsSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.User + + name = ma.auto_field(required=False) + email = ma.auto_field(required=False) + role_id = ma.auto_field(required=False) + city_id = ma.auto_field(required=False) + + +@blp.route("") +class Users(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, UserSchema) + def get(self): + """ Get currently logged in user. """ + return dbc.get.one(User, get_jwt_identity()) + @blp.authorization(allowed_roles=ALL) + @blp.arguments(UserEditArgsSchema) + @blp.response(http_codes.OK, UserSchema) + def put(self, args): + """ Edit current user. """ + return _edit_user(dbc.get.one(User, get_jwt_identity()), args) + + @blp.authorization(allowed_roles=["Admin"]) + @blp.arguments(UserAddArgsSchema) + @blp.response(http_codes.OK, UserSchema) + def post(self, args): + """ Creates a new user if the user does not already exist. """ + return dbc.add.user(**args) + + +@blp.route("/<user_id>") +class UsersById(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.response(http_codes.OK, UserSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="User not found") + def get(self, user_id): + """ Get user with <user_id> """ + return dbc.get.one(User, user_id) + + @blp.authorization(allowed_roles=["Admin"]) + @blp.arguments(UserEditArgsSchema) + @blp.response(http_codes.OK, UserSchema) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="User not found") + @blp.alt_response( + http_codes.CONFLICT, ErrorSchema, description="The user can't be updated with the provided values" + ) + def put(self, args, user_id): + """ Edits user with <user_id> """ + return _edit_user(dbc.get.one(User, user_id), args) + + @blp.authorization(allowed_roles=["Admin"]) + @blp.response(http_codes.NO_CONTENT, None) + @blp.alt_response(http_codes.NOT_FOUND, ErrorSchema, description="User not found") + @blp.alt_response(http_codes.CONFLICT, ErrorSchema, description="The user can't be deleted") + def delete(self, user_id): + """ Deletes the specified user and adds their token to the blacklist. """ + item_user = dbc.get.one(User, user_id) + dbc.delete.whitelist_to_blacklist(Whitelist.user_id == user_id) # Blacklist all the whitelisted tokens + dbc.delete.default(item_user) + return None + + +@blp.route("/search") +class UserSearch(MethodView): + @blp.authorization(allowed_roles=ALL) + @blp.arguments(UserSearchArgsSchema, location="query") + @blp.paginate() + @blp.response(http_codes.OK, UserSchema(many=True)) + def get(self, args, pagination_parameters): + """ Finds a specific user based on the provided arguments. """ + return dbc.search.user(pagination_parameters, **args) + + +def _edit_user(item_user, args): + """ Edits a user using the provided arguments. """ -def edit_user(item_user, args): email = args.get("email") name = args.get("name") - if email: - if dbc.get.user_exists(email): - api.abort(codes.BAD_REQUEST, "Email is already in use") - + if email and dbc.get.user_exists(email): + abort(http_codes.CONFLICT, message="En användare med den mejladressen finns redan") if name: args["name"] = args["name"].title() return dbc.edit.default(item_user, **args) - - -@api.route("") -class UsersList(Resource): - @check_jwt(editor=True) - def get(self): - item = dbc.get.user(get_jwt_identity()) - return item_response(schema.dump(item)) - - @check_jwt(editor=True) - def put(self): - args = user_parser.parse_args(strict=True) - item = dbc.get.user(get_jwt_identity()) - item = edit_user(item, args) - return item_response(schema.dump(item)) - - -@api.route("/<ID>") -@api.param("ID") -class Users(Resource): - @check_jwt(editor=True) - def get(self, ID): - item = dbc.get.user(ID) - return item_response(schema.dump(item)) - - @check_jwt(editor=False) - def put(self, ID): - args = user_parser.parse_args(strict=True) - item = dbc.get.user(ID) - item = edit_user(item, args) - return item_response(schema.dump(item)) - - -@api.route("/search") -class UserSearch(Resource): - @check_jwt(editor=True) - def get(self): - args = user_search_parser.parse_args(strict=True) - 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 acfca55431cf0f9b538e1a1487cea7299e3ad4e4..b65b3dc0df1360ea285e5820c7c18ee669c99551 100644 --- a/server/app/core/__init__.py +++ b/server/app/core/__init__.py @@ -1,10 +1,27 @@ +""" +The core submodule contains everything important to the server that doesn't +fit neatly in either apis or database. +""" +import app.database.models as models from app.database 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 +# Flask apps db = SQLAlchemy(model_class=Base, query_class=ExtendedQuery) bcrypt = Bcrypt() jwt = JWTManager() ma = Marshmallow() + + +@jwt.token_in_blocklist_loader +def check_if_token_in_blacklist(jwt_headers, jwt_data): + """ + An extension method with flask_jwt_extended that will execute when jwt verifies + Check if the token is blacklisted in the database + """ + + jti = jwt_data["jti"] + return models.Blacklist.query.filter_by(jti=jti).first() is not None diff --git a/server/app/core/codes.py b/server/app/core/codes.py index 47150767b17fb1b352f0b1ae3130dc6d4ffefb5a..f66bf1d6274520dcdfea296c3fd38212a4365d5a 100644 --- a/server/app/core/codes.py +++ b/server/app/core/codes.py @@ -1,15 +1,24 @@ +""" +Contains all functions purely related to creating and verifying a code. +""" + import random import re -import string CODE_LENGTH = 6 ALLOWED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" CODE_RE = re.compile(f"^[{ALLOWED_CHARS}]{{{CODE_LENGTH}}}$") -def generate_code_string(): +def generate_code_string() -> str: + """ + Return a 6 character long random sequence containing uppercase letters and numbers. + """ return "".join(random.choices(ALLOWED_CHARS, k=CODE_LENGTH)) -def verify_code(c): - return CODE_RE.search(c.upper()) is not None +def verify_code(code: str) -> bool: + """ + Returns True if code only contains letters and/or numbers and is exactly 6 characters long. + """ + return CODE_RE.search(code.upper()) is not None diff --git a/server/app/core/dto.py b/server/app/core/dto.py deleted file mode 100644 index 90e49d00b8062ee3afe6eac7b44e7b397ccfe45c..0000000000000000000000000000000000000000 --- a/server/app/core/dto.py +++ /dev/null @@ -1,82 +0,0 @@ -import app.core.rich_schemas as rich_schemas -import app.core.schemas as schemas -from flask_restx import Namespace -from flask_uploads import IMAGES, UploadSet - - -class ComponentDTO: - api = Namespace("component") - schema = schemas.ComponentSchema(many=False) - list_schema = schemas.ComponentSchema(many=True) - - -class MediaDTO: - api = Namespace("media") - image_set = UploadSet("photos", IMAGES) - schema = schemas.MediaSchema(many=False) - list_schema = schemas.MediaSchema(many=True) - - -class AuthDTO: - api = Namespace("auth") - schema = schemas.UserSchema(many=False) - list_schema = schemas.UserSchema(many=True) - - -class UserDTO: - api = Namespace("users") - schema = schemas.UserSchema(many=False) - list_schema = schemas.UserSchema(many=True) - - -class CompetitionDTO: - api = Namespace("competitions") - schema = schemas.CompetitionSchema(many=False) - list_schema = schemas.CompetitionSchema(many=True) - rich_schema = rich_schemas.CompetitionSchemaRich(many=False) - - -class CodeDTO: - api = Namespace("codes") - schema = schemas.CodeSchema(many=False) - list_schema = schemas.CodeSchema(many=True) - - -class SlideDTO: - api = Namespace("slides") - schema = schemas.SlideSchema(many=False) - list_schema = schemas.SlideSchema(many=True) - - -class TeamDTO: - api = Namespace("teams") - schema = schemas.TeamSchema(many=False) - list_schema = schemas.TeamSchema(many=True) - - -class MiscDTO: - api = Namespace("misc") - role_schema = schemas.RoleSchema(many=True) - question_type_schema = schemas.QuestionTypeSchema(many=True) - media_type_schema = schemas.MediaTypeSchema(many=True) - component_type_schema = schemas.ComponentTypeSchema(many=True) - view_type_schema = schemas.ViewTypeSchema(many=True) - city_schema = schemas.CitySchema(many=True) - - -class QuestionDTO: - api = Namespace("questions") - schema = schemas.QuestionSchema(many=False) - list_schema = schemas.QuestionSchema(many=True) - - -class QuestionAlternativeDTO: - api = Namespace("alternatives") - schema = schemas.QuestionAlternativeSchema(many=False) - list_schema = schemas.QuestionAlternativeSchema(many=True) - - -class QuestionAnswerDTO: - api = Namespace("answers") - schema = schemas.QuestionAnswerSchema(many=False) - list_schema = schemas.QuestionAnswerSchema(many=True) diff --git a/server/app/core/files.py b/server/app/core/files.py new file mode 100644 index 0000000000000000000000000000000000000000..4f5256fb795fdc2ebf599464e5b99ee7c30a22d5 --- /dev/null +++ b/server/app/core/files.py @@ -0,0 +1,113 @@ +""" +Contains functions related to file handling, mainly saving and deleting images. +""" + +import os + +from flask import current_app, has_app_context +from flask_uploads import IMAGES, UploadSet +from PIL import Image + +if has_app_context(): + PHOTO_PATH = current_app.config["UPLOADED_PHOTOS_DEST"] + THUMBNAIL_SIZE = current_app.config["THUMBNAIL_SIZE"] + image_set = UploadSet("photos", IMAGES) + + +# def compare_images(input_image, output_image): +# # compare image dimensions (assumption 1) +# if input_image.size != output_image.size: +# return False + +# rows, cols = input_image.size + +# # compare image pixels (assumption 2 and 3) +# for row in range(rows): +# for col in range(cols): +# input_pixel = input_image.getpixel((row, col)) +# output_pixel = output_image.getpixel((row, col)) +# if input_pixel != output_pixel: +# return False + +# return True + + +def _delete_image(filename): + """ + Private function + Delete an image with the given filename + :param filename: Of the image that will be deleted + :type filename: str + :rtype: None + """ + path = os.path.join(PHOTO_PATH, filename) + os.remove(path) + + +def save_image_with_thumbnail(image_file): + """ + Saves the given image and also creates a small thumbnail for it. + :param image_file: Image object that will be saved on server + :type image_file: object + :return: Filename of the saved image, if filename already exist the filename will be changed + :rtype: str + """ + + saved_filename = image_set.save(image_file) + saved_path = os.path.join(PHOTO_PATH, saved_filename) + with Image.open(saved_path) as im: + im_thumbnail = im.copy() + im_thumbnail.thumbnail(THUMBNAIL_SIZE) + thumb_path = os.path.join(PHOTO_PATH, f"thumbnail_{saved_filename}") + im_thumbnail.save(thumb_path) + im.close() + return saved_filename + + +def delete_image_and_thumbnail(filename): + """ + Delete the given image together with its thumbnail. + :param filename: + :type filename: + """ + _delete_image(filename) + _delete_image(f"thumbnail_{filename}") + + +""" +def _resolve_name_conflict(filename): + split = os.path.splitext(filename) + suffix = split[0] + preffix = split[1] + now = datetime.datetime.now() + time_stamp = now.strftime("%Y%m%d%H%M%S") + return f"{suffix}-{time_stamp}{preffix}" +""" +""" +def save_image_with_thumbnail(image_file): + filename = image_file.filename + path = os.path.join(PHOTO_PATH, filename) + + saved_filename = image_set.save(image_file) + saved_path = os.path.join(PHOTO_PATH, saved_filename) + im = Image.open(saved_path) + + # Check if image already exists + if path != saved_path: + im_existing = Image.open(path) + # If both images are identical, then return None + if compare_images(im, im_existing): + im.close() + im_existing.close() + _delete_image(saved_filename) + return filename + + path = os.path.join(PHOTO_PATH, saved_filename) + im_thumbnail = im.copy() + im_thumbnail.thumbnail(THUMBNAIL_SIZE) + + thumb_path = os.path.join(PHOTO_PATH, f"thumbnail_{saved_filename}") + im_thumbnail.save(thumb_path) + im.close() + return saved_filename +""" diff --git a/server/app/core/http_codes.py b/server/app/core/http_codes.py deleted file mode 100644 index 95a99175665427e51b8684792e98180c75e6dc72..0000000000000000000000000000000000000000 --- a/server/app/core/http_codes.py +++ /dev/null @@ -1,9 +0,0 @@ -OK = 200 -NO_CONTENT = 204 -BAD_REQUEST = 400 -UNAUTHORIZED = 401 -FORBIDDEN = 403 -NOT_FOUND = 404 -GONE = 410 -INTERNAL_SERVER_ERROR = 500 -SERVICE_UNAVAILABLE = 503 diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py deleted file mode 100644 index 71f8fdee352c3fe88831124b0c8907fcbc51f505..0000000000000000000000000000000000000000 --- a/server/app/core/parsers.py +++ /dev/null @@ -1,107 +0,0 @@ -from flask_restx import inputs, reqparse - -###SEARCH#### -search_parser = reqparse.RequestParser() -search_parser.add_argument("page", type=int, default=0, location="args") -search_parser.add_argument("page_size", type=int, default=15, location="args") -search_parser.add_argument("order", type=int, default=1, location="args") -search_parser.add_argument("order_by", type=str, default=None, location="args") - -###LOGIN#### -login_parser = reqparse.RequestParser() -login_parser.add_argument("email", type=inputs.email(), required=True, location="json") -login_parser.add_argument("password", required=True, location="json") - -###CREATE_USER#### -create_user_parser = login_parser.copy() -create_user_parser.add_argument("city_id", type=int, required=True, location="json") -create_user_parser.add_argument("role_id", type=int, required=True, location="json") - -###USER#### -user_parser = reqparse.RequestParser() -user_parser.add_argument("email", type=inputs.email(), location="json") -user_parser.add_argument("name", type=str, location="json") -user_parser.add_argument("city_id", type=int, location="json") -user_parser.add_argument("role_id", type=int, location="json") - -###SEARCH_USER#### -user_search_parser = search_parser.copy() -user_search_parser.add_argument("name", type=str, default=None, location="args") -user_search_parser.add_argument("email", type=str, default=None, location="args") -user_search_parser.add_argument("city_id", type=int, default=None, location="args") -user_search_parser.add_argument("role_id", type=int, default=None, location="args") - - -###COMPETITION#### -competition_parser = reqparse.RequestParser() -competition_parser.add_argument("name", type=str, location="json") -competition_parser.add_argument("year", type=int, location="json") -competition_parser.add_argument("city_id", type=int, location="json") - - -###SEARCH_COMPETITION#### -competition_search_parser = search_parser.copy() -competition_search_parser.add_argument("name", type=str, default=None, location="args") -competition_search_parser.add_argument("year", type=int, default=None, location="args") -competition_search_parser.add_argument("city_id", type=int, default=None, location="args") - - -###SLIDER_PARSER#### -slide_parser = reqparse.RequestParser() -slide_parser.add_argument("order", type=int, default=None, location="json") -slide_parser.add_argument("title", type=str, default=None, location="json") -slide_parser.add_argument("timer", type=int, default=None, location="json") - - -###QUESTION#### -question_parser = reqparse.RequestParser() -question_parser.add_argument("name", type=str, default=None, location="json") -question_parser.add_argument("total_score", type=int, default=None, location="json") -question_parser.add_argument("type_id", type=int, default=None, location="json") - - -###QUESTION ALTERNATIVES#### -question_alternative_parser = reqparse.RequestParser() -question_alternative_parser.add_argument("text", type=str, default=None, location="json") -question_alternative_parser.add_argument("value", type=int, default=None, location="json") - -###QUESTION ANSWERS#### -question_answer_parser = reqparse.RequestParser() -question_answer_parser.add_argument("data", type=dict, required=True, location="json") -question_answer_parser.add_argument("score", type=int, required=True, location="json") -question_answer_parser.add_argument("question_id", type=int, required=True, location="json") - -###QUESTION ANSWERS EDIT#### -question_answer_edit_parser = reqparse.RequestParser() -question_answer_edit_parser.add_argument("data", type=dict, default=None, location="json") -question_answer_edit_parser.add_argument("score", type=int, default=None, location="json") - -###CODE#### -code_parser = reqparse.RequestParser() -code_parser.add_argument("pointer", type=str, default=None, location="json") -code_parser.add_argument("view_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") - - -###COMPONENT### -component_parser = reqparse.RequestParser() -component_parser.add_argument("x", type=str, default=None, location="json") -component_parser.add_argument("y", type=int, default=None, location="json") -component_parser.add_argument("w", type=int, default=None, location="json") -component_parser.add_argument("h", type=int, default=None, location="json") -component_parser.add_argument("data", type=dict, default=None, location="json") - -component_create_parser = component_parser.copy() -component_create_parser.replace_argument("data", type=dict, required=True, location="json") -component_create_parser.add_argument("type_id", type=int, required=True, location="json") - -login_code_parser = reqparse.RequestParser() -login_code_parser.add_argument("code", type=str, location="json") diff --git a/server/app/core/rich_schemas.py b/server/app/core/rich_schemas.py index 8852c392c4a060a065f618a66393e55b8a2627a8..1b21e5b5feba0bd52983d649c646282fbd1c2bd2 100644 --- a/server/app/core/rich_schemas.py +++ b/server/app/core/rich_schemas.py @@ -1,3 +1,8 @@ +""" +This module contains rich schemas used to convert database objects into +dictionaries. This is the rich variant which means that objects will +pull in other whole objects instead of just the id. +""" import app.core.schemas as schemas import app.database.models as models from app.core import ma @@ -9,6 +14,7 @@ class RichSchema(ma.SQLAlchemySchema): strict = True load_instance = True include_relationships = True + ordered = False class QuestionSchemaRich(RichSchema): @@ -20,6 +26,7 @@ class QuestionSchemaRich(RichSchema): total_score = ma.auto_field() slide_id = ma.auto_field() type_id = ma.auto_field() + correcting_instructions = ma.auto_field() alternatives = fields.Nested(schemas.QuestionAlternativeSchema, many=True) @@ -30,7 +37,8 @@ class TeamSchemaRich(RichSchema): id = ma.auto_field() name = ma.auto_field() competition_id = ma.auto_field() - question_answers = fields.Nested(schemas.QuestionAnswerSchema, many=True) + question_alternative_answers = fields.Nested(schemas.QuestionAlternativeAnswerSchema, many=True) + question_scores = fields.Nested(schemas.QuestionScoreSchema, many=True) class SlideSchemaRich(RichSchema): @@ -42,6 +50,7 @@ class SlideSchemaRich(RichSchema): title = ma.auto_field() timer = ma.auto_field() competition_id = ma.auto_field() + background_image = fields.Nested(schemas.MediaSchema, many=False) questions = fields.Nested(QuestionSchemaRich, many=True) components = fields.Nested(schemas.ComponentSchema, many=True) @@ -54,6 +63,8 @@ class CompetitionSchemaRich(RichSchema): name = ma.auto_field() year = ma.auto_field() city_id = ma.auto_field() + background_image = fields.Nested(schemas.MediaSchema, many=False) + slides = fields.Nested( SlideSchemaRich, many=True, diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 378f99af09167fc7ea6d7b40798d355cff5e8514..b5206d59f81cc5f700330da65da1d4e5284c2a1c 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -1,4 +1,8 @@ -from marshmallow.decorators import pre_dump +""" +This module contains marshmallow schemas used to convert database objects into +dictionaries. +""" + import app.database.models as models from app.core import ma from marshmallow_sqlalchemy import fields @@ -9,6 +13,7 @@ class BaseSchema(ma.SQLAlchemySchema): strict = True load_instance = False include_relationships = False + ordered = False class IdNameSchema(BaseSchema): @@ -57,14 +62,24 @@ class QuestionSchema(BaseSchema): total_score = ma.auto_field() type_id = ma.auto_field() slide_id = ma.auto_field() + correcting_instructions = ma.auto_field() -class QuestionAnswerSchema(BaseSchema): +class QuestionAlternativeAnswerSchema(BaseSchema): class Meta(BaseSchema.Meta): - model = models.QuestionAnswer + model = models.QuestionAlternativeAnswer + + id = ma.auto_field() + answer = ma.auto_field() + question_alternative_id = ma.auto_field() + team_id = ma.auto_field() + + +class QuestionScoreSchema(BaseSchema): + class Meta(BaseSchema.Meta): + model = models.QuestionScore id = ma.auto_field() - data = ma.Function(lambda obj: obj.data) score = ma.auto_field() question_id = ma.auto_field() team_id = ma.auto_field() @@ -75,8 +90,10 @@ class QuestionAlternativeSchema(BaseSchema): model = models.QuestionAlternative id = ma.auto_field() - text = ma.auto_field() - value = ma.auto_field() + alternative = ma.auto_field() + alternative_order = ma.auto_field() + correct = ma.auto_field() + correct_order = ma.auto_field() question_id = ma.auto_field() @@ -115,6 +132,7 @@ class SlideSchema(BaseSchema): title = ma.auto_field() timer = ma.auto_field() competition_id = ma.auto_field() + background_image = fields.Nested(MediaSchema, many=False) class TeamSchema(BaseSchema): @@ -145,6 +163,7 @@ class CompetitionSchema(BaseSchema): name = ma.auto_field() year = ma.auto_field() city_id = ma.auto_field() + background_image = fields.Nested(MediaSchema, many=False) class ComponentSchema(BaseSchema): @@ -156,6 +175,10 @@ class ComponentSchema(BaseSchema): y = ma.auto_field() w = ma.auto_field() h = ma.auto_field() - data = ma.Function(lambda obj: obj.data) slide_id = ma.auto_field() type_id = ma.auto_field() + view_type_id = ma.auto_field() + + text = fields.fields.String() + media = fields.Nested(MediaSchema, many=False) + question_id = fields.fields.Integer() diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 04099ff2fc3268301d6a1e5cc130f30774a8bc16..b7a7e53c24e2a1cff90392cb5f70cb69f028eb69 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -1,175 +1,182 @@ -import app.database.controller as dbc -from app.core import db -from app.database.models import Competition, Slide, Team, ViewType, Code +""" +Contains all functionality related sockets. That is starting, joining, ending, +disconnecting from and syncing active competitions. +""" +import logging +from functools import wraps + from flask.globals import request +from flask_jwt_extended import verify_jwt_in_request +from flask_jwt_extended.utils import get_jwt from flask_socketio import SocketIO, emit, join_room -import logging logger = logging.getLogger(__name__) logger.propagate = False logger.setLevel(logging.INFO) -formatter = logging.Formatter('[%(levelname)s] %(funcName)s: %(message)s') +formatter = logging.Formatter("[%(levelname)s] %(message)s") stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) sio = SocketIO(cors_allowed_origins="http://localhost:3000") -presentations = {} - - -@sio.on("connect") -def connect(): - logger.info(f"Client '{request.sid}' connected") - - -@sio.on("disconnect") -def disconnect(): - for competition_id, presentation in presentations.items(): - if request.sid in presentation["clients"]: - del presentation["clients"][request.sid] - logger.debug(f"Client '{request.sid}' left presentation '{competition_id}'") - break - - if presentations and not presentations[competition_id]["clients"]: - del presentations[competition_id] - logger.info(f"No people left in presentation '{competition_id}', ended presentation") - - logger.info(f"Client '{request.sid}' disconnected") - - -@sio.on("start_presentation") -def start_presentation(data): - competition_id = data["competition_id"] - - if competition_id in presentations: - logger.error(f"Client '{request.sid}' failed to start competition '{competition_id}', presentation already active") - return - - presentations[competition_id] = { - "clients": {request.sid: {"view_type": "Operator"}}, - "slide": None, - "timer": {"enabled": False, "start_value": None, "value": None}, - } - - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - - logger.info(f"Client '{request.sid}' started competition '{competition_id}'") - -@sio.on("end_presentation") -def end_presentation(data): - competition_id = data["competition_id"] - - if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to end presentation '{competition_id}', no such presentation exists") - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error(f"Client '{request.sid}' failed to end presentation '{competition_id}', client not in presentation") - return - - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error(f"Client '{request.sid}' failed to end presentation '{competition_id}', client is not operator") - return - - del presentations[competition_id] - logger.debug(f"Deleted presentation {competition_id}") - +active_competitions = {} + + +def _unpack_claims(): + """ + :return: A tuple containing competition_id and view from claim + :rtype: tuple + """ + + jwt = get_jwt() + return jwt["competition_id"], jwt["view"] + + +def is_active_competition(competition_id): + """ + :return: True if competition with competition_id is currently active else False + :rtype: bool + """ + + return competition_id in active_competitions + + +def _get_sync_variables(active_competition, sync_values): + """ + Returns a dictionary with all values from active_competition that is to be + synced. + + :return: A dicationary containg key-value pairs from active_competition + thats in sync_values + :rtype: dictionary + """ + return {key: value for key, value in active_competition.items() if key in sync_values} + + +def authorization(allowed_views=None, require_active_competition=True): + """ + Decorator used to authorize a client that sends socket events. Check that + the client has authorization headers, that client view gotten from claims + is in allowed_views and that the competition the clients is in is active + if require_active_competition is True. + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + verify_jwt_in_request() + except: + logger.error(f"Won't call function '{func.__name__}': Missing Authorization Header") + return + + def _is_allowed(allowed, actual): + return actual and "*" in allowed or actual in allowed + + competition_id, view = _unpack_claims() + + if require_active_competition and not is_active_competition(competition_id): + logger.error(f"Won't call function '{func.__name__}': Competition '{competition_id}' is not active") + return + + nonlocal allowed_views + allowed_views = allowed_views or [] + if not _is_allowed(allowed_views, view): + logger.error( + f"Won't call function '{func.__name__}': View '{view}' is not '{' or '.join(allowed_views)}'" + ) + return + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +@sio.event +@authorization(require_active_competition=False, allowed_views=["*"]) +def connect() -> None: + """ + Connect to a active competition. If competition with competition_id is not active, + start it if client is an operator, otherwise do nothing. + """ + + competition_id, view = _unpack_claims() + + if is_active_competition(competition_id): + active_competition = active_competitions[competition_id] + active_competition["client_count"] += 1 + join_room(competition_id) + emit("sync", _get_sync_variables(active_competition, ["slide_order", "timer"])) + logger.info(f"Client '{request.sid}' with view '{view}' joined competition '{competition_id}'") + elif view == "Operator": + join_room(competition_id) + active_competitions[competition_id] = { + "client_count": 1, + "slide_order": 0, + "timer": { + "value": None, + "enabled": False, + }, + "show_scoreboard": False, + } + logger.info(f"Client '{request.sid}' with view '{view}' started competition '{competition_id}'") + else: + logger.error( + f"Client '{request.sid}' with view '{view}' tried to join non active competition '{competition_id}'" + ) + + +@sio.event +@authorization(allowed_views=["*"]) +def disconnect() -> None: + """ + Remove client from the active_competition it was in. Delete active_competition if no + clients are connected to it. + """ + + competition_id, _ = _unpack_claims() + active_competitions[competition_id]["client_count"] -= 1 + logger.info(f"Client '{request.sid}' disconnected from competition '{competition_id}'") + + if active_competitions[competition_id]["client_count"] <= 0: + del active_competitions[competition_id] + logger.info(f"No people left in active_competition '{competition_id}', ended active_competition") + + +@sio.event +@authorization(allowed_views=["Operator"]) +def end_presentation() -> None: + """ + End a presentation by sending end_presentation to all connected clients. + """ + + competition_id, _ = _unpack_claims() emit("end_presentation", room=competition_id, include_self=True) - logger.debug(f"Emitting event 'end_presentation' to room {competition_id} including self") - logger.info(f"Client '{request.sid}' ended presentation '{competition_id}'") +@sio.event +@authorization(allowed_views=["Operator"]) +def sync(data) -> None: + """ + Update all values from data thats in an active_competitions. Also sync all + the updated values to all clients connected to the same competition. + """ -@sio.on("join_presentation") -def join_presentation(data): - team_view_id = 1 - code = data["code"] - item_code = db.session.query(Code).filter(Code.code == code).first() - - if not item_code: - logger.error(f"Client '{request.sid}' failed to join presentation with code '{code}', no such code exists") - return - - competition_id = ( - item_code.pointer - if item_code.view_type_id != team_view_id - else db.session.query(Team).filter(Team.id == item_code.pointer).one().competition_id - ) + competition_id, view = _unpack_claims() + active_competition = active_competitions[competition_id] - if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to join presentation '{competition_id}', no such presentation exists") - return + for key, value in data.items(): + if key not in active_competition: + logger.warning(f"Invalid sync data: '{key}':'{value}'") + continue - if request.sid in presentations[competition_id]["clients"]: - logger.error(f"Client '{request.sid}' failed to join presentation '{competition_id}', client already in presentation") - return - - # TODO: Write function in database controller to do this - view_type_name = db.session.query(ViewType).filter(ViewType.id == item_code.view_type_id).one().name - - presentations[competition_id]["clients"][request.sid] = {"view_type": view_type_name} - - join_room(competition_id) - logger.debug(f"Client '{request.sid}' joined room {competition_id}") - - logger.info(f"Client '{request.sid}' joined competition '{competition_id}'") - - -@sio.on("set_slide") -def set_slide(data): - competition_id = data["competition_id"] - slide_order = data["slide_order"] - - if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists") - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation") - return - - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator") - return - - num_slides = db.session.query(Slide).filter(Slide.competition_id == competition_id).count() - - if not (0 <= slide_order < num_slides): - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', slide number {slide_order} does not exist") - return - - presentations[competition_id]["slide"] = slide_order - - emit("set_slide", {"slide_order": slide_order}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_slide' to room {competition_id} including self") - - logger.info(f"Client '{request.sid}' set slide '{slide_order}' in competition '{competition_id}'") - - -@sio.on("set_timer") -def set_timer(data): - competition_id = data["competition_id"] - timer = data["timer"] - - if competition_id not in presentations: - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', no such presentation exists") - return - - if request.sid not in presentations[competition_id]["clients"]: - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client not in presentation") - return - - if presentations[competition_id]["clients"][request.sid]["view_type"] != "Operator": - logger.error(f"Client '{request.sid}' failed to set slide in presentation '{competition_id}', client is not operator") - return - - # TODO: Save timer in presentation, maybe? - - emit("set_timer", {"timer": timer}, room=competition_id, include_self=True) - logger.debug(f"Emitting event 'set_timer' to room {competition_id} including self") - - logger.info(f"Client '{request.sid}' set timer '{timer}' in presentation '{competition_id}'") + active_competition[key] = value + emit("sync", _get_sync_variables(active_competition, data), room=competition_id, include_self=True) + logger.info( + f"Client '{request.sid}' with view '{view}' synced values {_get_sync_variables(active_competition, data)} in competition '{competition_id}'" + ) diff --git a/server/app/database/__init__.py b/server/app/database/__init__.py index 784c006b8fab59ca1dcc9e6dbac8ebffe91cb013..92e6dc8eff7a09ed2dbef788238693e5dde51190 100644 --- a/server/app/database/__init__.py +++ b/server/app/database/__init__.py @@ -1,55 +1,77 @@ -import json +""" +The database submodule contaisn all functionality that has to do with the +database. It can add, get, delete, edit, search and copy items. +""" -from flask_restx import abort +from app.apis import http_codes +from flask_smorest import abort +from flask_smorest.pagination import PaginationParameters + +# from flask_restx import abort from flask_sqlalchemy import BaseQuery from flask_sqlalchemy.model import Model -from sqlalchemy import Column, DateTime, Text +from sqlalchemy import Column, DateTime from sqlalchemy.sql import func -from sqlalchemy.types import TypeDecorator class Base(Model): + """ + Abstract table/model that all tables inherit + """ + __abstract__ = True _created = Column(DateTime(timezone=True), server_default=func.now()) _updated = Column(DateTime(timezone=True), onupdate=func.now()) class ExtendedQuery(BaseQuery): - def first_extended(self, required=True, error_message=None, error_code=404): - item = self.first() + """ + Extensions to a regular query which makes using the database more convenient. + """ - if required and not item: - if not error_message: - error_message = "Object not found" - abort(error_code, error_message) + def first_api(self, required=True, error_message=None, error_code=http_codes.NOT_FOUND): + """ + Extensions of the first() functions otherwise used on queries. Abort + if no item was found and it was required. - return item + :param required: Raise an exception if the query results in None + :type required: bool + :param error_message: The message that will be sent to the client with the exception + :type error_message:str + :param error_code: The status code that will be sent to the client with the exception + :type error_code: int + :return: + :rtype: + """ - 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 + item = self.first() + if required and not item: + abort(error_code, message=error_message or "Objektet hittades inte") -class Dictionary(TypeDecorator): + return item - impl = Text + def paginate_api(self, pagination_parameters, order_column=None, order=1): + """ + When looking for lists of items this is used to only return a few of + them to allow for pagination. + :param page: Offset of the result + :type page: int + :param page_size: Amount of rows that will be retrieved from the query + :type page_size: int + :param order_column: Field of a DbModel in which the query shall order by + :type order_column: sqlalchemy.sql.schema.Column + :param order: If equals 1 then order by ascending otherwise order by descending + :type order: int + :return: A page/list of items with offset page*page_size and the total count of all rows ignoring page and page_size + :rtype: list, int + """ - def process_bind_param(self, value, dialect): - if value is not None: - value = json.dumps(value) + pagination_parameters = pagination_parameters or PaginationParameters(page=1, page_size=10) - return value + if order_column: + self = self.order_by(order_column if order == 1 else order_column.desc()) - def process_result_value(self, value, dialect): - if value is not None: - value = json.loads(value) - return value + pagination = self.paginate(page=pagination_parameters.page, per_page=pagination_parameters.page_size) + pagination_parameters.item_count = pagination.total + return pagination.items diff --git a/server/app/database/controller/__init__.py b/server/app/database/controller/__init__.py index a46a65f1e6c2fe4e0664c462288a5f00c1cdd46c..b8d6fca38a446d447607362a871c437bfa713bfe 100644 --- a/server/app/database/controller/__init__.py +++ b/server/app/database/controller/__init__.py @@ -1,3 +1,7 @@ -# import add, get +""" +The controller subpackage provides a simple interface to the database. It +exposes methods to simply add, copy, delete, edit, get and search for items. +""" + from app.core import db from app.database.controller import add, copy, delete, edit, get, search, utils diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 1f57f7d06c74d9a0fd9999c99a5108b1d59ad25f..9031eded1b37000485a3f8e8b7c76d7342369f70 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -4,234 +4,279 @@ This file contains functionality to add data to the database. import os -import app.core.http_codes as codes +import app.database.controller as dbc +from app.apis import http_codes from app.core import db -from app.database.controller import get, search, utils from app.database.models import ( Blacklist, City, Code, Competition, - Component, ComponentType, + ImageComponent, Media, MediaType, Question, QuestionAlternative, - QuestionAnswer, + QuestionAlternativeAnswer, + QuestionComponent, + QuestionScore, QuestionType, Role, Slide, Team, + TextComponent, User, ViewType, + Whitelist, ) +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID +from flask import current_app from flask.globals import current_app -from flask_restx import abort +from flask_smorest import abort from PIL import Image from sqlalchemy import exc -from sqlalchemy.orm import relation -from sqlalchemy.orm.session import sessionmaker def db_add(item): """ - Internal function. Adds item to the database - and handles comitting and refreshing. + Internal function that add item to the database. + Handle different types of errors that occur when inserting an item into the database by calling abort. """ try: db.session.add(item) db.session.commit() db.session.refresh(item) + except (exc.IntegrityError): + db.session.rollback() + abort(http_codes.CONFLICT, message=f"Kunde inte lägga objektet") except (exc.SQLAlchemyError, exc.DBAPIError): db.session.rollback() # SQL errors such as item already exists - abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be created") + abort(http_codes.INTERNAL_SERVER_ERROR, message=f"Kunde inte lägga objektet") except: db.session.rollback() # Catching other errors - abort(codes.INTERNAL_SERVER_ERROR, f"Something went wrong when creating {type(item)}") + abort(http_codes.INTERNAL_SERVER_ERROR, message=f"Kunde lägga till objektet") return item -def blacklist(jti): - """ Adds a blacklist to the database. """ +def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, copy=False, **data): + """ + Adds a component to the slide at the specified + coordinates with the provided size and data. + """ - return db_add(Blacklist(jti)) + if type_id == TEXT_COMPONENT_ID: + item = db_add( + TextComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) + item.text = data.get("text") + elif type_id == IMAGE_COMPONENT_ID: + if not copy: # Scale image if adding a new one, a copied image should keep it's size + item_image = dbc.get.one(Media, data["media_id"]) + filename = item_image.filename + path = os.path.join( + current_app.config["UPLOADED_PHOTOS_DEST"], + filename, + ) + + with Image.open(path) as im: + h = im.height + w = im.width + + largest = max(w, h) + if largest > 600: + ratio = 600 / largest + w *= ratio + h *= ratio + + item = db_add( + ImageComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) + item.media_id = data.get("media_id") + elif type_id == QUESTION_COMPONENT_ID: + item = db_add( + QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h), + ) + item.question_id = data.get("question_id") + else: + abort(http_codes.BAD_REQUEST, f"Ogiltigt typ_id '{type_id}'") + + item = dbc.utils.commit_and_refresh(item) + return item -def mediaType(name): - """ Adds a media type to the database. """ +def code(view_type_id, competition_id=None, team_id=None): + """ Adds a code to the database using the provided arguments. """ - return db_add(MediaType(name)) + code_string = dbc.utils.generate_unique_code() + return db_add(Code(code_string, view_type_id, competition_id, team_id)) -def questionType(name): - """ Adds a question type to the database. """ +def team(name, competition_id): + """ Adds a team with the specified name to the provided competition. """ - return db_add(QuestionType(name)) + item = db_add(Team(name, competition_id)) + # Add code for the team + code(1, competition_id, item.id) -def componentType(name): - """ Adds a component type to the database. """ + return item - return db_add(ComponentType(name)) +def slide(competition_id, order=None): + """ Adds a slide to the provided competition. """ + if not order: + # Get the last order from given competition + order = dbc.utils.count(Slide, {"competition_id": competition_id}) -def viewType(name): - """ Adds a view type to the database. """ + # Add slide + item_slide = db_add(Slide(order, competition_id)) - return db_add(ViewType(name)) + return dbc.utils.refresh(item_slide) -def role(name): - """ Adds a role to the database. """ +def competition(name, year, city_id): + """ + Adds a competition to the database using the + provided arguments. Also adds slide and codes. + """ + item_competition = db_add(Competition(name, year, city_id)) - return db_add(Role(name)) + # Add default slide + slide(item_competition.id) + # Add code for Judge view + code(2, item_competition.id) -def city(name): - """ Adds a city to the database. """ + # Add code for Audience view + code(3, item_competition.id) - return db_add(City(name)) + # Add code for Operator view + code(4, item_competition.id) + item_competition = dbc.utils.refresh(item_competition) + return item_competition -def component(type_id, slide_id, data, x=0, y=0, w=0, h=0): + +def _competition_no_slides(name, year, city_id, font=None): """ - Adds a component to the slide at the specified coordinates with the - provided size and data . + Internal function. Adds a competition to the database + using the provided arguments. Also adds codes. """ - from app.apis.media import PHOTO_PATH - if type_id == 2: # 2 is image - item_image = get.one(Media, data["media_id"]) - filename = item_image.filename - path = os.path.join(PHOTO_PATH, filename) - with Image.open(path) as im: - h = im.height - w = im.width + item_competition = db_add(Competition(name, year, city_id)) + if font: + item_competition.font = font - largest = max(w, h) - if largest > 600: - ratio = 600 / largest - w *= ratio - h *= ratio + # Add code for Judge view + code(2, item_competition.id) - return db_add(Component(slide_id, type_id, data, x, y, w, h)) + # Add code for Audience view + code(3, item_competition.id) + # Add code for Operator view + code(4, item_competition.id) -def image(filename, user_id): - """ - Adds an image to the database and keeps track of who called the function. - """ + item_competition = dbc.utils.refresh(item_competition) + return item_competition - return db_add(Media(filename, 1, user_id)) +def blacklist(jti): + """ Adds a blacklist to the database. """ -def user(email, password, role_id, city_id, name=None): - """ Adds a user to the database using the provided arguments. """ + return db_add(Blacklist(jti)) - return db_add(User(email, password, role_id, city_id, name)) +def whitelist(jti, user_id=None, competition_id=None): + """ Adds a whitelist to the database. """ -def question(name, total_score, type_id, slide_id): - """ - Adds a question to the specified slide using the provided arguments. - """ + return db_add(Whitelist(jti, user_id, competition_id)) - return db_add(Question(name, total_score, type_id, slide_id)) +def mediaType(name): + """ Adds a media type to the database. """ -def question_alternative(text, value, question_id): - return db_add(QuestionAlternative(text, value, question_id)) + return db_add(MediaType(name)) -def question_answer(data, score, question_id, team_id): - return db_add(QuestionAnswer(data, score, question_id, team_id)) +def questionType(name): + """ Adds a question type to the database. """ + return db_add(QuestionType(name)) -def code(view_type_id, competition_id=None, team_id=None): - """ Adds a code to the database using the provided arguments. """ - code_string = utils.generate_unique_code() - return db_add(Code(code_string, view_type_id, competition_id, team_id)) +def componentType(name): + """ Adds a component type to the database. """ + return db_add(ComponentType(name)) -def team(name, competition_id): - """ Adds a team with the specified name to the provided competition. """ - item = db_add(Team(name, competition_id)) +def viewType(name): + """ Adds a view type to the database. """ - # Add code for the team - code(1, competition_id, item.id) + return db_add(ViewType(name)) - return item +def role(name): + """ Adds a role to the database. """ -def slide(competition_id): - """ Adds a slide to the provided competition. """ + return db_add(Role(name)) - # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() - # Add slide - item_slide = db_add(Slide(order, competition_id)) +def city(name): + """ Adds a city to the database. """ - # Add default question - question(f"Fråga {item_slide.order + 1}", 10, 0, item_slide.id) + return db_add(City(name)) - item_slide = utils.refresh(item_slide) - return item_slide +def image(filename, user_id): + """ + Adds an image to the database and keeps track of who called the function. + """ -def slide_without_question(competition_id): - """ Adds a slide to the provided competition. """ + return db_add(Media(filename, 1, user_id)) - # Get the last order from given competition - order = Slide.query.filter(Slide.competition_id == competition_id).count() - # Add slide - item_slide = db_add(Slide(order, competition_id)) +def user(email, password, role_id, city_id, name=None): + """ Adds a user to the database using the provided arguments. """ - item_slide = utils.refresh(item_slide) - return item_slide + return db_add(User(email, password, role_id, city_id, name)) -def competition(name, year, city_id): +def question(name, total_score, type_id, slide_id, correcting_instructions=None): """ - Adds a competition to the database using the - provided arguments. Also adds slide and codes. + Adds a question to the specified slide using the provided arguments. """ - item_competition = db_add(Competition(name, year, city_id)) - # Add default slide - slide(item_competition.id) + return db_add(Question(name, total_score, type_id, slide_id, correcting_instructions)) - # Add code for Judge view - code(2, item_competition.id) - # Add code for Audience view - code(3, item_competition.id) - item_competition = utils.refresh(item_competition) - return item_competition +def question_alternative(alternative, correct, question_id): + """ + Adds a question alternative to the specified + question using the provided arguments. + """ + order = dbc.utils.count(QuestionAlternative, {"question_id": question_id}) + return db_add(QuestionAlternative(alternative, order, correct, order, question_id)) -def _competition_no_slides(name, year, city_id, font=None): + +def question_score(score, question_id, team_id): """ - Internal function. Adds a competition to the database - using the provided arguments. Also adds codes. + Adds a question score to the specified team + and question using the provided arguments. """ - item_competition = db_add(Competition(name, year, city_id)) - if font: - item_competition.font = font + return db_add(QuestionScore(score, question_id, team_id)) - # Add code for Judge view - code(item_competition.id, 2) - # Add code for Audience view - code(item_competition.id, 3) +def question_alternative_answer(answer, question_alternative_id, team_id): + """ + Adds a question answer to the specified team + and question using the provided arguments. + """ - item_competition = utils.refresh(item_competition) - return item_competition + return db_add(QuestionAlternativeAnswer(answer, question_alternative_id, team_id)) diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 79a4df0f4a7f308b7bc40b5b9ab20e61423ffb14..f16ce3feb54b300aed46e8575cb1a78d8c82a967 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -3,12 +3,16 @@ This file contains functionality to copy and duplicate data to the database. """ from app.database.controller import add, get, search, utils -from app.database.models import Question +from app.database.models import Competition, Question +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID -def _alternative(item_old, question_id): - """Internal function. Makes a copy of the provided question alternative""" - return add.question_alternative(item_old.text, item_old.value, question_id) +def _alternative(item_alternative_old, question_id): + """ + Internal function. Makes a copy of the provided question alternative. + """ + + return add.question_alternative(item_alternative_old.alternative, item_alternative_old.correct, question_id) def _question(item_question_old, slide_id): @@ -23,6 +27,7 @@ def _question(item_question_old, slide_id): item_question_old.total_score, item_question_old.type_id, slide_id, + item_question_old.correcting_instructions, ) ) @@ -38,21 +43,40 @@ def _component(item_component, item_slide_new): component item to the specified slide. """ - add.component( + component(item_component, item_slide_new.id, item_component.view_type_id) + + +def component(item_component, slide_id_new, view_type_id): + """ + Makes a copy of the provided component item + to the specified slide and view_type. + """ + + data = {} + if item_component.type_id == TEXT_COMPONENT_ID: + data["text"] = item_component.text + elif item_component.type_id == IMAGE_COMPONENT_ID: + data["media_id"] = item_component.media_id + elif item_component.type_id == QUESTION_COMPONENT_ID: + data["question_id"] = item_component.question_id + + return add.component( item_component.type_id, - item_slide_new.id, - item_component.data, + slide_id_new, + view_type_id, item_component.x, item_component.y, item_component.w, item_component.h, + copy=True, + **data, ) def slide(item_slide_old): """ Deep copies a slide to the same competition. - Does not copy team, question answers. + Does not copy team and question answers. """ item_competition = get.competition(item_slide_old.competition_id) @@ -66,19 +90,17 @@ def slide_to_competition(item_slide_old, item_competition): Does not copy team, question answers. """ - item_slide_new = add.slide_without_question(item_competition.id) + item_slide_new = add.slide(item_competition.id, item_slide_old.order) # Copy all fields item_slide_new.title = item_slide_old.title item_slide_new.body = item_slide_old.body item_slide_new.timer = item_slide_old.timer item_slide_new.settings = item_slide_old.settings - - # TODO: Add background image + item_slide_new.background_image_id = item_slide_old.background_image_id for item_component in item_slide_old.components: _component(item_component, item_slide_new) - for item_question in item_slide_old.questions: _question(item_question, item_slide_new.id) @@ -93,10 +115,9 @@ def competition(item_competition_old): """ name = "Kopia av " + item_competition_old.name - item_competition, total = search.competition(name=name) - if item_competition: - print(f"{item_competition[total-1].name}, {total=}") - name = "Kopia av " + item_competition[total - 1].name + + while item_competition := Competition.query.filter(Competition.name == name).first(): + name = "Kopia av " + item_competition.name item_competition_new = add._competition_no_slides( name, @@ -104,7 +125,8 @@ def competition(item_competition_old): item_competition_old.city_id, item_competition_old.font, ) - # TODO: Add background image + + item_competition_new.background_image_id = item_competition_old.background_image_id for item_slide in item_competition_old.slides: slide_to_competition(item_slide, item_competition_new) diff --git a/server/app/database/controller/delete.py b/server/app/database/controller/delete.py index f3dc2dd51b680bc8834d9cf734001ac7291883d8..3a617d3c7df5d0b96fee21261f0694cd6bb9b27c 100644 --- a/server/app/database/controller/delete.py +++ b/server/app/database/controller/delete.py @@ -2,22 +2,43 @@ This file contains functionality to delete data to the database. """ -import app.core.http_codes as codes import app.database.controller as dbc +from app.apis import http_codes from app.core import db -from app.database.models import Blacklist, City, Competition, Role, Slide, User -from flask_restx import abort -from sqlalchemy import exc +from app.database.models import QuestionAlternativeAnswer, QuestionScore, Whitelist +from flask_smorest import abort + +# from flask_restx import abort +from sqlalchemy.exc import IntegrityError def default(item): """ Deletes item and commits. """ + try: db.session.delete(item) db.session.commit() + except IntegrityError: + db.session.rollback() + abort(http_codes.CONFLICT, message=f"Kunde inte ta bort objektet") except: db.session.rollback() - abort(codes.INTERNAL_SERVER_ERROR, f"Item of type {type(item)} could not be deleted") + abort(http_codes.INTERNAL_SERVER_ERROR, message=f"Kunde inte ta bort objektet") + + +def whitelist_to_blacklist(filters): + """ + Remove whitelist by condition(filters) and insert those into blacklist. + Example: When delete user all whitelisted tokens for that user should + be blacklisted. + """ + + whitelist = Whitelist.query.filter(filters).all() + for item in whitelist: + dbc.add.blacklist(item.jti) + + Whitelist.query.filter(filters).delete() + dbc.utils.commit() def component(item_component): @@ -31,7 +52,6 @@ def _slide(item_slide): for item_question in item_slide.questions: question(item_question) - for item_component in item_slide.components: default(item_component) @@ -56,42 +76,55 @@ def slide(item_slide): def team(item_team): - """ Deletes team and its question answers. """ + """ Deletes team, its question answers and the code. """ + + for item_question_answer in item_team.question_alternative_answers: + default(item_question_answer) + for item_code in item_team.code: + code(item_code) - for item_question_answer in item_team.question_answers: - question_answers(item_question_answer) default(item_team) def question(item_question): """ Deletes question and its alternatives and answers. """ - for item_question_answer in item_question.question_answers: - question_answers(item_question_answer) + scores = QuestionScore.query.filter(QuestionScore.question_id == item_question.id).all() + + for item_question_score in scores: + default(item_question_score) + for item_alternative in item_question.alternatives: alternatives(item_alternative) + default(item_question) def alternatives(item_alternatives): """ Deletes question alternative. """ + answers = QuestionAlternativeAnswer.query.filter( + QuestionAlternativeAnswer.question_alternative_id == item_alternatives.id + ).all() + for item_answer in answers: + default(item_answer) default(item_alternatives) -def question_answers(item_question_answers): - """ Deletes question answer. """ - - default(item_question_answers) - - def competition(item_competition): - """ Deletes competition and its slides and teams. """ + """ Deletes competition, its slides, teams and codes. """ for item_slide in item_competition.slides: _slide(item_slide) for item_team in item_competition.teams: team(item_team) + for item_code in item_competition.codes: + code(item_code) - # TODO codes default(item_competition) + + +def code(item_code): + """ Deletes competition code. """ + + default(item_code) diff --git a/server/app/database/controller/edit.py b/server/app/database/controller/edit.py index b1f5bc91e045e43c5f69618a685a39f86c7b081d..a5e35352163b04729851b53f62ed839dacdb93f7 100644 --- a/server/app/database/controller/edit.py +++ b/server/app/database/controller/edit.py @@ -2,28 +2,12 @@ This file contains functionality to get data from the database. """ +from app.apis import http_codes from app.core import db +from flask_smorest import abort - -def switch_order(item1, item2): - """ Switches order between two slides. """ - - old_order = item1.order - new_order = item2.order - - item2.order = -1 - db.session.commit() - db.session.refresh(item2) - - item1.order = new_order - db.session.commit() - db.session.refresh(item1) - - item2.order = old_order - db.session.commit() - db.session.refresh(item2) - - return item1 +# from flask_restx.errors import abort +from sqlalchemy import exc def default(item, **kwargs): @@ -46,8 +30,12 @@ def default(item, **kwargs): for key, value in kwargs.items(): if not hasattr(item, key): raise AttributeError(f"Item of type {type(item)} has no attribute '{key}'") - if value is not None: - setattr(item, key, value) - db.session.commit() + setattr(item, key, value) + try: + db.session.commit() + except exc.IntegrityError: + db.session.rollback() + abort(http_codes.CONFLICT, f"Kunde inte utföra ändringen") + db.session.refresh(item) return item diff --git a/server/app/database/controller/get.py b/server/app/database/controller/get.py index 3e5e7f872c2866c70131dd1b4f1205c3bd131798..bcaece403f8436f93bec5c9335c56a9efb763e3d 100644 --- a/server/app/database/controller/get.py +++ b/server/app/database/controller/get.py @@ -2,43 +2,52 @@ This file contains functionality to get data from the database. """ +import app.database.controller as dbc +from app.apis import http_codes from app.core import db -from app.core import http_codes as codes from app.database.models import ( Code, Competition, Component, + ImageComponent, Question, QuestionAlternative, - QuestionAnswer, + QuestionAlternativeAnswer, + QuestionScore, Slide, Team, + TextComponent, User, ) +from sqlalchemy import func from sqlalchemy.orm import joinedload, subqueryload +from sqlalchemy.orm.util import with_polymorphic def all(db_type): - """ Gets lazy db-item in the provided table. """ + """ Gets a list of all lazy db-items in the provided table. """ return db_type.query.all() -def one(db_type, id): +def one(db_type, id, required=True): """ Get lazy db-item in the table that has the same id. """ - return db_type.query.filter(db_type.id == id).first_extended() + return db_type.query.filter(db_type.id == id).first_api(required=required) ### Codes ### def code_by_code(code): """ Gets the code object associated with the provided code. """ - return Code.query.filter(Code.code == code.upper()).first_extended( True, "A presentation with that code does not exist") + return Code.query.filter(Code.code == code.upper()).first_api(True, "A presentation with that code does not exist") def code_list(competition_id): - """ Gets a list of all code objects associated with a the provided competition. """ + """ + Gets a list of all code objects associated with the provided competition. + """ + # team_view_id = 1 join_competition = Competition.id == Code.competition_id filters = Competition.id == competition_id @@ -49,103 +58,109 @@ def code_list(competition_id): def user_exists(email): """ Checks if an user has that email. """ - return User.query.filter(User.email == email).count() > 0 - - -def user(user_id): - """ Gets the user object associated with the provided id. """ - - return User.query.filter(User.id == user_id).first_extended() + return dbc.utils.count(User, {"email": email}) > 0 def user_by_email(email): """ Gets the user object associated with the provided email. """ - return User.query.filter(User.email == email).first_extended(error_code=codes.UNAUTHORIZED) + return User.query.filter(User.email == email).first_api() ### Slides ### def slide(competition_id, slide_id): - """ Gets the slide object associated with the provided id and order. """ + """ + Gets the slide object associated with the provided competition and slide. + """ + join_competition = Competition.id == Slide.competition_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) - return Slide.query.join(Competition, join_competition).filter(filters).first_extended() + return Slide.query.join(Competition, join_competition).filter(filters).first_api() def slide_list(competition_id): - """ Gets a list of all slide objects associated with a the provided competition. """ + """ + Gets a list of all slide objects associated with the provided competition. + """ + join_competition = Competition.id == Slide.competition_id filters = Competition.id == competition_id return Slide.query.join(Competition, join_competition).filter(filters).all() -def slide_count(competition_id): - """ Gets the number of slides in the provided competition. """ - - return Slide.query.filter(Slide.competition_id == competition_id).count() - - -def slide_count(competition_id): - """ Gets the number of slides in the provided competition. """ - - return Slide.query.filter(Slide.competition_id == competition_id).count() - - ### Teams ### def team(competition_id, team_id): - """ Gets the team object associated with the provided id and competition id. """ + """ Gets the team object associated with the competition and team. """ + join_competition = Competition.id == Team.competition_id filters = (Competition.id == competition_id) & (Team.id == team_id) - return Team.query.join(Competition, join_competition).filter(filters).first_extended() + return Team.query.join(Competition, join_competition).filter(filters).first_api() def team_list(competition_id): - """ Gets a list of all team objects associated with a the provided competition. """ + """ + Gets a list of all team objects associated with the provided competition. + """ join_competition = Competition.id == Team.competition_id filters = Competition.id == competition_id return Team.query.join(Competition, join_competition).filter(filters).all() - return Team.query.join(Competition, join_competition).filter(filters).all() - ### Questions ### def question(competition_id, slide_id, question_id): - """ Gets the question object associated with the provided id, slide order and competition id. """ + """ + Gets the question object associated with the + provided, competition, slide and question. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Question.id == question_id) - return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_extended() + return Question.query.join(Slide, join_slide).join(Competition, join_competition).filter(filters).first_api() def question_list(competition_id, slide_id): - """ Gets a list of all question objects associated with a the provided competition and slide. """ + """ + Gets a list of all question objects associated + with the provided competition and slide. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) - return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() + return Question.query.join(Slide, join_slide).join(Competition, join_competition).filter(filters).all() def question_list_for_competition(competition_id): - """ Gets a list of all question objects associated with a the provided competition. """ + """ + Gets a list of all question objects associated + with the provided competition. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id filters = Competition.id == competition_id - return Question.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() + return Question.query.join(Slide, join_slide).join(Competition, join_competition).filter(filters).all() ### Question Alternative ### -def question_alternative(competition_id, slide_id, question_id, alternative_id): - """ Get question alternative for a given question based on its competition and slide and ID. """ +def question_alternative( + competition_id, + slide_id, + question_id, + alternative_id, +): + """ + Get a question alternative for a given question + based on its competition, slide and question. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id @@ -158,80 +173,141 @@ def question_alternative(competition_id, slide_id, question_id, alternative_id): ) return ( - QuestionAlternative.query.join(Competition, join_competition) + QuestionAlternative.query.join(Question, join_question) .join(Slide, join_slide) - .join(Question, join_question) + .join(Competition, join_competition) .filter(filters) - .first_extended() + .first_api() ) def question_alternative_list(competition_id, slide_id, question_id): - """ Get all question alternatives for a given question based on its competition and slide. """ + """ + Get a list of all question alternative objects for a + given question based on its competition and slide. + """ + join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Question.slide_id join_question = Question.id == QuestionAlternative.question_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Question.id == question_id) return ( - QuestionAlternative.query.join(Competition, join_competition) + QuestionAlternative.query.join(Question, join_question) .join(Slide, join_slide) - .join(Question, join_question) + .join(Competition, join_competition) .filter(filters) .all() ) + +### Question Answers ### + + +def question_score(competition_id, team_id, question_id, required=True): + """ + Get question answer for a given team based on its competition. + """ + + join_competition = Competition.id == Team.competition_id + join_team = Team.id == QuestionScore.team_id + filters = (Competition.id == competition_id) & (Team.id == team_id) & (QuestionScore.question_id == question_id) return ( - QuestionAlternative.query.join(Competition, join_competition) - .join(Slide, join_slide) - .join(Question, join_question) + QuestionScore.query.join(Team, join_team) + .join(Competition, join_competition) .filter(filters) - .all() + .first_api(required) ) -### Question Answers ### -def question_answer(competition_id, team_id, answer_id): - """ Get question answer for a given team based on its competition and ID. """ +def question_score_list(competition_id, team_id): + """ + Get question answer for a given team based on its competition. + """ + join_competition = Competition.id == Team.competition_id - join_team = Team.id == QuestionAnswer.team_id - filters = (Competition.id == competition_id) & (Team.id == team_id) & (QuestionAnswer.id == answer_id) + join_team = Team.id == QuestionScore.team_id + filters = (Competition.id == competition_id) & (Team.id == team_id) + return QuestionScore.query.join(Team, join_team).join(Competition, join_competition).filter(filters).all() + + +def question_alternative_answer(competition_id, team_id, question_alternative_id, required=True): + """ + Get question answer for a given team based on its competition. + """ + + join_competition = Competition.id == Team.competition_id + join_team = Team.id == QuestionAlternativeAnswer.team_id + filters = ( + (Competition.id == competition_id) + & (Team.id == team_id) + & (QuestionAlternativeAnswer.question_alternative_id == question_alternative_id) + ) return ( - QuestionAnswer.query.join(Competition, join_competition).join(Team, join_team).filter(filters).first_extended() + QuestionAlternativeAnswer.query.join(Team, join_team) + .join(Competition, join_competition) + .filter(filters) + .first_api(required=required) ) -def question_answer_list(competition_id, team_id): - """ Get question answer for a given team based on its competition. """ +def question_alternative_answer_list(competition_id, team_id): + """ + Get a list of question answers for a given team based on its competition. + """ + join_competition = Competition.id == Team.competition_id - join_team = Team.id == QuestionAnswer.team_id + join_team = Team.id == QuestionAlternativeAnswer.team_id filters = (Competition.id == competition_id) & (Team.id == team_id) - return QuestionAnswer.query.join(Competition, join_competition).join(Team, join_team).filter(filters).all() + query = QuestionAlternativeAnswer.query.join(Team, join_team).join(Competition, join_competition).filter(filters) + # Get total score + # sum = query.with_entities(func.sum(QuestionAnswer.score)).all() + items = query.all() + return items ### Components ### def component(competition_id, slide_id, component_id): - """ Gets a list of all component objects associated with a the provided competition id and slide order. """ + """ + Gets a component object associated with + the provided competition id and slide order. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Component.slide_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) & (Component.id == component_id) - return Component.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).first_extended() + + poly = with_polymorphic(Component, [TextComponent, ImageComponent]) + return ( + db.session.query(poly).join(Slide, join_slide).join(Competition, join_competition).filter(filters).first_api() + ) def component_list(competition_id, slide_id): - """ Gets a list of all component objects associated with a the provided competition id and slide order. """ + """ + Gets a list of all component objects associated with + the provided competition and slide. + """ join_competition = Competition.id == Slide.competition_id join_slide = Slide.id == Component.slide_id filters = (Competition.id == competition_id) & (Slide.id == slide_id) - return Component.query.join(Competition, join_competition).join(Slide, join_slide).filter(filters).all() + return Component.query.join(Slide, join_slide).join(Competition, join_competition).filter(filters).all() ### Competitions ### -def competition(competition_id): - """ Get Competition and all it's sub-entities """ - os1 = joinedload(Competition.slides).joinedload(Slide.components) - os2 = joinedload(Competition.slides).joinedload(Slide.questions).joinedload(Question.alternatives) - ot = joinedload(Competition.teams).joinedload(Team.question_answers) - return Competition.query.filter(Competition.id == competition_id).options(os1).options(os2).options(ot).first() +def competition(competition_id, required=True): + """ Get Competition and all it's sub-entities. """ + + join_component = joinedload(Competition.slides).subqueryload(Slide.components) + join_alternatives = joinedload(Competition.slides).joinedload(Slide.questions).joinedload(Question.alternatives) + join_question_alternative_answer = joinedload(Competition.teams).joinedload(Team.question_alternative_answers) + join_question_score = joinedload(Competition.teams).joinedload(Team.question_scores) + return ( + Competition.query.filter(Competition.id == competition_id) + .options(join_component) + .options(join_alternatives) + .options(join_question_alternative_answer) + .options(join_question_score) + .first_api(required, "Tävlingen kunde inte hittas") + ) diff --git a/server/app/database/controller/search.py b/server/app/database/controller/search.py index bfc40843b83675c8758a63f4d117d98ea6b2f609..1be280e4230f43d5cdbf95456bc1b849c8a23398 100644 --- a/server/app/database/controller/search.py +++ b/server/app/database/controller/search.py @@ -2,30 +2,27 @@ This file contains functionality to find data to the database. """ -from app.database.models import Competition, Media, Question, Slide, Team, User +from app.database.models import Competition, Media, Question, Slide, User -def image(filename, page=0, page_size=15, order=1, order_by=None): +def image(pagination_parameters=None, filename=None, order=1, order_by=None): """ Finds and returns an image from the file name. """ 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) + return query.paginate_api(pagination_parameters) def user( + pagination_parameters=None, email=None, name=None, city_id=None, role_id=None, - page=0, - page_size=15, - order=1, - order_by=None, ): - """ Finds and returns a user from the provided parameters. """ + """ Finds and returns any number of users from the provided parameters. """ query = User.query if name: @@ -37,21 +34,14 @@ def user( if role_id: query = query.filter(User.role_id == role_id) - order_column = User.id # Default order_by - if order_by: - order_column = getattr(User.__table__.c, order_by) - - return query.pagination(page, page_size, order_column, order) + return query.paginate_api(pagination_parameters) def competition( + pagination_parameters=None, name=None, year=None, city_id=None, - page=0, - page_size=15, - order=1, - order_by=None, ): """ Finds and returns a competition from the provided parameters. """ @@ -63,21 +53,15 @@ def competition( 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) + return query.paginate_api(pagination_parameters) def slide( + pagination_paramters=None, slide_order=None, title=None, body=None, competition_id=None, - page=0, - page_size=15, - order=1, order_by=None, ): """ Finds and returns a slide from the provided parameters. """ @@ -92,11 +76,7 @@ def slide( if competition_id: query = query.filter(Slide.competition_id == competition_id) - order_column = Slide.id # Default order_by - if order_by: - order_column = getattr(Slide.__table__.c, order_by) - - return query.pagination(page, page_size, order_column, order) + return query.paginate_api(pagination_paramters) def questions( diff --git a/server/app/database/controller/utils.py b/server/app/database/controller/utils.py index c01205a6ec0f9dfc5772d87200268e035db0d14b..acfddc136a3e4c58135dede0839885ee1c93974c 100644 --- a/server/app/database/controller/utils.py +++ b/server/app/database/controller/utils.py @@ -2,60 +2,125 @@ This file contains some miscellaneous functionality. """ -import app.core.http_codes as codes +from app.apis import http_codes from app.core import db from app.core.codes import generate_code_string from app.database.models import Code -from flask_restx import abort -from sqlalchemy import exc +from flask_smorest import abort +# from flask_restx import abort -def move_slides(item_competition, start_order, end_order): - slides = item_competition.slides - # Move up - if start_order < end_order: - for i in range(start_order + 1, end_order): - slides[i].order -= 1 - # Move down - elif start_order > end_order: - for i in range(end_order, start_order): - slides[i].order += 1 +def move_order(orders, order_key, from_order, to_order): + """ + Move key from from_order to to_order in db_item. See examples in + alternatives.py and slides.py. + """ - # start = 5, end = 1 - # 1->2, 2->3, 4->5 - # 5 = 1 + num_orders = len(orders) + assert 0 <= from_order < num_orders, "Invalid order to move from" + assert 0 <= to_order < num_orders, "Invalid order to move to" - slides[start_order].order = end_order - return commit_and_refresh(item_competition) + # This function is sooo terrible, someone please tell me how to update + # multiple values in the database at the same time with unique constraints. + # If you update all the values at the same time none of them will collide + # but that database doesn't know that so you have to update them to some + # other value before and then change every value back to the correct one, + # so 2 commits. + + # An example will follow the entire code to make it clear what it does + # Lets say we have 5 orders, and we want to move the item at index 1 + # to index 4. + # We begin with a list of item with orders [0, 1, 2, 3, 4] + + change = 1 if to_order < from_order else -1 + start_order = min(from_order, to_order) + end_order = max(from_order, to_order) + + # Move orders up 100 + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) + 100) + + # Our items now look like [100, 101, 102, 103, 104] + + # Move orders between from and to order either up or down, but minus in front + for item_with_order in orders: + if start_order <= getattr(item_with_order, order_key) - 100 <= end_order: + setattr(item_with_order, order_key, -(getattr(item_with_order, order_key) + change)) + + # Our items now look like [100, -100, -101, -102, -103] + + # Find the item that was to be moved and change it to correct order with minus in front + for item_with_order in orders: + if getattr(item_with_order, order_key) == -(from_order + change + 100): + setattr(item_with_order, order_key, -(to_order + 100)) + break + + # Our items now look like [100, -104, -101, -102, -103] + + db.session.commit() + + # Negate all order so that they become positive + for item_with_order in orders: + if start_order <= -(getattr(item_with_order, order_key) + 100) <= end_order: + setattr(item_with_order, order_key, -getattr(item_with_order, order_key)) + + # Our items now look like [100, 104, 101, 102, 103] + + for item_with_order in orders: + setattr(item_with_order, order_key, getattr(item_with_order, order_key) - 100) + + # Our items now look like [0, 4, 1, 2, 3] + + # We have now successfully moved item from order 1 to order 4 + + db.session.commit() + + +def count(db_type, filter=None): + """ + Count number of db_type items that match all keys and values in filter. + + >>> count(User, {"city_id": 1}) # Get number of users with city_id equal to 1 + 5 + """ + + filter = filter or {} + query = db_type.query + for key, value in filter.items(): + query = query.filter(getattr(db_type, key) == value) + return query.count() def generate_unique_code(): """ Generates a unique competition code. """ code = generate_code_string() - while db.session.query(Code).filter(Code.code == code).count(): + + while count(Code, {"code": code}): code = generate_code_string() return code def refresh(item): """ Refreshes the provided item. """ + try: db.session.refresh(item) except Exception as e: - abort(codes.INTERNAL_SERVER_ERROR, f"Refresh failed!\n{str(e)}") + abort(http_codes.INTERNAL_SERVER_ERROR, f"Refresh failed!\n{str(e)}") return item def commit(): - """ Commits. """ + """ Commits to the database. """ + try: db.session.commit() except Exception as e: db.session.rollback() - abort(codes.INTERNAL_SERVER_ERROR, f"Commit failed!\n{str(e)}") + abort(http_codes.INTERNAL_SERVER_ERROR, f"Commit failed!\n{str(e)}") def commit_and_refresh(item): diff --git a/server/app/database/models.py b/server/app/database/models.py index 7174080b799eeea7d5956ca641c0c8fdca8d5ee2..7371bc773c9e66ef2c89be55e23e2501a43e11b0 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -1,20 +1,52 @@ +""" +This file contains every model in the database. In regular SQL terms, it +defines every table, the fields in those tables and their relationship to +each other. +""" + from app.core import bcrypt, db -from app.database import Dictionary +from app.database.types import IMAGE_COMPONENT_ID, QUESTION_COMPONENT_ID, TEXT_COMPONENT_ID from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property +from sqlalchemy.orm import backref + +STRING_SIZE = 254 # Default size of string Columns (varchar) + + +class Whitelist(db.Model): + """ + Table with allowed jwt. + + Depend on table: User, Competition. + """ -STRING_SIZE = 254 + id = db.Column(db.Integer, primary_key=True) + jti = db.Column(db.String, unique=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=True) + + def __init__(self, jti, user_id=None, competition_id=None): + self.jti = jti + self.user_id = user_id + self.competition_id = competition_id class Blacklist(db.Model): + """ + Table with banned jwt. + """ + id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String, unique=True) - expire_date = db.Column(db.Integer, nullable=True) def __init__(self, jti): self.jti = jti class Role(db.Model): + """ + Table with roles: Admin and Editor. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) @@ -24,8 +56,11 @@ class Role(db.Model): self.name = name -# TODO Region? class City(db.Model): + """ + Table with cities. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) @@ -37,6 +72,12 @@ class City(db.Model): class User(db.Model): + """ + Table with users. + + Depend on table: Role, City. + """ + id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(STRING_SIZE), unique=True) name = db.Column(db.String(STRING_SIZE), nullable=True) @@ -44,8 +85,9 @@ class User(db.Model): _password = db.Column(db.LargeBinary(60), nullable=False) authenticated = db.Column(db.Boolean, default=False) - # twoAuthConfirmed = db.Column(db.Boolean, default=True) - # twoAuthCode = db.Column(db.String(STRING_SIZE), nullable=True) + + login_attempts = db.Column(db.Integer, nullable=False, default=0) + locked = db.Column(db.DateTime(timezone=False), nullable=True, default=None) role_id = db.Column(db.Integer, db.ForeignKey("role.id"), nullable=False) city_id = db.Column(db.Integer, db.ForeignKey("city.id"), nullable=False) @@ -74,11 +116,21 @@ class User(db.Model): class Media(db.Model): + """ + Table with media objects that can be image or video depending on type_id. + + Depend on table: MediaType, User. + """ + id = db.Column(db.Integer, primary_key=True) filename = db.Column(db.String(STRING_SIZE), unique=True) type_id = db.Column(db.Integer, db.ForeignKey("media_type.id"), nullable=False) upload_by_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + image_components = db.relationship("ImageComponent", backref="media") + competition_background_images = db.relationship("Competition", backref="background_image") + slide_background_images = db.relationship("Slide", backref="background_image") + def __init__(self, filename, type_id, upload_by_id): self.filename = filename self.type_id = type_id @@ -86,17 +138,24 @@ class Media(db.Model): class Competition(db.Model): + """ + Table with competitions. + + Depend on table: Media, City. + """ + + __table_args__ = (db.UniqueConstraint("name", "year", "city_id"),) id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(STRING_SIZE), unique=True) + name = db.Column(db.String(STRING_SIZE), nullable=False) year = db.Column(db.Integer, nullable=False, default=2020) font = db.Column(db.String(STRING_SIZE), nullable=False) 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) + codes = db.relationship("Code", backref="competition") def __init__(self, name, year, city_id): self.name = name @@ -106,12 +165,21 @@ class Competition(db.Model): class Team(db.Model): + """ + Table with teams. + + Depend on table: Competition. + """ + __table_args__ = (db.UniqueConstraint("competition_id", "name"),) id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) - question_answers = db.relationship("QuestionAnswer", backref="team") + question_alternative_answers = db.relationship("QuestionAlternativeAnswer", backref="team") + question_scores = db.relationship("QuestionScore", backref="team") + + code = db.relationship("Code", backref="team") def __init__(self, name, competition_id): self.name = name @@ -119,17 +187,22 @@ class Team(db.Model): class Slide(db.Model): + """ + Table with slides. + + Depend on table: Competition, Media. + """ + __table_args__ = (db.UniqueConstraint("order", "competition_id"),) id = db.Column(db.Integer, primary_key=True) order = db.Column(db.Integer, nullable=False) title = db.Column(db.String(STRING_SIZE), nullable=False, default="") body = db.Column(db.Text, nullable=False, default="") - timer = db.Column(db.Integer, nullable=False, default=0) + timer = db.Column(db.Integer, nullable=True) settings = db.Column(db.Text, nullable=False, default="{}") competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) background_image_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) - background_image = db.relationship("Media", uselist=False) components = db.relationship("Component", backref="slide") questions = db.relationship("Question", backref="questions") @@ -140,77 +213,173 @@ class Slide(db.Model): class Question(db.Model): + """ + Table with questions of different types depending on type_id + + Depend on table: QuestionType, Slide. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), nullable=False) - total_score = db.Column(db.Integer, nullable=False, default=1) + total_score = db.Column(db.Integer, nullable=True, default=None) 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) + correcting_instructions = db.Column(db.Text, nullable=True, default=None) - question_answers = db.relationship("QuestionAnswer", backref="question") alternatives = db.relationship("QuestionAlternative", backref="question") - def __init__(self, name, total_score, type_id, slide_id): + def __init__(self, name, total_score, type_id, slide_id, correcting_instructions): self.name = name self.total_score = total_score self.type_id = type_id self.slide_id = slide_id + self.correcting_instructions = correcting_instructions class QuestionAlternative(db.Model): + """ + Table with question alternatives. + + Depend on table: Question. + """ + + __table_args__ = ( + db.UniqueConstraint("question_id", "alternative_order"), + db.UniqueConstraint("question_id", "correct_order"), + ) + id = db.Column(db.Integer, primary_key=True) - text = db.Column(db.String(STRING_SIZE), nullable=False) - value = db.Column(db.Integer, nullable=False) + alternative = db.Column(db.String(STRING_SIZE), nullable=False) + alternative_order = db.Column(db.Integer) + correct = db.Column(db.String(STRING_SIZE), nullable=False) + correct_order = db.Column(db.Integer) question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) - def __init__(self, text, value, question_id): - self.text = text - self.value = value + def __init__(self, alternative, alternative_order, correct, correct_order, question_id): + self.alternative = alternative + self.alternative_order = alternative_order + self.correct = correct + self.correct_order = correct_order self.question_id = question_id -class QuestionAnswer(db.Model): +class QuestionScore(db.Model): + """ + Table with question answers. + + Depend on table: Question, Team. + + Unique Constraint: Question.id, Team.id. + """ + __table_args__ = (db.UniqueConstraint("question_id", "team_id"),) id = db.Column(db.Integer, primary_key=True) - data = db.Column(Dictionary(), nullable=False) - score = db.Column(db.Integer, nullable=False, default=0) + score = db.Column(db.Integer, nullable=True, default=0) - question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=False) + question_id = db.Column(db.Integer, db.ForeignKey("question_alternative.id"), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=False) - def __init__(self, data, score, question_id, team_id): - self.data = data + def __init__(self, score, question_id, team_id): self.score = score self.question_id = question_id self.team_id = team_id +class QuestionAlternativeAnswer(db.Model): + __table_args__ = (db.UniqueConstraint("question_alternative_id", "team_id"),) + id = db.Column(db.Integer, primary_key=True) + answer = db.Column(db.String(STRING_SIZE), nullable=False) + + question_alternative_id = db.Column(db.Integer, db.ForeignKey("question_alternative.id"), nullable=False) + team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=False) + + def __init__(self, answer, question_alternative_id, team_id): + self.answer = answer + self.question_alternative_id = question_alternative_id + self.team_id = team_id + + class Component(db.Model): + """ + Table implemented with single table inheritance where each subclass is linked to a specific type_id + This class/table contains all fields from every subclass. + + Depend on table: :ViewType, Slide, ComponentType, Media, Question. + """ + id = db.Column(db.Integer, primary_key=True) x = db.Column(db.Integer, nullable=False, default=0) y = db.Column(db.Integer, nullable=False, default=0) w = db.Column(db.Integer, nullable=False, default=1) h = db.Column(db.Integer, nullable=False, default=1) - data = db.Column(Dictionary()) + view_type_id = db.Column(db.Integer, db.ForeignKey("view_type.id"), nullable=True) slide_id = db.Column(db.Integer, db.ForeignKey("slide.id"), nullable=False) type_id = db.Column(db.Integer, db.ForeignKey("component_type.id"), nullable=False) - def __init__(self, slide_id, type_id, data, x=0, y=0, w=1, h=1): + __mapper_args__ = {"polymorphic_on": type_id} + + def __init__(self, slide_id, type_id, view_type_id, x=0, y=0, w=1, h=1): self.x = x self.y = y self.w = w self.h = h - self.data = data self.slide_id = slide_id self.type_id = type_id + self.view_type_id = view_type_id + + +class TextComponent(Component): + """ + Subclass of Component that contains text. + """ + + text = db.Column(db.Text, default="", nullable=False) + + # __tablename__ = None + __mapper_args__ = {"polymorphic_identity": TEXT_COMPONENT_ID} + + +class ImageComponent(Component): + """ + Subclass of Component that contains an image. + + Depend on table: Media. + """ + + media_id = db.Column(db.Integer, db.ForeignKey("media.id"), nullable=True) + + # __tablename__ = None + __mapper_args__ = {"polymorphic_identity": IMAGE_COMPONENT_ID} + + +class QuestionComponent(Component): + """ + Subclass of Component that contains a question. + + Depend on table: Question. + """ + + question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=True) + + # __tablename__ = None + __mapper_args__ = {"polymorphic_identity": QUESTION_COMPONENT_ID} class Code(db.Model): + """ + Table with codes. + + Depend on table: ViewType, Competition, Team. + """ + id = db.Column(db.Integer, primary_key=True) code = db.Column(db.Text, unique=True) view_type_id = db.Column(db.Integer, db.ForeignKey("view_type.id"), nullable=False) competition_id = db.Column(db.Integer, db.ForeignKey("competition.id"), nullable=False) team_id = db.Column(db.Integer, db.ForeignKey("team.id"), nullable=True) + view_type = db.relationship("ViewType", uselist=False) + def __init__(self, code, view_type_id, competition_id=None, team_id=None): self.code = code self.view_type_id = view_type_id @@ -219,15 +388,22 @@ class Code(db.Model): class ViewType(db.Model): + """ + Table with view types: Team, Judge, Audience and Operator. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) - codes = db.relationship("Code", backref="view_type") def __init__(self, name): self.name = name class ComponentType(db.Model): + """ + Table with component types: Text, Image and Question. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) components = db.relationship("Component", backref="component_type") @@ -237,6 +413,10 @@ class ComponentType(db.Model): class MediaType(db.Model): + """ + Table with media types: Image and Video. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) media = db.relationship("Media", backref="type") @@ -246,6 +426,10 @@ class MediaType(db.Model): class QuestionType(db.Model): + """ + Table with question types: Text, Practical, Multiple and Single. + """ + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(STRING_SIZE), unique=True) questions = db.relationship("Question", backref="type") diff --git a/server/app/database/types.py b/server/app/database/types.py new file mode 100644 index 0000000000000000000000000000000000000000..e60dcacc77a6cf2d49abbb652b7dc0a98c7b5f44 --- /dev/null +++ b/server/app/database/types.py @@ -0,0 +1,25 @@ +""" +This module defines the different component types. +""" + +# Store all type ID in cache instead of here. + +IMAGE_MEDIA_ID = 1 +VIDEO_MEDIA_ID = 2 + +TEXT_QUESTION_ID = 1 +PRACTICAL_QUESTION_ID = 2 +MULTIPLE_QUESTION_ID = 3 +SINGLE_QUESTION_ID = 4 + +TEAM_VIEW_ID = 1 +JUDGE_VIEW_ID = 2 +AUDIENCE_VIEW_ID = 3 +OPERATOR_VIEW_ID = 4 + +ADMIN_ROLE_ID = 1 +EDITOR_ROLE_ID = 2 + +TEXT_COMPONENT_ID = 1 +IMAGE_COMPONENT_ID = 2 +QUESTION_COMPONENT_ID = 3 diff --git a/server/configmodule.py b/server/configmodule.py index e202abd3fd4f76899598d77df66de8bf148925a2..390b4ea74978671b6c04c3822d02c9440ca3357c 100644 --- a/server/configmodule.py +++ b/server/configmodule.py @@ -2,46 +2,75 @@ import os from datetime import timedelta +class DevDbConfig: + HOST = "localhost" + PORT = 5432 + USER = "postgres" + PASSWORD = "password" + DATABASE = "teknik8" + SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE + + +class TestDbConfig: + HOST = "localhost" + PORT = 5432 + USER = "postgres" + PASSWORD = "password" + DATABASE = "teknik8-test" + SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE + + +class LiteDevDbConfig: + SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" + + +class LiteTestDbConfig: + SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" + + class Config: DEBUG = False TESTING = False BUNDLE_ERRORS = True - SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = False JWT_SECRET_KEY = "super-secret" JWT_BLACKLIST_ENABLED = True JWT_BLACKLIST_TOKEN_CHECKS = ["access", "refresh"] JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=2) - JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) - UPLOADED_PHOTOS_DEST = os.path.join(os.getcwd(), "app/static/images") + # JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) + JSON_SORT_KEYS = False + UPLOADED_PHOTOS_DEST = os.path.join(os.getcwd(), "app", "static", "images") THUMBNAIL_SIZE = (120, 120) SECRET_KEY = os.urandom(24) - SQLALCHEMY_ECHO = False + USER_LOGIN_LOCKED_ATTEMPTS = 12 + USER_LOGIN_LOCKED_EXPIRES = timedelta(hours=3) + # Configure flask_smorest + API_TITLE = "Teknikåttan" + API_VERSION = "v1.0" + OPENAPI_VERSION = "3.0.3" + OPENAPI_URL_PREFIX = "/" + OPENAPI_SWAGGER_UI_PATH = "/" + OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" -class DevelopmentConfig(Config): + +class DevelopmentConfig(Config, LiteDevDbConfig): DEBUG = True - SQLALCHEMY_ECHO = False - # HOST = "localhost" - # PORT = 5432 - # USER = "postgres" - # PASSWORD = "password" - # DATABASE = "teknik8" - # SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" - # SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE -class TestingConfig(Config): +class TestingConfig(Config, LiteTestDbConfig): TESTING = True - SQLALCHEMY_DATABASE_URI = "sqlite:///test.db" + USER_LOGIN_LOCKED_ATTEMPTS = 4 + USER_LOGIN_LOCKED_EXPIRES = timedelta(seconds=4) class ProductionConfig(Config): - SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" + DEBUG = False + TESTING = False # HOST = "localhost" # PORT = 5432 # USER = "postgres" # PASSWORD = "password" # DATABASE = "teknik8" - # SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" # SQLALCHEMY_DATABASE_URI = "postgresql://" + USER + ":" + PASSWORD + "@" + HOST + ":" + str(PORT) + "/" + DATABASE diff --git a/server/populate.py b/server/populate.py index cc19aa4cdd43190ac52c302f3029ade08b2a6c7a..71aba44a517e05cfcc7bacbaeb0b3ab445cd976f 100644 --- a/server/populate.py +++ b/server/populate.py @@ -9,15 +9,25 @@ from app import create_app, db from app.database.models import City, QuestionType, Role -def _add_items(): +def create_default_items(): media_types = ["Image", "Video"] - question_types = ["Boolean", "Multiple", "Text"] - component_types = ["Text", "Image"] + question_types = ["Text", "Practical", "Multiple", "Single", "Match"] + component_types = ["Text", "Image", "Question"] view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] - cities = ["Linköping", "Stockholm", "Norrköping", "Örkelljunga"] - teams = ["Gymnasieskola A", "Gymnasieskola B", "Gymnasieskola C"] + cities = [ + "Linköping", + "Stockholm", + "Norrköping", + "Örkelljunga", + "Västerås", + "Falun", + "Sundsvall", + "Göteborg", + "Malmö", + ] + teams = ["Högstadie A", "Högstadie B", "Högstadie C"] for name in media_types: dbc.add.mediaType(name) @@ -43,14 +53,30 @@ def _add_items(): city_id = City.query.filter(City.name == "Linköping").one().id # Add users - dbc.add.user("admin@test.se", "password", admin_id, city_id) - dbc.add.user("test@test.se", "password", editor_id, city_id) + dbc.add.user("admin@test.se", "password", admin_id, city_id, "Admina Denfina") + dbc.add.user("test@test.se", "password", editor_id, city_id, "Test Osteron") + + dbc.add.user("sven@test.se", "password", editor_id, 1, "Sven Mattson") + dbc.add.user("erika@test.se", "password", editor_id, 2, "Erika Malmberg") + dbc.add.user("anette@test.se", "password", editor_id, 3, "Anette Frisk") + dbc.add.user("emil@test.se", "password", editor_id, 4, "Emil Svensson") + dbc.add.user("david@test.se", "password", editor_id, 5, "David Ek") + + dbc.add.competition(f"Regionfinal", 2012, 1) + dbc.add.competition(f"Regionfinal", 2012, 2) + dbc.add.competition(f"Regionfinal", 2012, 3) + dbc.add.competition(f"Regionfinal", 2012, 4) + dbc.add.competition(f"Regionfinal", 2012, 5) + dbc.add.competition(f"Rikssemifinal", 2012, 6) + dbc.add.competition(f"Rikssemifinal", 2012, 7) + dbc.add.competition(f"Rikssemifinal", 2012, 8) + dbc.add.competition(f"Riksfinal", 2012, 9) question_types_items = dbc.get.all(QuestionType) # Add competitions for i in range(len(question_types_items)): - item_comp = dbc.add.competition(f"Tävling {i}", 2000 + i, city_id) + item_comp = dbc.add.competition(f"Tävling {i}", 3000 + i, city_id) dbc.edit.default(item_comp.slides[0], timer=5, title="test-slide-title") # Add two more slides to competition @@ -67,26 +93,35 @@ def _add_items(): dbc.utils.commit_and_refresh(item_slide) # Add question to competition - """ + item_question = dbc.add.question( name=f"Question {j}: {question_types_items[j].name}", total_score=j, type_id=question_types_items[j].id, slide_id=item_slide.id, ) - """ - for i in range(3): - dbc.add.question_alternative(f"Alternative {i}", 0, item_slide.questions[0].id) + for k in range(3): + dbc.add.question_alternative(f"Alternative {k}", f"Correct {k}", item_question.id) # Add text components # TODO: Add images as components for k in range(3): x = random.randrange(1, 500) y = random.randrange(1, 500) - w = random.randrange(150, 400) - h = random.randrange(150, 400) - dbc.add.component(1, item_slide.id, {"text": f"hej{k}"}, x, y, w, h) + w = 350 + h = 50 + dbc.add.component( + 1, item_slide.id, 1, x, y, w, h, text=f"<p><span style='font-size: 24pt;'>{k}</span></p>" + ) + for k in range(3): + x = random.randrange(1, 500) + y = random.randrange(1, 500) + w = 350 + h = 50 + dbc.add.component( + 1, item_slide.id, 3, x, y, w, h, text=f"<p><span style='font-size: 24pt;'>{k}</span></p>" + ) # item_slide = dbc.add.slide(item_comp) # item_slide.title = f"Slide {len(item_comp.slides)}" @@ -99,6 +134,14 @@ def _add_items(): for name in teams: dbc.add.team(f"{name}{i}", item_comp.id) + # question_answer(answer, score, question_id, team_id) + # dbc.add.question_answer("ett svar som ger 2p", 2, 1, 1) + # dbc.add.question_answer("ett svar som ger 10p", 10, 2, 1) + # dbc.add.question_answer("ett svar som ger 6p", 6, 3, 1) + + # dbc.add.question_answer("ett svar som ger 2p", 2, 1, 2) + # dbc.add.question_answer("ett svar som ger 3p", 3, 1, 3) + if __name__ == "__main__": app, _ = create_app("configmodule.DevelopmentConfig") @@ -107,4 +150,4 @@ if __name__ == "__main__": db.drop_all() db.create_all() - _add_items() + create_default_items() diff --git a/server/requirements.txt b/server/requirements.txt index bda47a88036c81470bc7a6b2112b2ecedd904ed7..bcb325f5f3a16a5ee0e655435923c38d8926b807 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ diff --git a/server/tests/__init__.py b/server/tests/__init__.py index c5b8f20d24cbfabe4d6c87f66a3e9b893a51d23d..2bf699418f230976e4ac28827e6b7d2f08030ec3 100644 --- a/server/tests/__init__.py +++ b/server/tests/__init__.py @@ -1,6 +1,8 @@ import pytest from app import create_app, db +DISABLE_TESTS = False + @pytest.fixture def app(): diff --git a/server/tests/test_app.py b/server/tests/test_app.py index 199df387e878fdbbde759a80e6aa3d9ea486ea7d..1bf7663df8f0aac1ad005b58c892e6cf8dc28cd0 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -2,24 +2,49 @@ This file tests the api function calls. """ -import app.core.http_codes as codes -from app.database.models import Slide +import time -from tests import app, client, db +import pytest +from app.apis import http_codes +from app.core import sockets + +from tests import DISABLE_TESTS, app, client, db from tests.test_helpers import add_default_values, change_order_test, delete, get, post, put +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") +def test_locked_api(client): + add_default_values() + + # Login in with default user but wrong password until blocked + for i in range(4): + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == http_codes.UNAUTHORIZED + + # Login with right password, user should be locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == http_codes.UNAUTHORIZED + + # Sleep for 4 secounds + time.sleep(4) + + # Check so the user is no longer locked + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) + assert response.status_code == http_codes.OK + + +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") def test_misc_api(client): add_default_values() # Login in with default user response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} # Get types response, body = get(client, "/api/misc/types", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert len(body["media_types"]) >= 2 assert len(body["question_types"]) >= 3 assert len(body["component_types"]) >= 2 @@ -27,139 +52,146 @@ def test_misc_api(client): ## Get misc response, body = get(client, "/api/misc/roles", headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 2 + assert response.status_code == http_codes.OK + assert len(body) == 2 # There are currently two roles response, body = get(client, "/api/misc/cities", headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 2 - assert body["items"][0]["name"] == "Linköping" and body["items"][1]["name"] == "Testköping" + assert response.status_code == http_codes.OK + assert len(body) >= 2 + assert body[0]["name"] == "Linköping" and body[1]["name"] == "Testköping" ## Cities response, body = post(client, "/api/misc/cities", {"name": "Göteborg"}, headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 2 and body["items"][2]["name"] == "Göteborg" + assert response.status_code == http_codes.OK + assert len(body) >= 2 and body[2]["name"] == "Göteborg" # Rename city response, body = put(client, "/api/misc/cities/3", {"name": "Gbg"}, headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 2 and body["items"][2]["name"] == "Gbg" + assert response.status_code == http_codes.OK + assert len(body) >= 2 and body[2]["name"] == "Gbg" # Delete city # First checks current cities response, body = get(client, "/api/misc/cities", headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 3 - assert body["items"][0]["name"] == "Linköping" - assert body["items"][1]["name"] == "Testköping" - assert body["items"][2]["name"] == "Gbg" + assert response.status_code == http_codes.OK + assert len(body) >= 3 + assert body[0]["name"] == "Linköping" + assert body[1]["name"] == "Testköping" + assert body[2]["name"] == "Gbg" # Deletes city response, body = delete(client, "/api/misc/cities/3", headers=headers) - assert response.status_code == codes.OK - assert body["count"] >= 2 - assert body["items"][0]["name"] == "Linköping" and body["items"][1]["name"] == "Testköping" + assert response.status_code == http_codes.OK + assert len(body) >= 2 + assert body[0]["name"] == "Linköping" and body[1]["name"] == "Testköping" +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") def test_competition_api(client): add_default_values() # Login in with default user response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} # Create competition data = {"name": "c1", "year": 2020, "city_id": 1} response, body = post(client, "/api/competitions", data, headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert body["name"] == "c1" competition_id = body["id"] # Get competition response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert body["name"] == "c1" response, body = post(client, f"/api/competitions/{competition_id}/slides", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK response, body = get(client, f"/api/competitions/{competition_id}/slides", headers=headers) - assert response.status_code == codes.OK - assert len(body["items"]) == 2 + assert response.status_code == http_codes.OK + assert len(body) == 2 """ response, body = put(client, f"/api/competitions/{competition_id}/slides/{2}/order", {"order": 1}, headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK """ response, body = post(client, f"/api/competitions/{competition_id}/teams", {"name": "t1"}, headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK response, body = get(client, f"/api/competitions/{competition_id}/teams", headers=headers) - assert response.status_code == codes.OK - assert len(body["items"]) == 1 - assert body["items"][0]["name"] == "t1" + assert response.status_code == http_codes.OK + assert len(body) == 1 + assert body[0]["name"] == "t1" response, body = delete(client, f"/api/competitions/{competition_id}", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.NO_CONTENT # Get competition competition_id = 2 response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK # Copies competition - for _ in range(10): + for _ in range(3): response, _ = post(client, f"/api/competitions/{competition_id}/copy", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") def test_auth_and_user_api(client): add_default_values() # Login in with default user response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} + # Login in with default user but wrong password + response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password1"}) + assert response.status_code == http_codes.UNAUTHORIZED + # Create user register_data = {"email": "test1@test.se", "password": "abc123", "role_id": 2, "city_id": 1} - response, body = post(client, "/api/auth/signup", register_data, headers) - assert response.status_code == codes.OK + response, body = post(client, "/api/users", register_data, headers) + assert response.status_code == http_codes.OK assert body["id"] == 2 assert "password" not in body assert "_password" not in body # Try to create user with same email register_data = {"email": "test1@test.se", "password": "354213", "role_id": 1, "city_id": 1} - response, body = post(client, "/api/auth/signup", register_data, headers) - assert response.status_code == codes.BAD_REQUEST + response, body = post(client, "/api/users", register_data, headers) + assert response.status_code == http_codes.CONFLICT - # Try loggin with wrong PASSWORD + # Try login with wrong PASSWORD response, body = post(client, "/api/auth/login", {"email": "test1@test.se", "password": "abc1234"}) - assert response.status_code == codes.UNAUTHORIZED + assert response.status_code == http_codes.UNAUTHORIZED - # Try loggin with wrong Email + # Try login with wrong Email response, body = post(client, "/api/auth/login", {"email": "testx@test.se", "password": "abc1234"}) - assert response.status_code == codes.UNAUTHORIZED + assert response.status_code == http_codes.NOT_FOUND - # Try loggin with right PASSWORD + # Login with right PASSWORD response, body = post(client, "/api/auth/login", {"email": "test1@test.se", "password": "abc123"}) - assert response.status_code == codes.OK - refresh_token = body["refresh_token"] + assert response.status_code == http_codes.OK + headers = {"Authorization": "Bearer " + body["access_token"]} # Get the current user response, body = get(client, "/api/users", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert body["email"] == "test1@test.se" # Edit current user name response, body = put(client, "/api/users", {"name": "carl carlsson", "city_id": 2, "role_id": 1}, headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert body["name"] == "Carl Carlsson" - assert body["city_id"] == 2 and body["role_id"] == 1 + assert body["city_id"] == 2 + assert body["role_id"] == 1 # Find other user response, body = get( @@ -168,14 +200,14 @@ def test_auth_and_user_api(client): query_string={"name": "Carl Carlsson"}, headers=headers, ) - assert response.status_code == codes.OK - assert body["count"] == 1 + assert response.status_code == http_codes.OK + assert len(body) == 1 # Get user from ID - searched_user = body["items"][0] + searched_user = body[0] user_id = searched_user["id"] response, body = get(client, f"/api/users/{user_id}", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert searched_user["name"] == body["name"] assert searched_user["email"] == body["email"] assert searched_user["role_id"] == body["role_id"] @@ -184,47 +216,46 @@ def test_auth_and_user_api(client): # Login as admin response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} # Edit user from ID response, body = put(client, f"/api/users/{user_id}", {"email": "carl@carlsson.test"}, headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK # assert body["email"] == "carl@carlsson.test" # Edit user from ID but add the same email as other user response, body = put(client, f"/api/users/{user_id}", {"email": "test@test.se"}, headers=headers) - assert response.status_code == codes.BAD_REQUEST + assert response.status_code == http_codes.CONFLICT # Delete other user - response, body = delete(client, f"/api/auth/delete/{user_id}", headers=headers) - assert response.status_code == codes.OK + response, body = delete(client, f"/api/users/{user_id}", headers=headers) + assert response.status_code == http_codes.NO_CONTENT # Try to delete other user again - response, body = delete(client, f"/api/auth/delete/{user_id}", headers=headers) - assert response.status_code == codes.NOT_FOUND + response, body = delete(client, f"/api/users/{user_id}", headers=headers) + assert response.status_code == http_codes.NOT_FOUND # Logout and try to access current user response, body = post(client, f"/api/auth/logout", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.NO_CONTENT # TODO: Check if current users jwt (jti) is in blacklist after logging out - response, body = get(client, "/api/users", headers=headers) - assert response.status_code == codes.UNAUTHORIZED + assert response.status_code == http_codes.UNAUTHORIZED # Login in again with default user # response, body = post(client, "/api/auth/login", {"email": "test1@test.se", "password": "abc123"}) - # assert response.status_code == codes.OK + # assert response.status_code == http_codes.OK # headers = {"Authorization": "Bearer " + body["access_token"]} # # TODO: Add test for refresh api for current user # # response, body = post(client, "/api/auth/refresh", headers={**headers, "refresh_token": refresh_token}) - # # assert response.status_code == codes.OK + # # assert response.status_code == http_codes.OK # # Find current user # response, body = get(client, "/api/users", headers=headers) - # assert response.status_code == codes.OK + # assert response.status_code == http_codes.OK # assert body["email"] == "test1@test.se" # assert body["city_id"] == 2 # assert body["role_id"] == 1 @@ -232,44 +263,45 @@ def test_auth_and_user_api(client): # # Delete current user # user_id = body["id"] # response, body = delete(client, f"/api/auth/delete/{user_id}", headers=headers) - # assert response.status_code == codes.OK + # assert response.status_code == http_codes.OK # TODO: Check that user was blacklisted # Look for current users jwt in blacklist # Blacklist.query.filter(Blacklist.jti == ) +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") def test_slide_api(client): add_default_values() # Login in with default user response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} # Get slides from empty competition CID = 1 response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == 1 + assert response.status_code == http_codes.OK + assert len(body) == 1 # Get slides CID = 2 response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == 3 + assert response.status_code == http_codes.OK + assert len(body) == 3 # Add slide response, body = post(client, f"/api/competitions/{CID}/slides", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) - assert body["count"] == 4 + assert len(body) == 4 # Get slide slide_id = 2 response, item_slide = get(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK # Edit slide title = "Ny titel" @@ -281,12 +313,10 @@ def test_slide_api(client): response, item_slide = put( client, f"/api/competitions/{CID}/slides/{slide_id}", - # TODO: Implement so these commented lines can be edited - # {"order": order, "title": title, "body": body, "timer": timer}, {"title": title, "timer": timer}, headers=headers, ) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK # assert item_slide["order"] == order assert item_slide["title"] == title # assert item_slide["body"] == body @@ -294,22 +324,22 @@ def test_slide_api(client): # Delete slide response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) - assert response.status_code == codes.NO_CONTENT + assert response.status_code == http_codes.NO_CONTENT # Checks that there are fewer slides response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == 3 + assert response.status_code == http_codes.OK + assert len(body) == 3 # Tries to delete slide again, which will fail response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_id}", headers=headers) - assert response.status_code != codes.OK + assert response.status_code != http_codes.OK # Get all slides response, body = get(client, f"/api/competitions/{CID}/slides", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == 3 - assert body["items"][0]["id"] == 3 - assert body["items"][0]["order"] == 0 + assert response.status_code == http_codes.OK + assert len(body) == 3 + assert body[0]["id"] == 3 + assert body[0]["order"] == 0 slide_id = 3 """ @@ -317,7 +347,7 @@ def test_slide_api(client): response, _ = put( client, f"/api/competitions/{CID}/slides/{slide_id}/order", {"order": 0}, headers=headers ) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK # Changes the order change_order_test(client, CID, slide_id, slide_id + 1, headers) @@ -325,31 +355,49 @@ def test_slide_api(client): # Copies slide for _ in range(10): response, _ = post(client, f"/api/competitions/{CID}/slides/{slide_id}/copy", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK """ - + # Get a specific component + CID = 2 + SID = 3 + COMID = 2 + response, c1 = get(client, f"/api/competitions/{CID}/slides/{SID}/components/{COMID}", headers=headers) + assert response.status_code == http_codes.OK + + # Copy the component to another view + view_type_id = 3 + response, c2 = post( + client, f"/api/competitions/{CID}/slides/{SID}/components/{COMID}/copy/{view_type_id}", headers=headers + ) + # Check that the components metch + assert response.status_code == http_codes.OK + assert c1 != c2 + assert c1["x"] == c2["x"] + assert c1["y"] == c2["y"] + assert c1["w"] == c2["w"] + assert c1["h"] == c2["h"] + assert c1["slide_id"] == SID + assert c2["slide_id"] == SID + assert c1["type_id"] == c2["type_id"] + if c1["type_id"] == 1: + assert c1["text"] == c2["text"] + elif c1["type_id"] == 2: + assert c1["image_id"] == c2["image_id"] + assert c1["view_type_id"] == 1 + assert c2["view_type_id"] == 3 + + +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") def test_question_api(client): add_default_values() # Login in with default user response, body = post(client, "/api/auth/login", {"email": "test@test.se", "password": "password"}) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK headers = {"Authorization": "Bearer " + body["access_token"]} - # Get questions from empty competition - CID = 1 # TODO: Fix api-calls so that the ones not using CID don't require one - slide_order = 1 - response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == 1 - - # Get questions from another competition that should have some questions - CID = 3 num_questions = 3 - response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == num_questions # Add question name = "Nytt namn" @@ -357,31 +405,92 @@ def test_question_api(client): slide_order = 6 response, item_question = post( client, - f"/api/competitions/{CID}/slides/{slide_order}/questions", + f"/api/competitions/{3}/slides/{slide_order}/questions", {"name": name, "type_id": type_id}, headers=headers, ) - num_questions = 4 - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert item_question["name"] == name assert item_question["type_id"] == type_id + num_questions += 1 - # Checks number of questions - response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == codes.OK - assert body["count"] == num_questions """ # Delete question response, _ = delete(client, f"/api/competitions/{CID}/slides/{slide_order}/questions/{QID}", headers=headers) num_questions -= 1 - assert response.status_code == codes.NO_CONTENT + assert response.status_code == http_codes.NO_CONTENT # Checks that there are fewer questions response, body = get(client, f"/api/competitions/{CID}/questions", headers=headers) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK assert body["count"] == num_questions # Tries to delete question again response, _ = delete(client, f"/api/competitions/{CID}/slides/{NEW_slide_order}/questions/{QID}", headers=headers) - assert response.status_code == codes.NOT_FOUND + assert response.status_code == http_codes.NOT_FOUND """ + + +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") +def test_authorization(client): + add_default_values() + + # Fake that competition 1 is active + sockets.active_competitions[1] = {} + + #### TEAM #### + # Login in with team code + response, body = post(client, "/api/auth/code", {"code": "111111"}) + assert response.status_code == http_codes.OK + headers = {"Authorization": "Bearer " + body["access_token"]} + + competition_id = body["competition_id"] + team_id = body["team_id"] + + # Get competition team is in + response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == http_codes.OK + + # Try to delete competition team is in + response, body = delete(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == http_codes.UNAUTHORIZED + + # Try to get a different competition + response, body = get(client, f"/api/competitions/{competition_id+1}", headers=headers) + assert response.status_code == http_codes.UNAUTHORIZED + + # Get own answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id}/answers", headers=headers) + assert response.status_code == http_codes.OK + + # Try to get another teams answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) + assert response.status_code == http_codes.UNAUTHORIZED + + #### JUDGE #### + # Login in with judge code + response, body = post(client, "/api/auth/code", {"code": "222222"}) + assert response.status_code == http_codes.OK + headers = {"Authorization": "Bearer " + body["access_token"]} + + competition_id = body["competition_id"] + + # Get competition judge is in + response, body = get(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == http_codes.OK + + # Try to delete competition judge is in + response, body = delete(client, f"/api/competitions/{competition_id}", headers=headers) + assert response.status_code == http_codes.UNAUTHORIZED + + # Try to get a different competition + response, body = get(client, f"/api/competitions/{competition_id+1}", headers=headers) + assert response.status_code == http_codes.UNAUTHORIZED + + # Get team answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id}/answers", headers=headers) + assert response.status_code == http_codes.OK + + # Also get antoher teams answers + response, body = get(client, f"/api/competitions/{competition_id}/teams/{team_id+1}/answers", headers=headers) + assert response.status_code == http_codes.OK diff --git a/server/tests/test_db.py b/server/tests/test_db.py index cf10329ce6a320fdceb89ae9eba82da9e6b17092..6d000820135b88f21c5ab85071ce9be1f3bd9e4a 100644 --- a/server/tests/test_db.py +++ b/server/tests/test_db.py @@ -3,12 +3,46 @@ This file tests the database controller functions. """ import app.database.controller as dbc -from app.database.models import City, Media, MediaType, Role, User +import pytest +from app.database.models import City, Code, Competition, Media, MediaType, Role, Slide, User -from tests import app, client, db -from tests.test_helpers import add_default_values, assert_exists, assert_insert_fail +from tests import DISABLE_TESTS, app, client, db +from tests.test_helpers import add_default_values, assert_all_slide_orders, assert_should_fail, assert_slide_order +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is False") +def test_default_values(client): + add_default_values() + + # Basic funcs + def func(val): + assert val + + # Testing assert_should_fail when it should and should not fail + assert_should_fail(func, False) + + try: + assert_should_fail(func, True) + except: + pass + else: + assert False + + # All slides should be in order + assert_all_slide_orders() + + # Slides are moved so they are not in correct order + item_slides = dbc.get.all(Slide) + for item_slide in item_slides: + item_slide.order += 10 + db.session.add(item_slide) + db.session.commit() + + # The slides are not in order so the assert should fail + assert_should_fail(assert_all_slide_orders) + + +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is enabled") def test_user(client): add_default_values() item_user = User.query.filter_by(email="test@test.se").first() @@ -26,6 +60,7 @@ def test_user(client): assert len(item_city.users) == 1 and item_city.users[0].id == item_user.id +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is enabled") def test_media(client): add_default_values() item_user = User.query.filter_by(email="test@test.se").first() @@ -44,24 +79,34 @@ def test_media(client): assert item_media.upload_by.email == "test@test.se" +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is enabled") def test_copy(client): add_default_values() - # Fetches an empty competition - list_item_competitions, _ = dbc.search.competition(name="Tävling 1") + # Fetches a competition + list_item_competitions = dbc.search.competition(name="Tävling 1") item_competition_original = list_item_competitions[0] # Fetches the first slide in that competition num_slides = 3 - item_slides, total = dbc.search.slide(competition_id=item_competition_original.id) - assert total == num_slides - item_slide_original = item_slides[0] + item_slides = dbc.search.slide(competition_id=item_competition_original.id) + assert len(item_slides) == num_slides + item_slide_original = item_slides[1] + + dbc.delete.slide(item_slides[0]) + num_slides -= 1 # Inserts several copies of the same slide - num_copies = 10 + num_copies = 3 for _ in range(num_copies): + # Slide must contain some of these things to copy + assert len(item_slide_original.components) > 0 + assert len(item_slide_original.questions) > 0 + assert len(item_slide_original.questions[0].alternatives) > 0 + item_slide_copy = dbc.copy.slide(item_slide_original) num_slides += 1 + check_slides_copy(item_slide_original, item_slide_copy, num_slides, num_slides - 1) assert item_slide_copy.competition_id == item_slide_original.competition_id @@ -69,15 +114,38 @@ def test_copy(client): num_copies = 3 for _ in range(num_copies): item_competition_copy = dbc.copy.competition(item_competition_original) + assert len(item_competition_copy.slides) > 0 for order, item_slide in enumerate(item_competition_copy.slides): item_slide_original = item_competition_original.slides[order] check_slides_copy(item_slide_original, item_slide, num_slides, order) assert item_slide.competition_id != item_slide_original.competition_id + # TODO: Check that all codes are corectly created + + # Deleting competition deletes all corresponding codes + item_competitions = dbc.get.all(Competition) + for item_competition in item_competitions: + dbc.delete.competition(item_competition) + assert len(dbc.get.all(Code)) == 0 + + # Deleting team deletes the right code + item_competition = dbc.add.competition("tom", 1971, 1) + item_team_1 = dbc.add.team("Lag 1", item_competition.id) + item_team_2 = dbc.add.team("Lag 2", item_competition.id) + assert len(dbc.get.all(Code)) == 5 + dbc.delete.team(item_team_1) + assert len(dbc.get.all(Code)) == 4 + dbc.delete.team(item_team_2) + assert len(dbc.get.all(Code)) == 3 + + assert_all_slide_orders() def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): - """ Checks that two slides are correct copies of each other. Looks big but is quite fast. """ - assert item_slide_copy.order == order # 0 indexing + """ + Checks that two slides are correct copies of each other. + This function looks big but is quite fast. + """ + assert item_slide_copy.order == order assert item_slide_copy.title == item_slide_original.title assert item_slide_copy.body == item_slide_original.body assert item_slide_copy.timer == item_slide_original.timer @@ -94,10 +162,13 @@ def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): assert c1.y == c2.y assert c1.w == c2.w assert c1.h == c2.h - assert c1.data == c2.data assert c1.slide_id == item_slide_original.id assert c2.slide_id == item_slide_copy.id assert c1.type_id == c2.type_id + if c1.type_id == 1: + assert c1.text == c2.text + elif c1.type_id == 2: + assert c1.image_id == c2.image_id # Checks that all questions were correctly copied questions = item_slide_original.questions @@ -118,20 +189,55 @@ def check_slides_copy(item_slide_original, item_slide_copy, num_slides, order): assert len(alternatives) == len(alternatives_copy) for a1, a2 in zip(alternatives, alternatives_copy): - assert a1.text == a2.text - assert a1.value == a2.value - assert a1.quesiton_id == q1.id - assert a2.quesiton_id == q2.id + assert a1.alternative == a2.alternative + assert a1.alternative_order == a2.alternative_order + assert a1.correct == a2.correct + assert a1.correct_order == a2.correct_order + assert a1.question_id == q1.id + assert a2.question_id == q2.id # Checks that the copy put the slide in the database - item_slides, total = dbc.search.slide( + item_slides = dbc.search.slide( competition_id=item_slide_copy.competition_id, # page_size=num_slides + 1, # Use this total > 15 ) - assert total == num_slides + assert len(item_slides) == num_slides assert item_slide_copy == item_slides[order] +@pytest.mark.skipif(DISABLE_TESTS, reason="Only run when DISABLE_TESTS is enabled") +def test_move_slides(client): + add_default_values() + + item_comp = dbc.get.one(Competition, 1) + + for _ in range(9): + dbc.add.slide(item_comp.id) + + # Move from beginning to end + dbc.utils.move_order(item_comp.slides, "order", 0, 9) + dbc.utils.refresh(item_comp) + assert_slide_order(item_comp, [9, 0, 1, 2, 3, 4, 5, 6, 7, 8]) + + # Move from end to beginning + dbc.utils.move_order(item_comp.slides, "order", 9, 0) + dbc.utils.refresh(item_comp) + assert_slide_order(item_comp, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + + # Move some things in the middle + dbc.utils.move_order(item_comp.slides, "order", 3, 7) + dbc.utils.refresh(item_comp) + assert_slide_order(item_comp, [0, 1, 2, 7, 3, 4, 5, 6, 8, 9]) + + dbc.utils.move_order(item_comp.slides, "order", 1, 5) + dbc.utils.refresh(item_comp) + assert_slide_order(item_comp, [0, 5, 1, 7, 2, 3, 4, 6, 8, 9]) + + dbc.utils.move_order(item_comp.slides, "order", 8, 2) + dbc.utils.refresh(item_comp) + assert_slide_order(item_comp, [0, 6, 1, 8, 3, 4, 5, 7, 2, 9]) + + """ def test_question(client): add_default_values() @@ -231,6 +337,7 @@ def test_question(client): ) assert item_q1 == item_q2[0] + assert_all_slide_orders() def test_slide(client): add_default_values() diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index cc630626822aa3358da79b7d9a9a132705982bdf..fdcef127df249587a45ad8647c62731497cdb8ff 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -1,16 +1,16 @@ import json -import app.core.http_codes as codes import app.database.controller as dbc +from app.apis import http_codes from app.core import db -from app.database.models import City, Role +from app.database.models import City, Code, Role, Slide def add_default_values(): media_types = ["Image", "Video"] question_types = ["Boolean", "Multiple", "Text"] component_types = ["Text", "Image"] - view_types = ["Team", "Judge", "Audience"] + view_types = ["Team", "Judge", "Audience", "Operator"] roles = ["Admin", "Editor"] cities = ["Linköping", "Testköping"] @@ -40,6 +40,17 @@ def add_default_values(): # Add competitions item_competition = dbc.add.competition("Tom tävling", 2012, item_city.id) + + item_team1 = dbc.add.team("Hej lag 3", item_competition.id) + + db.session.add(Code("111111", 1, item_competition.id, item_team1.id)) # Team + db.session.add(Code("222222", 2, item_competition.id)) # Judge + + # dbc.add.question_answer("hej", 5, item_question.id, item_team1.id) + # dbc.add.question_answer("då", 5, item_question.id, item_team2.id) + + db.session.commit() + for j in range(2): item_comp = dbc.add.competition(f"Tävling {j}", 2012, item_city.id) # Add two more slides to competition @@ -56,10 +67,13 @@ def add_default_values(): dbc.utils.commit_and_refresh(item_slide) # Add question to competition - # dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) + item_question = dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) + + for k in range(3): + dbc.add.question_alternative(f"Alternative {k}", f"Correct {k}", item_question.id) # Add text component - dbc.add.component(1, item_slide.id, {"text": "Text"}, i, 2 * i, 3 * i, 4 * i) + dbc.add.component(1, item_slide.id, 1, i, 2 * i, 3 * i, 4 * i, text="Text") def get_body(response): @@ -130,12 +144,48 @@ def assert_object_values(obj, values): # Changes order of slides def change_order_test(client, cid, slide_id, new_slide_id, h): response, new_order_body = get(client, f"/api/competitions/{cid}/slides/{new_slide_id}", headers=h) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK response, order_body = get(client, f"/api/competitions/{cid}/slides/{slide_id}", headers=h) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK new_order = new_order_body["order"] # Changes order response, _ = put(client, f"/api/competitions/{cid}/slides/{slide_id}/order", {"order": new_order}, headers=h) - assert response.status_code == codes.OK + assert response.status_code == http_codes.OK + + +def assert_slide_order(item_comp, correct_order): + """ + Assert that the slides in the given competition are in the correct order + """ + for slide, order in zip(item_comp.slides, correct_order): + assert slide.order == order + + +def assert_all_slide_orders(): + """ Checks that all slides are in order. """ + + # Get slides in competition order and slide order + item_slides = Slide.query.order_by(Slide.competition_id).order_by(Slide.order).all() + + order = 0 + competition_id = 1 + for item_slide in item_slides: + if item_slide.competition_id != competition_id: + order = 0 + competition_id = item_slide.competition_id + + assert item_slide.order == order + order += 1 + + +def assert_should_fail(func, *args): + """ Runs the function which should raise an exception. """ + + try: + func(*args) + except: + pass # Assert failed, as it should + else: + assert False # Assertion didn't fail