From d9acb769646e62b033051b654541b27aace605b3 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 19 Feb 2024 15:00:46 +0100 Subject: [PATCH 001/397] chore: directory layout --- backend/.gitkeep | 0 frontend/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/.gitkeep create mode 100644 frontend/.gitkeep diff --git a/backend/.gitkeep b/backend/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/.gitkeep b/frontend/.gitkeep new file mode 100644 index 00000000..e69de29b From c227d28000e720ff745230fd180d8d3963fa0db2 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 19 Feb 2024 15:05:25 +0100 Subject: [PATCH 002/397] chore: initial readme file --- README.md | 14 +++++++++++++- backend/README.md | 0 frontend/README.md | 0 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 backend/README.md create mode 100644 frontend/README.md diff --git a/README.md b/README.md index fd6335f8..0c3a257d 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ -# UGent-7 \ No newline at end of file +# Ypovoli + +This application was developed within the framework of the course "Software Engineering Lab 2" within the Computer Science program at Ghent University. + +## Development + +### Backend + +Instructions for the setup of the Django backend are to be found in `backend/README.md`. + +### Frontend + +Instructions for the setup of the Frontend backend are to be found in `frontend/README.md`. \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..e69de29b diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..e69de29b From a100ab490a069dc30fe1d811b3b9f1e3b5e50372 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 19 Feb 2024 15:24:14 +0100 Subject: [PATCH 003/397] chore: initialized vite + vue project --- frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 18 + frontend/index.html | 13 + frontend/package-lock.json | 1767 ++++++++++++++++++++++++ frontend/package.json | 20 + frontend/public/vite.svg | 1 + frontend/src/App.vue | 30 + frontend/src/assets/vue.svg | 1 + frontend/src/components/HelloWorld.vue | 38 + frontend/src/main.ts | 5 + frontend/src/style.css | 79 ++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 7 + 16 files changed, 2043 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 00000000..c0a6e5a4 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] +} diff --git a/frontend/README.md b/frontend/README.md index e69de29b..ef72fd52 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -0,0 +1,18 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..d375df59 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1767 @@ +{ + "name": "vite-vue-typescript-starter", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "vite-vue-typescript-starter", + "version": "0.0.0", + "dependencies": { + "vue": "^3.4.18" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.2.2", + "vite": "^5.1.1", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz", + "integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", + "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.19", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", + "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", + "dependencies": { + "@vue/compiler-core": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", + "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.19", + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.6", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", + "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", + "dependencies": { + "@vue/compiler-dom": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", + "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", + "dependencies": { + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", + "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", + "dependencies": { + "@vue/reactivity": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", + "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", + "dependencies": { + "@vue/runtime-core": "3.4.19", + "@vue/shared": "3.4.19", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", + "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", + "dependencies": { + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19" + }, + "peerDependencies": { + "vue": "3.4.19" + } + }, + "node_modules/@vue/shared": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", + "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "dev": true, + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", + "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", + "dependencies": { + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-sfc": "3.4.19", + "@vue/runtime-dom": "3.4.19", + "@vue/server-renderer": "3.4.19", + "@vue/shared": "3.4.19" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + }, + "dependencies": { + "@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==" + }, + "@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "dev": true, + "optional": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "dev": true, + "optional": true + }, + "@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "@vitejs/plugin-vue": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz", + "integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==", + "dev": true, + "requires": {} + }, + "@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "requires": { + "@volar/source-map": "1.11.1" + } + }, + "@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "requires": { + "muggle-string": "^0.3.1" + } + }, + "@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "requires": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "@vue/compiler-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", + "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", + "requires": { + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.19", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", + "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", + "requires": { + "@vue/compiler-core": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/compiler-sfc": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", + "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", + "requires": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.19", + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.6", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-ssr": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", + "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", + "requires": { + "@vue/compiler-dom": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "requires": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + } + }, + "@vue/reactivity": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", + "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", + "requires": { + "@vue/shared": "3.4.19" + } + }, + "@vue/runtime-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", + "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", + "requires": { + "@vue/reactivity": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/runtime-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", + "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", + "requires": { + "@vue/runtime-core": "3.4.19", + "@vue/shared": "3.4.19", + "csstype": "^3.1.3" + } + }, + "@vue/server-renderer": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", + "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", + "requires": { + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/shared": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", + "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "magic-string": { + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" + }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "requires": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "rollup": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", + "@types/estree": "1.0.5", + "fsevents": "~2.3.2" + } + }, + "semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true + }, + "vite": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "dev": true, + "requires": { + "esbuild": "^0.19.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + } + }, + "vue": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", + "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", + "requires": { + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-sfc": "3.4.19", + "@vue/runtime-dom": "3.4.19", + "@vue/server-renderer": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "requires": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "requires": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..57fc78e0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "vite-vue-typescript-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.18" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.2.2", + "vite": "^5.1.1", + "vue-tsc": "^1.8.27" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..bb666a8d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 00000000..770e9d33 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 00000000..7b25f3f2 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..2425c0f7 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 00000000..bb131d6b --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..9e03e604 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..05c17402 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], +}) From 8686e5c63b331dbcb772a94cd9d5c711f04c63d5 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 20 Feb 2024 12:17:37 +0100 Subject: [PATCH 004/397] added template --- .gitignore | 1 + backend/.gitignore | 1 + backend/manage.py | 22 ++++ backend/{.gitkeep => ypovoli/__init__.py} | 0 backend/ypovoli/asgi.py | 16 +++ backend/ypovoli/settings.py | 123 ++++++++++++++++++++++ backend/ypovoli/urls.py | 22 ++++ backend/ypovoli/wsgi.py | 16 +++ 8 files changed, 201 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.gitignore create mode 100755 backend/manage.py rename backend/{.gitkeep => ypovoli/__init__.py} (100%) create mode 100644 backend/ypovoli/asgi.py create mode 100644 backend/ypovoli/settings.py create mode 100644 backend/ypovoli/urls.py create mode 100644 backend/ypovoli/wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f494b1b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.tool-versions diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..1d17dae1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 00000000..f2b51f89 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/.gitkeep b/backend/ypovoli/__init__.py similarity index 100% rename from backend/.gitkeep rename to backend/ypovoli/__init__.py diff --git a/backend/ypovoli/asgi.py b/backend/ypovoli/asgi.py new file mode 100644 index 00000000..70cd7d09 --- /dev/null +++ b/backend/ypovoli/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for ypovoli project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') + +application = get_asgi_application() diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py new file mode 100644 index 00000000..6feb9a36 --- /dev/null +++ b/backend/ypovoli/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for ypovoli project. + +Generated by 'django-admin startproject' using Django 5.0.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-_upw+)mo--8_0slsl&8ot0*h8p50z_rlid6nwobd*%%gm$_!1x' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'ypovoli.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'ypovoli.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py new file mode 100644 index 00000000..1e45059e --- /dev/null +++ b/backend/ypovoli/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for ypovoli project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/backend/ypovoli/wsgi.py b/backend/ypovoli/wsgi.py new file mode 100644 index 00000000..c617cd31 --- /dev/null +++ b/backend/ypovoli/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for ypovoli project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') + +application = get_wsgi_application() From a48c1a794a6996d1d370ce8fe8967da21799521c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 20 Feb 2024 12:18:19 +0100 Subject: [PATCH 005/397] added swagger --- backend/ypovoli/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 6feb9a36..cf18d097 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -37,6 +37,9 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework_swagger', # Swagger + 'rest_framework', # Django rest framework + 'drf_yasg' # Yet Another Swagger generator ] MIDDLEWARE = [ From 646d80998327b1a91295d17082084a9236ec5782 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 20 Feb 2024 12:20:10 +0100 Subject: [PATCH 006/397] added requirements --- backend/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backend/requirements.txt diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..812db535 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +Django==5.0.2 +djangorestframework==3.14.0 +drf-yasg==1.21.7 From 3101cd9f0e68f8036e3179a48e4587262b3e2dcb Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 20 Feb 2024 12:34:42 +0100 Subject: [PATCH 007/397] readme instructions --- backend/.gitignore | 2 ++ backend/README.md | 15 +++++++++++++++ backend/requirements.txt | 1 + 3 files changed, 18 insertions(+) diff --git a/backend/.gitignore b/backend/.gitignore index 1d17dae1..ecdc87a1 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,3 @@ .venv +db.sqlite3 +__pycache__ \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index e69de29b..a90d5449 100644 --- a/backend/README.md +++ b/backend/README.md @@ -0,0 +1,15 @@ +# Prerequisites + +- python 3.11.8 + +__Django doesn't support python 3.12__ + +# Install instructions + +- Create a virtual environment `python -m venv .venv` (make sure to use the right python version) + +- Activate the virtual environment `source .venv/bin/activate` + +- Install all requirements `pip install -r requirements.txt` + +- Run the server `python manage.py runserver` \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 812db535..1dea149d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ Django==5.0.2 +django-rest-swagger==2.2.0 djangorestframework==3.14.0 drf-yasg==1.21.7 From 9d1e34b5cff1e8acacfab92f1ee4c0ef7e3eb6d4 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 22 Feb 2024 20:57:48 +0100 Subject: [PATCH 008/397] chore: added apps --- backend/authentication/__init__.py | 0 backend/authentication/admin.py | 3 +++ backend/authentication/apps.py | 6 ++++++ backend/authentication/migrations/__init__.py | 0 backend/authentication/models.py | 3 +++ backend/authentication/tests.py | 3 +++ backend/authentication/views.py | 3 +++ backend/logic/__init__.py | 0 backend/logic/admin.py | 3 +++ backend/logic/apps.py | 6 ++++++ backend/logic/migrations/__init__.py | 0 backend/logic/models.py | 3 +++ backend/logic/tests.py | 3 +++ backend/logic/views.py | 3 +++ backend/notification/__init__.py | 0 backend/notification/admin.py | 3 +++ backend/notification/apps.py | 6 ++++++ backend/notification/migrations/__init__.py | 0 backend/notification/models.py | 3 +++ backend/notification/tests.py | 3 +++ backend/notification/views.py | 3 +++ backend/testing/__init__.py | 0 backend/testing/admin.py | 3 +++ backend/testing/apps.py | 6 ++++++ backend/testing/migrations/__init__.py | 0 backend/testing/models.py | 3 +++ backend/testing/tests.py | 3 +++ backend/testing/views.py | 3 +++ 28 files changed, 72 insertions(+) create mode 100644 backend/authentication/__init__.py create mode 100644 backend/authentication/admin.py create mode 100644 backend/authentication/apps.py create mode 100644 backend/authentication/migrations/__init__.py create mode 100644 backend/authentication/models.py create mode 100644 backend/authentication/tests.py create mode 100644 backend/authentication/views.py create mode 100644 backend/logic/__init__.py create mode 100644 backend/logic/admin.py create mode 100644 backend/logic/apps.py create mode 100644 backend/logic/migrations/__init__.py create mode 100644 backend/logic/models.py create mode 100644 backend/logic/tests.py create mode 100644 backend/logic/views.py create mode 100644 backend/notification/__init__.py create mode 100644 backend/notification/admin.py create mode 100644 backend/notification/apps.py create mode 100644 backend/notification/migrations/__init__.py create mode 100644 backend/notification/models.py create mode 100644 backend/notification/tests.py create mode 100644 backend/notification/views.py create mode 100644 backend/testing/__init__.py create mode 100644 backend/testing/admin.py create mode 100644 backend/testing/apps.py create mode 100644 backend/testing/migrations/__init__.py create mode 100644 backend/testing/models.py create mode 100644 backend/testing/tests.py create mode 100644 backend/testing/views.py diff --git a/backend/authentication/__init__.py b/backend/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/authentication/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py new file mode 100644 index 00000000..8bab8df0 --- /dev/null +++ b/backend/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'authentication' diff --git a/backend/authentication/migrations/__init__.py b/backend/authentication/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/models.py b/backend/authentication/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/authentication/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/authentication/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/logic/__init__.py b/backend/logic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/admin.py b/backend/logic/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/logic/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/logic/apps.py b/backend/logic/apps.py new file mode 100644 index 00000000..4a6d3eba --- /dev/null +++ b/backend/logic/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LogicConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'logic' diff --git a/backend/logic/migrations/__init__.py b/backend/logic/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/models.py b/backend/logic/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/logic/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/logic/tests.py b/backend/logic/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/logic/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/logic/views.py b/backend/logic/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/logic/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/notification/__init__.py b/backend/notification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/notification/admin.py b/backend/notification/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/notification/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/notification/apps.py b/backend/notification/apps.py new file mode 100644 index 00000000..8757bbe5 --- /dev/null +++ b/backend/notification/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notification' diff --git a/backend/notification/migrations/__init__.py b/backend/notification/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/notification/models.py b/backend/notification/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/notification/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/notification/tests.py b/backend/notification/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/notification/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/notification/views.py b/backend/notification/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/notification/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/testing/__init__.py b/backend/testing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/testing/admin.py b/backend/testing/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/backend/testing/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/testing/apps.py b/backend/testing/apps.py new file mode 100644 index 00000000..957b7dc3 --- /dev/null +++ b/backend/testing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'testing' diff --git a/backend/testing/migrations/__init__.py b/backend/testing/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/testing/models.py b/backend/testing/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/backend/testing/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/testing/tests.py b/backend/testing/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/testing/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/testing/views.py b/backend/testing/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/backend/testing/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 3d5a8e25b14b1f70fb06452049e9d5d2e101e6d9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Fri, 23 Feb 2024 10:15:12 +0100 Subject: [PATCH 009/397] chore: rename apps --- backend/{notification => checks}/__init__.py | 0 backend/{notification => checks}/admin.py | 0 backend/{testing => checks}/apps.py | 4 ++-- backend/{notification => checks}/migrations/__init__.py | 0 backend/{notification => checks}/models.py | 0 backend/{notification => checks}/tests.py | 0 backend/{notification => checks}/views.py | 0 backend/{testing => notifications}/__init__.py | 0 backend/{testing => notifications}/admin.py | 0 backend/{notification => notifications}/apps.py | 4 ++-- backend/{testing => notifications}/migrations/__init__.py | 0 backend/{testing => notifications}/models.py | 0 backend/{testing => notifications}/tests.py | 0 backend/{testing => notifications}/views.py | 0 14 files changed, 4 insertions(+), 4 deletions(-) rename backend/{notification => checks}/__init__.py (100%) rename backend/{notification => checks}/admin.py (100%) rename backend/{testing => checks}/apps.py (63%) rename backend/{notification => checks}/migrations/__init__.py (100%) rename backend/{notification => checks}/models.py (100%) rename backend/{notification => checks}/tests.py (100%) rename backend/{notification => checks}/views.py (100%) rename backend/{testing => notifications}/__init__.py (100%) rename backend/{testing => notifications}/admin.py (100%) rename backend/{notification => notifications}/apps.py (58%) rename backend/{testing => notifications}/migrations/__init__.py (100%) rename backend/{testing => notifications}/models.py (100%) rename backend/{testing => notifications}/tests.py (100%) rename backend/{testing => notifications}/views.py (100%) diff --git a/backend/notification/__init__.py b/backend/checks/__init__.py similarity index 100% rename from backend/notification/__init__.py rename to backend/checks/__init__.py diff --git a/backend/notification/admin.py b/backend/checks/admin.py similarity index 100% rename from backend/notification/admin.py rename to backend/checks/admin.py diff --git a/backend/testing/apps.py b/backend/checks/apps.py similarity index 63% rename from backend/testing/apps.py rename to backend/checks/apps.py index 957b7dc3..28a74284 100644 --- a/backend/testing/apps.py +++ b/backend/checks/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class TestingConfig(AppConfig): +class ChecksConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'testing' + name = 'checks' diff --git a/backend/notification/migrations/__init__.py b/backend/checks/migrations/__init__.py similarity index 100% rename from backend/notification/migrations/__init__.py rename to backend/checks/migrations/__init__.py diff --git a/backend/notification/models.py b/backend/checks/models.py similarity index 100% rename from backend/notification/models.py rename to backend/checks/models.py diff --git a/backend/notification/tests.py b/backend/checks/tests.py similarity index 100% rename from backend/notification/tests.py rename to backend/checks/tests.py diff --git a/backend/notification/views.py b/backend/checks/views.py similarity index 100% rename from backend/notification/views.py rename to backend/checks/views.py diff --git a/backend/testing/__init__.py b/backend/notifications/__init__.py similarity index 100% rename from backend/testing/__init__.py rename to backend/notifications/__init__.py diff --git a/backend/testing/admin.py b/backend/notifications/admin.py similarity index 100% rename from backend/testing/admin.py rename to backend/notifications/admin.py diff --git a/backend/notification/apps.py b/backend/notifications/apps.py similarity index 58% rename from backend/notification/apps.py rename to backend/notifications/apps.py index 8757bbe5..001b4f98 100644 --- a/backend/notification/apps.py +++ b/backend/notifications/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class NotificationConfig(AppConfig): +class NotificationsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'notification' + name = 'notifications' diff --git a/backend/testing/migrations/__init__.py b/backend/notifications/migrations/__init__.py similarity index 100% rename from backend/testing/migrations/__init__.py rename to backend/notifications/migrations/__init__.py diff --git a/backend/testing/models.py b/backend/notifications/models.py similarity index 100% rename from backend/testing/models.py rename to backend/notifications/models.py diff --git a/backend/testing/tests.py b/backend/notifications/tests.py similarity index 100% rename from backend/testing/tests.py rename to backend/notifications/tests.py diff --git a/backend/testing/views.py b/backend/notifications/views.py similarity index 100% rename from backend/testing/views.py rename to backend/notifications/views.py From ebb01009c4f133a884063d9a5b7f40f959cce939 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 23 Feb 2024 12:02:52 +0100 Subject: [PATCH 010/397] chore: authentication package scaffolding --- backend/.gitignore | 2 ++ backend/authentication/admin.py | 3 --- backend/authentication/models/__init__.py | 0 backend/authentication/urls.py | 6 ++++++ backend/authentication/views.py | 3 +++ backend/authentication/views/__init__.py | 0 backend/ypovoli/settings.py | 10 +++++++++- backend/ypovoli/urls.py | 7 +++---- 8 files changed, 23 insertions(+), 8 deletions(-) delete mode 100644 backend/authentication/admin.py create mode 100644 backend/authentication/models/__init__.py create mode 100644 backend/authentication/urls.py create mode 100644 backend/authentication/views/__init__.py diff --git a/backend/.gitignore b/backend/.gitignore index ecdc87a1..c7d45f80 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ .venv +.idea + db.sqlite3 __pycache__ \ No newline at end of file diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/backend/authentication/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/backend/authentication/models/__init__.py b/backend/authentication/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py new file mode 100644 index 00000000..1275421a --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from authentication import views + +urlpatterns = [ + path('hello', views.hello) +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 91ea44a2..900318b9 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,3 +1,6 @@ from django.shortcuts import render +from django.http import HttpResponse, HttpRequest # Create your views here. +def hello(request: HttpRequest) -> HttpResponse: + return HttpResponse('Hello World') diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index cf18d097..9f2f6fd5 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -37,9 +37,17 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + + # Third party packages. 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework - 'drf_yasg' # Yet Another Swagger generator + 'drf_yasg', # Yet Another Swagger generator + + # First party packages. + 'logic', # Base business logic + 'checks', # Submission checks using Docker + 'authentication', # Authentication using CAS + 'notifications' # Notifications on events ] MIDDLEWARE = [ diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 1e45059e..78f352c7 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -14,9 +14,8 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ - path('admin/', admin.site.urls), -] + path('auth', include('authentication.urls')) +] \ No newline at end of file From 0c33c4ec80c921077e930e717f9fae0a706b600a Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 23 Feb 2024 13:01:52 +0100 Subject: [PATCH 011/397] chore: added .idea to .gitignore --- backend/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index c7d45f80..805a8c47 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,5 +1,4 @@ .venv .idea - db.sqlite3 __pycache__ \ No newline at end of file From d56d2869705175d5e1135335607df357c63d4023 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 23 Feb 2024 13:37:40 +0100 Subject: [PATCH 012/397] chore: more auth app scaffolding --- backend/authentication/urls.py | 6 +++--- backend/authentication/views.py | 6 ------ backend/authentication/views/auth_view.py | 4 ++++ backend/ypovoli/settings.py | 11 +++-------- backend/ypovoli/urls.py | 9 ++++++--- 5 files changed, 16 insertions(+), 20 deletions(-) delete mode 100644 backend/authentication/views.py create mode 100644 backend/authentication/views/auth_view.py diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 1275421a..3cfdf647 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from authentication import views +from authentication.views import auth_view urlpatterns = [ - path('hello', views.hello) -] + path('hello', auth_view.hello) +] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py deleted file mode 100644 index 900318b9..00000000 --- a/backend/authentication/views.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.shortcuts import render -from django.http import HttpResponse, HttpRequest - -# Create your views here. -def hello(request: HttpRequest) -> HttpResponse: - return HttpResponse('Hello World') diff --git a/backend/authentication/views/auth_view.py b/backend/authentication/views/auth_view.py new file mode 100644 index 00000000..474392ec --- /dev/null +++ b/backend/authentication/views/auth_view.py @@ -0,0 +1,4 @@ +from django.http import HttpRequest, HttpResponse + +def hello(request: HttpRequest) -> HttpResponse: + return 'hello' \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 9f2f6fd5..cea9e784 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -37,17 +37,12 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - - # Third party packages. + # Third party apps. 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework 'drf_yasg', # Yet Another Swagger generator - - # First party packages. - 'logic', # Base business logic - 'checks', # Submission checks using Docker - 'authentication', # Authentication using CAS - 'notifications' # Notifications on events + # First party apps. + 'authentication' ] MIDDLEWARE = [ diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 78f352c7..7d99b1aa 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -14,8 +14,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import include, path +from django.urls import path, include urlpatterns = [ - path('auth', include('authentication.urls')) -] \ No newline at end of file + # Base API endpoints. + + # Authentication endpoints. + path('auth/', include('authentication.urls')) +] From e706d6fcf96e42e72f7a773536b6a91d2b47c9d7 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 23 Feb 2024 14:53:23 +0100 Subject: [PATCH 013/397] chore: finished basic scaffolding and auth structure --- backend/authentication/models.py | 3 -- backend/authentication/models/user.py | 52 +++++++++++++++++++++++ backend/authentication/tests.py | 3 -- backend/authentication/tests/__init__.py | 0 backend/authentication/urls.py | 5 ++- backend/authentication/views/auth.py | 10 +++++ backend/authentication/views/auth_view.py | 4 -- backend/ypovoli/settings.py | 30 +++---------- backend/ypovoli/urls.py | 2 +- 9 files changed, 73 insertions(+), 36 deletions(-) delete mode 100644 backend/authentication/models.py create mode 100644 backend/authentication/models/user.py delete mode 100644 backend/authentication/tests.py create mode 100644 backend/authentication/tests/__init__.py create mode 100644 backend/authentication/views/auth.py delete mode 100644 backend/authentication/views/auth_view.py diff --git a/backend/authentication/models.py b/backend/authentication/models.py deleted file mode 100644 index 71a83623..00000000 --- a/backend/authentication/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/authentication/models/user.py b/backend/authentication/models/user.py new file mode 100644 index 00000000..b90d980d --- /dev/null +++ b/backend/authentication/models/user.py @@ -0,0 +1,52 @@ +import datetime + +from django.db.models import CharField, EmailField, IntegerField, DateTimeField +from django.contrib.auth.models import AbstractUser + +class User(AbstractUser): + """This model represents a single authenticatable user. + It extends the built-in Django user model with CAS-specific attributes. + """ + id = CharField( + primary_key=True + ) + + first_name = CharField( + blank=False, + null=False + ) + + last_name = CharField( + blank=False, + null=False + ) + + email = EmailField( + blank=False, + null=False, + unique=True + ) + + faculty = CharField( + blank=True + ) + + last_enrolled = IntegerField( + default = datetime.MINYEAR, + blank = True, + null = True + ) + + last_login_at = DateTimeField( + null=True, + blank=True + ) + + created_at = DateTimeField( + auto_now_add=True + ) + + @property + def full_name(self) -> str: + """The full name of the user.""" + return f"{self.first_name} {self.last_name}" \ No newline at end of file diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/authentication/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/authentication/tests/__init__.py b/backend/authentication/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 3cfdf647..e58427de 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from authentication.views import auth_view +from authentication.views import auth urlpatterns = [ - path('hello', auth_view.hello) + path('login', auth.login), + path('logout', auth.logout) ] \ No newline at end of file diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py new file mode 100644 index 00000000..c83cbf0c --- /dev/null +++ b/backend/authentication/views/auth.py @@ -0,0 +1,10 @@ +from django.http import HttpRequest, JsonResponse +from django.views.decorators.http import require_http_methods + +@require_http_methods(['GET', 'POST']) +def login(request: HttpRequest) -> JsonResponse: + raise NotImplementedError() + +@require_http_methods(['GET', 'POST']) +def logout(request: HttpRequest) -> JsonResponse: + raise NotImplementedError() diff --git a/backend/authentication/views/auth_view.py b/backend/authentication/views/auth_view.py deleted file mode 100644 index 474392ec..00000000 --- a/backend/authentication/views/auth_view.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.http import HttpRequest, HttpResponse - -def hello(request: HttpRequest) -> HttpResponse: - return 'hello' \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index cea9e784..009b0789 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -31,18 +31,20 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', + # 'django.contrib.admin' + # 'django.contrib.staticfiles' 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', - 'django.contrib.staticfiles', - # Third party apps. + + # Third party 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework 'drf_yasg', # Yet Another Swagger generator - # First party apps. - 'authentication' + + # First party + 'authentication' # Ypovoli authentication ] MIDDLEWARE = [ @@ -105,24 +107,6 @@ }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ - -STATIC_URL = 'static/' - # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 7d99b1aa..1ece5e7e 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - + # path('/', include('logic.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From 3082211aa93dcfb00209402d7f5180d233900e8c Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 23 Feb 2024 19:39:39 +0100 Subject: [PATCH 014/397] chore: logic package scaffolding --- backend/logic/admin.py | 3 --- backend/logic/models/__init__.py | 0 backend/logic/urls.py | 6 ++++++ backend/logic/views/__init__.py | 0 backend/logic/views/logic_view.py | 5 +++++ 5 files changed, 11 insertions(+), 3 deletions(-) delete mode 100644 backend/logic/admin.py create mode 100644 backend/logic/models/__init__.py create mode 100644 backend/logic/urls.py create mode 100644 backend/logic/views/__init__.py create mode 100644 backend/logic/views/logic_view.py diff --git a/backend/logic/admin.py b/backend/logic/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/backend/logic/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/backend/logic/models/__init__.py b/backend/logic/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/urls.py b/backend/logic/urls.py new file mode 100644 index 00000000..22b4f157 --- /dev/null +++ b/backend/logic/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from logic.views import logic_view + +urlpatterns = [ + path('hello', logic_view.hello) +] diff --git a/backend/logic/views/__init__.py b/backend/logic/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/views/logic_view.py b/backend/logic/views/logic_view.py new file mode 100644 index 00000000..3aac11f9 --- /dev/null +++ b/backend/logic/views/logic_view.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def hello(request: HttpRequest) -> HttpResponse: + return 'hello' From 3f0f8e6522ce2b93226352df4ad9d16efee1b5dc Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 23 Feb 2024 19:44:54 +0100 Subject: [PATCH 015/397] chore: more logic structure --- backend/logic/models.py | 3 --- backend/logic/tests.py | 3 --- backend/logic/tests/__init__.py | 0 backend/logic/views.py | 3 --- backend/ypovoli/urls.py | 2 +- 5 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 backend/logic/models.py delete mode 100644 backend/logic/tests.py create mode 100644 backend/logic/tests/__init__.py delete mode 100644 backend/logic/views.py diff --git a/backend/logic/models.py b/backend/logic/models.py deleted file mode 100644 index 71a83623..00000000 --- a/backend/logic/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/logic/tests.py b/backend/logic/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/logic/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/logic/tests/__init__.py b/backend/logic/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/views.py b/backend/logic/views.py deleted file mode 100644 index 91ea44a2..00000000 --- a/backend/logic/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 1ece5e7e..caca31ee 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - # path('/', include('logic.urls')), + path('/', include('logic.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From 819d44433041a06f4db9444b4d5e051d1bb18367 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 10:45:16 +0100 Subject: [PATCH 016/397] chore: Init structure specific user classes --- backend/logic/models/admin.py | 10 ++++++++++ backend/logic/models/assistant.py | 11 +++++++++++ backend/logic/models/student.py | 10 ++++++++++ backend/logic/models/teacher.py | 10 ++++++++++ backend/ypovoli/settings.py | 3 ++- 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 backend/logic/models/admin.py create mode 100644 backend/logic/models/assistant.py create mode 100644 backend/logic/models/student.py create mode 100644 backend/logic/models/teacher.py diff --git a/backend/logic/models/admin.py b/backend/logic/models/admin.py new file mode 100644 index 00000000..542b4389 --- /dev/null +++ b/backend/logic/models/admin.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Admin(User): + """This model represents the admin. + It extends the User model from the authentication app with + admin-specific attributes. + """ + + # At the moment, there are no additional attributes for the Admin model. diff --git a/backend/logic/models/assistant.py b/backend/logic/models/assistant.py new file mode 100644 index 00000000..262d174b --- /dev/null +++ b/backend/logic/models/assistant.py @@ -0,0 +1,11 @@ +from authentication.models.user import User + + +class Assistant(User): + """This model represents a single assistant. + It extends the User model from the authentication app with + assistant-specific attributes. + """ + + # At the moment, there are no additional attributes for the + # Assistant model. diff --git a/backend/logic/models/student.py b/backend/logic/models/student.py new file mode 100644 index 00000000..83f85f71 --- /dev/null +++ b/backend/logic/models/student.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Student(User): + """This model represents a single student. + It extends the User model from the authentication app with + student-specific attributes. + """ + + # At the moment, there are no additional attributes for the Student model. diff --git a/backend/logic/models/teacher.py b/backend/logic/models/teacher.py new file mode 100644 index 00000000..338b50d2 --- /dev/null +++ b/backend/logic/models/teacher.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Teacher(User): + """This model represents a single teacher. + It extends the User model from the authentication app with + teacher-specific attributes. + """ + + # At the moment, there are no additional attributes for the Teacher model. diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 009b0789..10c274e9 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -44,7 +44,8 @@ 'drf_yasg', # Yet Another Swagger generator # First party - 'authentication' # Ypovoli authentication + 'authentication', # Ypovoli authentication + 'logic' # Ypovoli logic of the base application ] MIDDLEWARE = [ From 922da09495114c7bf0e3b4f42345ae6a6e08e132 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 12:33:12 +0100 Subject: [PATCH 017/397] chore: course model + links with specific user roles --- backend/logic/models/assistant.py | 9 +++++-- backend/logic/models/course.py | 44 +++++++++++++++++++++++++++++++ backend/logic/models/student.py | 8 +++++- backend/logic/models/teacher.py | 8 +++++- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 backend/logic/models/course.py diff --git a/backend/logic/models/assistant.py b/backend/logic/models/assistant.py index 262d174b..5137444e 100644 --- a/backend/logic/models/assistant.py +++ b/backend/logic/models/assistant.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Assistant(User): @@ -7,5 +8,9 @@ class Assistant(User): assistant-specific attributes. """ - # At the moment, there are no additional attributes for the - # Assistant model. + # All the courses the assistant is assisting in + courses = models.ManyToManyField( + 'Course', + related_name='assistants', # Allows us to access the assistants from the course + blank=True + ) diff --git a/backend/logic/models/course.py b/backend/logic/models/course.py new file mode 100644 index 00000000..2f6a3f1d --- /dev/null +++ b/backend/logic/models/course.py @@ -0,0 +1,44 @@ +from django.db import models + + +class Course(models.Model): + """This model represents a single course. + It contains all the information about a course.""" + + # ID of the course should automatically be generated + + name = models.CharField( + max_length=100, + blank=False, + null=False + ) + + # Begin year of the academic year + academic_startyear = models.IntegerField( + blank=False, + null=False + ) + + description = models.TextField( + blank=True, + null=True + ) + + # OneToOneField is used to represent a one-to-one relationship + # with the course of the previous academic year + parent_course = models.OneToOneField( + 'self', + on_delete=models.CASCADE, + related_name='child_course', # Allows us to access the child course from the parent course + blank=True, + null=True + ) + + def __str__(self) -> str: + """The string representation of the course.""" + return str(self.name) + + @property + def academic_year(self) -> str: + """The academic year of the course.""" + return f"{self.academic_startyear}-{self.academic_startyear + 1}" diff --git a/backend/logic/models/student.py b/backend/logic/models/student.py index 83f85f71..8498b820 100644 --- a/backend/logic/models/student.py +++ b/backend/logic/models/student.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Student(User): @@ -7,4 +8,9 @@ class Student(User): student-specific attributes. """ - # At the moment, there are no additional attributes for the Student model. + # All the courses the student is enrolled in + courses = models.ManyToManyField( + 'Course', + related_name='students', # Allows us to access the students from the course + blank=True + ) diff --git a/backend/logic/models/teacher.py b/backend/logic/models/teacher.py index 338b50d2..75b39307 100644 --- a/backend/logic/models/teacher.py +++ b/backend/logic/models/teacher.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Teacher(User): @@ -7,4 +8,9 @@ class Teacher(User): teacher-specific attributes. """ - # At the moment, there are no additional attributes for the Teacher model. + # All the courses the teacher is teaching + courses = models.ManyToManyField( + 'Course', + related_name='teachers', # Allows us to access the teachers from the course + blank=True + ) From 17f516f26a29c0256788adbe84375654af130e61 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 13:49:27 +0100 Subject: [PATCH 018/397] chore: define check model --- backend/logic/models/checks.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/logic/models/checks.py diff --git a/backend/logic/models/checks.py b/backend/logic/models/checks.py new file mode 100644 index 00000000..d1c1908a --- /dev/null +++ b/backend/logic/models/checks.py @@ -0,0 +1,35 @@ +from django.db import models + + +class FileExtension(models.Model): + """Model that represents a file extension.""" + + extension = models.CharField(max_length=10, unique=True) + + def __str__(self) -> str: + return str(self.extension) + + +class Checks(models.Model): + """Model that represents checks for a project.""" + + # ID check should be generated automatically + + dockerfile = models.FileField( + blank=True, + null=True + ) + + # Link to the file extensions that are allowed + allowed_file_extensions = models.ManyToManyField( + FileExtension, + related_name='checks_allowed', + blank=True + ) + + # Link to the file extensions that are forbidden + forbidden_file_extensions = models.ManyToManyField( + FileExtension, + related_name='checks_forbidden', + blank=True + ) From bc628de7aa6aa1d23b84e061efabc637565b9f03 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:02:06 +0100 Subject: [PATCH 019/397] chore: define project model --- backend/logic/models/project.py | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/logic/models/project.py diff --git a/backend/logic/models/project.py b/backend/logic/models/project.py new file mode 100644 index 00000000..be8efb8c --- /dev/null +++ b/backend/logic/models/project.py @@ -0,0 +1,55 @@ +import datetime +from django.db import models + + +class Project(models.Model): + """Model that represents a project.""" + + # ID should be generated automatically + + name = models.CharField( + max_length=100, + blank=False, + null=False + ) + + description = models.TextField( + blank=True, + null=True + ) + + # Project already visible to students + visible = models.BooleanField( + default=True + ) + + # Project archived + archived = models.BooleanField( + default=False + ) + + start_date = models.DateTimeField( + default=datetime.datetime.now, # The default value is the current date and time + blank=True, + ) + + deadline = models.DateTimeField( + blank=False, + null=False + ) + + # Check entity that is linked to the project + checks = models.ForeignKey( + 'Checks', + on_delete=models.SET_NULL, # If the checks are deleted, the project should remain + blank=True, + null=True + ) + + # Course that the project belongs to + course = models.ForeignKey( + 'Course', + on_delete=models.CASCADE, # If the course is deleted, the project should be deleted as well + blank=False, + null=False + ) From 80661710850a21b0b190eca9d276a3228800ba30 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:04:25 +0100 Subject: [PATCH 020/397] chore: change delete cascade courses --- backend/logic/models/course.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/logic/models/course.py b/backend/logic/models/course.py index 2f6a3f1d..f3217f35 100644 --- a/backend/logic/models/course.py +++ b/backend/logic/models/course.py @@ -28,7 +28,7 @@ class Course(models.Model): # with the course of the previous academic year parent_course = models.OneToOneField( 'self', - on_delete=models.CASCADE, + on_delete=models.SET_NULL, # If the old course is deleted, the child course should remain related_name='child_course', # Allows us to access the child course from the parent course blank=True, null=True From 3b872d15957c0af3ac0834e709debbde1382602f Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:31:38 +0100 Subject: [PATCH 021/397] chore: define group model --- backend/logic/models/group.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/logic/models/group.py diff --git a/backend/logic/models/group.py b/backend/logic/models/group.py new file mode 100644 index 00000000..cbb32c21 --- /dev/null +++ b/backend/logic/models/group.py @@ -0,0 +1,23 @@ +from django.db import models + + +class Group(models.Model): + """Model for group of students that work together on a project.""" + + # ID should be generated automatically + + project = models.ForeignKey( + 'Project', + on_delete=models.CASCADE, # If the project is deleted, the group should be deleted as well + related_name='groups', # This is how we can access groups from a project + blank=False, + null=False + ) + + # Students that are part of the group + students = models.ManyToManyField( + 'Student', + related_name='groups', # This is how we can access groups from a student + blank=False, + null=False + ) From 1e35b5ca277c4f10be03943c97840fdc6450f8ad Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 17:39:14 +0100 Subject: [PATCH 022/397] chore: define submission model --- backend/logic/models/submission.py | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/logic/models/submission.py diff --git a/backend/logic/models/submission.py b/backend/logic/models/submission.py new file mode 100644 index 00000000..17289cef --- /dev/null +++ b/backend/logic/models/submission.py @@ -0,0 +1,50 @@ +from django.db import models + + +class SubmissionFile(models.Model): + """Model for a file that is part of a submission.""" + + # File ID should be generated automatically + + submission = models.ForeignKey( + 'Submission', + on_delete=models.CASCADE, # If the submission is deleted, the file should be deleted as well + related_name='files', + blank=False, + null=False + ) + + # TODO - Set the right place to save the file + file = models.FileField( + blank=False, + null=False + ) + + +class Submission(models.Model): + """Model for submission of a project by a group of students.""" + + # Submission ID should be generated automatically + + group = models.ForeignKey( + 'Group', + on_delete=models.CASCADE, # If the group is deleted, the submission should be deleted as well + related_name='submissions', + blank=False, + null=False + ) + + # Multiple submissions can be made by a group + submission_number = models.PositiveIntegerField( + blank=False, + null=False + ) + + # Automatically set the submission time to the current time + submission_time = models.DateTimeField( + auto_now_add=True + ) + + class Meta: + # A group can only have one submission with a specific number + unique_together = ('group', 'submission_number') From a6d0a12d19486b90ee066a3739f3de65e0026102 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 17:40:11 +0100 Subject: [PATCH 023/397] refactor: score belongs to group --- backend/logic/models/group.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/logic/models/group.py b/backend/logic/models/group.py index cbb32c21..eed8f22c 100644 --- a/backend/logic/models/group.py +++ b/backend/logic/models/group.py @@ -21,3 +21,9 @@ class Group(models.Model): blank=False, null=False ) + + # Score of the group + score = models.FloatField( + blank=True, + null=True + ) From ad33da5fc54cdb0ba3875e3690a6e021c5c5b988 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 20:53:20 +0100 Subject: [PATCH 024/397] feat: CAS integration --- backend/README.md | 2 +- .../authentication/migrations/0001_initial.py | 30 +++++++++ .../{models/user.py => models.py} | 36 +++++------ backend/authentication/serializers.py | 4 ++ backend/authentication/urls.py | 7 +- backend/authentication/views.py | 52 +++++++++++++++ backend/authentication/views/__init__.py | 0 backend/authentication/views/auth.py | 10 --- .../models/__init__.py => knox.settings} | 0 backend/requirements.txt | 5 +- backend/ypovoli/settings.py | 64 +++++++------------ 11 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 backend/authentication/migrations/0001_initial.py rename backend/authentication/{models/user.py => models.py} (52%) create mode 100644 backend/authentication/serializers.py create mode 100644 backend/authentication/views.py delete mode 100644 backend/authentication/views/__init__.py delete mode 100644 backend/authentication/views/auth.py rename backend/{authentication/models/__init__.py => knox.settings} (100%) diff --git a/backend/README.md b/backend/README.md index a90d5449..5ad570d2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,4 +12,4 @@ __Django doesn't support python 3.12__ - Install all requirements `pip install -r requirements.txt` -- Run the server `python manage.py runserver` \ No newline at end of file +- Run the server `python manage.py runsslserver` \ No newline at end of file diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py new file mode 100644 index 00000000..46f3cb5a --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.2 on 2024-02-24 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('create_time', models.DateTimeField(auto_created=True)), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('first_name', models.CharField(max_length=50)), + ('last_name', models.CharField(max_length=50)), + ('email', models.EmailField(max_length=254, unique=True)), + ('faculty', models.CharField(max_length=50, null=True)), + ('last_enrolled', models.IntegerField(default=1, null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/authentication/models/user.py b/backend/authentication/models.py similarity index 52% rename from backend/authentication/models/user.py rename to backend/authentication/models.py index b90d980d..6199414f 100644 --- a/backend/authentication/models/user.py +++ b/backend/authentication/models.py @@ -1,52 +1,52 @@ import datetime from django.db.models import CharField, EmailField, IntegerField, DateTimeField -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractBaseUser -class User(AbstractUser): +class User(AbstractBaseUser): """This model represents a single authenticatable user. It extends the built-in Django user model with CAS-specific attributes. """ + + """Model fields""" + password = None # We don't use passwords for our user model. + username = None # We don't work with usernames. + id = CharField( + max_length=12, primary_key=True ) first_name = CharField( - blank=False, + max_length=50, null=False ) last_name = CharField( - blank=False, + max_length=50, null=False ) email = EmailField( - blank=False, null=False, unique=True ) faculty = CharField( - blank=True + max_length=50, + null = True ) last_enrolled = IntegerField( default = datetime.MINYEAR, - blank = True, null = True ) - last_login_at = DateTimeField( - null=True, - blank=True - ) - - created_at = DateTimeField( - auto_now_add=True + create_time = DateTimeField( + auto_created=True ) - @property - def full_name(self) -> str: - """The full name of the user.""" - return f"{self.first_name} {self.last_name}" \ No newline at end of file + """Model settings""" + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = [] diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 00000000..4074969f --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,4 @@ +from rest_framework.serializers import Serializer, CharField + +class ValidateSerializer(Serializer): + ticket = CharField(required=True) \ No newline at end of file diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index e58427de..b58230f0 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from authentication.views import auth +from authentication.views import login, logout, validate urlpatterns = [ - path('login', auth.login), - path('logout', auth.logout) + path('login', login, name='auth.login'), + path('validate', validate, name='auth.validate'), + path('logout', logout, name='auth.logout') ] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 00000000..1cdba1f5 --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,52 @@ +from django.shortcuts import redirect, reverse +from rest_framework.decorators import api_view +from rest_framework.request import Request +from rest_framework.response import Response +from cas_client import CASClient +from authentication.serializers import ValidateSerializer +from ypovoli import settings + +client = CASClient( + server_url=settings.CAS_ENDPOINT, + service_url=settings.API_VALIDATE_ENDPOINT, + auth_prefix='' +) + +@api_view(['GET']) +def login(_: Request) -> Response: + """Attempt to log in. + Redirect to our single CAS endpoint. + """ + return redirect( + client.get_login_url() + ) + + +@api_view(['GET']) +def validate(request: Request) -> Response: + """Validate a Service Ticket obtained from the CAS endpoint. + Returns a user token for further API authentication. + """ + serializer = ValidateSerializer(data=request.query_params) + + if serializer.is_valid(): + response = client.perform_service_validate(ticket=serializer.data['ticket']) + + if response.success: + # Fetch or create user, generate token + return Response(response.data) + else: + return Response({ + 'errors': response.error + }) + + return Response({ + 'errors': serializer.errors + }) + +@api_view(['GET']) +def logout(request: Request) -> Response: + """Attempt to log out. + Redirect to our single CAS endpoint. + """ + raise NotImplementedError() diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py deleted file mode 100644 index c83cbf0c..00000000 --- a/backend/authentication/views/auth.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.http import HttpRequest, JsonResponse -from django.views.decorators.http import require_http_methods - -@require_http_methods(['GET', 'POST']) -def login(request: HttpRequest) -> JsonResponse: - raise NotImplementedError() - -@require_http_methods(['GET', 'POST']) -def logout(request: HttpRequest) -> JsonResponse: - raise NotImplementedError() diff --git a/backend/authentication/models/__init__.py b/backend/knox.settings similarity index 100% rename from backend/authentication/models/__init__.py rename to backend/knox.settings diff --git a/backend/requirements.txt b/backend/requirements.txt index 1dea149d..5e9c1402 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,7 @@ Django==5.0.2 -django-rest-swagger==2.2.0 +django-sslserver==0.22 djangorestframework==3.14.0 +django-rest-swagger==2.2.0 +django-rest-knox==4.2.0 drf-yasg==1.21.7 +requests==2.31.0 \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 009b0789..b0b43b08 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.0/ref/settings/ """ - +from datetime import timedelta from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -31,8 +31,7 @@ # Application definition INSTALLED_APPS = [ - # 'django.contrib.admin' - # 'django.contrib.staticfiles' + # Built-ins 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -41,8 +40,10 @@ # Third party 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework + 'knox', # Token based authentication 'drf_yasg', # Yet Another Swagger generator - + 'sslserver', # Used for local SSL support (needed by CAS) + # First party 'authentication' # Ypovoli authentication ] @@ -54,60 +55,39 @@ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware' ] -ROOT_URLCONF = 'ypovoli.urls' +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ] +} -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] +AUTH_USER_MODEL = 'authentication.User' + +ROOT_URLCONF = 'ypovoli.urls' WSGI_APPLICATION = 'ypovoli.wsgi.application' +# Application endpoints + +CAS_ENDPOINT = 'https://login.ugent.be' +API_ENDPOINT = 'https://localhost:8080' +API_VALIDATE_ENDPOINT = 'https://localhost:8080/auth/validate' # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', + }, + 'production': { + 'ENGINE': 'django.db.backends.postgresql' } } - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' From b36c0f445d7c091dc319bce739e8d3672ae71cd4 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 20:54:43 +0100 Subject: [PATCH 025/397] chore: added cas-client as a dependency --- backend/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5e9c1402..ec3c90df 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ djangorestframework==3.14.0 django-rest-swagger==2.2.0 django-rest-knox==4.2.0 drf-yasg==1.21.7 -requests==2.31.0 \ No newline at end of file +requests==2.31.0 +cas-client==1.0.0 \ No newline at end of file From a19c1f824fbaefec1ebc262850f8761431fc2971 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 22:32:21 +0100 Subject: [PATCH 026/397] feat(authentication): CAS model serializer --- backend/README.md | 2 +- backend/authentication/models.py | 1 - backend/authentication/serializers.py | 12 ++++++++++-- backend/authentication/views.py | 23 +++++++++++++++-------- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/backend/README.md b/backend/README.md index 5ad570d2..5027cdd7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,4 +12,4 @@ __Django doesn't support python 3.12__ - Install all requirements `pip install -r requirements.txt` -- Run the server `python manage.py runsslserver` \ No newline at end of file +- Run the server `python manage.py runsslserver localhost:8080` \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6199414f..6523c98b 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -10,7 +10,6 @@ class User(AbstractBaseUser): """Model fields""" password = None # We don't use passwords for our user model. - username = None # We don't work with usernames. id = CharField( max_length=12, diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 4074969f..060436d8 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,4 +1,12 @@ from rest_framework.serializers import Serializer, CharField -class ValidateSerializer(Serializer): - ticket = CharField(required=True) \ No newline at end of file +class CASTicketSerializer(Serializer): + """Serializer for CAS ticket validation""" + ticket = CharField(required=True, min_length=49, max_length=49) + + +class CASUserSerializer(Serializer): + """Serializer for CAS success responses""" + +class UserSerializer(Serializer): + """Serializer for User models""" \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 1cdba1f5..f5767e7d 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -3,7 +3,7 @@ from rest_framework.request import Request from rest_framework.response import Response from cas_client import CASClient -from authentication.serializers import ValidateSerializer +from authentication.serializers import CASTicketSerializer, CASUserSerializer from ypovoli import settings client = CASClient( @@ -27,13 +27,16 @@ def validate(request: Request) -> Response: """Validate a Service Ticket obtained from the CAS endpoint. Returns a user token for further API authentication. """ - serializer = ValidateSerializer(data=request.query_params) + ticket = CASTicketSerializer(data=request.query_params) - if serializer.is_valid(): - response = client.perform_service_validate(ticket=serializer.data['ticket']) + if ticket.is_valid(raise_exception=True): + response = client.perform_service_validate( + ticket=ticket.validated_data['ticket'] + ) if response.success: - # Fetch or create user, generate token + # Fetch or create user, generate token. + cas_user = CASUserSerializer(data=response.data) return Response(response.data) else: return Response({ @@ -41,12 +44,16 @@ def validate(request: Request) -> Response: }) return Response({ - 'errors': serializer.errors + 'errors': ticket.errors }) -@api_view(['GET']) +@api_view(['POST']) def logout(request: Request) -> Response: """Attempt to log out. Redirect to our single CAS endpoint. """ - raise NotImplementedError() + return redirect( + client.get_logout_url( + service_url=settings.API_ENDPOINT + ) + ) From 078aa9a1a0361bf8e5eeb8f0fba5a7d65071f0f2 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 20:53:20 +0100 Subject: [PATCH 027/397] feat: CAS integration --- backend/README.md | 2 +- .../authentication/migrations/0001_initial.py | 30 +++++++++ .../{models/user.py => models.py} | 36 +++++------ backend/authentication/serializers.py | 4 ++ backend/authentication/urls.py | 7 +- backend/authentication/views.py | 52 +++++++++++++++ backend/authentication/views/__init__.py | 0 backend/authentication/views/auth.py | 10 --- .../models/__init__.py => knox.settings} | 0 backend/requirements.txt | 5 +- backend/ypovoli/settings.py | 64 +++++++------------ 11 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 backend/authentication/migrations/0001_initial.py rename backend/authentication/{models/user.py => models.py} (52%) create mode 100644 backend/authentication/serializers.py create mode 100644 backend/authentication/views.py delete mode 100644 backend/authentication/views/__init__.py delete mode 100644 backend/authentication/views/auth.py rename backend/{authentication/models/__init__.py => knox.settings} (100%) diff --git a/backend/README.md b/backend/README.md index a90d5449..5ad570d2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,4 +12,4 @@ __Django doesn't support python 3.12__ - Install all requirements `pip install -r requirements.txt` -- Run the server `python manage.py runserver` \ No newline at end of file +- Run the server `python manage.py runsslserver` \ No newline at end of file diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py new file mode 100644 index 00000000..46f3cb5a --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.2 on 2024-02-24 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('create_time', models.DateTimeField(auto_created=True)), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('first_name', models.CharField(max_length=50)), + ('last_name', models.CharField(max_length=50)), + ('email', models.EmailField(max_length=254, unique=True)), + ('faculty', models.CharField(max_length=50, null=True)), + ('last_enrolled', models.IntegerField(default=1, null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/authentication/models/user.py b/backend/authentication/models.py similarity index 52% rename from backend/authentication/models/user.py rename to backend/authentication/models.py index b90d980d..6199414f 100644 --- a/backend/authentication/models/user.py +++ b/backend/authentication/models.py @@ -1,52 +1,52 @@ import datetime from django.db.models import CharField, EmailField, IntegerField, DateTimeField -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractBaseUser -class User(AbstractUser): +class User(AbstractBaseUser): """This model represents a single authenticatable user. It extends the built-in Django user model with CAS-specific attributes. """ + + """Model fields""" + password = None # We don't use passwords for our user model. + username = None # We don't work with usernames. + id = CharField( + max_length=12, primary_key=True ) first_name = CharField( - blank=False, + max_length=50, null=False ) last_name = CharField( - blank=False, + max_length=50, null=False ) email = EmailField( - blank=False, null=False, unique=True ) faculty = CharField( - blank=True + max_length=50, + null = True ) last_enrolled = IntegerField( default = datetime.MINYEAR, - blank = True, null = True ) - last_login_at = DateTimeField( - null=True, - blank=True - ) - - created_at = DateTimeField( - auto_now_add=True + create_time = DateTimeField( + auto_created=True ) - @property - def full_name(self) -> str: - """The full name of the user.""" - return f"{self.first_name} {self.last_name}" \ No newline at end of file + """Model settings""" + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = [] diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 00000000..4074969f --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,4 @@ +from rest_framework.serializers import Serializer, CharField + +class ValidateSerializer(Serializer): + ticket = CharField(required=True) \ No newline at end of file diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index e58427de..b58230f0 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from authentication.views import auth +from authentication.views import login, logout, validate urlpatterns = [ - path('login', auth.login), - path('logout', auth.logout) + path('login', login, name='auth.login'), + path('validate', validate, name='auth.validate'), + path('logout', logout, name='auth.logout') ] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 00000000..1cdba1f5 --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,52 @@ +from django.shortcuts import redirect, reverse +from rest_framework.decorators import api_view +from rest_framework.request import Request +from rest_framework.response import Response +from cas_client import CASClient +from authentication.serializers import ValidateSerializer +from ypovoli import settings + +client = CASClient( + server_url=settings.CAS_ENDPOINT, + service_url=settings.API_VALIDATE_ENDPOINT, + auth_prefix='' +) + +@api_view(['GET']) +def login(_: Request) -> Response: + """Attempt to log in. + Redirect to our single CAS endpoint. + """ + return redirect( + client.get_login_url() + ) + + +@api_view(['GET']) +def validate(request: Request) -> Response: + """Validate a Service Ticket obtained from the CAS endpoint. + Returns a user token for further API authentication. + """ + serializer = ValidateSerializer(data=request.query_params) + + if serializer.is_valid(): + response = client.perform_service_validate(ticket=serializer.data['ticket']) + + if response.success: + # Fetch or create user, generate token + return Response(response.data) + else: + return Response({ + 'errors': response.error + }) + + return Response({ + 'errors': serializer.errors + }) + +@api_view(['GET']) +def logout(request: Request) -> Response: + """Attempt to log out. + Redirect to our single CAS endpoint. + """ + raise NotImplementedError() diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py deleted file mode 100644 index c83cbf0c..00000000 --- a/backend/authentication/views/auth.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.http import HttpRequest, JsonResponse -from django.views.decorators.http import require_http_methods - -@require_http_methods(['GET', 'POST']) -def login(request: HttpRequest) -> JsonResponse: - raise NotImplementedError() - -@require_http_methods(['GET', 'POST']) -def logout(request: HttpRequest) -> JsonResponse: - raise NotImplementedError() diff --git a/backend/authentication/models/__init__.py b/backend/knox.settings similarity index 100% rename from backend/authentication/models/__init__.py rename to backend/knox.settings diff --git a/backend/requirements.txt b/backend/requirements.txt index 1dea149d..5e9c1402 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,7 @@ Django==5.0.2 -django-rest-swagger==2.2.0 +django-sslserver==0.22 djangorestframework==3.14.0 +django-rest-swagger==2.2.0 +django-rest-knox==4.2.0 drf-yasg==1.21.7 +requests==2.31.0 \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index ba0bebad..60e1acb3 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.0/ref/settings/ """ - +from datetime import timedelta from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -31,8 +31,7 @@ # Application definition INSTALLED_APPS = [ - # 'django.contrib.admin' - # 'django.contrib.staticfiles' + # Built-ins 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -41,8 +40,10 @@ # Third party 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework + 'knox', # Token based authentication 'drf_yasg', # Yet Another Swagger generator - + 'sslserver', # Used for local SSL support (needed by CAS) + # First party 'authentication', # Ypovoli authentication 'api' # Ypovoli logic of the base application @@ -55,60 +56,39 @@ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware' ] -ROOT_URLCONF = 'ypovoli.urls' +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ] +} -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] +AUTH_USER_MODEL = 'authentication.User' + +ROOT_URLCONF = 'ypovoli.urls' WSGI_APPLICATION = 'ypovoli.wsgi.application' +# Application endpoints + +CAS_ENDPOINT = 'https://login.ugent.be' +API_ENDPOINT = 'https://localhost:8080' +API_VALIDATE_ENDPOINT = 'https://localhost:8080/auth/validate' # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', + }, + 'production': { + 'ENGINE': 'django.db.backends.postgresql' } } - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' From 0742b39ba187621b5f8a26f636776290b002f5c7 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 20:54:43 +0100 Subject: [PATCH 028/397] chore: added cas-client as a dependency --- backend/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5e9c1402..ec3c90df 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ djangorestframework==3.14.0 django-rest-swagger==2.2.0 django-rest-knox==4.2.0 drf-yasg==1.21.7 -requests==2.31.0 \ No newline at end of file +requests==2.31.0 +cas-client==1.0.0 \ No newline at end of file From fc2bc6312b208bf4d3d62deab099b3bbab4e392e Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 24 Feb 2024 22:32:21 +0100 Subject: [PATCH 029/397] feat(authentication): CAS model serializer --- backend/README.md | 2 +- backend/authentication/models.py | 1 - backend/authentication/serializers.py | 12 ++++++++++-- backend/authentication/views.py | 23 +++++++++++++++-------- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/backend/README.md b/backend/README.md index 5ad570d2..5027cdd7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,4 +12,4 @@ __Django doesn't support python 3.12__ - Install all requirements `pip install -r requirements.txt` -- Run the server `python manage.py runsslserver` \ No newline at end of file +- Run the server `python manage.py runsslserver localhost:8080` \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6199414f..6523c98b 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -10,7 +10,6 @@ class User(AbstractBaseUser): """Model fields""" password = None # We don't use passwords for our user model. - username = None # We don't work with usernames. id = CharField( max_length=12, diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 4074969f..060436d8 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,4 +1,12 @@ from rest_framework.serializers import Serializer, CharField -class ValidateSerializer(Serializer): - ticket = CharField(required=True) \ No newline at end of file +class CASTicketSerializer(Serializer): + """Serializer for CAS ticket validation""" + ticket = CharField(required=True, min_length=49, max_length=49) + + +class CASUserSerializer(Serializer): + """Serializer for CAS success responses""" + +class UserSerializer(Serializer): + """Serializer for User models""" \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 1cdba1f5..f5767e7d 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -3,7 +3,7 @@ from rest_framework.request import Request from rest_framework.response import Response from cas_client import CASClient -from authentication.serializers import ValidateSerializer +from authentication.serializers import CASTicketSerializer, CASUserSerializer from ypovoli import settings client = CASClient( @@ -27,13 +27,16 @@ def validate(request: Request) -> Response: """Validate a Service Ticket obtained from the CAS endpoint. Returns a user token for further API authentication. """ - serializer = ValidateSerializer(data=request.query_params) + ticket = CASTicketSerializer(data=request.query_params) - if serializer.is_valid(): - response = client.perform_service_validate(ticket=serializer.data['ticket']) + if ticket.is_valid(raise_exception=True): + response = client.perform_service_validate( + ticket=ticket.validated_data['ticket'] + ) if response.success: - # Fetch or create user, generate token + # Fetch or create user, generate token. + cas_user = CASUserSerializer(data=response.data) return Response(response.data) else: return Response({ @@ -41,12 +44,16 @@ def validate(request: Request) -> Response: }) return Response({ - 'errors': serializer.errors + 'errors': ticket.errors }) -@api_view(['GET']) +@api_view(['POST']) def logout(request: Request) -> Response: """Attempt to log out. Redirect to our single CAS endpoint. """ - raise NotImplementedError() + return redirect( + client.get_logout_url( + service_url=settings.API_ENDPOINT + ) + ) From 13ac1e7ef13d10a6df0e23f82804f1c2ad2e9484 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 22:44:34 +0100 Subject: [PATCH 030/397] refactor: rename logic to api --- backend/{logic => api}/__init__.py | 0 backend/{logic => api}/apps.py | 4 ++-- backend/{logic => api}/migrations/__init__.py | 0 backend/{logic => api}/models/__init__.py | 0 backend/{logic => api}/models/admin.py | 0 backend/{logic => api}/models/assistant.py | 0 backend/{logic => api}/models/checks.py | 0 backend/{logic => api}/models/course.py | 0 backend/{logic => api}/models/group.py | 0 backend/{logic => api}/models/project.py | 0 backend/{logic => api}/models/student.py | 0 backend/{logic => api}/models/submission.py | 0 backend/{logic => api}/models/teacher.py | 0 backend/{logic => api}/tests/__init__.py | 0 backend/{logic => api}/urls.py | 2 +- backend/{logic => api}/views/__init__.py | 0 backend/{logic => api}/views/logic_view.py | 0 backend/ypovoli/settings.py | 2 +- backend/ypovoli/urls.py | 2 +- 19 files changed, 5 insertions(+), 5 deletions(-) rename backend/{logic => api}/__init__.py (100%) rename backend/{logic => api}/apps.py (65%) rename backend/{logic => api}/migrations/__init__.py (100%) rename backend/{logic => api}/models/__init__.py (100%) rename backend/{logic => api}/models/admin.py (100%) rename backend/{logic => api}/models/assistant.py (100%) rename backend/{logic => api}/models/checks.py (100%) rename backend/{logic => api}/models/course.py (100%) rename backend/{logic => api}/models/group.py (100%) rename backend/{logic => api}/models/project.py (100%) rename backend/{logic => api}/models/student.py (100%) rename backend/{logic => api}/models/submission.py (100%) rename backend/{logic => api}/models/teacher.py (100%) rename backend/{logic => api}/tests/__init__.py (100%) rename backend/{logic => api}/urls.py (70%) rename backend/{logic => api}/views/__init__.py (100%) rename backend/{logic => api}/views/logic_view.py (100%) diff --git a/backend/logic/__init__.py b/backend/api/__init__.py similarity index 100% rename from backend/logic/__init__.py rename to backend/api/__init__.py diff --git a/backend/logic/apps.py b/backend/api/apps.py similarity index 65% rename from backend/logic/apps.py rename to backend/api/apps.py index 4a6d3eba..66656fd2 100644 --- a/backend/logic/apps.py +++ b/backend/api/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class LogicConfig(AppConfig): +class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'logic' + name = 'api' diff --git a/backend/logic/migrations/__init__.py b/backend/api/migrations/__init__.py similarity index 100% rename from backend/logic/migrations/__init__.py rename to backend/api/migrations/__init__.py diff --git a/backend/logic/models/__init__.py b/backend/api/models/__init__.py similarity index 100% rename from backend/logic/models/__init__.py rename to backend/api/models/__init__.py diff --git a/backend/logic/models/admin.py b/backend/api/models/admin.py similarity index 100% rename from backend/logic/models/admin.py rename to backend/api/models/admin.py diff --git a/backend/logic/models/assistant.py b/backend/api/models/assistant.py similarity index 100% rename from backend/logic/models/assistant.py rename to backend/api/models/assistant.py diff --git a/backend/logic/models/checks.py b/backend/api/models/checks.py similarity index 100% rename from backend/logic/models/checks.py rename to backend/api/models/checks.py diff --git a/backend/logic/models/course.py b/backend/api/models/course.py similarity index 100% rename from backend/logic/models/course.py rename to backend/api/models/course.py diff --git a/backend/logic/models/group.py b/backend/api/models/group.py similarity index 100% rename from backend/logic/models/group.py rename to backend/api/models/group.py diff --git a/backend/logic/models/project.py b/backend/api/models/project.py similarity index 100% rename from backend/logic/models/project.py rename to backend/api/models/project.py diff --git a/backend/logic/models/student.py b/backend/api/models/student.py similarity index 100% rename from backend/logic/models/student.py rename to backend/api/models/student.py diff --git a/backend/logic/models/submission.py b/backend/api/models/submission.py similarity index 100% rename from backend/logic/models/submission.py rename to backend/api/models/submission.py diff --git a/backend/logic/models/teacher.py b/backend/api/models/teacher.py similarity index 100% rename from backend/logic/models/teacher.py rename to backend/api/models/teacher.py diff --git a/backend/logic/tests/__init__.py b/backend/api/tests/__init__.py similarity index 100% rename from backend/logic/tests/__init__.py rename to backend/api/tests/__init__.py diff --git a/backend/logic/urls.py b/backend/api/urls.py similarity index 70% rename from backend/logic/urls.py rename to backend/api/urls.py index 22b4f157..f0efafe3 100644 --- a/backend/logic/urls.py +++ b/backend/api/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from logic.views import logic_view +from api.views import logic_view urlpatterns = [ path('hello', logic_view.hello) diff --git a/backend/logic/views/__init__.py b/backend/api/views/__init__.py similarity index 100% rename from backend/logic/views/__init__.py rename to backend/api/views/__init__.py diff --git a/backend/logic/views/logic_view.py b/backend/api/views/logic_view.py similarity index 100% rename from backend/logic/views/logic_view.py rename to backend/api/views/logic_view.py diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 10c274e9..ba0bebad 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -45,7 +45,7 @@ # First party 'authentication', # Ypovoli authentication - 'logic' # Ypovoli logic of the base application + 'api' # Ypovoli logic of the base application ] MIDDLEWARE = [ diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index caca31ee..c39d0e7d 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - path('/', include('logic.urls')), + path('/', include('api.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From 8693975f144aabaea1005305d8876552a4af4fc2 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 23 Feb 2024 19:39:39 +0100 Subject: [PATCH 031/397] chore: logic package scaffolding --- backend/logic/admin.py | 3 --- backend/logic/models/__init__.py | 0 backend/logic/urls.py | 6 ++++++ backend/logic/views/__init__.py | 0 backend/logic/views/logic_view.py | 5 +++++ 5 files changed, 11 insertions(+), 3 deletions(-) delete mode 100644 backend/logic/admin.py create mode 100644 backend/logic/models/__init__.py create mode 100644 backend/logic/urls.py create mode 100644 backend/logic/views/__init__.py create mode 100644 backend/logic/views/logic_view.py diff --git a/backend/logic/admin.py b/backend/logic/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/backend/logic/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/backend/logic/models/__init__.py b/backend/logic/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/urls.py b/backend/logic/urls.py new file mode 100644 index 00000000..22b4f157 --- /dev/null +++ b/backend/logic/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from logic.views import logic_view + +urlpatterns = [ + path('hello', logic_view.hello) +] diff --git a/backend/logic/views/__init__.py b/backend/logic/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/views/logic_view.py b/backend/logic/views/logic_view.py new file mode 100644 index 00000000..3aac11f9 --- /dev/null +++ b/backend/logic/views/logic_view.py @@ -0,0 +1,5 @@ +from django.http import HttpRequest, HttpResponse + + +def hello(request: HttpRequest) -> HttpResponse: + return 'hello' From 953202b9b8a61f69f46eae9b5ab66006cc2fa8f9 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 23 Feb 2024 19:44:54 +0100 Subject: [PATCH 032/397] chore: more logic structure --- backend/logic/models.py | 3 --- backend/logic/tests.py | 3 --- backend/logic/tests/__init__.py | 0 backend/logic/views.py | 3 --- backend/ypovoli/urls.py | 2 +- 5 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 backend/logic/models.py delete mode 100644 backend/logic/tests.py create mode 100644 backend/logic/tests/__init__.py delete mode 100644 backend/logic/views.py diff --git a/backend/logic/models.py b/backend/logic/models.py deleted file mode 100644 index 71a83623..00000000 --- a/backend/logic/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/logic/tests.py b/backend/logic/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/logic/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/logic/tests/__init__.py b/backend/logic/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/logic/views.py b/backend/logic/views.py deleted file mode 100644 index 91ea44a2..00000000 --- a/backend/logic/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 1ece5e7e..caca31ee 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - # path('/', include('logic.urls')), + path('/', include('logic.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From 3ed8eedb0fda352e198b704367b29581baf0d8b8 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 10:45:16 +0100 Subject: [PATCH 033/397] chore: Init structure specific user classes --- backend/logic/models/admin.py | 10 ++++++++++ backend/logic/models/assistant.py | 11 +++++++++++ backend/logic/models/student.py | 10 ++++++++++ backend/logic/models/teacher.py | 10 ++++++++++ backend/ypovoli/settings.py | 3 ++- 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 backend/logic/models/admin.py create mode 100644 backend/logic/models/assistant.py create mode 100644 backend/logic/models/student.py create mode 100644 backend/logic/models/teacher.py diff --git a/backend/logic/models/admin.py b/backend/logic/models/admin.py new file mode 100644 index 00000000..542b4389 --- /dev/null +++ b/backend/logic/models/admin.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Admin(User): + """This model represents the admin. + It extends the User model from the authentication app with + admin-specific attributes. + """ + + # At the moment, there are no additional attributes for the Admin model. diff --git a/backend/logic/models/assistant.py b/backend/logic/models/assistant.py new file mode 100644 index 00000000..262d174b --- /dev/null +++ b/backend/logic/models/assistant.py @@ -0,0 +1,11 @@ +from authentication.models.user import User + + +class Assistant(User): + """This model represents a single assistant. + It extends the User model from the authentication app with + assistant-specific attributes. + """ + + # At the moment, there are no additional attributes for the + # Assistant model. diff --git a/backend/logic/models/student.py b/backend/logic/models/student.py new file mode 100644 index 00000000..83f85f71 --- /dev/null +++ b/backend/logic/models/student.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Student(User): + """This model represents a single student. + It extends the User model from the authentication app with + student-specific attributes. + """ + + # At the moment, there are no additional attributes for the Student model. diff --git a/backend/logic/models/teacher.py b/backend/logic/models/teacher.py new file mode 100644 index 00000000..338b50d2 --- /dev/null +++ b/backend/logic/models/teacher.py @@ -0,0 +1,10 @@ +from authentication.models.user import User + + +class Teacher(User): + """This model represents a single teacher. + It extends the User model from the authentication app with + teacher-specific attributes. + """ + + # At the moment, there are no additional attributes for the Teacher model. diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index b0b43b08..153078f1 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -45,7 +45,8 @@ 'sslserver', # Used for local SSL support (needed by CAS) # First party - 'authentication' # Ypovoli authentication + 'authentication', # Ypovoli authentication + 'logic' # Ypovoli logic of the base application ] MIDDLEWARE = [ From d8fbbd9c45f63ba976e4bdb8770f04f67812d4cd Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 12:33:12 +0100 Subject: [PATCH 034/397] chore: course model + links with specific user roles --- backend/logic/models/assistant.py | 9 +++++-- backend/logic/models/course.py | 44 +++++++++++++++++++++++++++++++ backend/logic/models/student.py | 8 +++++- backend/logic/models/teacher.py | 8 +++++- 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 backend/logic/models/course.py diff --git a/backend/logic/models/assistant.py b/backend/logic/models/assistant.py index 262d174b..5137444e 100644 --- a/backend/logic/models/assistant.py +++ b/backend/logic/models/assistant.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Assistant(User): @@ -7,5 +8,9 @@ class Assistant(User): assistant-specific attributes. """ - # At the moment, there are no additional attributes for the - # Assistant model. + # All the courses the assistant is assisting in + courses = models.ManyToManyField( + 'Course', + related_name='assistants', # Allows us to access the assistants from the course + blank=True + ) diff --git a/backend/logic/models/course.py b/backend/logic/models/course.py new file mode 100644 index 00000000..2f6a3f1d --- /dev/null +++ b/backend/logic/models/course.py @@ -0,0 +1,44 @@ +from django.db import models + + +class Course(models.Model): + """This model represents a single course. + It contains all the information about a course.""" + + # ID of the course should automatically be generated + + name = models.CharField( + max_length=100, + blank=False, + null=False + ) + + # Begin year of the academic year + academic_startyear = models.IntegerField( + blank=False, + null=False + ) + + description = models.TextField( + blank=True, + null=True + ) + + # OneToOneField is used to represent a one-to-one relationship + # with the course of the previous academic year + parent_course = models.OneToOneField( + 'self', + on_delete=models.CASCADE, + related_name='child_course', # Allows us to access the child course from the parent course + blank=True, + null=True + ) + + def __str__(self) -> str: + """The string representation of the course.""" + return str(self.name) + + @property + def academic_year(self) -> str: + """The academic year of the course.""" + return f"{self.academic_startyear}-{self.academic_startyear + 1}" diff --git a/backend/logic/models/student.py b/backend/logic/models/student.py index 83f85f71..8498b820 100644 --- a/backend/logic/models/student.py +++ b/backend/logic/models/student.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Student(User): @@ -7,4 +8,9 @@ class Student(User): student-specific attributes. """ - # At the moment, there are no additional attributes for the Student model. + # All the courses the student is enrolled in + courses = models.ManyToManyField( + 'Course', + related_name='students', # Allows us to access the students from the course + blank=True + ) diff --git a/backend/logic/models/teacher.py b/backend/logic/models/teacher.py index 338b50d2..75b39307 100644 --- a/backend/logic/models/teacher.py +++ b/backend/logic/models/teacher.py @@ -1,4 +1,5 @@ from authentication.models.user import User +from django.db import models class Teacher(User): @@ -7,4 +8,9 @@ class Teacher(User): teacher-specific attributes. """ - # At the moment, there are no additional attributes for the Teacher model. + # All the courses the teacher is teaching + courses = models.ManyToManyField( + 'Course', + related_name='teachers', # Allows us to access the teachers from the course + blank=True + ) From 2e1d1ee13cda157b0db6e86260dfbb1c657349e7 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 13:49:27 +0100 Subject: [PATCH 035/397] chore: define check model --- backend/logic/models/checks.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/logic/models/checks.py diff --git a/backend/logic/models/checks.py b/backend/logic/models/checks.py new file mode 100644 index 00000000..d1c1908a --- /dev/null +++ b/backend/logic/models/checks.py @@ -0,0 +1,35 @@ +from django.db import models + + +class FileExtension(models.Model): + """Model that represents a file extension.""" + + extension = models.CharField(max_length=10, unique=True) + + def __str__(self) -> str: + return str(self.extension) + + +class Checks(models.Model): + """Model that represents checks for a project.""" + + # ID check should be generated automatically + + dockerfile = models.FileField( + blank=True, + null=True + ) + + # Link to the file extensions that are allowed + allowed_file_extensions = models.ManyToManyField( + FileExtension, + related_name='checks_allowed', + blank=True + ) + + # Link to the file extensions that are forbidden + forbidden_file_extensions = models.ManyToManyField( + FileExtension, + related_name='checks_forbidden', + blank=True + ) From 4e0a85e8155bde9a94ce2b9058def4ffd03318bd Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:02:06 +0100 Subject: [PATCH 036/397] chore: define project model --- backend/logic/models/project.py | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/logic/models/project.py diff --git a/backend/logic/models/project.py b/backend/logic/models/project.py new file mode 100644 index 00000000..be8efb8c --- /dev/null +++ b/backend/logic/models/project.py @@ -0,0 +1,55 @@ +import datetime +from django.db import models + + +class Project(models.Model): + """Model that represents a project.""" + + # ID should be generated automatically + + name = models.CharField( + max_length=100, + blank=False, + null=False + ) + + description = models.TextField( + blank=True, + null=True + ) + + # Project already visible to students + visible = models.BooleanField( + default=True + ) + + # Project archived + archived = models.BooleanField( + default=False + ) + + start_date = models.DateTimeField( + default=datetime.datetime.now, # The default value is the current date and time + blank=True, + ) + + deadline = models.DateTimeField( + blank=False, + null=False + ) + + # Check entity that is linked to the project + checks = models.ForeignKey( + 'Checks', + on_delete=models.SET_NULL, # If the checks are deleted, the project should remain + blank=True, + null=True + ) + + # Course that the project belongs to + course = models.ForeignKey( + 'Course', + on_delete=models.CASCADE, # If the course is deleted, the project should be deleted as well + blank=False, + null=False + ) From 6612c53cbb61e7913d98791a14ef3554463fe10b Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:04:25 +0100 Subject: [PATCH 037/397] chore: change delete cascade courses --- backend/logic/models/course.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/logic/models/course.py b/backend/logic/models/course.py index 2f6a3f1d..f3217f35 100644 --- a/backend/logic/models/course.py +++ b/backend/logic/models/course.py @@ -28,7 +28,7 @@ class Course(models.Model): # with the course of the previous academic year parent_course = models.OneToOneField( 'self', - on_delete=models.CASCADE, + on_delete=models.SET_NULL, # If the old course is deleted, the child course should remain related_name='child_course', # Allows us to access the child course from the parent course blank=True, null=True From 027b914d3c7a2da512e0fff93af83504216fb9b2 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 16:31:38 +0100 Subject: [PATCH 038/397] chore: define group model --- backend/logic/models/group.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/logic/models/group.py diff --git a/backend/logic/models/group.py b/backend/logic/models/group.py new file mode 100644 index 00000000..cbb32c21 --- /dev/null +++ b/backend/logic/models/group.py @@ -0,0 +1,23 @@ +from django.db import models + + +class Group(models.Model): + """Model for group of students that work together on a project.""" + + # ID should be generated automatically + + project = models.ForeignKey( + 'Project', + on_delete=models.CASCADE, # If the project is deleted, the group should be deleted as well + related_name='groups', # This is how we can access groups from a project + blank=False, + null=False + ) + + # Students that are part of the group + students = models.ManyToManyField( + 'Student', + related_name='groups', # This is how we can access groups from a student + blank=False, + null=False + ) From c37f19cd09bb5bf12edb662b84c116f59b597dcd Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 17:39:14 +0100 Subject: [PATCH 039/397] chore: define submission model --- backend/logic/models/submission.py | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/logic/models/submission.py diff --git a/backend/logic/models/submission.py b/backend/logic/models/submission.py new file mode 100644 index 00000000..17289cef --- /dev/null +++ b/backend/logic/models/submission.py @@ -0,0 +1,50 @@ +from django.db import models + + +class SubmissionFile(models.Model): + """Model for a file that is part of a submission.""" + + # File ID should be generated automatically + + submission = models.ForeignKey( + 'Submission', + on_delete=models.CASCADE, # If the submission is deleted, the file should be deleted as well + related_name='files', + blank=False, + null=False + ) + + # TODO - Set the right place to save the file + file = models.FileField( + blank=False, + null=False + ) + + +class Submission(models.Model): + """Model for submission of a project by a group of students.""" + + # Submission ID should be generated automatically + + group = models.ForeignKey( + 'Group', + on_delete=models.CASCADE, # If the group is deleted, the submission should be deleted as well + related_name='submissions', + blank=False, + null=False + ) + + # Multiple submissions can be made by a group + submission_number = models.PositiveIntegerField( + blank=False, + null=False + ) + + # Automatically set the submission time to the current time + submission_time = models.DateTimeField( + auto_now_add=True + ) + + class Meta: + # A group can only have one submission with a specific number + unique_together = ('group', 'submission_number') From 246fa67603866ed1f75dc8c32aad41439bd024aa Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 17:40:11 +0100 Subject: [PATCH 040/397] refactor: score belongs to group --- backend/logic/models/group.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/logic/models/group.py b/backend/logic/models/group.py index cbb32c21..eed8f22c 100644 --- a/backend/logic/models/group.py +++ b/backend/logic/models/group.py @@ -21,3 +21,9 @@ class Group(models.Model): blank=False, null=False ) + + # Score of the group + score = models.FloatField( + blank=True, + null=True + ) From b0b9aebbbb9e23c2ffc532242cbda762aced18f1 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 24 Feb 2024 22:44:34 +0100 Subject: [PATCH 041/397] refactor: rename logic to api --- backend/{logic => api}/__init__.py | 0 backend/{logic => api}/apps.py | 4 ++-- backend/{logic => api}/migrations/__init__.py | 0 backend/{logic => api}/models/__init__.py | 0 backend/{logic => api}/models/admin.py | 0 backend/{logic => api}/models/assistant.py | 0 backend/{logic => api}/models/checks.py | 0 backend/{logic => api}/models/course.py | 0 backend/{logic => api}/models/group.py | 0 backend/{logic => api}/models/project.py | 0 backend/{logic => api}/models/student.py | 0 backend/{logic => api}/models/submission.py | 0 backend/{logic => api}/models/teacher.py | 0 backend/{logic => api}/tests/__init__.py | 0 backend/{logic => api}/urls.py | 2 +- backend/{logic => api}/views/__init__.py | 0 backend/{logic => api}/views/logic_view.py | 0 backend/ypovoli/settings.py | 2 +- backend/ypovoli/urls.py | 2 +- 19 files changed, 5 insertions(+), 5 deletions(-) rename backend/{logic => api}/__init__.py (100%) rename backend/{logic => api}/apps.py (65%) rename backend/{logic => api}/migrations/__init__.py (100%) rename backend/{logic => api}/models/__init__.py (100%) rename backend/{logic => api}/models/admin.py (100%) rename backend/{logic => api}/models/assistant.py (100%) rename backend/{logic => api}/models/checks.py (100%) rename backend/{logic => api}/models/course.py (100%) rename backend/{logic => api}/models/group.py (100%) rename backend/{logic => api}/models/project.py (100%) rename backend/{logic => api}/models/student.py (100%) rename backend/{logic => api}/models/submission.py (100%) rename backend/{logic => api}/models/teacher.py (100%) rename backend/{logic => api}/tests/__init__.py (100%) rename backend/{logic => api}/urls.py (70%) rename backend/{logic => api}/views/__init__.py (100%) rename backend/{logic => api}/views/logic_view.py (100%) diff --git a/backend/logic/__init__.py b/backend/api/__init__.py similarity index 100% rename from backend/logic/__init__.py rename to backend/api/__init__.py diff --git a/backend/logic/apps.py b/backend/api/apps.py similarity index 65% rename from backend/logic/apps.py rename to backend/api/apps.py index 4a6d3eba..66656fd2 100644 --- a/backend/logic/apps.py +++ b/backend/api/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class LogicConfig(AppConfig): +class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'logic' + name = 'api' diff --git a/backend/logic/migrations/__init__.py b/backend/api/migrations/__init__.py similarity index 100% rename from backend/logic/migrations/__init__.py rename to backend/api/migrations/__init__.py diff --git a/backend/logic/models/__init__.py b/backend/api/models/__init__.py similarity index 100% rename from backend/logic/models/__init__.py rename to backend/api/models/__init__.py diff --git a/backend/logic/models/admin.py b/backend/api/models/admin.py similarity index 100% rename from backend/logic/models/admin.py rename to backend/api/models/admin.py diff --git a/backend/logic/models/assistant.py b/backend/api/models/assistant.py similarity index 100% rename from backend/logic/models/assistant.py rename to backend/api/models/assistant.py diff --git a/backend/logic/models/checks.py b/backend/api/models/checks.py similarity index 100% rename from backend/logic/models/checks.py rename to backend/api/models/checks.py diff --git a/backend/logic/models/course.py b/backend/api/models/course.py similarity index 100% rename from backend/logic/models/course.py rename to backend/api/models/course.py diff --git a/backend/logic/models/group.py b/backend/api/models/group.py similarity index 100% rename from backend/logic/models/group.py rename to backend/api/models/group.py diff --git a/backend/logic/models/project.py b/backend/api/models/project.py similarity index 100% rename from backend/logic/models/project.py rename to backend/api/models/project.py diff --git a/backend/logic/models/student.py b/backend/api/models/student.py similarity index 100% rename from backend/logic/models/student.py rename to backend/api/models/student.py diff --git a/backend/logic/models/submission.py b/backend/api/models/submission.py similarity index 100% rename from backend/logic/models/submission.py rename to backend/api/models/submission.py diff --git a/backend/logic/models/teacher.py b/backend/api/models/teacher.py similarity index 100% rename from backend/logic/models/teacher.py rename to backend/api/models/teacher.py diff --git a/backend/logic/tests/__init__.py b/backend/api/tests/__init__.py similarity index 100% rename from backend/logic/tests/__init__.py rename to backend/api/tests/__init__.py diff --git a/backend/logic/urls.py b/backend/api/urls.py similarity index 70% rename from backend/logic/urls.py rename to backend/api/urls.py index 22b4f157..f0efafe3 100644 --- a/backend/logic/urls.py +++ b/backend/api/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from logic.views import logic_view +from api.views import logic_view urlpatterns = [ path('hello', logic_view.hello) diff --git a/backend/logic/views/__init__.py b/backend/api/views/__init__.py similarity index 100% rename from backend/logic/views/__init__.py rename to backend/api/views/__init__.py diff --git a/backend/logic/views/logic_view.py b/backend/api/views/logic_view.py similarity index 100% rename from backend/logic/views/logic_view.py rename to backend/api/views/logic_view.py diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 153078f1..60e1acb3 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -46,7 +46,7 @@ # First party 'authentication', # Ypovoli authentication - 'logic' # Ypovoli logic of the base application + 'api' # Ypovoli logic of the base application ] MIDDLEWARE = [ diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index caca31ee..c39d0e7d 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - path('/', include('logic.urls')), + path('/', include('api.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From a0b21ba89bdebc6b887c0deafcbef0845638c28b Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 25 Feb 2024 16:35:07 +0100 Subject: [PATCH 042/397] fix: import issues after rebase --- backend/api/models/admin.py | 2 +- backend/api/models/assistant.py | 2 +- backend/api/models/student.py | 2 +- backend/api/models/teacher.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/models/admin.py b/backend/api/models/admin.py index 542b4389..f577ae9b 100644 --- a/backend/api/models/admin.py +++ b/backend/api/models/admin.py @@ -1,4 +1,4 @@ -from authentication.models.user import User +from authentication.models import User class Admin(User): diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py index 5137444e..bedfd2e5 100644 --- a/backend/api/models/assistant.py +++ b/backend/api/models/assistant.py @@ -1,4 +1,4 @@ -from authentication.models.user import User +from authentication.models import User from django.db import models diff --git a/backend/api/models/student.py b/backend/api/models/student.py index 8498b820..5b0a9c13 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -1,4 +1,4 @@ -from authentication.models.user import User +from authentication.models import User from django.db import models diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 75b39307..c9f443cd 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -1,4 +1,4 @@ -from authentication.models.user import User +from authentication.models import User from django.db import models From 4305dc27fcb3d907a972f136db4193fe49592aab Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sun, 25 Feb 2024 17:38:18 +0100 Subject: [PATCH 043/397] feat: CAS integration and final user model --- backend/authentication/cas/__init__.py | 0 backend/authentication/cas/client.py | 8 +++ .../authentication/migrations/0001_initial.py | 10 +-- backend/authentication/models.py | 33 +++++++--- backend/authentication/serializers.py | 63 +++++++++++++++++-- backend/authentication/services/__init__.py | 0 backend/authentication/services/users.py | 50 +++++++++++++++ backend/authentication/urls.py | 3 +- backend/authentication/views.py | 31 ++++----- backend/ypovoli/settings.py | 2 +- 10 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 backend/authentication/cas/__init__.py create mode 100644 backend/authentication/cas/client.py create mode 100644 backend/authentication/services/__init__.py create mode 100644 backend/authentication/services/users.py diff --git a/backend/authentication/cas/__init__.py b/backend/authentication/cas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/cas/client.py b/backend/authentication/cas/client.py new file mode 100644 index 00000000..5ac3bc2a --- /dev/null +++ b/backend/authentication/cas/client.py @@ -0,0 +1,8 @@ +from cas_client import CASClient +from ypovoli import settings + +client = CASClient( + server_url=settings.CAS_ENDPOINT, + service_url=settings.API_VALIDATE_ENDPOINT, + auth_prefix='' +) diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index 46f3cb5a..6ea41806 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 14:55 +# Generated by Django 5.0.2 on 2024-02-25 13:40 from django.db import migrations, models @@ -14,14 +14,16 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('create_time', models.DateTimeField(auto_created=True)), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('student_id', models.CharField(max_length=16, null=True, unique=True)), + ('username', models.CharField(max_length=10, unique=True)), ('first_name', models.CharField(max_length=50)), ('last_name', models.CharField(max_length=50)), ('email', models.EmailField(max_length=254, unique=True)), - ('faculty', models.CharField(max_length=50, null=True)), - ('last_enrolled', models.IntegerField(default=1, null=True)), + ('faculty', models.CharField(blank=True, max_length=50, null=True)), + ('last_enrolled', models.CharField(blank=True, max_length=11, null=True)), + ('create_time', models.DateTimeField(auto_now_add=True)), ], options={ 'abstract': False, diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6523c98b..7bbeda3e 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,6 +1,5 @@ -import datetime - -from django.db.models import CharField, EmailField, IntegerField, DateTimeField +from __future__ import annotations +from django.db.models import CharField, EmailField, DateTimeField from django.contrib.auth.models import AbstractBaseUser class User(AbstractBaseUser): @@ -16,6 +15,18 @@ class User(AbstractBaseUser): primary_key=True ) + student_id = CharField( + max_length=16, + unique=True, + null=True + ) + + username = CharField( + max_length=10, + null=False, + unique=True + ) + first_name = CharField( max_length=50, null=False @@ -33,19 +44,21 @@ class User(AbstractBaseUser): faculty = CharField( max_length=50, - null = True + null = True, + blank = True ) - last_enrolled = IntegerField( - default = datetime.MINYEAR, - null = True + last_enrolled = CharField( + max_length=11, + null = True, + blank = True ) create_time = DateTimeField( - auto_created=True + auto_now_add=True ) """Model settings""" - USERNAME_FIELD = "email" + USERNAME_FIELD = "username" EMAIL_FIELD = "email" - REQUIRED_FIELDS = [] + REQUIRED_FIELDS = [] \ No newline at end of file diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 060436d8..40860085 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,12 +1,63 @@ -from rest_framework.serializers import Serializer, CharField +from rest_framework.serializers import Serializer, CharField, EmailField, ModelSerializer, DateTimeField, ValidationError +from authentication.models import User +from authentication.cas.client import client class CASTicketSerializer(Serializer): - """Serializer for CAS ticket validation""" + """Serializer for CAS ticket validation + This serializer takes the CAS ticket and tries to validate it. + Upon successful validation, the user field will contain a UserSerializer. + """ ticket = CharField(required=True, min_length=49, max_length=49) + def validate(self, data: dict) -> dict: + """Validate a ticket using CAS client""" + response = client.perform_service_validate( + ticket=data['ticket'] + ) -class CASUserSerializer(Serializer): - """Serializer for CAS success responses""" + if response.error: + raise ValidationError(response.error) -class UserSerializer(Serializer): - """Serializer for User models""" \ No newline at end of file + attributes = response.data.get('attributes', dict) + + # Convert the CAS attributes to a user object. + user = UserSerializer(data={ + 'id': attributes.get('ugentID'), + 'username': attributes.get('uid'), + 'student_id': attributes.get('ugentStudentID'), + 'faculty': attributes.get('faculty'), + 'email': attributes.get('mail'), + 'first_name': attributes.get('givenname'), + 'last_name': attributes.get('surname'), + 'last_enrolled': attributes.get('lastenrolled') + }) + + if not user.is_valid(): + raise ValidationError(user.errors) + + data['user'] = user.validated_data + + return data + +class UserSerializer(ModelSerializer): + """Serializer for the user model + This serializer validates the user fields for creation and updating. + """ + id = CharField() + username = CharField() + student_id = CharField(allow_null=True) + faculty = CharField(allow_null=True) + email = EmailField() + first_name = CharField() + last_name = CharField() + create_time = DateTimeField(required=False) + last_enrolled = CharField() + + class Meta: + model = User + fields = [ + 'id', 'username', 'student_id', 'email', + 'first_name', 'last_name', + 'faculty', + 'last_enrolled', 'last_login', 'create_time' + ] \ No newline at end of file diff --git a/backend/authentication/services/__init__.py b/backend/authentication/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py new file mode 100644 index 00000000..3e9c5e01 --- /dev/null +++ b/backend/authentication/services/users.py @@ -0,0 +1,50 @@ +from authentication.models import User + +def get_by_id(user_id: str) -> User|None: + """Get a user by its user id""" + return User.objects.filter(id=user_id).first() + +def get_by_username(username: str) -> User: + """Get a user by its username""" + return User.objects.filter(username=username).first() + +def create( + user_id: str, username: str, email: str, + first_name: str, last_name: str, + faculty: str = None, + last_enrolled: str = None, + student_id: str = None +) -> User: + """Create a new user + Note: this does not assign specific user classes. + This should be handled by consumers of this package. + """ + return User.objects.create( + id = user_id, + student_id = student_id, + username = username, + email = email, + first_name = first_name, + last_name = last_name, + faculty = faculty, + last_enrolled = last_enrolled + ) + +def get_or_create( + id: str, username: str, email: str, + first_name: str, last_name: str, + faculty: str = None, + last_enrolled: str = None, + student_id: str = None +) -> User: + """Get a user by ID, or create if it doesn't exist""" + user = get_by_id(id) + + if user is None: + return create( + id, username, email, + first_name, last_name, + faculty, last_enrolled, student_id + ) + + return user \ No newline at end of file diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index b58230f0..7f9e8849 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from authentication.views import login, logout, validate +from authentication.views import login, logout, validate, whoami urlpatterns = [ path('login', login, name='auth.login'), path('validate', validate, name='auth.validate'), + path('whoami', whoami, name='auth.whoami'), path('logout', logout, name='auth.logout') ] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index f5767e7d..ec724200 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,16 +1,15 @@ -from django.shortcuts import redirect, reverse +from django.shortcuts import redirect from rest_framework.decorators import api_view from rest_framework.request import Request from rest_framework.response import Response -from cas_client import CASClient -from authentication.serializers import CASTicketSerializer, CASUserSerializer +from authentication.serializers import CASTicketSerializer, UserSerializer +from authentication.services import users +from authentication.cas.client import client from ypovoli import settings -client = CASClient( - server_url=settings.CAS_ENDPOINT, - service_url=settings.API_VALIDATE_ENDPOINT, - auth_prefix='' -) +@api_view(['GET']) +def whoami(_: Request) -> Response: + pass @api_view(['GET']) def login(_: Request) -> Response: @@ -21,7 +20,6 @@ def login(_: Request) -> Response: client.get_login_url() ) - @api_view(['GET']) def validate(request: Request) -> Response: """Validate a Service Ticket obtained from the CAS endpoint. @@ -29,19 +27,12 @@ def validate(request: Request) -> Response: """ ticket = CASTicketSerializer(data=request.query_params) - if ticket.is_valid(raise_exception=True): - response = client.perform_service_validate( - ticket=ticket.validated_data['ticket'] + if ticket.is_valid(): + user = UserSerializer( + users.get_or_create(**ticket.validated_data['user']) ) - if response.success: - # Fetch or create user, generate token. - cas_user = CASUserSerializer(data=response.data) - return Response(response.data) - else: - return Response({ - 'errors': response.error - }) + return Response(user.data) return Response({ 'errors': ticket.errors diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 60e1acb3..567b4bbb 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -46,7 +46,7 @@ # First party 'authentication', # Ypovoli authentication - 'api' # Ypovoli logic of the base application + 'api' # Ypovoli logic of the base application ] MIDDLEWARE = [ From 85272871a99b5cbb9b74c956d3b0243883618519 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 25 Feb 2024 18:30:25 +0100 Subject: [PATCH 044/397] chore: create init migrations file --- backend/api/migrations/0001_initial.py | 130 +++++++++++++++++++++++++ backend/api/models/__init__.py | 9 ++ backend/api/models/group.py | 1 - 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 backend/api/migrations/0001_initial.py diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 00000000..781576fd --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -0,0 +1,130 @@ +# Generated by Django 5.0.2 on 2024-02-25 17:11 + +import datetime +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Admin', + fields=[ + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + migrations.CreateModel( + name='FileExtension', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extension', models.CharField(max_length=10, unique=True)), + ], + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('academic_startyear', models.IntegerField()), + ('description', models.TextField(blank=True, null=True)), + ('parent_course', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_course', to='api.course')), + ], + ), + migrations.CreateModel( + name='Assistant', + fields=[ + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('courses', models.ManyToManyField(blank=True, related_name='assistants', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + migrations.CreateModel( + name='Checks', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dockerfile', models.FileField(blank=True, null=True, upload_to='')), + ('allowed_file_extensions', models.ManyToManyField(blank=True, related_name='checks_allowed', to='api.fileextension')), + ('forbidden_file_extensions', models.ManyToManyField(blank=True, related_name='checks_forbidden', to='api.fileextension')), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('visible', models.BooleanField(default=True)), + ('archived', models.BooleanField(default=False)), + ('start_date', models.DateTimeField(blank=True, default=datetime.datetime.now)), + ('deadline', models.DateTimeField()), + ('checks', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.checks')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.course')), + ], + ), + migrations.CreateModel( + name='Student', + fields=[ + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('courses', models.ManyToManyField(blank=True, related_name='students', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.FloatField(blank=True, null=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='api.project')), + ('students', models.ManyToManyField(related_name='groups', to='api.student')), + ], + ), + migrations.CreateModel( + name='Submission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submission_number', models.PositiveIntegerField()), + ('submission_time', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='api.group')), + ], + options={ + 'unique_together': {('group', 'submission_number')}, + }, + ), + migrations.CreateModel( + name='SubmissionFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.submission')), + ], + ), + migrations.CreateModel( + name='Teacher', + fields=[ + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('courses', models.ManyToManyField(blank=True, related_name='teachers', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + ] diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index e69de29b..cdea16ef 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -0,0 +1,9 @@ +from .admin import * +from .assistant import * +from .course import * +from .checks import * +from .student import * +from .teacher import * +from .submission import * +from .project import * +from .group import * diff --git a/backend/api/models/group.py b/backend/api/models/group.py index eed8f22c..e4d51380 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -19,7 +19,6 @@ class Group(models.Model): 'Student', related_name='groups', # This is how we can access groups from a student blank=False, - null=False ) # Score of the group From 10da3c6b0f57224af84f91b8d4e291bc0d95f888 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 18:32:42 +0100 Subject: [PATCH 045/397] chore: api endpoints scaffolding --- backend/api/serializers.py | 7 +++++++ backend/api/views/Teacher_view.py | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 backend/api/serializers.py create mode 100644 backend/api/views/Teacher_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py new file mode 100644 index 00000000..3349287c --- /dev/null +++ b/backend/api/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models.teacher import Teacher + +class TeacherSerializer(serializers.ModelSerializer): + class Meta: + model = Teacher + fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] \ No newline at end of file diff --git a/backend/api/views/Teacher_view.py b/backend/api/views/Teacher_view.py new file mode 100644 index 00000000..e93a763c --- /dev/null +++ b/backend/api/views/Teacher_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.teacher import Teacher +from ..serializers import TeacherSerializer + + +class TeacherViewSet(viewsets.ModelViewSet): + queryset = Teacher.objects.all() + serializer_class = TeacherSerializer \ No newline at end of file From 352654774996b39b21fc691c54b170c06a1bb820 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 18:33:06 +0100 Subject: [PATCH 046/397] chore: api endpoints scaffolding --- backend/api/urls.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/api/urls.py b/backend/api/urls.py index f0efafe3..538aae23 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,6 +1,11 @@ -from django.urls import path -from api.views import logic_view +from django.urls import include, path +from api.views import Teacher_view +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +#router.register(r'teachers', Teacher_view) +router.register(r'teachers', Teacher_view, basename='teacher') urlpatterns = [ - path('hello', logic_view.hello) -] + path('api/', include((router.urls, 'api'), namespace='api')), +] \ No newline at end of file From 3b5b6299bfb51fb66172b82e65fc614f2e5b1734 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 25 Feb 2024 18:45:13 +0100 Subject: [PATCH 047/397] fix: import view + api route --- backend/api/serializers.py | 3 ++- backend/api/urls.py | 6 +++--- backend/api/views/Teacher_view.py | 2 +- backend/ypovoli/urls.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 3349287c..78928d4a 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,7 +1,8 @@ from rest_framework import serializers from .models.teacher import Teacher + class TeacherSerializer(serializers.ModelSerializer): class Meta: model = Teacher - fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] \ No newline at end of file + fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 538aae23..b571dd5e 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,8 +4,8 @@ router = DefaultRouter() #router.register(r'teachers', Teacher_view) -router.register(r'teachers', Teacher_view, basename='teacher') +router.register(r'teachers', Teacher_view.TeacherViewSet, basename='teacher') urlpatterns = [ - path('api/', include((router.urls, 'api'), namespace='api')), -] \ No newline at end of file + # path('api/', include((router.urls, 'api'), namespace='api')), +] diff --git a/backend/api/views/Teacher_view.py b/backend/api/views/Teacher_view.py index e93a763c..938a3cc0 100644 --- a/backend/api/views/Teacher_view.py +++ b/backend/api/views/Teacher_view.py @@ -5,4 +5,4 @@ class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() - serializer_class = TeacherSerializer \ No newline at end of file + serializer_class = TeacherSerializer diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index c39d0e7d..2f84c57d 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - path('/', include('api.urls')), + path('api/', include('api.urls')), # Authentication endpoints. path('auth/', include('authentication.urls')) ] From ea9b8875dac7a2e7ec1e0bea4046d721d27c09fd Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 25 Feb 2024 18:47:36 +0100 Subject: [PATCH 048/397] fix: route api update --- backend/api/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/urls.py b/backend/api/urls.py index b571dd5e..bb830be9 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -7,5 +7,5 @@ router.register(r'teachers', Teacher_view.TeacherViewSet, basename='teacher') urlpatterns = [ - # path('api/', include((router.urls, 'api'), namespace='api')), + path('', include(router.urls)), ] From da288c9d1c253fcfd13e75c5aeda5974c5ebe16f Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 19:50:16 +0100 Subject: [PATCH 049/397] chore: add admin endpoint --- backend/api/migrations/populate.py | 56 ++++++++++++++++++++++++++++++ backend/api/serializers.py | 6 ++++ backend/api/urls.py | 6 ++-- backend/api/views/admin_view.py | 8 +++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 backend/api/migrations/populate.py create mode 100644 backend/api/views/admin_view.py diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py new file mode 100644 index 00000000..c5d3f5ad --- /dev/null +++ b/backend/api/migrations/populate.py @@ -0,0 +1,56 @@ +from django.db import migrations, transaction + + +def populate_teacher_student_courses(apps, schema_editor): + with transaction.atomic(): + + Teacher = apps.get_model("api", "Teacher") + Student = apps.get_model("api", "Student") + Course = apps.get_model("api", "Course") + + teacher1 = Teacher.objects.create( + id=123, + first_name="Tom", + last_name="Boonen", + email="Tom.Boonen@gmail.be", + faculty="Science", + create_time="2023-01-01T00:00:00Z", + ) + + teacher2 = Teacher.objects.create( + id=124, + first_name="Peter", + last_name="Sagan", + email="Peter.Sagan@gmail.com", + faculty="Engineering", + create_time="2023-01-01T00:00:00Z", + ) + + student1 = Student.objects.create( + id=1, + first_name="John", + last_name="Doe", + email="John.Doe@hotmail.com", + faculty="Science", + create_time="2023-01-01T00:00:00Z", + ) + + course = Course.objects.create( + name="Math", + academic_startyear=2023, + description="Math course", + ) + + teacher1.courses.add(course) + teacher2.courses.add(course) + student1.courses.add(course) + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0001_initial"), + ] + + operations = [ + migrations.RunPython(populate_teacher_student_courses), + ] \ No newline at end of file diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 78928d4a..88d5e3ca 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,8 +1,14 @@ from rest_framework import serializers from .models.teacher import Teacher +from .models.admin import Admin class TeacherSerializer(serializers.ModelSerializer): class Meta: model = Teacher + fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + +class AdminSerializer(serializers.ModelSerializer): + class Meta: + model = Admin fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] diff --git a/backend/api/urls.py b/backend/api/urls.py index bb830be9..f677d26b 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,10 +1,12 @@ from django.urls import include, path -from api.views import Teacher_view +from api.views import teacher_view +from api.views import admin_view from rest_framework.routers import DefaultRouter router = DefaultRouter() #router.register(r'teachers', Teacher_view) -router.register(r'teachers', Teacher_view.TeacherViewSet, basename='teacher') +router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') +router.register(r'admins', admin_view.AdminViewSet, basename='admin') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py new file mode 100644 index 00000000..56976c98 --- /dev/null +++ b/backend/api/views/admin_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.admin import Admin +from ..serializers import AdminSerializer + + +class AdminViewSet(viewsets.ModelViewSet): + queryset = Admin.objects.all() + serializer_class = AdminSerializer \ No newline at end of file From b32a5d649494da595e054f679a7c5efb97d1c485 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 20:09:58 +0100 Subject: [PATCH 050/397] chore: add assistant endpoint --- backend/api/migrations/populate.py | 22 ++++++++++++++++++++++ backend/api/serializers.py | 7 +++++++ backend/api/urls.py | 2 ++ backend/api/views/assistant_view.py | 8 ++++++++ 4 files changed, 39 insertions(+) create mode 100644 backend/api/views/assistant_view.py diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index c5d3f5ad..0e64c493 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -7,6 +7,7 @@ def populate_teacher_student_courses(apps, schema_editor): Teacher = apps.get_model("api", "Teacher") Student = apps.get_model("api", "Student") Course = apps.get_model("api", "Course") + Assistant = apps.get_model("api","Assistant") teacher1 = Teacher.objects.create( id=123, @@ -17,6 +18,24 @@ def populate_teacher_student_courses(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) + assistant1 = Assistant.objects.create( + id=235, + first_name="Bart", + last_name="Simpson", + email="Bart.Simpson@gmail.be", + faculty="Science", + create_time="2023-01-01T00:00:00Z", + ) + + assistant2 = Assistant.objects.create( + id=236, + first_name="Kim", + last_name="Clijsters", + email="Kim.Clijsters@gmail.be", + faculty="Science", + create_time="2023-01-01T00:00:00Z", + ) + teacher2 = Teacher.objects.create( id=124, first_name="Peter", @@ -44,6 +63,9 @@ def populate_teacher_student_courses(apps, schema_editor): teacher1.courses.add(course) teacher2.courses.add(course) student1.courses.add(course) + #course.assistants.add(assistant1) + #course.assistants.add(assistant2) + class Migration(migrations.Migration): diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 88d5e3ca..ba0b5184 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models.teacher import Teacher from .models.admin import Admin +from .models.assistant import Assistant class TeacherSerializer(serializers.ModelSerializer): @@ -12,3 +13,9 @@ class AdminSerializer(serializers.ModelSerializer): class Meta: model = Admin fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] + + +class AssistantSerializer(serializers.ModelSerializer): + class Meta: + model = Assistant + fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] diff --git a/backend/api/urls.py b/backend/api/urls.py index f677d26b..2c2c0833 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,12 +1,14 @@ from django.urls import include, path from api.views import teacher_view from api.views import admin_view +from api.views import assistant_view from rest_framework.routers import DefaultRouter router = DefaultRouter() #router.register(r'teachers', Teacher_view) router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') router.register(r'admins', admin_view.AdminViewSet, basename='admin') +router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py new file mode 100644 index 00000000..7e37c343 --- /dev/null +++ b/backend/api/views/assistant_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.assistant import Assistant +from ..serializers import AssistantSerializer + + +class AssistantViewSet(viewsets.ModelViewSet): + queryset = Assistant.objects.all() + serializer_class = AssistantSerializer \ No newline at end of file From 07128927d1f25f7c1cba89ca80167084ad05ef70 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 20:18:35 +0100 Subject: [PATCH 051/397] chore: add student endpoint scafolding --- backend/api/serializers.py | 6 ++++++ backend/api/urls.py | 2 ++ backend/api/views/student_view.py | 8 ++++++++ 3 files changed, 16 insertions(+) create mode 100644 backend/api/views/student_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index ba0b5184..bf00e2ce 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -2,6 +2,7 @@ from .models.teacher import Teacher from .models.admin import Admin from .models.assistant import Assistant +from .models.student import Student class TeacherSerializer(serializers.ModelSerializer): @@ -19,3 +20,8 @@ class AssistantSerializer(serializers.ModelSerializer): class Meta: model = Assistant fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + +class StudentSerializer(serializers.ModelSerializer): + class Meta: + model = Student + fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 2c2c0833..204ca32b 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -2,6 +2,7 @@ from api.views import teacher_view from api.views import admin_view from api.views import assistant_view +from api.views import student_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -9,6 +10,7 @@ router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') router.register(r'admins', admin_view.AdminViewSet, basename='admin') router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') +router.register(r'students', student_view.StudentViewSet, basename='student') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py new file mode 100644 index 00000000..345370c8 --- /dev/null +++ b/backend/api/views/student_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.student import Student +from ..serializers import StudentSerializer + + +class StudentViewSet(viewsets.ModelViewSet): + queryset = Student.objects.all() + serializer_class = StudentSerializer \ No newline at end of file From df475a1fd579cc8a62c4d6ab07353492148ff082 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 20:36:42 +0100 Subject: [PATCH 052/397] chore: add project endpoint scafolding --- backend/api/serializers.py | 7 +++++++ backend/api/urls.py | 2 ++ backend/api/views/project_view.py | 8 ++++++++ 3 files changed, 17 insertions(+) create mode 100644 backend/api/views/project_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index bf00e2ce..45a733f0 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -3,6 +3,7 @@ from .models.admin import Admin from .models.assistant import Assistant from .models.student import Student +from .models.project import Project class TeacherSerializer(serializers.ModelSerializer): @@ -25,3 +26,9 @@ class StudentSerializer(serializers.ModelSerializer): class Meta: model = Student fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + + +class ProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 204ca32b..871c2e18 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -3,6 +3,7 @@ from api.views import admin_view from api.views import assistant_view from api.views import student_view +from api.views import project_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -11,6 +12,7 @@ router.register(r'admins', admin_view.AdminViewSet, basename='admin') router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') router.register(r'students', student_view.StudentViewSet, basename='student') +router.register(r'projects', project_view.ProjectViewSet, basename='project') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py new file mode 100644 index 00000000..ff63d266 --- /dev/null +++ b/backend/api/views/project_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.project import Project +from ..serializers import ProjectSerializer + + +class ProjectViewSet(viewsets.ModelViewSet): + queryset = Project.objects.all() + serializer_class = ProjectSerializer \ No newline at end of file From d4815ad8036a2bf56b3711347af151c909b6b45f Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 20:41:35 +0100 Subject: [PATCH 053/397] chore: add group endpoint scafolding --- backend/api/serializers.py | 6 ++++++ backend/api/urls.py | 2 ++ backend/api/views/group_view.py | 8 ++++++++ 3 files changed, 16 insertions(+) create mode 100644 backend/api/views/group_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 45a733f0..b81791cb 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -4,6 +4,7 @@ from .models.assistant import Assistant from .models.student import Student from .models.project import Project +from .models.group import Group class TeacherSerializer(serializers.ModelSerializer): @@ -32,3 +33,8 @@ class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'project', 'students','score'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 871c2e18..3080ec96 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,6 +4,7 @@ from api.views import assistant_view from api.views import student_view from api.views import project_view +from api.views import group_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -13,6 +14,7 @@ router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') router.register(r'students', student_view.StudentViewSet, basename='student') router.register(r'projects', project_view.ProjectViewSet, basename='project') +router.register(r'groups', group_view.GroupSerializer, basename='group') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py new file mode 100644 index 00000000..7ca2777f --- /dev/null +++ b/backend/api/views/group_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.group import Group +from ..serializers import GroupSerializer + + +class GroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer \ No newline at end of file From 080ff77d333adecb468b5de746b495e8021b7d4f Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sun, 25 Feb 2024 21:21:28 +0100 Subject: [PATCH 054/397] feat: final authentication service with CAS + JWT --- backend/authentication/cas/client.py | 2 +- backend/authentication/serializers.py | 43 ++++++++--------- backend/authentication/services/users.py | 10 ++-- backend/authentication/urls.py | 16 +++++-- backend/authentication/views.py | 60 +++++++++--------------- backend/requirements.txt | 1 - backend/ypovoli/settings.py | 14 +++++- 7 files changed, 74 insertions(+), 72 deletions(-) diff --git a/backend/authentication/cas/client.py b/backend/authentication/cas/client.py index 5ac3bc2a..e9133f75 100644 --- a/backend/authentication/cas/client.py +++ b/backend/authentication/cas/client.py @@ -3,6 +3,6 @@ client = CASClient( server_url=settings.CAS_ENDPOINT, - service_url=settings.API_VALIDATE_ENDPOINT, + service_url=settings.CAS_RESPONSE, auth_prefix='' ) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 40860085..874beb3c 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,15 +1,18 @@ -from rest_framework.serializers import Serializer, CharField, EmailField, ModelSerializer, DateTimeField, ValidationError +from rest_framework.serializers import CharField, EmailField, ModelSerializer, DateTimeField, ValidationError, Serializer +from rest_framework_simplejwt.tokens import RefreshToken, AccessToken +from authentication.services import users from authentication.models import User from authentication.cas.client import client -class CASTicketSerializer(Serializer): +class CASTokenObtainSerializer(Serializer): """Serializer for CAS ticket validation This serializer takes the CAS ticket and tries to validate it. - Upon successful validation, the user field will contain a UserSerializer. + Upon successful validation, create a new user if it doesn't exist. """ + token = RefreshToken ticket = CharField(required=True, min_length=49, max_length=49) - def validate(self, data: dict) -> dict: + def validate(self, data): """Validate a ticket using CAS client""" response = client.perform_service_validate( ticket=data['ticket'] @@ -18,26 +21,24 @@ def validate(self, data: dict) -> dict: if response.error: raise ValidationError(response.error) + # Validation success: create user if it doesn't exist yet. attributes = response.data.get('attributes', dict) - # Convert the CAS attributes to a user object. - user = UserSerializer(data={ - 'id': attributes.get('ugentID'), - 'username': attributes.get('uid'), - 'student_id': attributes.get('ugentStudentID'), - 'faculty': attributes.get('faculty'), - 'email': attributes.get('mail'), - 'first_name': attributes.get('givenname'), - 'last_name': attributes.get('surname'), - 'last_enrolled': attributes.get('lastenrolled') - }) - - if not user.is_valid(): - raise ValidationError(user.errors) - - data['user'] = user.validated_data + user = users.get_or_create( + user_id = attributes.get('ugentID'), + student_id = attributes.get('ugentStudentID'), + username = attributes.get('uid'), + email = attributes.get('mail'), + first_name = attributes.get('givenname'), + last_name = attributes.get('surname'), + faculty = attributes.get('faculty'), + last_enrolled = attributes.get('lastenrolled') + ) - return data + return { + 'access': str(AccessToken.for_user(user)), + 'refresh': str(RefreshToken.for_user(user)) + } class UserSerializer(ModelSerializer): """Serializer for the user model diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py index 3e9c5e01..0b621fc5 100644 --- a/backend/authentication/services/users.py +++ b/backend/authentication/services/users.py @@ -1,5 +1,9 @@ from authentication.models import User +def exists(user_id: str) -> bool: + """Check if a user exists""" + return User.objects.filter(id = user_id).exists() + def get_by_id(user_id: str) -> User|None: """Get a user by its user id""" return User.objects.filter(id=user_id).first() @@ -31,18 +35,18 @@ def create( ) def get_or_create( - id: str, username: str, email: str, + user_id: str, username: str, email: str, first_name: str, last_name: str, faculty: str = None, last_enrolled: str = None, student_id: str = None ) -> User: """Get a user by ID, or create if it doesn't exist""" - user = get_by_id(id) + user = get_by_id(user_id) if user is None: return create( - id, username, email, + user_id, username, email, first_name, last_name, faculty, last_enrolled, student_id ) diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 7f9e8849..9e582ecd 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,9 +1,15 @@ from django.urls import path -from authentication.views import login, logout, validate, whoami +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView +from authentication.views import WhoAmIView, LoginView, LogoutView, TokenEchoView urlpatterns = [ - path('login', login, name='auth.login'), - path('validate', validate, name='auth.validate'), - path('whoami', whoami, name='auth.whoami'), - path('logout', logout, name='auth.logout') + # CAS endpoints. + path('login', LoginView.as_view(), name='auth.login'), + path('logout', LogoutView.as_view(), name='auth.logout'), + path('whoami', WhoAmIView.as_view(), name='auth.whoami'), + path('echo', TokenEchoView.as_view(), name='auth.echo'), + # TOKEN endpoints. + path('token', TokenObtainPairView.as_view(), name='auth.token'), + path('token/refresh', TokenRefreshView.as_view(), name='auth.token.refresh'), + path('token/verify', TokenVerifyView.as_view(), name='auth.token.verify') ] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index ec724200..df61466b 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,50 +1,32 @@ from django.shortcuts import redirect -from rest_framework.decorators import api_view -from rest_framework.request import Request +from rest_framework.views import APIView from rest_framework.response import Response -from authentication.serializers import CASTicketSerializer, UserSerializer -from authentication.services import users +from rest_framework.request import Request +from rest_framework.permissions import IsAuthenticated +from authentication.serializers import UserSerializer from authentication.cas.client import client from ypovoli import settings -@api_view(['GET']) -def whoami(_: Request) -> Response: - pass -@api_view(['GET']) -def login(_: Request) -> Response: - """Attempt to log in. - Redirect to our single CAS endpoint. - """ - return redirect( - client.get_login_url() - ) +class WhoAmIView(APIView): + permission_classes = [IsAuthenticated] -@api_view(['GET']) -def validate(request: Request) -> Response: - """Validate a Service Ticket obtained from the CAS endpoint. - Returns a user token for further API authentication. - """ - ticket = CASTicketSerializer(data=request.query_params) + def get(self, request: Request) -> Response: + """Get the user account data for the current user""" + return Response(UserSerializer(request.user).data) - if ticket.is_valid(): - user = UserSerializer( - users.get_or_create(**ticket.validated_data['user']) - ) +class LogoutView(APIView): + permission_classes = [IsAuthenticated] - return Response(user.data) + def post(self, request: Request) -> Response: + """Attempt to log out. Redirect to our single CAS endpoint.""" + return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) - return Response({ - 'errors': ticket.errors - }) +class LoginView(APIView): + def get(self, request: Request): + """Attempt to log in. Redirect to our single CAS endpoint.""" + return redirect(client.get_login_url()) -@api_view(['POST']) -def logout(request: Request) -> Response: - """Attempt to log out. - Redirect to our single CAS endpoint. - """ - return redirect( - client.get_logout_url( - service_url=settings.API_ENDPOINT - ) - ) +class TokenEchoView(APIView): + def get(self, request: Request) -> Response: + return Response(request.query_params.get('ticket')) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index ec3c90df..0304b10f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,6 @@ Django==5.0.2 django-sslserver==0.22 djangorestframework==3.14.0 django-rest-swagger==2.2.0 -django-rest-knox==4.2.0 drf-yasg==1.21.7 requests==2.31.0 cas-client==1.0.0 \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 567b4bbb..3dbfacb4 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -40,7 +40,6 @@ # Third party 'rest_framework_swagger', # Swagger 'rest_framework', # Django rest framework - 'knox', # Token based authentication 'drf_yasg', # Yet Another Swagger generator 'sslserver', # Used for local SSL support (needed by CAS) @@ -62,9 +61,20 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': [ 'rest_framework.renderers.JSONRenderer', + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication' ] } +SIMPLE_JWT = { + # Token settings + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + # Serializers + 'TOKEN_OBTAIN_SERIALIZER': 'authentication.serializers.CASTokenObtainSerializer' +} + AUTH_USER_MODEL = 'authentication.User' ROOT_URLCONF = 'ypovoli.urls' @@ -74,8 +84,8 @@ # Application endpoints CAS_ENDPOINT = 'https://login.ugent.be' +CAS_RESPONSE = 'https://localhost:8080/auth/echo' API_ENDPOINT = 'https://localhost:8080' -API_VALIDATE_ENDPOINT = 'https://localhost:8080/auth/validate' # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases From 16f4e4e054f0aab1077f0804102c9deed1083314 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 22:00:05 +0100 Subject: [PATCH 055/397] chore: add course endpoint scafolding and fix group endpoint scafolding --- backend/api/serializers.py | 6 ++++++ backend/api/urls.py | 3 +-- backend/api/views/course_view.py | 8 ++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 backend/api/views/course_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index b81791cb..9591dac3 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -5,6 +5,7 @@ from .models.student import Student from .models.project import Project from .models.group import Group +from .models.course import Course class TeacherSerializer(serializers.ModelSerializer): @@ -34,6 +35,11 @@ class Meta: model = Project fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] +class CourseSerializer(serializers.ModelSerializer): + class Meta: + model = Course + fields = ['id', 'name', 'academic_startyear','description','parent_course'] + class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group diff --git a/backend/api/urls.py b/backend/api/urls.py index 3080ec96..80502734 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -8,13 +8,12 @@ from rest_framework.routers import DefaultRouter router = DefaultRouter() -#router.register(r'teachers', Teacher_view) router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') router.register(r'admins', admin_view.AdminViewSet, basename='admin') router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') router.register(r'students', student_view.StudentViewSet, basename='student') router.register(r'projects', project_view.ProjectViewSet, basename='project') -router.register(r'groups', group_view.GroupSerializer, basename='group') +router.register(r'groups', group_view.GroupViewSet, basename='group') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py new file mode 100644 index 00000000..6bcf73d2 --- /dev/null +++ b/backend/api/views/course_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from ..models.course import Course +from ..serializers import CourseSerializer + + +class CourseViewSet(viewsets.ModelViewSet): + queryset = Course.objects.all() + serializer_class = CourseSerializer \ No newline at end of file From d67128c0270ad08ce58354311e9f10e5fc93de1c Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 22:29:37 +0100 Subject: [PATCH 056/397] chore: add submison endpoint scafolding --- backend/api/serializers.py | 11 +++++++++++ backend/api/urls.py | 3 +++ backend/api/views/submision_view.py | 12 ++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 backend/api/views/submision_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 9591dac3..a57dd987 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -6,6 +6,7 @@ from .models.project import Project from .models.group import Group from .models.course import Course +from .models.submission import Submission, SubmissionFile class TeacherSerializer(serializers.ModelSerializer): @@ -44,3 +45,13 @@ class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ['id', 'project', 'students','score'] + +class SubmissionFileSerializer(serializers.ModelSerializer): + class Meta: + model = SubmissionFile + fields = ['id', 'submission', 'file'] + +class SubmissionSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = ['id', 'group', 'submission_number','submission_time'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 80502734..4c26a635 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -5,6 +5,7 @@ from api.views import student_view from api.views import project_view from api.views import group_view +from api.views import submision_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -14,6 +15,8 @@ router.register(r'students', student_view.StudentViewSet, basename='student') router.register(r'projects', project_view.ProjectViewSet, basename='project') router.register(r'groups', group_view.GroupViewSet, basename='group') +router.register(r'submissions', submision_view.SubmissionViewSet, basename='submission') +router.register(r'submission_file', submision_view.SubmissionFileViewSet, basename='submission_file') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py new file mode 100644 index 00000000..0842c1ba --- /dev/null +++ b/backend/api/views/submision_view.py @@ -0,0 +1,12 @@ +from rest_framework import viewsets +from ..models.submission import Submission,SubmissionFile +from ..serializers import SubmissionSerializer, SubmissionFileSerializer + + +class SubmissionFileViewSet(viewsets.ModelViewSet): + queryset = SubmissionFile.objects.all() + serializer_class = SubmissionFileSerializer + +class SubmissionViewSet(viewsets.ModelViewSet): + queryset = Submission.objects.all() + serializer_class = SubmissionSerializer \ No newline at end of file From ce61e599d3c01da4bf2f03a397fa0aeaccaaf6dc Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 25 Feb 2024 22:39:37 +0100 Subject: [PATCH 057/397] chore: add scafolding checks api endpoint --- backend/api/models/checks.py | 2 ++ backend/api/serializers.py | 11 +++++++++++ backend/api/urls.py | 4 +++- backend/api/views/checks_view.py | 12 ++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 backend/api/views/checks_view.py diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index d1c1908a..db11e6e9 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -4,6 +4,8 @@ class FileExtension(models.Model): """Model that represents a file extension.""" + # ID check should be generated automatically + extension = models.CharField(max_length=10, unique=True) def __str__(self) -> str: diff --git a/backend/api/serializers.py b/backend/api/serializers.py index a57dd987..559fe7f6 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -7,6 +7,7 @@ from .models.group import Group from .models.course import Course from .models.submission import Submission, SubmissionFile +from .models.checks import Checks, FileExtension class TeacherSerializer(serializers.ModelSerializer): @@ -55,3 +56,13 @@ class SubmissionSerializer(serializers.ModelSerializer): class Meta: model = Submission fields = ['id', 'group', 'submission_number','submission_time'] + +class ChecksSerializer(serializers.ModelSerializer): + class Meta: + model = Checks + fields = ['id', 'dockerfile'] + +class FileExtensionSerializer(serializers.ModelSerializer): + class Meta: + model = FileExtension + fields = ['id', 'extension', 'allowed_file_extensions', 'forbidden_file_extensions'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 4c26a635..76930aea 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -6,6 +6,7 @@ from api.views import project_view from api.views import group_view from api.views import submision_view +from api.views import checks_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -16,7 +17,8 @@ router.register(r'projects', project_view.ProjectViewSet, basename='project') router.register(r'groups', group_view.GroupViewSet, basename='group') router.register(r'submissions', submision_view.SubmissionViewSet, basename='submission') -router.register(r'submission_file', submision_view.SubmissionFileViewSet, basename='submission_file') +router.register(r'checks', checks_view.ChecksViewSet, basename='check') +router.register(r'fileExtensions', checks_view.FileExtensionViewSet, basename='fileExtension') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py new file mode 100644 index 00000000..306b29c4 --- /dev/null +++ b/backend/api/views/checks_view.py @@ -0,0 +1,12 @@ +from rest_framework import viewsets +from ..models.checks import Checks,FileExtension +from ..serializers import ChecksSerializer,FileExtensionSerializer + + +class ChecksViewSet(viewsets.ModelViewSet): + queryset = Checks.objects.all() + serializer_class = ChecksSerializer + +class FileExtensionViewSet(viewsets.ModelViewSet): + queryset = FileExtension.objects.all() + serializer_class = FileExtensionSerializer \ No newline at end of file From 4ee0c61e1b888488250c8e994310cf4b93d1401c Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 26 Feb 2024 09:59:45 +0100 Subject: [PATCH 058/397] chore: fill populate.py --- backend/api/migrations/populate.py | 31 ++++++++++++++++++++++++++---- backend/api/serializers.py | 20 +++++++++++++++++++ backend/api/urls.py | 2 ++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index 0e64c493..a6dac4d1 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -1,4 +1,5 @@ from django.db import migrations, transaction +from datetime import date def populate_teacher_student_courses(apps, schema_editor): @@ -8,6 +9,7 @@ def populate_teacher_student_courses(apps, schema_editor): Student = apps.get_model("api", "Student") Course = apps.get_model("api", "Course") Assistant = apps.get_model("api","Assistant") + Project = apps.get_model("api","Project") teacher1 = Teacher.objects.create( id=123, @@ -18,7 +20,7 @@ def populate_teacher_student_courses(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant1 = Assistant.objects.create( + assistant1 = Assistant.objects.create( #dont work id=235, first_name="Bart", last_name="Simpson", @@ -27,7 +29,7 @@ def populate_teacher_student_courses(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant2 = Assistant.objects.create( + assistant2 = Assistant.objects.create( #dont work id=236, first_name="Kim", last_name="Clijsters", @@ -60,11 +62,32 @@ def populate_teacher_student_courses(apps, schema_editor): description="Math course", ) + course2 = Course.objects.create( + name="Sel2", + academic_startyear=2023, + description="Software course", + ) + + project = Project.objects.create( #dont work + id=123456, + name="sel2", + description="make a project", + visible=True, + archived=False, + start_date=date(2024, 2, 26), # Set the start date as 26th February 2024 + deadline=date(2024, 2, 27) # Set the deadline as 27th February 2024 + ) + teacher1.courses.add(course) teacher2.courses.add(course) student1.courses.add(course) - #course.assistants.add(assistant1) - #course.assistants.add(assistant2) + + teacher1.courses.add(course2) + teacher2.courses.add(course2) + student1.courses.add(course2) + course.assistants.add(assistant1) + course.assistants.add(assistant2) + project.course = course2 #dont work diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 559fe7f6..45c733c3 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -11,6 +11,11 @@ class TeacherSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) class Meta: model = Teacher fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] @@ -22,17 +27,32 @@ class Meta: class AssistantSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) class Meta: model = Assistant fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] class StudentSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) class Meta: model = Student fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] class ProjectSerializer(serializers.ModelSerializer): + course = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='course-detail' + ) class Meta: model = Project fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 76930aea..f192256e 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -5,6 +5,7 @@ from api.views import student_view from api.views import project_view from api.views import group_view +from api.views import course_view from api.views import submision_view from api.views import checks_view from rest_framework.routers import DefaultRouter @@ -16,6 +17,7 @@ router.register(r'students', student_view.StudentViewSet, basename='student') router.register(r'projects', project_view.ProjectViewSet, basename='project') router.register(r'groups', group_view.GroupViewSet, basename='group') +router.register(r'courses', course_view.CourseViewSet, basename='course') router.register(r'submissions', submision_view.SubmissionViewSet, basename='submission') router.register(r'checks', checks_view.ChecksViewSet, basename='check') router.register(r'fileExtensions', checks_view.FileExtensionViewSet, basename='fileExtension') From e673bb08e2be15ccf9a748651c8e263d72a254a1 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Mon, 26 Feb 2024 10:15:25 +0100 Subject: [PATCH 059/397] chore: update filling db --- backend/api/migrations/populate.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index a6dac4d1..6017872a 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -2,7 +2,7 @@ from datetime import date -def populate_teacher_student_courses(apps, schema_editor): +def populate_db(apps, schema_editor): with transaction.atomic(): Teacher = apps.get_model("api", "Teacher") @@ -20,7 +20,7 @@ def populate_teacher_student_courses(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant1 = Assistant.objects.create( #dont work + assistant1 = Assistant.objects.create( id=235, first_name="Bart", last_name="Simpson", @@ -29,7 +29,7 @@ def populate_teacher_student_courses(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant2 = Assistant.objects.create( #dont work + assistant2 = Assistant.objects.create( id=236, first_name="Kim", last_name="Clijsters", @@ -68,27 +68,23 @@ def populate_teacher_student_courses(apps, schema_editor): description="Software course", ) - project = Project.objects.create( #dont work + project = Project.objects.create( id=123456, name="sel2", description="make a project", visible=True, archived=False, start_date=date(2024, 2, 26), # Set the start date as 26th February 2024 - deadline=date(2024, 2, 27) # Set the deadline as 27th February 2024 + deadline=date(2024, 2, 27), # Set the deadline as 27th February 2024 + course=course2 ) teacher1.courses.add(course) teacher2.courses.add(course) student1.courses.add(course) - teacher1.courses.add(course2) - teacher2.courses.add(course2) - student1.courses.add(course2) course.assistants.add(assistant1) - course.assistants.add(assistant2) - project.course = course2 #dont work - + course2.assistants.add(assistant2) class Migration(migrations.Migration): @@ -97,5 +93,5 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(populate_teacher_student_courses), - ] \ No newline at end of file + migrations.RunPython(populate_db), + ] From 3e62270ede55f96360f3e2e85db8a8a18dbfe6b8 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 26 Feb 2024 11:26:14 +0100 Subject: [PATCH 060/397] chore: make courses display all the students, teachers and assistants --- backend/api/migrations/populate.py | 1 + backend/api/serializers.py | 19 ++++++++++++++++++- backend/api/urls.py | 3 +++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index 6017872a..295ab9f2 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -82,6 +82,7 @@ def populate_db(apps, schema_editor): teacher1.courses.add(course) teacher2.courses.add(course) student1.courses.add(course) + teacher2.courses.add(course2) course.assistants.add(assistant1) course2.assistants.add(assistant2) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 45c733c3..7f7dceb1 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -58,9 +58,26 @@ class Meta: fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] class CourseSerializer(serializers.ModelSerializer): + assistants = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='assistant-detail' + ) + + teachers = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='teacher-detail' + ) + + students = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='student-detail' + ) class Meta: model = Course - fields = ['id', 'name', 'academic_startyear','description','parent_course'] + fields = ['id', 'name', 'academic_startyear','description','parent_course', 'teachers', 'assistants', 'students'] class GroupSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index f192256e..156c5400 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,6 +9,7 @@ from api.views import submision_view from api.views import checks_view from rest_framework.routers import DefaultRouter +#from rest_framework.urlpatterns import format_suffix_patterns router = DefaultRouter() router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') @@ -25,3 +26,5 @@ urlpatterns = [ path('', include(router.urls)), ] + +#urlpatterns = format_suffix_patterns(urlpatterns) From 8de2a421050886c7f053efddce28d55ed225f1d1 Mon Sep 17 00:00:00 2001 From: Ewoutv Date: Mon, 26 Feb 2024 16:28:55 +0100 Subject: [PATCH 061/397] chore: added extra needed requirements --- backend/requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0304b10f..f0eec8cc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,6 @@ djangorestframework==3.14.0 django-rest-swagger==2.2.0 drf-yasg==1.21.7 requests==2.31.0 -cas-client==1.0.0 \ No newline at end of file +cas-client==1.0.0 +psycopg2-binary==2.9.9 +djangorestframework-simplejwt==5.3.1 \ No newline at end of file From c8ed5ffb6333f61e159462fa2a319c67da9162c8 Mon Sep 17 00:00:00 2001 From: Ewoutv Date: Mon, 26 Feb 2024 17:00:28 +0100 Subject: [PATCH 062/397] refactor: removed unecessary fields in UserSerializer --- backend/authentication/serializers.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 874beb3c..b88ac929 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -44,16 +44,6 @@ class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ - id = CharField() - username = CharField() - student_id = CharField(allow_null=True) - faculty = CharField(allow_null=True) - email = EmailField() - first_name = CharField() - last_name = CharField() - create_time = DateTimeField(required=False) - last_enrolled = CharField() - class Meta: model = User fields = [ From d529f5e1a6a30d223fbb702553c331d013a9b9ae Mon Sep 17 00:00:00 2001 From: Ewoutv Date: Mon, 26 Feb 2024 18:33:48 +0100 Subject: [PATCH 063/397] refactor: ModelSerializer.create instead of custom user service method --- backend/authentication/models.py | 6 ---- backend/authentication/serializers.py | 45 ++++++++++++++++++--------- backend/knox.settings | 0 backend/ypovoli/settings.py | 2 +- 4 files changed, 32 insertions(+), 21 deletions(-) delete mode 100644 backend/knox.settings diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7bbeda3e..ae0b9ddf 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -15,12 +15,6 @@ class User(AbstractBaseUser): primary_key=True ) - student_id = CharField( - max_length=16, - unique=True, - null=True - ) - username = CharField( max_length=10, null=False, diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index b88ac929..422f119b 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,6 +1,5 @@ -from rest_framework.serializers import CharField, EmailField, ModelSerializer, DateTimeField, ValidationError, Serializer +from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer from rest_framework_simplejwt.tokens import RefreshToken, AccessToken -from authentication.services import users from authentication.models import User from authentication.cas.client import client @@ -24,16 +23,20 @@ def validate(self, data): # Validation success: create user if it doesn't exist yet. attributes = response.data.get('attributes', dict) - user = users.get_or_create( - user_id = attributes.get('ugentID'), - student_id = attributes.get('ugentStudentID'), - username = attributes.get('uid'), - email = attributes.get('mail'), - first_name = attributes.get('givenname'), - last_name = attributes.get('surname'), - faculty = attributes.get('faculty'), - last_enrolled = attributes.get('lastenrolled') - ) + user = UserSerializer(data={ + 'id': attributes.get('ugentID'), + 'username': attributes.get('uid'), + 'email': attributes.get('mail'), + 'first_name': attributes.get('givenname'), + 'last_name': attributes.get('surname'), + 'faculty': attributes.get('faculty'), + 'last_enrolled': attributes.get('lastenrolled') + }) + + if not user.is_valid(): + raise ValidationError(user.errors) + + user = user.create(user.validated_data) return { 'access': str(AccessToken.for_user(user)), @@ -44,11 +47,25 @@ class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ + id = CharField() + username = CharField() + email = EmailField() + class Meta: model = User fields = [ - 'id', 'username', 'student_id', 'email', + 'id', 'username', 'email', 'first_name', 'last_name', 'faculty', 'last_enrolled', 'last_login', 'create_time' - ] \ No newline at end of file + ] + + def create(self, validated_data: dict) -> User: + """Create or fetch the user based on the validated data.""" + user, created = User.objects.get_or_create(**validated_data) + + if created: + # Todo: send USER_CREATED signal + pass + + return user \ No newline at end of file diff --git a/backend/knox.settings b/backend/knox.settings deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 3dbfacb4..dcdcf3e7 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -69,7 +69,7 @@ SIMPLE_JWT = { # Token settings - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'ACCESS_TOKEN_LIFETIME': timedelta(days=365), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), # Serializers 'TOKEN_OBTAIN_SERIALIZER': 'authentication.serializers.CASTokenObtainSerializer' From 458838c77d489a442ada7837a6ed1535551541bb Mon Sep 17 00:00:00 2001 From: Ewoutv Date: Mon, 26 Feb 2024 18:48:30 +0100 Subject: [PATCH 064/397] feat: user created signal --- backend/api/apps.py | 11 +++++++++++ backend/authentication/serializers.py | 9 ++++++--- backend/authentication/signals.py | 3 +++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 backend/authentication/signals.py diff --git a/backend/api/apps.py b/backend/api/apps.py index 66656fd2..21c41595 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -1,6 +1,17 @@ from django.apps import AppConfig +from authentication.signals import user_created_signal +def receiver(sender, **kwargs): + # todo: handle the user created event + # todo: move this function to a more appropriate module + pass class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + user_created_signal.connect( + receiver=receiver, + dispatch_uid='user_created_signal' + ) \ No newline at end of file diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 422f119b..80acf443 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,6 @@ from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer from rest_framework_simplejwt.tokens import RefreshToken, AccessToken +from authentication.signals import user_created_signal from authentication.models import User from authentication.cas.client import client @@ -64,8 +65,10 @@ def create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" user, created = User.objects.get_or_create(**validated_data) - if created: - # Todo: send USER_CREATED signal - pass + if True: + user_created_signal.send( + sender=User, + attributes=validated_data + ) return user \ No newline at end of file diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py new file mode 100644 index 00000000..c1f47888 --- /dev/null +++ b/backend/authentication/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +user_created_signal = Signal() \ No newline at end of file From 2ddfc94ffdf8a83e1b8d02045c31fb7520e6abdd Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 26 Feb 2024 13:54:39 +0100 Subject: [PATCH 065/397] chore: removed testing branch --- backend/authentication/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 80acf443..2a507cd5 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -65,7 +65,7 @@ def create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" user, created = User.objects.get_or_create(**validated_data) - if True: + if created: user_created_signal.send( sender=User, attributes=validated_data From 61900c98104155c7370a4fc3cb6279ff3f407fc3 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 26 Feb 2024 14:54:51 +0100 Subject: [PATCH 066/397] feat: update last_login of users upon token generation --- backend/authentication/serializers.py | 7 ++++++- backend/ypovoli/settings.py | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 2a507cd5..a841f8dd 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,7 @@ +from django.contrib.auth.models import update_last_login from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer from rest_framework_simplejwt.tokens import RefreshToken, AccessToken +from rest_framework_simplejwt.settings import api_settings from authentication.signals import user_created_signal from authentication.models import User from authentication.cas.client import client @@ -39,6 +41,9 @@ def validate(self, data): user = user.create(user.validated_data) + if api_settings.UPDATE_LAST_LOGIN: + update_last_login(self, user) + return { 'access': str(AccessToken.for_user(user)), 'refresh': str(RefreshToken.for_user(user)) @@ -67,7 +72,7 @@ def create(self, validated_data: dict) -> User: if created: user_created_signal.send( - sender=User, + sender=self, attributes=validated_data ) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index dcdcf3e7..72a1fdbb 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -68,10 +68,9 @@ } SIMPLE_JWT = { - # Token settings 'ACCESS_TOKEN_LIFETIME': timedelta(days=365), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - # Serializers + 'UPDATE_LAST_LOGIN': True, 'TOKEN_OBTAIN_SERIALIZER': 'authentication.serializers.CASTokenObtainSerializer' } From e18f8a32ca15932ef5a04e7dabe6d0b3abe69986 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 26 Feb 2024 20:30:12 +0100 Subject: [PATCH 067/397] fix: fix the flake warnings --- backend/api/migrations/populate.py | 14 ++++--- backend/api/models/assistant.py | 3 +- backend/api/models/course.py | 6 ++- backend/api/models/group.py | 9 ++-- backend/api/models/project.py | 9 ++-- backend/api/models/student.py | 3 +- backend/api/models/submission.py | 6 ++- backend/api/models/teacher.py | 3 +- backend/api/serializers.py | 64 ++++++++++++++++++++++------- backend/api/urls.py | 55 ++++++++++++++++++------- backend/api/views/admin_view.py | 2 +- backend/api/views/assistant_view.py | 2 +- backend/api/views/checks_view.py | 7 ++-- backend/api/views/course_view.py | 2 +- backend/api/views/group_view.py | 2 +- backend/api/views/project_view.py | 2 +- backend/api/views/student_view.py | 2 +- backend/api/views/submision_view.py | 5 ++- 18 files changed, 137 insertions(+), 59 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index 295ab9f2..b28cdb3a 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -8,8 +8,8 @@ def populate_db(apps, schema_editor): Teacher = apps.get_model("api", "Teacher") Student = apps.get_model("api", "Student") Course = apps.get_model("api", "Course") - Assistant = apps.get_model("api","Assistant") - Project = apps.get_model("api","Project") + Assistant = apps.get_model("api", "Assistant") + Project = apps.get_model("api", "Project") teacher1 = Teacher.objects.create( id=123, @@ -55,7 +55,7 @@ def populate_db(apps, schema_editor): faculty="Science", create_time="2023-01-01T00:00:00Z", ) - + course = Course.objects.create( name="Math", academic_startyear=2023, @@ -68,14 +68,16 @@ def populate_db(apps, schema_editor): description="Software course", ) - project = Project.objects.create( + Project.objects.create( id=123456, name="sel2", description="make a project", visible=True, archived=False, - start_date=date(2024, 2, 26), # Set the start date as 26th February 2024 - deadline=date(2024, 2, 27), # Set the deadline as 27th February 2024 + # Set the start date as 26th February 2024 + start_date=date(2024, 2, 26), + # Set the deadline as 27th February 2024 + deadline=date(2024, 2, 27), course=course2 ) diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py index bedfd2e5..3489a776 100644 --- a/backend/api/models/assistant.py +++ b/backend/api/models/assistant.py @@ -11,6 +11,7 @@ class Assistant(User): # All the courses the assistant is assisting in courses = models.ManyToManyField( 'Course', - related_name='assistants', # Allows us to access the assistants from the course + # Allows us to access the assistants from the course + related_name='assistants', blank=True ) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index f3217f35..f0804caf 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -28,8 +28,10 @@ class Course(models.Model): # with the course of the previous academic year parent_course = models.OneToOneField( 'self', - on_delete=models.SET_NULL, # If the old course is deleted, the child course should remain - related_name='child_course', # Allows us to access the child course from the parent course + # If the old course is deleted, the child course should remain + on_delete=models.SET_NULL, + # Allows us to access the child course from the parent course + related_name='child_course', blank=True, null=True ) diff --git a/backend/api/models/group.py b/backend/api/models/group.py index e4d51380..3a8158c2 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -8,8 +8,10 @@ class Group(models.Model): project = models.ForeignKey( 'Project', - on_delete=models.CASCADE, # If the project is deleted, the group should be deleted as well - related_name='groups', # This is how we can access groups from a project + # If the project is deleted, the group should be deleted as well + on_delete=models.CASCADE, + # This is how we can access groups from a project + related_name='groups', blank=False, null=False ) @@ -17,7 +19,8 @@ class Group(models.Model): # Students that are part of the group students = models.ManyToManyField( 'Student', - related_name='groups', # This is how we can access groups from a student + # This is how we can access groups from a student + related_name='groups', blank=False, ) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index be8efb8c..9435c6f8 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -29,7 +29,8 @@ class Project(models.Model): ) start_date = models.DateTimeField( - default=datetime.datetime.now, # The default value is the current date and time + # The default value is the current date and time + default=datetime.datetime.now, blank=True, ) @@ -41,7 +42,8 @@ class Project(models.Model): # Check entity that is linked to the project checks = models.ForeignKey( 'Checks', - on_delete=models.SET_NULL, # If the checks are deleted, the project should remain + # If the checks are deleted, the project should remain + on_delete=models.SET_NULL, blank=True, null=True ) @@ -49,7 +51,8 @@ class Project(models.Model): # Course that the project belongs to course = models.ForeignKey( 'Course', - on_delete=models.CASCADE, # If the course is deleted, the project should be deleted as well + # If the course is deleted, the project should be deleted as well + on_delete=models.CASCADE, blank=False, null=False ) diff --git a/backend/api/models/student.py b/backend/api/models/student.py index 5b0a9c13..3d5b948b 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -11,6 +11,7 @@ class Student(User): # All the courses the student is enrolled in courses = models.ManyToManyField( 'Course', - related_name='students', # Allows us to access the students from the course + # Allows us to access the students from the course + related_name='students', blank=True ) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 17289cef..7adac13a 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -8,7 +8,8 @@ class SubmissionFile(models.Model): submission = models.ForeignKey( 'Submission', - on_delete=models.CASCADE, # If the submission is deleted, the file should be deleted as well + # If the submission is deleted, the file should be deleted as well + on_delete=models.CASCADE, related_name='files', blank=False, null=False @@ -28,7 +29,8 @@ class Submission(models.Model): group = models.ForeignKey( 'Group', - on_delete=models.CASCADE, # If the group is deleted, the submission should be deleted as well + # If the group is deleted, the submission should be deleted as well + on_delete=models.CASCADE, related_name='submissions', blank=False, null=False diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index c9f443cd..37bb264b 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -11,6 +11,7 @@ class Teacher(User): # All the courses the teacher is teaching courses = models.ManyToManyField( 'Course', - related_name='teachers', # Allows us to access the teachers from the course + # Allows us to access the teachers from the course + related_name='teachers', blank=True ) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 7f7dceb1..fe46c064 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -15,15 +15,23 @@ class TeacherSerializer(serializers.ModelSerializer): many=True, read_only=True, view_name='course-detail' - ) + ) + class Meta: model = Teacher - fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time', 'courses' + ] + class AdminSerializer(serializers.ModelSerializer): class Meta: model = Admin - fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time'] + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time' + ] class AssistantSerializer(serializers.ModelSerializer): @@ -31,20 +39,29 @@ class AssistantSerializer(serializers.ModelSerializer): many=True, read_only=True, view_name='course-detail' - ) + ) + class Meta: model = Assistant - fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time', 'courses' + ] + class StudentSerializer(serializers.ModelSerializer): courses = serializers.HyperlinkedRelatedField( many=True, read_only=True, view_name='course-detail' - ) + ) + class Meta: model = Student - fields = ['id', 'first_name', 'last_name', 'email', 'faculty','last_enrolled','create_time','courses'] + fields = [ + 'id', 'first_name', 'last_name', 'email', 'faculty', + 'last_enrolled', 'create_time', 'courses' + ] class ProjectSerializer(serializers.ModelSerializer): @@ -52,10 +69,15 @@ class ProjectSerializer(serializers.ModelSerializer): many=False, read_only=True, view_name='course-detail' - ) + ) + class Meta: model = Project - fields = ['id', 'name', 'description', 'visible', 'archived', 'start_date', 'deadline', 'checks', 'course'] + fields = [ + 'id', 'name', 'description', 'visible', 'archived', + 'start_date', 'deadline', 'checks', 'course' + ] + class CourseSerializer(serializers.ModelSerializer): assistants = serializers.HyperlinkedRelatedField( @@ -63,43 +85,55 @@ class CourseSerializer(serializers.ModelSerializer): read_only=True, view_name='assistant-detail' ) - + teachers = serializers.HyperlinkedRelatedField( many=True, read_only=True, view_name='teacher-detail' ) - + students = serializers.HyperlinkedRelatedField( many=True, read_only=True, view_name='student-detail' ) + class Meta: model = Course - fields = ['id', 'name', 'academic_startyear','description','parent_course', 'teachers', 'assistants', 'students'] + fields = [ + 'id', 'name', 'academic_startyear', 'description', + 'parent_course', 'teachers', 'assistants', 'students' + ] + class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['id', 'project', 'students','score'] + fields = ['id', 'project', 'students', 'score'] + class SubmissionFileSerializer(serializers.ModelSerializer): class Meta: model = SubmissionFile fields = ['id', 'submission', 'file'] + class SubmissionSerializer(serializers.ModelSerializer): class Meta: model = Submission - fields = ['id', 'group', 'submission_number','submission_time'] + fields = ['id', 'group', 'submission_number', 'submission_time'] + class ChecksSerializer(serializers.ModelSerializer): class Meta: model = Checks fields = ['id', 'dockerfile'] + class FileExtensionSerializer(serializers.ModelSerializer): class Meta: model = FileExtension - fields = ['id', 'extension', 'allowed_file_extensions', 'forbidden_file_extensions'] + fields = [ + 'id', 'extension', + 'allowed_file_extensions', 'forbidden_file_extensions' + ] diff --git a/backend/api/urls.py b/backend/api/urls.py index 156c5400..ee877712 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,22 +9,49 @@ from api.views import submision_view from api.views import checks_view from rest_framework.routers import DefaultRouter -#from rest_framework.urlpatterns import format_suffix_patterns router = DefaultRouter() -router.register(r'teachers', teacher_view.TeacherViewSet, basename='teacher') -router.register(r'admins', admin_view.AdminViewSet, basename='admin') -router.register(r'assistants', assistant_view.AssistantViewSet, basename='assistant') -router.register(r'students', student_view.StudentViewSet, basename='student') -router.register(r'projects', project_view.ProjectViewSet, basename='project') -router.register(r'groups', group_view.GroupViewSet, basename='group') -router.register(r'courses', course_view.CourseViewSet, basename='course') -router.register(r'submissions', submision_view.SubmissionViewSet, basename='submission') -router.register(r'checks', checks_view.ChecksViewSet, basename='check') -router.register(r'fileExtensions', checks_view.FileExtensionViewSet, basename='fileExtension') +router.register( + r'teachers', + teacher_view.TeacherViewSet, + basename='teacher') +router.register( + r'admins', + admin_view.AdminViewSet, + basename='admin') +router.register( + r'assistants', + assistant_view.AssistantViewSet, + basename='assistant') +router.register( + r'students', + student_view.StudentViewSet, + basename='student') +router.register( + r'projects', + project_view.ProjectViewSet, + basename='project') +router.register( + r'groups', + group_view.GroupViewSet, + basename='group') +router.register( + r'courses', + course_view.CourseViewSet, + basename='course') +router.register( + r'submissions', + submision_view.SubmissionViewSet, + basename='submission') +router.register( + r'checks', + checks_view.ChecksViewSet, + basename='check') +router.register( + r'fileExtensions', + checks_view.FileExtensionViewSet, + basename='fileExtension') urlpatterns = [ - path('', include(router.urls)), + path('', include(router.urls)), ] - -#urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 56976c98..668f002e 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -5,4 +5,4 @@ class AdminViewSet(viewsets.ModelViewSet): queryset = Admin.objects.all() - serializer_class = AdminSerializer \ No newline at end of file + serializer_class = AdminSerializer diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 7e37c343..3277b335 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -5,4 +5,4 @@ class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() - serializer_class = AssistantSerializer \ No newline at end of file + serializer_class = AssistantSerializer diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 306b29c4..75a1742f 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,12 +1,13 @@ from rest_framework import viewsets -from ..models.checks import Checks,FileExtension -from ..serializers import ChecksSerializer,FileExtensionSerializer +from ..models.checks import Checks, FileExtension +from ..serializers import ChecksSerializer, FileExtensionSerializer class ChecksViewSet(viewsets.ModelViewSet): queryset = Checks.objects.all() serializer_class = ChecksSerializer + class FileExtensionViewSet(viewsets.ModelViewSet): queryset = FileExtension.objects.all() - serializer_class = FileExtensionSerializer \ No newline at end of file + serializer_class = FileExtensionSerializer diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 6bcf73d2..791cd00c 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -5,4 +5,4 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() - serializer_class = CourseSerializer \ No newline at end of file + serializer_class = CourseSerializer diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 7ca2777f..59e8e0f1 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -5,4 +5,4 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() - serializer_class = GroupSerializer \ No newline at end of file + serializer_class = GroupSerializer diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index ff63d266..1569e18c 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -5,4 +5,4 @@ class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() - serializer_class = ProjectSerializer \ No newline at end of file + serializer_class = ProjectSerializer diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 345370c8..ae9c9c4d 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -5,4 +5,4 @@ class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() - serializer_class = StudentSerializer \ No newline at end of file + serializer_class = StudentSerializer diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index 0842c1ba..5cc17f89 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,5 +1,5 @@ from rest_framework import viewsets -from ..models.submission import Submission,SubmissionFile +from ..models.submission import Submission, SubmissionFile from ..serializers import SubmissionSerializer, SubmissionFileSerializer @@ -7,6 +7,7 @@ class SubmissionFileViewSet(viewsets.ModelViewSet): queryset = SubmissionFile.objects.all() serializer_class = SubmissionFileSerializer + class SubmissionViewSet(viewsets.ModelViewSet): queryset = Submission.objects.all() - serializer_class = SubmissionSerializer \ No newline at end of file + serializer_class = SubmissionSerializer From 874f8a732966bd7ae25ab99ecb8e8b23416a3abe Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 26 Feb 2024 21:21:13 +0100 Subject: [PATCH 068/397] fix: rename Peacher_view.py --- backend/api/models/__init__.py | 9 --------- backend/api/views/{Teacher_view.py => teacher_view.py} | 0 2 files changed, 9 deletions(-) rename backend/api/views/{Teacher_view.py => teacher_view.py} (100%) diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index cdea16ef..e69de29b 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -1,9 +0,0 @@ -from .admin import * -from .assistant import * -from .course import * -from .checks import * -from .student import * -from .teacher import * -from .submission import * -from .project import * -from .group import * diff --git a/backend/api/views/Teacher_view.py b/backend/api/views/teacher_view.py similarity index 100% rename from backend/api/views/Teacher_view.py rename to backend/api/views/teacher_view.py From 492aa96ecff2f0d84aebe945e943a9f236c21051 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 26 Feb 2024 21:29:58 +0100 Subject: [PATCH 069/397] chore: split serializers in to seperate files --- backend/api/serializers.py | 139 ------------------ backend/api/serializers/__init__.py | 0 backend/api/serializers/admin_serializer.py | 11 ++ .../api/serializers/assistant_serializer.py | 17 +++ backend/api/serializers/checks_serializer.py | 17 +++ backend/api/serializers/course_serializer.py | 29 ++++ backend/api/serializers/group_serializer.py | 8 + backend/api/serializers/project_serializer.py | 17 +++ backend/api/serializers/student_serializer.py | 17 +++ .../api/serializers/submision_serializer.py | 14 ++ backend/api/serializers/teacher_serializer.py | 17 +++ backend/api/views/Teacher_view.py | 2 +- backend/api/views/admin_view.py | 2 +- backend/api/views/assistant_view.py | 2 +- backend/api/views/checks_view.py | 2 +- backend/api/views/course_view.py | 2 +- backend/api/views/group_view.py | 2 +- backend/api/views/logic_view.py | 5 - backend/api/views/project_view.py | 2 +- backend/api/views/student_view.py | 2 +- backend/api/views/submision_view.py | 2 +- 21 files changed, 156 insertions(+), 153 deletions(-) delete mode 100644 backend/api/serializers.py create mode 100644 backend/api/serializers/__init__.py create mode 100644 backend/api/serializers/admin_serializer.py create mode 100644 backend/api/serializers/assistant_serializer.py create mode 100644 backend/api/serializers/checks_serializer.py create mode 100644 backend/api/serializers/course_serializer.py create mode 100644 backend/api/serializers/group_serializer.py create mode 100644 backend/api/serializers/project_serializer.py create mode 100644 backend/api/serializers/student_serializer.py create mode 100644 backend/api/serializers/submision_serializer.py create mode 100644 backend/api/serializers/teacher_serializer.py delete mode 100644 backend/api/views/logic_view.py diff --git a/backend/api/serializers.py b/backend/api/serializers.py deleted file mode 100644 index fe46c064..00000000 --- a/backend/api/serializers.py +++ /dev/null @@ -1,139 +0,0 @@ -from rest_framework import serializers -from .models.teacher import Teacher -from .models.admin import Admin -from .models.assistant import Assistant -from .models.student import Student -from .models.project import Project -from .models.group import Group -from .models.course import Course -from .models.submission import Submission, SubmissionFile -from .models.checks import Checks, FileExtension - - -class TeacherSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' - ) - - class Meta: - model = Teacher - fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' - ] - - -class AdminSerializer(serializers.ModelSerializer): - class Meta: - model = Admin - fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time' - ] - - -class AssistantSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' - ) - - class Meta: - model = Assistant - fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' - ] - - -class StudentSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' - ) - - class Meta: - model = Student - fields = [ - 'id', 'first_name', 'last_name', 'email', 'faculty', - 'last_enrolled', 'create_time', 'courses' - ] - - -class ProjectSerializer(serializers.ModelSerializer): - course = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='course-detail' - ) - - class Meta: - model = Project - fields = [ - 'id', 'name', 'description', 'visible', 'archived', - 'start_date', 'deadline', 'checks', 'course' - ] - - -class CourseSerializer(serializers.ModelSerializer): - assistants = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='assistant-detail' - ) - - teachers = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='teacher-detail' - ) - - students = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='student-detail' - ) - - class Meta: - model = Course - fields = [ - 'id', 'name', 'academic_startyear', 'description', - 'parent_course', 'teachers', 'assistants', 'students' - ] - - -class GroupSerializer(serializers.ModelSerializer): - class Meta: - model = Group - fields = ['id', 'project', 'students', 'score'] - - -class SubmissionFileSerializer(serializers.ModelSerializer): - class Meta: - model = SubmissionFile - fields = ['id', 'submission', 'file'] - - -class SubmissionSerializer(serializers.ModelSerializer): - class Meta: - model = Submission - fields = ['id', 'group', 'submission_number', 'submission_time'] - - -class ChecksSerializer(serializers.ModelSerializer): - class Meta: - model = Checks - fields = ['id', 'dockerfile'] - - -class FileExtensionSerializer(serializers.ModelSerializer): - class Meta: - model = FileExtension - fields = [ - 'id', 'extension', - 'allowed_file_extensions', 'forbidden_file_extensions' - ] diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py new file mode 100644 index 00000000..3cd2404c --- /dev/null +++ b/backend/api/serializers/admin_serializer.py @@ -0,0 +1,11 @@ +from rest_framework import serializers +from ..models.admin import Admin + + +class AdminSerializer(serializers.ModelSerializer): + class Meta: + model = Admin + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time' + ] diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py new file mode 100644 index 00000000..8cd09e65 --- /dev/null +++ b/backend/api/serializers/assistant_serializer.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from ..models.assistant import Assistant + + +class AssistantSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) + + class Meta: + model = Assistant + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time', 'courses' + ] diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py new file mode 100644 index 00000000..51bbfd84 --- /dev/null +++ b/backend/api/serializers/checks_serializer.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from ..models.checks import Checks, FileExtension + + +class ChecksSerializer(serializers.ModelSerializer): + class Meta: + model = Checks + fields = ['id', 'dockerfile'] + + +class FileExtensionSerializer(serializers.ModelSerializer): + class Meta: + model = FileExtension + fields = [ + 'id', 'extension', + 'allowed_file_extensions', 'forbidden_file_extensions' + ] diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py new file mode 100644 index 00000000..504d8dd1 --- /dev/null +++ b/backend/api/serializers/course_serializer.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from ..models.course import Course + + +class CourseSerializer(serializers.ModelSerializer): + assistants = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='assistant-detail' + ) + + teachers = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='teacher-detail' + ) + + students = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='student-detail' + ) + + class Meta: + model = Course + fields = [ + 'id', 'name', 'academic_startyear', 'description', + 'parent_course', 'teachers', 'assistants', 'students' + ] diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py new file mode 100644 index 00000000..c67a71a3 --- /dev/null +++ b/backend/api/serializers/group_serializer.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from ..models.group import Group + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'project', 'students', 'score'] diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py new file mode 100644 index 00000000..be2195c5 --- /dev/null +++ b/backend/api/serializers/project_serializer.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from ..models.project import Project + + +class ProjectSerializer(serializers.ModelSerializer): + course = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='course-detail' + ) + + class Meta: + model = Project + fields = [ + 'id', 'name', 'description', 'visible', 'archived', + 'start_date', 'deadline', 'checks', 'course' + ] diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py new file mode 100644 index 00000000..b1813cc3 --- /dev/null +++ b/backend/api/serializers/student_serializer.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from ..models.student import Student + + +class StudentSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) + + class Meta: + model = Student + fields = [ + 'id', 'first_name', 'last_name', 'email', 'faculty', + 'last_enrolled', 'create_time', 'courses' + ] diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py new file mode 100644 index 00000000..6f92e926 --- /dev/null +++ b/backend/api/serializers/submision_serializer.py @@ -0,0 +1,14 @@ +from rest_framework import serializers +from ..models.submission import Submission, SubmissionFile + + +class SubmissionFileSerializer(serializers.ModelSerializer): + class Meta: + model = SubmissionFile + fields = ['id', 'submission', 'file'] + + +class SubmissionSerializer(serializers.ModelSerializer): + class Meta: + model = Submission + fields = ['id', 'group', 'submission_number', 'submission_time'] diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py new file mode 100644 index 00000000..7d313aa2 --- /dev/null +++ b/backend/api/serializers/teacher_serializer.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from ..models.teacher import Teacher + + +class TeacherSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='course-detail' + ) + + class Meta: + model = Teacher + fields = [ + 'id', 'first_name', 'last_name', 'email', + 'faculty', 'last_enrolled', 'create_time', 'courses' + ] diff --git a/backend/api/views/Teacher_view.py b/backend/api/views/Teacher_view.py index 938a3cc0..2a0b939a 100644 --- a/backend/api/views/Teacher_view.py +++ b/backend/api/views/Teacher_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.teacher import Teacher -from ..serializers import TeacherSerializer +from ..serializers.teacher_serializer import TeacherSerializer class TeacherViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 668f002e..f44fdcdb 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.admin import Admin -from ..serializers import AdminSerializer +from ..serializers.admin_serializer import AdminSerializer class AdminViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 3277b335..479565f7 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.assistant import Assistant -from ..serializers import AssistantSerializer +from ..serializers.assistant_serializer import AssistantSerializer class AssistantViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 75a1742f..654eb1f1 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.checks import Checks, FileExtension -from ..serializers import ChecksSerializer, FileExtensionSerializer +from ..serializers.checks_serializer import ChecksSerializer, FileExtensionSerializer class ChecksViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 791cd00c..3fc252af 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.course import Course -from ..serializers import CourseSerializer +from ..serializers.course_serializer import CourseSerializer class CourseViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 59e8e0f1..1db7064e 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.group import Group -from ..serializers import GroupSerializer +from ..serializers.group_serializer import GroupSerializer class GroupViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/logic_view.py b/backend/api/views/logic_view.py deleted file mode 100644 index 3aac11f9..00000000 --- a/backend/api/views/logic_view.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.http import HttpRequest, HttpResponse - - -def hello(request: HttpRequest) -> HttpResponse: - return 'hello' diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 1569e18c..c8e6dc83 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.project import Project -from ..serializers import ProjectSerializer +from ..serializers.project_serializer import ProjectSerializer class ProjectViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index ae9c9c4d..31294a61 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.student import Student -from ..serializers import StudentSerializer +from ..serializers.student_serializer import StudentSerializer class StudentViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index 5cc17f89..c7a25333 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile -from ..serializers import SubmissionSerializer, SubmissionFileSerializer +from ..serializers.submision_serializer import SubmissionSerializer, SubmissionFileSerializer class SubmissionFileViewSet(viewsets.ModelViewSet): From 46883db8dd739210dc3c9938ce55c3abe880e275 Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 26 Feb 2024 21:34:52 +0100 Subject: [PATCH 070/397] chore: add faculty model --- .../authentication/migrations/0002_faculty.py | 22 +++++++++++++++++++ backend/authentication/models.py | 18 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 backend/authentication/migrations/0002_faculty.py diff --git a/backend/authentication/migrations/0002_faculty.py b/backend/authentication/migrations/0002_faculty.py new file mode 100644 index 00000000..0928d1cb --- /dev/null +++ b/backend/authentication/migrations/0002_faculty.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.2 on 2024-02-26 20:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Faculty', + fields=[ + ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='faculties', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6523c98b..ea0e56c4 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,5 +1,6 @@ import datetime +from django.db import models from django.db.models import CharField, EmailField, IntegerField, DateTimeField from django.contrib.auth.models import AbstractBaseUser @@ -49,3 +50,20 @@ class User(AbstractBaseUser): USERNAME_FIELD = "email" EMAIL_FIELD = "email" REQUIRED_FIELDS = [] + +class Faculty(models.Model): + """This model represents a faculty.""" + + """Model fields""" + name = CharField( + max_length=50, + primary_key=True + ) + + user = models.ForeignKey( + User, + on_delete=models.DO_NOTHING, + # This is how we can access groups from a project + related_name='faculties', + null=True + ) From c2fd99b0508c1960e6c39c1acf4db78d8193c820 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Mon, 26 Feb 2024 23:02:34 +0100 Subject: [PATCH 071/397] chore: link to teachers in course --- backend/api/serializers/course_serializer.py | 8 +++--- backend/api/urls.py | 3 +++ backend/api/views/course_view.py | 26 +++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 504d8dd1..5e44be3b 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -9,10 +9,10 @@ class CourseSerializer(serializers.ModelSerializer): view_name='assistant-detail' ) - teachers = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='teacher-detail' + teachers = serializers.HyperlinkedIdentityField( + view_name='course-teachers', # View that handles the hyperlink + lookup_field='id', # Field to use for the lookup + lookup_url_kwarg='course_id' # URL keyword argument to use ) students = serializers.HyperlinkedRelatedField( diff --git a/backend/api/urls.py b/backend/api/urls.py index ee877712..e3812007 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -54,4 +54,7 @@ urlpatterns = [ path('', include(router.urls)), + path('courses//teachers/', + course_view.CourseTeachersViewSet.as_view({'get': 'list'}), + name='course-teachers'), ] diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 3fc252af..b1108a91 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,8 +1,32 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status from ..models.course import Course from ..serializers.course_serializer import CourseSerializer +from ..serializers.teacher_serializer import TeacherSerializer +from rest_framework.response import Response class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer + + +class CourseTeachersViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of teachers for the given course""" + course_id = kwargs.get('course_id') + + try: + queryset = Course.objects.get(id=course_id) + teachers = queryset.teachers.all() + + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Course.DoesNotExist: + # Invalid course ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Course not found"}) From 0e40e920d8d4be3692175233c517ccd613b7b050 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 10:42:13 +0100 Subject: [PATCH 072/397] chore: link to assistants + students in course --- backend/api/serializers/course_serializer.py | 16 +++---- backend/api/urls.py | 6 +++ backend/api/views/course_view.py | 48 +++++++++++++++++++- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 5e44be3b..387b4e26 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -3,10 +3,10 @@ class CourseSerializer(serializers.ModelSerializer): - assistants = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='assistant-detail' + assistants = serializers.HyperlinkedIdentityField( + view_name='course-assistants', + lookup_field='id', + lookup_url_kwarg='course_id', ) teachers = serializers.HyperlinkedIdentityField( @@ -15,10 +15,10 @@ class CourseSerializer(serializers.ModelSerializer): lookup_url_kwarg='course_id' # URL keyword argument to use ) - students = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='student-detail' + students = serializers.HyperlinkedIdentityField( + view_name='course-students', + lookup_field='id', + lookup_url_kwarg='course_id', ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index e3812007..c0a19204 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -57,4 +57,10 @@ path('courses//teachers/', course_view.CourseTeachersViewSet.as_view({'get': 'list'}), name='course-teachers'), + path('courses//assistants/', + course_view.CourseAssistantsViewSet.as_view({'get': 'list'}), + name='course-assistants'), + path('courses//students/', + course_view.CourseStudentsViewSet.as_view({'get': 'list'}), + name='course-students'), ] diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index b1108a91..5481dde2 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -2,6 +2,8 @@ from ..models.course import Course from ..serializers.course_serializer import CourseSerializer from ..serializers.teacher_serializer import TeacherSerializer +from ..serializers.assistant_serializer import AssistantSerializer +from ..serializers.student_serializer import StudentSerializer from rest_framework.response import Response @@ -28,5 +30,49 @@ def list(self, request, *args, **kwargs): except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Course not found"}) + + +class CourseAssistantsViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of assistants for the given course""" + course_id = kwargs.get('course_id') + + try: + queryset = Course.objects.get(id=course_id) + assistants = queryset.assistants.all() + + # Serialize the assistant objects + serializer = AssistantSerializer( + assistants, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Course.DoesNotExist: + # Invalid course ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Course not found"}) + + +class CourseStudentsViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of students for the given course""" + course_id = kwargs.get('course_id') + + try: + queryset = Course.objects.get(id=course_id) + students = queryset.students.all() + + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Course.DoesNotExist: + # Invalid course ID + return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) From 694ad5643020a4d5fdb3f7466a38ede25aa4c79a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 10:57:27 +0100 Subject: [PATCH 073/397] fix: link from course to projects --- .../migrations/0001_alter_project_course.py | 19 +++++++++++++++++++ backend/api/models/project.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 backend/api/migrations/0001_alter_project_course.py diff --git a/backend/api/migrations/0001_alter_project_course.py b/backend/api/migrations/0001_alter_project_course.py new file mode 100644 index 00000000..bd7d0f71 --- /dev/null +++ b/backend/api/migrations/0001_alter_project_course.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.2 on 2024-02-27 09:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', 'populate'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course'), + ), + ] diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 9435c6f8..a83ffe89 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -53,6 +53,7 @@ class Project(models.Model): 'Course', # If the course is deleted, the project should be deleted as well on_delete=models.CASCADE, + related_name='projects', blank=False, null=False ) From 22d506da7bec31815bf8863b57988dd6ec9d68a9 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:01:15 +0100 Subject: [PATCH 074/397] chore: add projects link to course --- backend/api/serializers/course_serializer.py | 8 ++++++- backend/api/urls.py | 5 ++++- backend/api/views/course_view.py | 23 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 387b4e26..ba9a0881 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -21,9 +21,15 @@ class CourseSerializer(serializers.ModelSerializer): lookup_url_kwarg='course_id', ) + projects = serializers.HyperlinkedIdentityField( + view_name='course-projects', + lookup_field='id', + lookup_url_kwarg='course_id', + ) + class Meta: model = Course fields = [ 'id', 'name', 'academic_startyear', 'description', - 'parent_course', 'teachers', 'assistants', 'students' + 'parent_course', 'teachers', 'assistants', 'students', 'projects' ] diff --git a/backend/api/urls.py b/backend/api/urls.py index c0a19204..847cd924 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -62,5 +62,8 @@ name='course-assistants'), path('courses//students/', course_view.CourseStudentsViewSet.as_view({'get': 'list'}), - name='course-students'), + name='course-students'), + path('courses//projects/', + course_view.CourseProjectsViewSet.as_view({'get': 'list'}), + name='course-projects'), ] diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 5481dde2..ab432174 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -4,6 +4,7 @@ from ..serializers.teacher_serializer import TeacherSerializer from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.student_serializer import StudentSerializer +from ..serializers.project_serializer import ProjectSerializer from rest_framework.response import Response @@ -76,3 +77,25 @@ def list(self, request, *args, **kwargs): # Invalid course ID return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) + + +class CourseProjectsViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of projects for the given course""" + course_id = kwargs.get('course_id') + + try: + queryset = Course.objects.get(id=course_id) + projects = queryset.projects.all() + + # Serialize the project objects + serializer = ProjectSerializer( + projects, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Course.DoesNotExist: + # Invalid course ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Course not found"}) From 20384f918c1c0b9c655804a2d5e6b5a795600461 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:19:49 +0100 Subject: [PATCH 075/397] chore: assitant link to courses --- .../api/serializers/assistant_serializer.py | 8 +++--- backend/api/urls.py | 5 ++++ backend/api/views/assistant_view.py | 26 ++++++++++++++++++- backend/api/views/course_view.py | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 8cd09e65..5aac9356 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -3,10 +3,10 @@ class AssistantSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' + courses = serializers.HyperlinkedIdentityField( + view_name='assistant-courses', + lookup_field='id', + lookup_url_kwarg='assistant_id' ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index 847cd924..877a2faf 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -54,6 +54,7 @@ urlpatterns = [ path('', include(router.urls)), + path('courses//teachers/', course_view.CourseTeachersViewSet.as_view({'get': 'list'}), name='course-teachers'), @@ -66,4 +67,8 @@ path('courses//projects/', course_view.CourseProjectsViewSet.as_view({'get': 'list'}), name='course-projects'), + + path('assistants//courses/', + assistant_view.AssistantCoursesViewSet.as_view({'get': 'list'}), + name='assistant-courses'), ] diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 479565f7..ee0cdaac 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,8 +1,32 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.response import Response from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer +from ..serializers.course_serializer import CourseSerializer class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer + + +class AssistantCoursesViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of courses for the given assistant""" + assistant_id = kwargs.get('assistant_id') + + try: + queryset = Assistant.objects.get(id=assistant_id) + courses = queryset.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Assistant.DoesNotExist: + # Invalid assistant ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Assistant not found"}) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index ab432174..a6bca29a 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,11 +1,11 @@ from rest_framework import viewsets, status +from rest_framework.response import Response from ..models.course import Course from ..serializers.course_serializer import CourseSerializer from ..serializers.teacher_serializer import TeacherSerializer from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.student_serializer import StudentSerializer from ..serializers.project_serializer import ProjectSerializer -from rest_framework.response import Response class CourseViewSet(viewsets.ModelViewSet): From fc8529dce0cd5deacc19eb69b98f50399903de91 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:23:29 +0100 Subject: [PATCH 076/397] chore: student link to courses --- backend/api/serializers/student_serializer.py | 8 +++--- backend/api/urls.py | 6 +++++ backend/api/views/student_view.py | 26 ++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index b1813cc3..fcd9b340 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -3,10 +3,10 @@ class StudentSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' + courses = serializers.HyperlinkedIdentityField( + view_name='student-courses', + lookup_field='id', + lookup_url_kwarg='student_id' ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index 877a2faf..0272b0ab 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -71,4 +71,10 @@ path('assistants//courses/', assistant_view.AssistantCoursesViewSet.as_view({'get': 'list'}), name='assistant-courses'), + + path('students//courses/', + student_view.StudentCoursesViewSet.as_view({'get': 'list'}), + name='student-courses'), + + ] diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 31294a61..84757c45 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,8 +1,32 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.response import Response from ..models.student import Student from ..serializers.student_serializer import StudentSerializer +from ..serializers.course_serializer import CourseSerializer class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer + + +class StudentCoursesViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of courses for the given student""" + student_id = kwargs.get('student_id') + + try: + queryset = Student.objects.get(id=student_id) + courses = queryset.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Student.DoesNotExist: + # Invalid student ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Student not found"}) From 3f063e9ae4d23710e6cee01953a02a887c580106 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:26:18 +0100 Subject: [PATCH 077/397] chore: teacher link to courses --- backend/api/serializers/teacher_serializer.py | 8 +++--- backend/api/urls.py | 3 +++ backend/api/views/teacher_view.py | 26 ++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index 7d313aa2..1a996afd 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -3,10 +3,10 @@ class TeacherSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='course-detail' + courses = serializers.HyperlinkedIdentityField( + view_name='teacher-courses', + lookup_field='id', + lookup_url_kwarg='teacher_id' ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index 0272b0ab..dd89c53a 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -76,5 +76,8 @@ student_view.StudentCoursesViewSet.as_view({'get': 'list'}), name='student-courses'), + path('teachers//courses/', + teacher_view.TeacherCoursesViewSet.as_view({'get': 'list'}), + name='teacher-courses'), ] diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 2a0b939a..c57decd5 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,8 +1,32 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.response import Response from ..models.teacher import Teacher from ..serializers.teacher_serializer import TeacherSerializer +from ..serializers.course_serializer import CourseSerializer class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer + + +class TeacherCoursesViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of courses for the given teacher""" + teacher_id = kwargs.get('teacher_id') + + try: + queryset = Teacher.objects.get(id=teacher_id) + courses = queryset.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Teacher.DoesNotExist: + # Invalid teacher ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Teacher not found"}) From 318b94eff260564781d6e25238dcc3974bdccd93 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:32:46 +0100 Subject: [PATCH 078/397] chore: return the groups of a student --- backend/api/migrations/populate.py | 19 ++++++++++++++++++- backend/api/serializers/student_serializer.py | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index b28cdb3a..58d1a10f 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -10,6 +10,7 @@ def populate_db(apps, schema_editor): Course = apps.get_model("api", "Course") Assistant = apps.get_model("api", "Assistant") Project = apps.get_model("api", "Project") + Group = apps.get_model("api", "Group") teacher1 = Teacher.objects.create( id=123, @@ -56,6 +57,15 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) + student2 = Student.objects.create( + id=2, + first_name="Bartje", + last_name="Verhaege", + email="Bartje.Verhaege@gmail.com", + faculty="Science", + create_time="2023-01-01T00:00:00Z", + ) + course = Course.objects.create( name="Math", academic_startyear=2023, @@ -68,7 +78,7 @@ def populate_db(apps, schema_editor): description="Software course", ) - Project.objects.create( + project1 = Project.objects.create( id=123456, name="sel2", description="make a project", @@ -81,6 +91,13 @@ def populate_db(apps, schema_editor): course=course2 ) + group1 = Group.objects.create( + project=project1, + ) + + group1.students.add(student1) + group1.students.add(student2) + teacher1.courses.add(course) teacher2.courses.add(course) student1.courses.add(course) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index fcd9b340..b90319d0 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -13,5 +13,5 @@ class Meta: model = Student fields = [ 'id', 'first_name', 'last_name', 'email', 'faculty', - 'last_enrolled', 'create_time', 'courses' + 'last_enrolled', 'create_time', 'courses', 'groups' ] From 95591817366ea7076e70c5c3c38757232b999a72 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:37:55 +0100 Subject: [PATCH 079/397] chore: student link to groups --- backend/api/serializers/student_serializer.py | 6 +++++ backend/api/urls.py | 3 +++ backend/api/views/student_view.py | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index b90319d0..61e1e38a 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -9,6 +9,12 @@ class StudentSerializer(serializers.ModelSerializer): lookup_url_kwarg='student_id' ) + groups = serializers.HyperlinkedIdentityField( + view_name='student-groups', + lookup_field='id', + lookup_url_kwarg='student_id' + ) + class Meta: model = Student fields = [ diff --git a/backend/api/urls.py b/backend/api/urls.py index dd89c53a..53013049 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -75,6 +75,9 @@ path('students//courses/', student_view.StudentCoursesViewSet.as_view({'get': 'list'}), name='student-courses'), + path('students//groups/', + student_view.StudentGroupsViewSet.as_view({'get': 'list'}), + name='student-groups'), path('teachers//courses/', teacher_view.TeacherCoursesViewSet.as_view({'get': 'list'}), diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 84757c45..085a9ace 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -3,6 +3,7 @@ from ..models.student import Student from ..serializers.student_serializer import StudentSerializer from ..serializers.course_serializer import CourseSerializer +from ..serializers.group_serializer import GroupSerializer class StudentViewSet(viewsets.ModelViewSet): @@ -30,3 +31,25 @@ def list(self, request, *args, **kwargs): # Invalid student ID return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"}) + + +class StudentGroupsViewSet(viewsets.ModelViewSet): + + def list(self, request, *args, **kwargs): + """Returns a list of groups for the given student""" + student_id = kwargs.get('student_id') + + try: + queryset = Student.objects.get(id=student_id) + groups = queryset.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Student.DoesNotExist: + # Invalid student ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Student not found"}) From 974f6291c1e285e881b4fae5eb0c98ed1a956d16 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 27 Feb 2024 11:44:07 +0100 Subject: [PATCH 080/397] fix: link to project instead of id --- backend/api/serializers/group_serializer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index c67a71a3..0573a78e 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -3,6 +3,12 @@ class GroupSerializer(serializers.ModelSerializer): + project = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='project-detail' + ) + class Meta: model = Group fields = ['id', 'project', 'students', 'score'] From cbc448ad62672896b634e45ca904f780597c2116 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 15:08:24 +0100 Subject: [PATCH 081/397] chore: models --- backend/notifications/models.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 71a83623..c46c800e 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -1,3 +1,18 @@ +from authentication.models import User from django.db import models -# Create your models here. + +class NotificationTemplate(models.Model): + id = models.AutoField(auto_created=True, primary_key=True) + title_key = models.CharField(required=True, max_length=255) + description_key = models.CharField(required=True, max_length=255) + + +class Notification(models.Model): + id = models.AutoField(auto_created=True, primary_key=True) + user = models.ForeignKey(User, required=True, on_delete=models.CASCADE) + template = models.ForeignKey(NotificationTemplate, required=True, on_delete=models.CASCADE, db_column='template_id') + created_at = models.DateTimeField(auto_now_add=True) + arguments = models.JSONField(required=False, default=dict) + is_read = models.BooleanField(default=False) + is_sent = models.BooleanField(default=False) From 6c4ef65cb72a6eeb9fd5cba618997f2cd6559bbb Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 15:13:32 +0100 Subject: [PATCH 082/397] chore: add serializers --- backend/notifications/models.py | 8 ++++++++ backend/notifications/serializers.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 backend/notifications/serializers.py diff --git a/backend/notifications/models.py b/backend/notifications/models.py index c46c800e..dc773b16 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -16,3 +16,11 @@ class Notification(models.Model): arguments = models.JSONField(required=False, default=dict) is_read = models.BooleanField(default=False) is_sent = models.BooleanField(default=False) + + def read(self): + self.is_read = True + self.save() + + def send(self): + self.is_sent = True + self.save() diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py new file mode 100644 index 00000000..79eab5b5 --- /dev/null +++ b/backend/notifications/serializers.py @@ -0,0 +1,14 @@ +from notifications.models import Notification, NotificationTemplate +from rest_framework import serializers + + +class NotificationTemplateSerializer(serializers.ModelSerializer): + class Meta: + model = NotificationTemplate + fields = "__all__" + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = "__all__" From 8aaf9cb441feb2399764d4994edb3d217b7d300e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 15:15:29 +0100 Subject: [PATCH 083/397] chore: add views --- backend/notifications/views.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 91ea44a2..7e2e7047 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,3 +1,13 @@ -from django.shortcuts import render +from notifications.models import Notification +from notifications.serializers import NotificationSerializer +from rest_framework import generics -# Create your views here. + +class NotificationList(generics.ListCreateAPIView): + queryset = Notification.objects.all() + serializer_class = NotificationSerializer + + +class NotificationDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = Notification.objects.all() + serializer_class = NotificationSerializer From 2f2e2c66b8bca891a5fda2be0ce0984b5011af1d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 15:15:54 +0100 Subject: [PATCH 084/397] chore: add urls --- backend/notifications/urls.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/notifications/urls.py diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py new file mode 100644 index 00000000..5912829c --- /dev/null +++ b/backend/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from notifications import views +from rest_framework.urlpatterns import format_suffix_patterns + +urlpatterns = [ + path("", views.NotificationList.as_view()), + path("/", views.NotificationDetail.as_view()), +] + +urlpatterns = format_suffix_patterns(urlpatterns) From acc33561c5740dc9afe5ecf23d3cec6ec63d6a11 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 16:02:30 +0100 Subject: [PATCH 085/397] feat(i18n): Add i18n --- backend/README.md | 1 + .../locale/en/LC_MESSAGES/django.mo | Bin 0 -> 672 bytes .../locale/en/LC_MESSAGES/django.po | 29 ++++++ .../locale/nl/LC_MESSAGES/django.mo | Bin 0 -> 683 bytes .../locale/nl/LC_MESSAGES/django.po | 29 ++++++ backend/notifications/models.py | 2 +- backend/notifications/serializers.py | 36 ++++++- backend/ypovoli/settings.py | 94 ++++++++++-------- 8 files changed, 147 insertions(+), 44 deletions(-) create mode 100644 backend/notifications/locale/en/LC_MESSAGES/django.mo create mode 100644 backend/notifications/locale/en/LC_MESSAGES/django.po create mode 100644 backend/notifications/locale/nl/LC_MESSAGES/django.mo create mode 100644 backend/notifications/locale/nl/LC_MESSAGES/django.po diff --git a/backend/README.md b/backend/README.md index 5027cdd7..c4765e91 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,4 +12,5 @@ __Django doesn't support python 3.12__ - Install all requirements `pip install -r requirements.txt` +- Migrate the database `python manage.py migrate` - Run the server `python manage.py runsslserver localhost:8080` \ No newline at end of file diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.mo b/backend/notifications/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..17117cc276935850ac4fdda3aff1e7ae1eb0d472 GIT binary patch literal 672 zcmZvZ&u-K(5XKD@A;PWVh|p9BsTK0la;m&t)U?T#i2PY)vz3;co0!y9YDczH;0=H~ zeThB{Z@__<;L2pT>b8QBe){b3H=2>>*ZSHIg7FY}h6KnXq(S=lifka?kQc}mvX1;j zo+7`IgL{PhL4A&}`-E(x&QYJBen)+bdWnj*MV|+#eXJpvUPd5nX=Ll9Rt%=4HUfB6 zi3(nB+Wxjh{^RJ{io4sMLb^uWsV8?**9Bx^^qD9fjVgL9j9oOH_=oTPqY#e6d>Un` zmy1>#M-yAiiXL>erG;kTO){E`3)$Du0J}4ohkj0zj0Qfu%UBY<`jDle7jx@qVYspl zciJ!*&*B(TKM7$kO#CR`_k$o0r_;A+RdvT}K?}jp8N_~iH0vkXi{t%94z#IP9-JVF zl5jQl&S2X+)XE8k?Y^`)j}xEW>$c%i4WT^c#tJu@6=S-)^7UhV6o!VX)Rj~, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-27 15:52+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Score Added +msgid "Title: Score added" +msgstr "Score Added" +msgid "Description: Score added %(score)" +msgstr "You received a score: %(score)" +# Score Updated +msgid "Title: Score updated" +msgstr "New score" +msgid "Description: Score updated %(score)" +msgstr "Your score has been updated.\nNew score: %(score)" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.mo b/backend/notifications/locale/nl/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..ff66484e10f7889ae3cef11db740841029dc8149 GIT binary patch literal 683 zcmZvZ&u-H|5XKi;gk*6*6-N$Jp-Qb_1LagX4XRr^#o#}Y<5YqZZalb)++AyT9m*TP zfg@L5fQR7?IPel&8OIe#B}V%B^X`7D`Rw_%wfT)v-60+lK5>_*iLO2q+r$^*5%GiA zB7PDNh~LEVEyn(k?hx*6#-5PABfU@hmGmCzcT%z~`)rVQwMo!+Up|_-7L653x-gw9 zjZl;&mawyHy44>0kE3nM!mfLAVQai`pWWEHzQ870eZaZpVad->n`P69ck~qaFrB_2tE}3hLY`xB=|bcsr?W1}VH6$JVxV-j z@=$^}jDuC|{lPQmL`jR1+I?y$kHstdw5barhcLe=v_U(Xpu1u3w*t` literal 0 HcmV?d00001 diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..c9794802 --- /dev/null +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,29 @@ +# Translations for notification. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-27 15:50+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Score Added +msgid "Title: Score added" +msgstr "Score toegevoegd" +msgid "Description: Score added %(score)" +msgstr "Je hebt een score ontvangen: %(score)" +# Score Updated +msgid "Title: Score updated" +msgstr "Nieuwe score" +msgid "Description: Score updated %(score)" +msgstr "Je score is geupdate.\nNieuwe score: %(score)" diff --git a/backend/notifications/models.py b/backend/notifications/models.py index dc773b16..ccccb7d1 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -11,7 +11,7 @@ class NotificationTemplate(models.Model): class Notification(models.Model): id = models.AutoField(auto_created=True, primary_key=True) user = models.ForeignKey(User, required=True, on_delete=models.CASCADE) - template = models.ForeignKey(NotificationTemplate, required=True, on_delete=models.CASCADE, db_column='template_id') + template_id = models.ForeignKey(NotificationTemplate, required=True, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) arguments = models.JSONField(required=False, default=dict) is_read = models.BooleanField(default=False) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 79eab5b5..cc13cc79 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -9,6 +10,39 @@ class Meta: class NotificationSerializer(serializers.ModelSerializer): + user = serializers.HyperlinkedRelatedField( + view_name="user-detail", read_only=True, lookup_field="pk" + ) + title = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() + class Meta: model = Notification - fields = "__all__" + exclude = ["template_id"] + + def get_title(self, obj): + return _(NotificationTemplate.objects.get(pk=obj.template_id).title_key) + + def get_description(self, obj): + description_key = NotificationTemplate.objects.get( + pk=obj.template_id + ).description_key + + return _(description_key).format(**obj.arguments) + + # def to_representation(self, instance): + # data = super().to_representation(instance) + # data["template_id"] = { + # "title_key": NotificationTemplate.objects.get( + # pk=instance.template_id + # ).title_key, + # "description_key": NotificationTemplate.objects.get( + # pk=instance.template_id + # ).description_key, + # } + + # return data + + +# TODO: NotificationSerializer exclude = ['user'] and use depth = 1 +# TODO: HyperlinkedModelSerializer? diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 72a1fdbb..ee9036ef 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.0/ref/settings/ """ + from datetime import timedelta from pathlib import Path @@ -20,7 +21,7 @@ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-_upw+)mo--8_0slsl&8ot0*h8p50z_rlid6nwobd*%%gm$_!1x' +SECRET_KEY = "django-insecure-_upw+)mo--8_0slsl&8ot0*h8p50z_rlid6nwobd*%%gm$_!1x" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -32,72 +33,81 @@ INSTALLED_APPS = [ # Built-ins - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", # Third party - 'rest_framework_swagger', # Swagger - 'rest_framework', # Django rest framework - 'drf_yasg', # Yet Another Swagger generator - 'sslserver', # Used for local SSL support (needed by CAS) - + "rest_framework_swagger", # Swagger + "rest_framework", # Django rest framework + "drf_yasg", # Yet Another Swagger generator + "sslserver", # Used for local SSL support (needed by CAS) # First party - 'authentication', # Ypovoli authentication - 'api' # Ypovoli logic of the base application + "authentication", # Ypovoli authentication + "api", # Ypovoli logic of the base application ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware' + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.locale.LocaleMiddleware", ] REST_FRAMEWORK = { - 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication" ], - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication' - ] } SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=365), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'UPDATE_LAST_LOGIN': True, - 'TOKEN_OBTAIN_SERIALIZER': 'authentication.serializers.CASTokenObtainSerializer' + "ACCESS_TOKEN_LIFETIME": timedelta(days=365), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "UPDATE_LAST_LOGIN": True, + "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer", } -AUTH_USER_MODEL = 'authentication.User' +AUTH_USER_MODEL = "authentication.User" -ROOT_URLCONF = 'ypovoli.urls' +ROOT_URLCONF = "ypovoli.urls" -WSGI_APPLICATION = 'ypovoli.wsgi.application' +WSGI_APPLICATION = "ypovoli.wsgi.application" # Application endpoints -CAS_ENDPOINT = 'https://login.ugent.be' -CAS_RESPONSE = 'https://localhost:8080/auth/echo' -API_ENDPOINT = 'https://localhost:8080' +CAS_ENDPOINT = "https://login.ugent.be" +CAS_RESPONSE = "https://localhost:8080/auth/echo" +API_ENDPOINT = "https://localhost:8080" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", }, - 'production': { - 'ENGINE': 'django.db.backends.postgresql' - } + "production": {"ENGINE": "django.db.backends.postgresql"}, } # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Internationalization + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True From 5f597ab7eaf57aa75599bc82b4c90c47267dd189 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 27 Feb 2024 16:10:39 +0100 Subject: [PATCH 086/397] chore: migration --- backend/notifications/migrations/0001.py | 53 ++++++++++++++++++++++++ backend/notifications/models.py | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 backend/notifications/migrations/0001.py diff --git a/backend/notifications/migrations/0001.py b/backend/notifications/migrations/0001.py new file mode 100644 index 00000000..ddc3ea1d --- /dev/null +++ b/backend/notifications/migrations/0001.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("authentication", "0001_initial")] + + operations = [ + migrations.CreateModel( + name="NotificationTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.CharField(max_length=511)), + ], + ), + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + ), + ), + ( + "user", + models.ForeignKey( + to="authentication.User", on_delete=models.deletion.CASCADE + ), + ), + ( + "template_id", + models.ForeignKey( + to="notifications.NotificationTemplate", + on_delete=models.deletion.CASCADE, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("arguments", models.JSONField(default=dict)), + ("is_read", models.BooleanField(default=False)), + ("is_sent", models.BooleanField(default=False)), + ], + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index ccccb7d1..fc44d3d7 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -5,7 +5,7 @@ class NotificationTemplate(models.Model): id = models.AutoField(auto_created=True, primary_key=True) title_key = models.CharField(required=True, max_length=255) - description_key = models.CharField(required=True, max_length=255) + description_key = models.CharField(required=True, max_length=511) class Notification(models.Model): From bf46f6099d40cc746c802336728cf71acea57eb4 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 27 Feb 2024 22:46:08 +0100 Subject: [PATCH 087/397] fix: many-to-many between faculty and user --- ...y_user_remove_user_faculty_user_faculty.py | 26 +++++++++++++++++++ .../migrations/0004_alter_user_faculty.py | 18 +++++++++++++ .../migrations/0005_alter_user_faculty.py | 18 +++++++++++++ backend/authentication/models.py | 15 +++-------- 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py create mode 100644 backend/authentication/migrations/0004_alter_user_faculty.py create mode 100644 backend/authentication/migrations/0005_alter_user_faculty.py diff --git a/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py b/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py new file mode 100644 index 00000000..37f0e5fa --- /dev/null +++ b/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.2 on 2024-02-27 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_faculty'), + ] + + operations = [ + migrations.RemoveField( + model_name='faculty', + name='user', + ), + migrations.RemoveField( + model_name='user', + name='faculty', + ), + migrations.AddField( + model_name='user', + name='faculty', + field=models.ManyToManyField(null=True, related_name='faculties', to='authentication.faculty'), + ), + ] diff --git a/backend/authentication/migrations/0004_alter_user_faculty.py b/backend/authentication/migrations/0004_alter_user_faculty.py new file mode 100644 index 00000000..ed4fb71e --- /dev/null +++ b/backend/authentication/migrations/0004_alter_user_faculty.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-27 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_remove_faculty_user_remove_user_faculty_user_faculty'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='faculty', + field=models.ManyToManyField(related_name='faculties', to='authentication.faculty'), + ), + ] diff --git a/backend/authentication/migrations/0005_alter_user_faculty.py b/backend/authentication/migrations/0005_alter_user_faculty.py new file mode 100644 index 00000000..749276af --- /dev/null +++ b/backend/authentication/migrations/0005_alter_user_faculty.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-27 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0004_alter_user_faculty'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='faculty', + field=models.ManyToManyField(blank=True, related_name='faculties', to='authentication.faculty'), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index ea0e56c4..7814f43e 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -32,9 +32,10 @@ class User(AbstractBaseUser): unique=True ) - faculty = CharField( - max_length=50, - null = True + faculty = models.ManyToManyField( + 'Faculty', + related_name='faculties', + blank=True ) last_enrolled = IntegerField( @@ -59,11 +60,3 @@ class Faculty(models.Model): max_length=50, primary_key=True ) - - user = models.ForeignKey( - User, - on_delete=models.DO_NOTHING, - # This is how we can access groups from a project - related_name='faculties', - null=True - ) From 75e3da32d38017a43e6900ab001318c7f965761d Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Wed, 28 Feb 2024 07:06:09 +0100 Subject: [PATCH 088/397] chore: add faculty endpoint scafolding --- backend/api/migrations/populate.py | 6 +++++- backend/api/serializers/faculty_serializer.py | 10 ++++++++++ backend/api/urls.py | 5 +++++ backend/api/views/faculty_view.py | 8 ++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 backend/api/serializers/faculty_serializer.py create mode 100644 backend/api/views/faculty_view.py diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index 58d1a10f..d08f8eca 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -4,7 +4,7 @@ def populate_db(apps, schema_editor): with transaction.atomic(): - + #Faculty = apps.get_model("api", "Faculty") Teacher = apps.get_model("api", "Teacher") Student = apps.get_model("api", "Student") Course = apps.get_model("api", "Course") @@ -12,6 +12,10 @@ def populate_db(apps, schema_editor): Project = apps.get_model("api", "Project") Group = apps.get_model("api", "Group") + #faculty = Faculty.objects.create( + # name = "science" + #) + teacher1 = Teacher.objects.create( id=123, first_name="Tom", diff --git a/backend/api/serializers/faculty_serializer.py b/backend/api/serializers/faculty_serializer.py new file mode 100644 index 00000000..9e7d194b --- /dev/null +++ b/backend/api/serializers/faculty_serializer.py @@ -0,0 +1,10 @@ +from rest_framework import serializers +from authentication.models import Faculty + + +class facultySerializer(serializers.ModelSerializer): + class Meta: + model = Faculty + fields = [ + 'id', 'name' + ] diff --git a/backend/api/urls.py b/backend/api/urls.py index 53013049..cd105c33 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -8,6 +8,7 @@ from api.views import course_view from api.views import submision_view from api.views import checks_view +from api.views import faculty_view from rest_framework.routers import DefaultRouter router = DefaultRouter() @@ -51,6 +52,10 @@ r'fileExtensions', checks_view.FileExtensionViewSet, basename='fileExtension') +router.register( + r'facultys', + faculty_view.facultyViewSet, + basename='faculty') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views/faculty_view.py b/backend/api/views/faculty_view.py new file mode 100644 index 00000000..92975d0f --- /dev/null +++ b/backend/api/views/faculty_view.py @@ -0,0 +1,8 @@ +from rest_framework import viewsets +from authentication.models import Faculty +from ..serializers.faculty_serializer import facultySerializer + + +class facultyViewSet(viewsets.ModelViewSet): + queryset = Faculty.objects.all() + serializer_class = facultySerializer From bff4c9662ae6edd3a570c4dc6bc25e1d1e064ca6 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 12:17:20 +0100 Subject: [PATCH 089/397] fix: populate database --- backend/api/migrations/populate.py | 27 +++++++++++++++++---------- backend/authentication/models.py | 1 + 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index d08f8eca..4ad3a0ed 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -4,72 +4,78 @@ def populate_db(apps, schema_editor): with transaction.atomic(): - #Faculty = apps.get_model("api", "Faculty") Teacher = apps.get_model("api", "Teacher") Student = apps.get_model("api", "Student") Course = apps.get_model("api", "Course") Assistant = apps.get_model("api", "Assistant") Project = apps.get_model("api", "Project") Group = apps.get_model("api", "Group") + Faculty = apps.get_model("authentication", "Faculty") - #faculty = Faculty.objects.create( - # name = "science" - #) + f = Faculty.objects.create( + name="science" + ) teacher1 = Teacher.objects.create( id=123, first_name="Tom", last_name="Boonen", email="Tom.Boonen@gmail.be", - faculty="Science", create_time="2023-01-01T00:00:00Z", ) + teacher1.faculty.add(f) + assistant1 = Assistant.objects.create( id=235, first_name="Bart", last_name="Simpson", email="Bart.Simpson@gmail.be", - faculty="Science", create_time="2023-01-01T00:00:00Z", ) + assistant1.faculty.add(f) + assistant2 = Assistant.objects.create( id=236, first_name="Kim", last_name="Clijsters", email="Kim.Clijsters@gmail.be", - faculty="Science", create_time="2023-01-01T00:00:00Z", ) + assistant2.faculty.add(f) + teacher2 = Teacher.objects.create( id=124, first_name="Peter", last_name="Sagan", email="Peter.Sagan@gmail.com", - faculty="Engineering", create_time="2023-01-01T00:00:00Z", ) + teacher2.faculty.add(f) + student1 = Student.objects.create( id=1, first_name="John", last_name="Doe", email="John.Doe@hotmail.com", - faculty="Science", create_time="2023-01-01T00:00:00Z", ) + student1.faculty.add(f) + student2 = Student.objects.create( id=2, first_name="Bartje", last_name="Verhaege", email="Bartje.Verhaege@gmail.com", - faculty="Science", create_time="2023-01-01T00:00:00Z", ) + student2.faculty.add(f) + course = Course.objects.create( name="Math", academic_startyear=2023, @@ -114,6 +120,7 @@ def populate_db(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ ("api", "0001_initial"), + ("authentication", "0005_alter_user_faculty"), ] operations = [ diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7814f43e..f76a40aa 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -52,6 +52,7 @@ class User(AbstractBaseUser): EMAIL_FIELD = "email" REQUIRED_FIELDS = [] + class Faculty(models.Model): """This model represents a faculty.""" From ef700174a44cbe31956eb7352e8be7cb2f84bc88 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 13:22:58 +0100 Subject: [PATCH 090/397] chore: temporary update links --- backend/api/serializers/course_serializer.py | 22 +--- backend/api/urls.py | 13 --- backend/api/views/course_view.py | 113 ++++++++++++++++--- 3 files changed, 99 insertions(+), 49 deletions(-) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index ba9a0881..2465048c 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -3,28 +3,10 @@ class CourseSerializer(serializers.ModelSerializer): - assistants = serializers.HyperlinkedIdentityField( - view_name='course-assistants', - lookup_field='id', - lookup_url_kwarg='course_id', - ) teachers = serializers.HyperlinkedIdentityField( - view_name='course-teachers', # View that handles the hyperlink - lookup_field='id', # Field to use for the lookup - lookup_url_kwarg='course_id' # URL keyword argument to use - ) - - students = serializers.HyperlinkedIdentityField( - view_name='course-students', - lookup_field='id', - lookup_url_kwarg='course_id', - ) - - projects = serializers.HyperlinkedIdentityField( - view_name='course-projects', - lookup_field='id', - lookup_url_kwarg='course_id', + view_name='course-teachers', + read_only=True, ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index cd105c33..830d07db 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -60,19 +60,6 @@ urlpatterns = [ path('', include(router.urls)), - path('courses//teachers/', - course_view.CourseTeachersViewSet.as_view({'get': 'list'}), - name='course-teachers'), - path('courses//assistants/', - course_view.CourseAssistantsViewSet.as_view({'get': 'list'}), - name='course-assistants'), - path('courses//students/', - course_view.CourseStudentsViewSet.as_view({'get': 'list'}), - name='course-students'), - path('courses//projects/', - course_view.CourseProjectsViewSet.as_view({'get': 'list'}), - name='course-projects'), - path('assistants//courses/', assistant_view.AssistantCoursesViewSet.as_view({'get': 'list'}), name='assistant-courses'), diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index a6bca29a..863f2896 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,4 +1,5 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from ..models.course import Course from ..serializers.course_serializer import CourseSerializer @@ -12,10 +13,8 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer - -class CourseTeachersViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True) + def teachers(self, request, *args, **kwargs): """Returns a list of teachers for the given course""" course_id = kwargs.get('course_id') @@ -34,10 +33,8 @@ def list(self, request, *args, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) - -class CourseAssistantsViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def assistants(self, request, *args, **kwargs): """Returns a list of assistants for the given course""" course_id = kwargs.get('course_id') @@ -56,10 +53,8 @@ def list(self, request, *args, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) - -class CourseStudentsViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def students(self, request, *args, **kwargs): """Returns a list of students for the given course""" course_id = kwargs.get('course_id') @@ -78,10 +73,8 @@ def list(self, request, *args, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) - -class CourseProjectsViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def projects(self, request, *args, **kwargs): """Returns a list of projects for the given course""" course_id = kwargs.get('course_id') @@ -99,3 +92,91 @@ def list(self, request, *args, **kwargs): # Invalid course ID return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) + + +# class CourseTeachersViewSet(viewsets.ModelViewSet): + +# def list(self, request, *args, **kwargs): +# """Returns a list of teachers for the given course""" +# course_id = kwargs.get('course_id') + +# try: +# queryset = Course.objects.get(id=course_id) +# teachers = queryset.teachers.all() + +# # Serialize the teacher objects +# serializer = TeacherSerializer( +# teachers, many=True, context={'request': request} +# ) +# return Response(serializer.data) + +# except Course.DoesNotExist: +# # Invalid course ID +# return Response(status=status.HTTP_404_NOT_FOUND, +# data={"message": "Course not found"}) + + +# class CourseAssistantsViewSet(viewsets.ModelViewSet): + +# def list(self, request, *args, **kwargs): +# """Returns a list of assistants for the given course""" +# course_id = kwargs.get('course_id') + +# try: +# queryset = Course.objects.get(id=course_id) +# assistants = queryset.assistants.all() + +# # Serialize the assistant objects +# serializer = AssistantSerializer( +# assistants, many=True, context={'request': request} +# ) +# return Response(serializer.data) + +# except Course.DoesNotExist: +# # Invalid course ID +# return Response(status=status.HTTP_404_NOT_FOUND, +# data={"message": "Course not found"}) + + +# class CourseStudentsViewSet(viewsets.ModelViewSet): + +# def list(self, request, *args, **kwargs): +# """Returns a list of students for the given course""" +# course_id = kwargs.get('course_id') + +# try: +# queryset = Course.objects.get(id=course_id) +# students = queryset.students.all() + +# # Serialize the student objects +# serializer = StudentSerializer( +# students, many=True, context={'request': request} +# ) +# return Response(serializer.data) + +# except Course.DoesNotExist: +# # Invalid course ID +# return Response(status=status.HTTP_404_NOT_FOUND, +# data={"message": "Course not found"}) + + +# class CourseProjectsViewSet(viewsets.ModelViewSet): + +# def list(self, request, *args, **kwargs): +# """Returns a list of projects for the given course""" +# course_id = kwargs.get('course_id') + +# try: +# queryset = Course.objects.get(id=course_id) +# projects = queryset.projects.all() + +# # Serialize the project objects +# serializer = ProjectSerializer( +# projects, many=True, context={'request': request} +# ) +# return Response(serializer.data) + +# except Course.DoesNotExist: +# # Invalid course ID +# return Response(status=status.HTTP_404_NOT_FOUND, +# data={"message": "Course not found"}) From 45fc47d83982924a290ed8056944aec91db6dffd Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 13:35:48 +0100 Subject: [PATCH 091/397] fix: fix error + start refactor to '@actions' --- .../api/serializers/assistant_serializer.py | 4 +- backend/api/serializers/course_serializer.py | 17 ++- backend/api/urls.py | 4 - backend/api/views/assistant_view.py | 10 +- backend/api/views/course_view.py | 110 ++---------------- 5 files changed, 31 insertions(+), 114 deletions(-) diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 5aac9356..378f5322 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -3,10 +3,10 @@ class AssistantSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( view_name='assistant-courses', - lookup_field='id', - lookup_url_kwarg='assistant_id' + read_only=True, ) class Meta: diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 2465048c..378d41c4 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -5,7 +5,22 @@ class CourseSerializer(serializers.ModelSerializer): teachers = serializers.HyperlinkedIdentityField( - view_name='course-teachers', + view_name='course-teachers', + read_only=True, + ) + + assistants = serializers.HyperlinkedIdentityField( + view_name='course-assistants', + read_only=True, + ) + + students = serializers.HyperlinkedIdentityField( + view_name='course-students', + read_only=True, + ) + + projects = serializers.HyperlinkedIdentityField( + view_name='course-projects', read_only=True, ) diff --git a/backend/api/urls.py b/backend/api/urls.py index 830d07db..fd110abc 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -60,10 +60,6 @@ urlpatterns = [ path('', include(router.urls)), - path('assistants//courses/', - assistant_view.AssistantCoursesViewSet.as_view({'get': 'list'}), - name='assistant-courses'), - path('students//courses/', student_view.StudentCoursesViewSet.as_view({'get': 'list'}), name='student-courses'), diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index ee0cdaac..3d7d8ba8 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,4 +1,5 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer @@ -9,15 +10,12 @@ class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer - -class AssistantCoursesViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def courses(self, request, pk=None): """Returns a list of courses for the given assistant""" - assistant_id = kwargs.get('assistant_id') try: - queryset = Assistant.objects.get(id=assistant_id) + queryset = Assistant.objects.get(id=pk) courses = queryset.courses.all() # Serialize the course objects diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 863f2896..85012452 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -13,13 +13,12 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer - @action(detail=True) - def teachers(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def teachers(self, request, pk=None): """Returns a list of teachers for the given course""" - course_id = kwargs.get('course_id') try: - queryset = Course.objects.get(id=course_id) + queryset = Course.objects.get(id=pk) teachers = queryset.teachers.all() # Serialize the teacher objects @@ -34,12 +33,11 @@ def teachers(self, request, *args, **kwargs): data={"message": "Course not found"}) @action(detail=True, methods=['get']) - def assistants(self, request, *args, **kwargs): + def assistants(self, request, pk=None): """Returns a list of assistants for the given course""" - course_id = kwargs.get('course_id') try: - queryset = Course.objects.get(id=course_id) + queryset = Course.objects.get(id=pk) assistants = queryset.assistants.all() # Serialize the assistant objects @@ -54,12 +52,11 @@ def assistants(self, request, *args, **kwargs): data={"message": "Course not found"}) @action(detail=True, methods=['get']) - def students(self, request, *args, **kwargs): + def students(self, request, pk=None): """Returns a list of students for the given course""" - course_id = kwargs.get('course_id') try: - queryset = Course.objects.get(id=course_id) + queryset = Course.objects.get(id=pk) students = queryset.students.all() # Serialize the student objects @@ -74,12 +71,11 @@ def students(self, request, *args, **kwargs): data={"message": "Course not found"}) @action(detail=True, methods=['get']) - def projects(self, request, *args, **kwargs): + def projects(self, request, pk=None): """Returns a list of projects for the given course""" - course_id = kwargs.get('course_id') try: - queryset = Course.objects.get(id=course_id) + queryset = Course.objects.get(id=pk) projects = queryset.projects.all() # Serialize the project objects @@ -92,91 +88,3 @@ def projects(self, request, *args, **kwargs): # Invalid course ID return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"}) - - -# class CourseTeachersViewSet(viewsets.ModelViewSet): - -# def list(self, request, *args, **kwargs): -# """Returns a list of teachers for the given course""" -# course_id = kwargs.get('course_id') - -# try: -# queryset = Course.objects.get(id=course_id) -# teachers = queryset.teachers.all() - -# # Serialize the teacher objects -# serializer = TeacherSerializer( -# teachers, many=True, context={'request': request} -# ) -# return Response(serializer.data) - -# except Course.DoesNotExist: -# # Invalid course ID -# return Response(status=status.HTTP_404_NOT_FOUND, -# data={"message": "Course not found"}) - - -# class CourseAssistantsViewSet(viewsets.ModelViewSet): - -# def list(self, request, *args, **kwargs): -# """Returns a list of assistants for the given course""" -# course_id = kwargs.get('course_id') - -# try: -# queryset = Course.objects.get(id=course_id) -# assistants = queryset.assistants.all() - -# # Serialize the assistant objects -# serializer = AssistantSerializer( -# assistants, many=True, context={'request': request} -# ) -# return Response(serializer.data) - -# except Course.DoesNotExist: -# # Invalid course ID -# return Response(status=status.HTTP_404_NOT_FOUND, -# data={"message": "Course not found"}) - - -# class CourseStudentsViewSet(viewsets.ModelViewSet): - -# def list(self, request, *args, **kwargs): -# """Returns a list of students for the given course""" -# course_id = kwargs.get('course_id') - -# try: -# queryset = Course.objects.get(id=course_id) -# students = queryset.students.all() - -# # Serialize the student objects -# serializer = StudentSerializer( -# students, many=True, context={'request': request} -# ) -# return Response(serializer.data) - -# except Course.DoesNotExist: -# # Invalid course ID -# return Response(status=status.HTTP_404_NOT_FOUND, -# data={"message": "Course not found"}) - - -# class CourseProjectsViewSet(viewsets.ModelViewSet): - -# def list(self, request, *args, **kwargs): -# """Returns a list of projects for the given course""" -# course_id = kwargs.get('course_id') - -# try: -# queryset = Course.objects.get(id=course_id) -# projects = queryset.projects.all() - -# # Serialize the project objects -# serializer = ProjectSerializer( -# projects, many=True, context={'request': request} -# ) -# return Response(serializer.data) - -# except Course.DoesNotExist: -# # Invalid course ID -# return Response(status=status.HTTP_404_NOT_FOUND, -# data={"message": "Course not found"}) From 69a29a59520e2ef87827284309f6d902e6e25fd7 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 13:41:47 +0100 Subject: [PATCH 092/397] chore: student + teacher refactor with '@actions' --- backend/api/serializers/student_serializer.py | 11 +++++------ backend/api/serializers/teacher_serializer.py | 6 +++--- backend/api/urls.py | 14 +------------- backend/api/views/student_view.py | 19 +++++++------------ backend/api/views/teacher_view.py | 10 ++++------ 5 files changed, 20 insertions(+), 40 deletions(-) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 61e1e38a..4e5b794b 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -3,16 +3,15 @@ class StudentSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( - view_name='student-courses', - lookup_field='id', - lookup_url_kwarg='student_id' + view_name='student-courses', + read_only=True, ) groups = serializers.HyperlinkedIdentityField( - view_name='student-groups', - lookup_field='id', - lookup_url_kwarg='student_id' + view_name='student-groups', + read_only=True, ) class Meta: diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index 1a996afd..e24b0ed0 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -3,10 +3,10 @@ class TeacherSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( - view_name='teacher-courses', - lookup_field='id', - lookup_url_kwarg='teacher_id' + view_name='teacher-courses', + read_only=True, ) class Meta: diff --git a/backend/api/urls.py b/backend/api/urls.py index fd110abc..1bdb15e5 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -58,17 +58,5 @@ basename='faculty') urlpatterns = [ - path('', include(router.urls)), - - path('students//courses/', - student_view.StudentCoursesViewSet.as_view({'get': 'list'}), - name='student-courses'), - path('students//groups/', - student_view.StudentGroupsViewSet.as_view({'get': 'list'}), - name='student-groups'), - - path('teachers//courses/', - teacher_view.TeacherCoursesViewSet.as_view({'get': 'list'}), - name='teacher-courses'), - + path('', include(router.urls)), ] diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 085a9ace..b5f87e82 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,4 +1,5 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from ..models.student import Student from ..serializers.student_serializer import StudentSerializer @@ -10,15 +11,12 @@ class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer - -class StudentCoursesViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def courses(self, request, pk=None): """Returns a list of courses for the given student""" - student_id = kwargs.get('student_id') try: - queryset = Student.objects.get(id=student_id) + queryset = Student.objects.get(id=pk) courses = queryset.courses.all() # Serialize the course objects @@ -32,15 +30,12 @@ def list(self, request, *args, **kwargs): return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"}) - -class StudentGroupsViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def groups(self, request, pk=None): """Returns a list of groups for the given student""" - student_id = kwargs.get('student_id') try: - queryset = Student.objects.get(id=student_id) + queryset = Student.objects.get(id=pk) groups = queryset.groups.all() # Serialize the group objects diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index c57decd5..9f26361b 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,4 +1,5 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from ..models.teacher import Teacher from ..serializers.teacher_serializer import TeacherSerializer @@ -9,15 +10,12 @@ class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer - -class TeacherCoursesViewSet(viewsets.ModelViewSet): - - def list(self, request, *args, **kwargs): + @action(detail=True, methods=['get']) + def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" - teacher_id = kwargs.get('teacher_id') try: - queryset = Teacher.objects.get(id=teacher_id) + queryset = Teacher.objects.get(id=pk) courses = queryset.courses.all() # Serialize the course objects From 4d007be7191cee74697371b2c984f6556d728985 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 13:48:52 +0100 Subject: [PATCH 093/397] chore: link students from group --- backend/api/serializers/group_serializer.py | 5 +++++ backend/api/urls.py | 2 +- backend/api/views/group_view.py | 24 ++++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 0573a78e..9a9e1604 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -9,6 +9,11 @@ class GroupSerializer(serializers.ModelSerializer): view_name='project-detail' ) + students = serializers.HyperlinkedIdentityField( + view_name='group-students', + read_only=True, + ) + class Meta: model = Group fields = ['id', 'project', 'students', 'score'] diff --git a/backend/api/urls.py b/backend/api/urls.py index 1bdb15e5..4722cca6 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -58,5 +58,5 @@ basename='faculty') urlpatterns = [ - path('', include(router.urls)), + path('', include(router.urls)), ] diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 1db7064e..809fb893 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,8 +1,30 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response from ..models.group import Group from ..serializers.group_serializer import GroupSerializer +from ..serializers.student_serializer import StudentSerializer class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer + + @action(detail=True, methods=['get']) + def students(self, request, pk=None): + """Returns a list of students for the given group""" + + try: + queryset = Group.objects.get(id=pk) + students = queryset.students.all() + + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={'request': request} + ) + return Response(serializer.data) + + except Group.DoesNotExist: + # Invalid group ID + return Response(status=status.HTTP_404_NOT_FOUND, + data={"message": "Group not found"}) From e35b16abeb2f184e05d4b09d658ea81d5f238262 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Wed, 28 Feb 2024 13:54:29 +0100 Subject: [PATCH 094/397] feat(users): user API endpoints --- backend/authentication/serializers.py | 5 ++--- backend/authentication/signals.py | 4 +++- backend/authentication/urls.py | 13 ++++++++++--- backend/authentication/views/__init__.py | 0 backend/authentication/{views.py => views/auth.py} | 1 - backend/authentication/views/users.py | 8 ++++++++ 6 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 backend/authentication/views/__init__.py rename backend/authentication/{views.py => views/auth.py} (97%) create mode 100644 backend/authentication/views/users.py diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index a841f8dd..2d3f2357 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -56,7 +56,7 @@ class UserSerializer(ModelSerializer): id = CharField() username = CharField() email = EmailField() - + class Meta: model = User fields = [ @@ -71,8 +71,7 @@ def create(self, validated_data: dict) -> User: user, created = User.objects.get_or_create(**validated_data) if created: - user_created_signal.send( - sender=self, + user_created_signal.send(sender=self, attributes=validated_data ) diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py index c1f47888..123f4f26 100644 --- a/backend/authentication/signals.py +++ b/backend/authentication/signals.py @@ -1,3 +1,5 @@ from django.dispatch import Signal -user_created_signal = Signal() \ No newline at end of file +user_created_signal = Signal() +user_login_signal = Signal() +user_logout_signal = Signal() \ No newline at end of file diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 9e582ecd..4bdc45cd 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,9 +1,16 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView -from authentication.views import WhoAmIView, LoginView, LogoutView, TokenEchoView +from authentication.views.auth import WhoAmIView, LoginView, LogoutView, TokenEchoView +from authentication.views.users import UsersView + +router = DefaultRouter() +router.register('users', UsersView, basename='user') urlpatterns = [ - # CAS endpoints. + # USER endpoints. + path('', include(router.urls)), + # AUTH endpoints. path('login', LoginView.as_view(), name='auth.login'), path('logout', LogoutView.as_view(), name='auth.logout'), path('whoami', WhoAmIView.as_view(), name='auth.whoami'), diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/views.py b/backend/authentication/views/auth.py similarity index 97% rename from backend/authentication/views.py rename to backend/authentication/views/auth.py index df61466b..20730e50 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views/auth.py @@ -7,7 +7,6 @@ from authentication.cas.client import client from ypovoli import settings - class WhoAmIView(APIView): permission_classes = [IsAuthenticated] diff --git a/backend/authentication/views/users.py b/backend/authentication/views/users.py new file mode 100644 index 00000000..4c6c4b2b --- /dev/null +++ b/backend/authentication/views/users.py @@ -0,0 +1,8 @@ +from rest_framework.viewsets import GenericViewSet +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from authentication.models import User +from authentication.serializers import UserSerializer + +class UsersView(ListModelMixin, RetrieveModelMixin, GenericViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer \ No newline at end of file From bdf6e10589091a29509f5edf0de4d4c85596dfa3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 28 Feb 2024 13:55:07 +0100 Subject: [PATCH 095/397] fix: make notifications --- backend/.gitignore | 3 +- .../locale/en/LC_MESSAGES/django.mo | Bin 672 -> 0 bytes .../locale/en/LC_MESSAGES/django.po | 8 +-- .../locale/nl/LC_MESSAGES/django.mo | Bin 683 -> 0 bytes .../locale/nl/LC_MESSAGES/django.po | 8 +-- backend/notifications/migrations/0001.py | 7 +-- backend/notifications/models.py | 10 ++-- backend/notifications/serializers.py | 51 ++++++++---------- backend/ypovoli/settings.py | 7 +-- backend/ypovoli/urls.py | 8 +-- 10 files changed, 50 insertions(+), 52 deletions(-) delete mode 100644 backend/notifications/locale/en/LC_MESSAGES/django.mo delete mode 100644 backend/notifications/locale/nl/LC_MESSAGES/django.mo diff --git a/backend/.gitignore b/backend/.gitignore index 805a8c47..4c4ced1e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,5 @@ .venv .idea db.sqlite3 -__pycache__ \ No newline at end of file +__pycache__ +*.mo \ No newline at end of file diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.mo b/backend/notifications/locale/en/LC_MESSAGES/django.mo deleted file mode 100644 index 17117cc276935850ac4fdda3aff1e7ae1eb0d472..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 672 zcmZvZ&u-K(5XKD@A;PWVh|p9BsTK0la;m&t)U?T#i2PY)vz3;co0!y9YDczH;0=H~ zeThB{Z@__<;L2pT>b8QBe){b3H=2>>*ZSHIg7FY}h6KnXq(S=lifka?kQc}mvX1;j zo+7`IgL{PhL4A&}`-E(x&QYJBen)+bdWnj*MV|+#eXJpvUPd5nX=Ll9Rt%=4HUfB6 zi3(nB+Wxjh{^RJ{io4sMLb^uWsV8?**9Bx^^qD9fjVgL9j9oOH_=oTPqY#e6d>Un` zmy1>#M-yAiiXL>erG;kTO){E`3)$Du0J}4ohkj0zj0Qfu%UBY<`jDle7jx@qVYspl zciJ!*&*B(TKM7$kO#CR`_k$o0r_;A+RdvT}K?}jp8N_~iH0vkXi{t%94z#IP9-JVF zl5jQl&S2X+)XE8k?Y^`)j}xEW>$c%i4WT^c#tJu@6=S-)^7UhV6o!VX)Rj~_*iLO2q+r$^*5%GiA zB7PDNh~LEVEyn(k?hx*6#-5PABfU@hmGmCzcT%z~`)rVQwMo!+Up|_-7L653x-gw9 zjZl;&mawyHy44>0kE3nM!mfLAVQai`pWWEHzQ870eZaZpVad->n`P69ck~qaFrB_2tE}3hLY`xB=|bcsr?W1}VH6$JVxV-j z@=$^}jDuC|{lPQmL`jR1+I?y$kHstdw5barhcLe=v_U(Xpu1u3w*t` diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po index c9794802..4d9cb70f 100644 --- a/backend/notifications/locale/nl/LC_MESSAGES/django.po +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -20,10 +20,10 @@ msgstr "" # Score Added msgid "Title: Score added" msgstr "Score toegevoegd" -msgid "Description: Score added %(score)" -msgstr "Je hebt een score ontvangen: %(score)" +msgid "Description: Score added %(score)s" +msgstr "Je hebt een score ontvangen: %(score)s" # Score Updated msgid "Title: Score updated" msgstr "Nieuwe score" -msgid "Description: Score updated %(score)" -msgstr "Je score is geupdate.\nNieuwe score: %(score)" +msgid "Description: Score updated %(score)s" +msgstr "Je score is geupdate.\nNieuwe score: %(score)s" diff --git a/backend/notifications/migrations/0001.py b/backend/notifications/migrations/0001.py index ddc3ea1d..5d730ba2 100644 --- a/backend/notifications/migrations/0001.py +++ b/backend/notifications/migrations/0001.py @@ -17,8 +17,8 @@ class Migration(migrations.Migration): primary_key=True, ), ), - ("title", models.CharField(max_length=255)), - ("description", models.CharField(max_length=511)), + ("title_key", models.CharField(max_length=255)), + ("description_key", models.CharField(max_length=511)), ], ), migrations.CreateModel( @@ -34,7 +34,8 @@ class Migration(migrations.Migration): ( "user", models.ForeignKey( - to="authentication.User", on_delete=models.deletion.CASCADE + to="authentication.User", + on_delete=models.deletion.CASCADE, ), ), ( diff --git a/backend/notifications/models.py b/backend/notifications/models.py index fc44d3d7..235723cf 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -4,16 +4,16 @@ class NotificationTemplate(models.Model): id = models.AutoField(auto_created=True, primary_key=True) - title_key = models.CharField(required=True, max_length=255) - description_key = models.CharField(required=True, max_length=511) + title_key = models.CharField(max_length=255) + description_key = models.CharField(max_length=511) class Notification(models.Model): id = models.AutoField(auto_created=True, primary_key=True) - user = models.ForeignKey(User, required=True, on_delete=models.CASCADE) - template_id = models.ForeignKey(NotificationTemplate, required=True, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + template_id = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) - arguments = models.JSONField(required=False, default=dict) + arguments = models.JSONField(default=dict) is_read = models.BooleanField(default=False) is_sent = models.BooleanField(default=False) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index cc13cc79..d75c0166 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -10,39 +10,32 @@ class Meta: class NotificationSerializer(serializers.ModelSerializer): - user = serializers.HyperlinkedRelatedField( - view_name="user-detail", read_only=True, lookup_field="pk" - ) - title = serializers.SerializerMethodField() - description = serializers.SerializerMethodField() + message = serializers.SerializerMethodField() class Meta: model = Notification - exclude = ["template_id"] - - def get_title(self, obj): - return _(NotificationTemplate.objects.get(pk=obj.template_id).title_key) - - def get_description(self, obj): - description_key = NotificationTemplate.objects.get( - pk=obj.template_id - ).description_key - - return _(description_key).format(**obj.arguments) - - # def to_representation(self, instance): - # data = super().to_representation(instance) - # data["template_id"] = { - # "title_key": NotificationTemplate.objects.get( - # pk=instance.template_id - # ).title_key, - # "description_key": NotificationTemplate.objects.get( - # pk=instance.template_id - # ).description_key, - # } - - # return data + fields = [ + "id", + "user", + "template_id", + "arguments", + "message", + "created_at", + "is_read", + "is_sent", + ] + + def get_message(self, obj): + if obj.arguments != {}: + return { + "title": _(obj.template_id.title_key), + "description": _(obj.template_id.description_key) % obj.arguments, + } + else: + return {"title": "test", "description": "test2"} # TODO: NotificationSerializer exclude = ['user'] and use depth = 1 # TODO: HyperlinkedModelSerializer? +# TODO: serializer method field as class? +# TODO: Error checking diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index ee9036ef..c9103263 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -45,17 +45,18 @@ # First party "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application + "notifications", # Ypovoli notifications ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "django.middleware.locale.LocaleMiddleware", ] REST_FRAMEWORK = { @@ -108,6 +109,6 @@ USE_I18N = True -USE_L10N = True +USE_L10N = False -USE_TZ = True +USE_TZ = False diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index c39d0e7d..26d5d80f 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -14,11 +14,13 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import path, include + +from django.urls import include, path urlpatterns = [ # Base API endpoints. - path('/', include('api.urls')), + path("/", include("api.urls")), # Authentication endpoints. - path('auth/', include('authentication.urls')) + path("auth/", include("authentication.urls")), + path("notifications/", include("notifications.urls")), ] From 0983b997c090ebbe2631fe41ddcc0942d7681e3a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 28 Feb 2024 14:17:21 +0100 Subject: [PATCH 096/397] chore: link group in submission --- backend/api/serializers/course_serializer.py | 6 ++++++ backend/api/serializers/submision_serializer.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 378d41c4..3ff5e1c7 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -24,6 +24,12 @@ class CourseSerializer(serializers.ModelSerializer): read_only=True, ) + parent_course = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='course-detail' + ) + class Meta: model = Course fields = [ diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py index 6f92e926..3429022d 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submision_serializer.py @@ -9,6 +9,13 @@ class Meta: class SubmissionSerializer(serializers.ModelSerializer): + + group = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='group-detail' + ) + class Meta: model = Submission fields = ['id', 'group', 'submission_number', 'submission_time'] From 0ad42d4cff6548db6b415432feaf438451305a39 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Wed, 28 Feb 2024 14:27:23 +0100 Subject: [PATCH 097/397] chore: added explicit timezone support, fixed population warning --- backend/api/migrations/populate.py | 4 ++-- backend/ypovoli/settings.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index 4ad3a0ed..cbf04469 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -95,9 +95,9 @@ def populate_db(apps, schema_editor): visible=True, archived=False, # Set the start date as 26th February 2024 - start_date=date(2024, 2, 26), + start_date="2024-02-26T00:00:00+00:00", # Set the deadline as 27th February 2024 - deadline=date(2024, 2, 27), + deadline="2024-02-27T00:00:00+00:00", course=course2 ) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 60e1acb3..de1e400f 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -24,7 +24,6 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True - ALLOWED_HOSTS = [] From cbc766644c07fed6a6451d994c1100d211714647 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 28 Feb 2024 14:31:34 +0100 Subject: [PATCH 098/397] chore: arguments error checking --- backend/notifications/serializers.py | 54 +++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index d75c0166..080b8555 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,3 +1,6 @@ +import re +from typing import Dict, List + from django.utils.translation import gettext as _ from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -25,17 +28,42 @@ class Meta: "is_sent", ] + def _get_missing_keys(self, s: str, d: Dict[str, str]) -> List[str]: + required_keys = re.findall(r"%\((\w+)\)", s) + missing_keys = [key for key in required_keys if key not in d] + + return missing_keys + + def validate(self, data): + data = super().validate(data) + + if "arguments" not in data: + data["arguments"] = {} + + title_missing = self._get_missing_keys( + data["template_id"].title_key, data["arguments"] + ) + description_missing = self._get_missing_keys( + data["template_id"].description_key, data["arguments"] + ) + + if title_missing or description_missing: + raise serializers.ValidationError( + { + "missing arguments": { + "title": title_missing, + "description": description_missing, + } + } + ) + + return data + def get_message(self, obj): - if obj.arguments != {}: - return { - "title": _(obj.template_id.title_key), - "description": _(obj.template_id.description_key) % obj.arguments, - } - else: - return {"title": "test", "description": "test2"} - - -# TODO: NotificationSerializer exclude = ['user'] and use depth = 1 -# TODO: HyperlinkedModelSerializer? -# TODO: serializer method field as class? -# TODO: Error checking + return { + "title": _(obj.template_id.title_key), + "description": _(obj.template_id.description_key) % obj.arguments, + } + + +# TODO: HyperlinkedModelSerializer From 4423ce940de3a1be2238d1395d8230a18745f22f Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 28 Feb 2024 14:45:40 +0100 Subject: [PATCH 099/397] chore: added documentation --- backend/README.md | 3 +-- backend/fixtures/notification_template.yaml | 11 +++++++++++ backend/setup.sh | 13 +++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 backend/fixtures/notification_template.yaml create mode 100755 backend/setup.sh diff --git a/backend/README.md b/backend/README.md index c4765e91..c870a0eb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,7 +10,6 @@ __Django doesn't support python 3.12__ - Activate the virtual environment `source .venv/bin/activate` -- Install all requirements `pip install -r requirements.txt` +- Run `setup.sh` -- Migrate the database `python manage.py migrate` - Run the server `python manage.py runsslserver localhost:8080` \ No newline at end of file diff --git a/backend/fixtures/notification_template.yaml b/backend/fixtures/notification_template.yaml new file mode 100644 index 00000000..baaf6c2d --- /dev/null +++ b/backend/fixtures/notification_template.yaml @@ -0,0 +1,11 @@ +- model: notifications.notificationtemplate + pk: 1 + fields: + title_key: "Title: Score added" + description_key: "Description: Score added %(score)s" +- model: notifications.notificationtemplate + pk: 2 + fields: + title_key: "Title: Score updated" + description_key: "Description: Score updated %(score)s" + \ No newline at end of file diff --git a/backend/setup.sh b/backend/setup.sh new file mode 100755 index 00000000..a667bf67 --- /dev/null +++ b/backend/setup.sh @@ -0,0 +1,13 @@ +echo "Installing requirements..." +pip install -r requirements.txt > /dev/null + +echo "Migrating database..." +python manage.py migrate > /dev/null + +echo "Populating database..." +python manage.py loaddata fixtures/* > /dev/null + +echo "Compiling translations..." +django-admin compilemessages > /dev/null + +echo "Done" \ No newline at end of file From aab308897c6a4635abcbb1feb64fd68f248a7a74 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 28 Feb 2024 15:35:47 +0100 Subject: [PATCH 100/397] chore: change to viewset --- backend/notifications/urls.py | 14 ++++++-------- backend/notifications/views.py | 9 ++------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index 5912829c..cdd8a247 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,10 +1,8 @@ -from django.urls import path -from notifications import views -from rest_framework.urlpatterns import format_suffix_patterns +from notifications.views import NotificationViewSet +from rest_framework.routers import DefaultRouter -urlpatterns = [ - path("", views.NotificationList.as_view()), - path("/", views.NotificationDetail.as_view()), -] +router = DefaultRouter() -urlpatterns = format_suffix_patterns(urlpatterns) +router.register(r"", NotificationViewSet, basename="notification") + +urlpatterns = router.urls diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 7e2e7047..88bbbb54 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,13 +1,8 @@ from notifications.models import Notification from notifications.serializers import NotificationSerializer -from rest_framework import generics +from rest_framework.viewsets import ModelViewSet -class NotificationList(generics.ListCreateAPIView): - queryset = Notification.objects.all() - serializer_class = NotificationSerializer - - -class NotificationDetail(generics.RetrieveUpdateDestroyAPIView): +class NotificationViewSet(ModelViewSet): queryset = Notification.objects.all() serializer_class = NotificationSerializer From b6b71a145d5720421b2c236e950a008b27d6150a Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Wed, 28 Feb 2024 18:47:02 +0100 Subject: [PATCH 101/397] chore: add extra facultys and fix them and fix linter warnings --- backend/api/migrations/populate.py | 41 +++++++++++++++---- backend/api/serializers/faculty_serializer.py | 2 +- backend/api/views/checks_view.py | 4 +- backend/api/views/submision_view.py | 4 +- .../authentication/migrations/0001_initial.py | 6 ++- .../authentication/migrations/0002_faculty.py | 8 +++- ...y_user_remove_user_faculty_user_faculty.py | 3 +- .../migrations/0004_alter_user_faculty.py | 6 ++- .../migrations/0005_alter_user_faculty.py | 3 +- backend/authentication/models.py | 7 ++-- backend/authentication/serializers.py | 4 +- backend/authentication/urls.py | 2 +- backend/authentication/views.py | 4 +- backend/checks/admin.py | 2 +- backend/checks/models.py | 2 +- backend/checks/tests.py | 2 +- backend/checks/views.py | 2 +- backend/notifications/admin.py | 2 +- backend/notifications/models.py | 2 +- backend/notifications/tests.py | 2 +- backend/notifications/views.py | 2 +- 21 files changed, 76 insertions(+), 34 deletions(-) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index cbf04469..eedc8b78 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -1,5 +1,5 @@ from django.db import migrations, transaction -from datetime import date +# from datetime import date def populate_db(apps, schema_editor): @@ -12,9 +12,32 @@ def populate_db(apps, schema_editor): Group = apps.get_model("api", "Group") Faculty = apps.get_model("authentication", "Faculty") - f = Faculty.objects.create( - name="science" + # Faculteit Letteren en Wijsbegeerte + Faculty.objects.create(name="Letteren_Wijsbegeerte") + # Faculteit Recht en Criminologie + Faculty.objects.create(name="Recht_Criminologie") + # Faculteit Wetenschappen + f_wet = Faculty.objects.create(name="Wetenschappen") + # Faculteit Geneeskunde en Gezondheidswetenschappen + f_genGez = Faculty.objects.create( + name="Geneeskunde_Gezondheidswetenschappen" + ) + # Faculteit Ingenieurswetenschappen en Architectuur + Faculty.objects.create(name="Ingenieurswetenschappen_Architectuur") + # Faculteit Economie en Bedrijfskunde + Faculty.objects.create(name="Economie_Bedrijfskunde") + # Faculteit Diergeneeskunde + Faculty.objects.create(name="Diergeneeskunde") + # Faculteit Psychologie en Pedagogische Wetenschappen + f_psyPeda = Faculty.objects.create( + name="Psychologie_PedagogischeWetenschappen" ) + # Faculteit Bio-ingenieurswetenschappen + Faculty.objects.create(name="Bio-ingenieurswetenschappen") + # Faculteit Farmaceutische Wetenschappen + Faculty.objects.create(name="Farmaceutische_Wetenschappen") + # Faculteit Politieke en Sociale Wetenschappen + Faculty.objects.create(name="Politieke_Sociale_Wetenschappen") teacher1 = Teacher.objects.create( id=123, @@ -24,7 +47,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - teacher1.faculty.add(f) + teacher1.faculty.add(f_psyPeda) assistant1 = Assistant.objects.create( id=235, @@ -34,7 +57,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant1.faculty.add(f) + assistant1.faculty.add(f_wet) assistant2 = Assistant.objects.create( id=236, @@ -44,7 +67,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant2.faculty.add(f) + assistant2.faculty.add(f_psyPeda) teacher2 = Teacher.objects.create( id=124, @@ -54,7 +77,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - teacher2.faculty.add(f) + teacher2.faculty.add(f_psyPeda) student1 = Student.objects.create( id=1, @@ -64,7 +87,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - student1.faculty.add(f) + student1.faculty.add(f_wet) student2 = Student.objects.create( id=2, @@ -74,7 +97,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - student2.faculty.add(f) + student2.faculty.add(f_genGez) course = Course.objects.create( name="Math", diff --git a/backend/api/serializers/faculty_serializer.py b/backend/api/serializers/faculty_serializer.py index 9e7d194b..9c22e1ce 100644 --- a/backend/api/serializers/faculty_serializer.py +++ b/backend/api/serializers/faculty_serializer.py @@ -6,5 +6,5 @@ class facultySerializer(serializers.ModelSerializer): class Meta: model = Faculty fields = [ - 'id', 'name' + 'name' ] diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 654eb1f1..9d136f23 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,6 +1,8 @@ from rest_framework import viewsets from ..models.checks import Checks, FileExtension -from ..serializers.checks_serializer import ChecksSerializer, FileExtensionSerializer +from ..serializers.checks_serializer import ( + ChecksSerializer, FileExtensionSerializer +) class ChecksViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index c7a25333..72c95e45 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,6 +1,8 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile -from ..serializers.submision_serializer import SubmissionSerializer, SubmissionFileSerializer +from ..serializers.submision_serializer import ( + SubmissionSerializer, SubmissionFileSerializer +) class SubmissionFileViewSet(viewsets.ModelViewSet): diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index 46f3cb5a..20cfef92 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -15,8 +15,10 @@ class Migration(migrations.Migration): name='User', fields=[ ('create_time', models.DateTimeField(auto_created=True)), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('last_login', models.DateTimeField(blank=True, null=True, + verbose_name='last login')), + ('id', models.CharField(max_length=12, primary_key=True, + serialize=False)), ('first_name', models.CharField(max_length=50)), ('last_name', models.CharField(max_length=50)), ('email', models.EmailField(max_length=254, unique=True)), diff --git a/backend/authentication/migrations/0002_faculty.py b/backend/authentication/migrations/0002_faculty.py index 0928d1cb..63b9c8e3 100644 --- a/backend/authentication/migrations/0002_faculty.py +++ b/backend/authentication/migrations/0002_faculty.py @@ -15,8 +15,12 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Faculty', fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='faculties', to=settings.AUTH_USER_MODEL)), + ('name', models.CharField(max_length=50, primary_key=True, + serialize=False)), + ('user', models.ForeignKey(null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='faculties', + to=settings.AUTH_USER_MODEL)), ], ), ] diff --git a/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py b/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py index 37f0e5fa..741fab07 100644 --- a/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py +++ b/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py @@ -21,6 +21,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='user', name='faculty', - field=models.ManyToManyField(null=True, related_name='faculties', to='authentication.faculty'), + field=models.ManyToManyField(null=True, related_name='faculties', + to='authentication.faculty'), ), ] diff --git a/backend/authentication/migrations/0004_alter_user_faculty.py b/backend/authentication/migrations/0004_alter_user_faculty.py index ed4fb71e..70c5f381 100644 --- a/backend/authentication/migrations/0004_alter_user_faculty.py +++ b/backend/authentication/migrations/0004_alter_user_faculty.py @@ -6,13 +6,15 @@ class Migration(migrations.Migration): dependencies = [ - ('authentication', '0003_remove_faculty_user_remove_user_faculty_user_faculty'), + ('authentication', + '0003_remove_faculty_user_remove_user_faculty_user_faculty'), ] operations = [ migrations.AlterField( model_name='user', name='faculty', - field=models.ManyToManyField(related_name='faculties', to='authentication.faculty'), + field=models.ManyToManyField(related_name='faculties', + to='authentication.faculty'), ), ] diff --git a/backend/authentication/migrations/0005_alter_user_faculty.py b/backend/authentication/migrations/0005_alter_user_faculty.py index 749276af..25b0e734 100644 --- a/backend/authentication/migrations/0005_alter_user_faculty.py +++ b/backend/authentication/migrations/0005_alter_user_faculty.py @@ -13,6 +13,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='user', name='faculty', - field=models.ManyToManyField(blank=True, related_name='faculties', to='authentication.faculty'), + field=models.ManyToManyField(blank=True, related_name='faculties', + to='authentication.faculty'), ), ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index f76a40aa..6bc37dab 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -4,13 +4,14 @@ from django.db.models import CharField, EmailField, IntegerField, DateTimeField from django.contrib.auth.models import AbstractBaseUser + class User(AbstractBaseUser): """This model represents a single authenticatable user. It extends the built-in Django user model with CAS-specific attributes. """ """Model fields""" - password = None # We don't use passwords for our user model. + password = None # We don't use passwords for our user model. id = CharField( max_length=12, @@ -39,8 +40,8 @@ class User(AbstractBaseUser): ) last_enrolled = IntegerField( - default = datetime.MINYEAR, - null = True + default=datetime.MINYEAR, + null=True ) create_time = DateTimeField( diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 060436d8..0c00fbdf 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,6 @@ from rest_framework.serializers import Serializer, CharField + class CASTicketSerializer(Serializer): """Serializer for CAS ticket validation""" ticket = CharField(required=True, min_length=49, max_length=49) @@ -8,5 +9,6 @@ class CASTicketSerializer(Serializer): class CASUserSerializer(Serializer): """Serializer for CAS success responses""" + class UserSerializer(Serializer): - """Serializer for User models""" \ No newline at end of file + """Serializer for User models""" diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index b58230f0..8331ee76 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -5,4 +5,4 @@ path('login', login, name='auth.login'), path('validate', validate, name='auth.validate'), path('logout', logout, name='auth.logout') -] \ No newline at end of file +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index f5767e7d..523dcbe9 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,4 +1,4 @@ -from django.shortcuts import redirect, reverse +from django.shortcuts import redirect from rest_framework.decorators import api_view from rest_framework.request import Request from rest_framework.response import Response @@ -12,6 +12,7 @@ auth_prefix='' ) + @api_view(['GET']) def login(_: Request) -> Response: """Attempt to log in. @@ -47,6 +48,7 @@ def validate(request: Request) -> Response: 'errors': ticket.errors }) + @api_view(['POST']) def logout(request: Request) -> Response: """Attempt to log out. diff --git a/backend/checks/admin.py b/backend/checks/admin.py index 8c38f3f3..4185d360 100644 --- a/backend/checks/admin.py +++ b/backend/checks/admin.py @@ -1,3 +1,3 @@ -from django.contrib import admin +# from django.contrib import admin # Register your models here. diff --git a/backend/checks/models.py b/backend/checks/models.py index 71a83623..0b4331b3 100644 --- a/backend/checks/models.py +++ b/backend/checks/models.py @@ -1,3 +1,3 @@ -from django.db import models +# from django.db import models # Create your models here. diff --git a/backend/checks/tests.py b/backend/checks/tests.py index 7ce503c2..a79ca8be 100644 --- a/backend/checks/tests.py +++ b/backend/checks/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/backend/checks/views.py b/backend/checks/views.py index 91ea44a2..fd0e0449 100644 --- a/backend/checks/views.py +++ b/backend/checks/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render +# from django.shortcuts import render # Create your views here. diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py index 8c38f3f3..4185d360 100644 --- a/backend/notifications/admin.py +++ b/backend/notifications/admin.py @@ -1,3 +1,3 @@ -from django.contrib import admin +# from django.contrib import admin # Register your models here. diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 71a83623..0b4331b3 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -1,3 +1,3 @@ -from django.db import models +# from django.db import models # Create your models here. diff --git a/backend/notifications/tests.py b/backend/notifications/tests.py index 7ce503c2..a79ca8be 100644 --- a/backend/notifications/tests.py +++ b/backend/notifications/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase +# from django.test import TestCase # Create your tests here. diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 91ea44a2..fd0e0449 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render +# from django.shortcuts import render # Create your views here. From bdc20876c88479aea3e2858e660864327966cba2 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Wed, 28 Feb 2024 22:06:02 +0100 Subject: [PATCH 102/397] fix: outdated commits --- backend/authentication/models.py | 45 ++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index ae0b9ddf..ea0e56c4 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,5 +1,7 @@ -from __future__ import annotations -from django.db.models import CharField, EmailField, DateTimeField +import datetime + +from django.db import models +from django.db.models import CharField, EmailField, IntegerField, DateTimeField from django.contrib.auth.models import AbstractBaseUser class User(AbstractBaseUser): @@ -15,12 +17,6 @@ class User(AbstractBaseUser): primary_key=True ) - username = CharField( - max_length=10, - null=False, - unique=True - ) - first_name = CharField( max_length=50, null=False @@ -38,21 +34,36 @@ class User(AbstractBaseUser): faculty = CharField( max_length=50, - null = True, - blank = True + null = True ) - last_enrolled = CharField( - max_length=11, - null = True, - blank = True + last_enrolled = IntegerField( + default = datetime.MINYEAR, + null = True ) create_time = DateTimeField( - auto_now_add=True + auto_created=True ) """Model settings""" - USERNAME_FIELD = "username" + USERNAME_FIELD = "email" EMAIL_FIELD = "email" - REQUIRED_FIELDS = [] \ No newline at end of file + REQUIRED_FIELDS = [] + +class Faculty(models.Model): + """This model represents a faculty.""" + + """Model fields""" + name = CharField( + max_length=50, + primary_key=True + ) + + user = models.ForeignKey( + User, + on_delete=models.DO_NOTHING, + # This is how we can access groups from a project + related_name='faculties', + null=True + ) From 045fdba631400259a3180e75eeab3a221b77166a Mon Sep 17 00:00:00 2001 From: EwoutV Date: Wed, 28 Feb 2024 22:07:40 +0100 Subject: [PATCH 103/397] fix: use tz --- backend/ypovoli/settings.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 9b02be29..8733f6cb 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -103,11 +103,7 @@ # Internationalization LANGUAGE_CODE = "en-us" - TIME_ZONE = "UTC" - USE_I18N = True - USE_L10N = False - -USE_TZ = False +USE_TZ = True From 141a0b8f9c05cfb871417746093a1cb7e0dce0ac Mon Sep 17 00:00:00 2001 From: EwoutV Date: Wed, 28 Feb 2024 22:10:44 +0100 Subject: [PATCH 104/397] fix: database seeders --- backend/api/migrations/populate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/populate.py index eedc8b78..bae83d71 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/populate.py @@ -44,6 +44,7 @@ def populate_db(apps, schema_editor): first_name="Tom", last_name="Boonen", email="Tom.Boonen@gmail.be", + username="tboonen", create_time="2023-01-01T00:00:00Z", ) @@ -53,6 +54,7 @@ def populate_db(apps, schema_editor): id=235, first_name="Bart", last_name="Simpson", + username="bsimpson", email="Bart.Simpson@gmail.be", create_time="2023-01-01T00:00:00Z", ) @@ -63,6 +65,7 @@ def populate_db(apps, schema_editor): id=236, first_name="Kim", last_name="Clijsters", + username="kclijster", email="Kim.Clijsters@gmail.be", create_time="2023-01-01T00:00:00Z", ) @@ -73,6 +76,7 @@ def populate_db(apps, schema_editor): id=124, first_name="Peter", last_name="Sagan", + username="psagan", email="Peter.Sagan@gmail.com", create_time="2023-01-01T00:00:00Z", ) @@ -83,6 +87,7 @@ def populate_db(apps, schema_editor): id=1, first_name="John", last_name="Doe", + username="jdoe", email="John.Doe@hotmail.com", create_time="2023-01-01T00:00:00Z", ) @@ -93,6 +98,7 @@ def populate_db(apps, schema_editor): id=2, first_name="Bartje", last_name="Verhaege", + username="bverhae", email="Bartje.Verhaege@gmail.com", create_time="2023-01-01T00:00:00Z", ) From d8f89cf4b6f2ebc529117008f23650c04d188750 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 00:05:04 +0100 Subject: [PATCH 105/397] feat: automatically link students to created users, if CAS student_id is present --- backend/api/apps.py | 11 +--- .../migrations/0001_alter_project_course.py | 19 ------- backend/api/migrations/0001_initial.py | 5 +- .../{populate.py => 0002_populate.py} | 17 +++--- backend/api/models/student.py | 6 +++ backend/api/signals.py | 12 +++++ backend/api/urls.py | 2 +- .../authentication/migrations/0001_initial.py | 25 ++++++--- .../authentication/migrations/0002_faculty.py | 26 --------- ...y_user_remove_user_faculty_user_faculty.py | 27 ---------- .../migrations/0004_alter_user_faculty.py | 20 ------- .../migrations/0005_alter_user_faculty.py | 19 ------- backend/authentication/models.py | 36 +++++++------ backend/authentication/serializers.py | 33 +++++++----- backend/authentication/signals.py | 6 +-- .../fixtures/notification_template.yaml | 0 backend/notifications/migrations/0001.py | 54 ------------------- .../notifications/migrations/0001_initial.py | 37 +++++++++++++ backend/ypovoli/settings.py | 2 + backend/ypovoli/urls.py | 2 +- 20 files changed, 133 insertions(+), 226 deletions(-) delete mode 100644 backend/api/migrations/0001_alter_project_course.py rename backend/api/migrations/{populate.py => 0002_populate.py} (91%) create mode 100644 backend/api/signals.py delete mode 100644 backend/authentication/migrations/0002_faculty.py delete mode 100644 backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py delete mode 100644 backend/authentication/migrations/0004_alter_user_faculty.py delete mode 100644 backend/authentication/migrations/0005_alter_user_faculty.py rename backend/{ => notifications}/fixtures/notification_template.yaml (100%) delete mode 100644 backend/notifications/migrations/0001.py create mode 100644 backend/notifications/migrations/0001_initial.py diff --git a/backend/api/apps.py b/backend/api/apps.py index 21c41595..60def201 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -1,17 +1,8 @@ from django.apps import AppConfig -from authentication.signals import user_created_signal - -def receiver(sender, **kwargs): - # todo: handle the user created event - # todo: move this function to a more appropriate module - pass class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' def ready(self): - user_created_signal.connect( - receiver=receiver, - dispatch_uid='user_created_signal' - ) \ No newline at end of file + from . import signals \ No newline at end of file diff --git a/backend/api/migrations/0001_alter_project_course.py b/backend/api/migrations/0001_alter_project_course.py deleted file mode 100644 index bd7d0f71..00000000 --- a/backend/api/migrations/0001_alter_project_course.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-27 09:54 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', 'populate'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='course', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course'), - ), - ] diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py index 781576fd..bb01d604 100644 --- a/backend/api/migrations/0001_initial.py +++ b/backend/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-25 17:11 +# Generated by Django 5.0.2 on 2024-02-28 21:55 import datetime import django.db.models.deletion @@ -73,13 +73,14 @@ class Migration(migrations.Migration): ('start_date', models.DateTimeField(blank=True, default=datetime.datetime.now)), ('deadline', models.DateTimeField()), ('checks', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.checks')), - ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.course')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course')), ], ), migrations.CreateModel( name='Student', fields=[ ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('student_id', models.CharField(max_length=8, null=True, unique=True)), ('courses', models.ManyToManyField(blank=True, related_name='students', to='api.course')), ], options={ diff --git a/backend/api/migrations/populate.py b/backend/api/migrations/0002_populate.py similarity index 91% rename from backend/api/migrations/populate.py rename to backend/api/migrations/0002_populate.py index bae83d71..a289fd86 100644 --- a/backend/api/migrations/populate.py +++ b/backend/api/migrations/0002_populate.py @@ -1,17 +1,16 @@ from django.db import migrations, transaction +from api.models.teacher import Teacher +from api.models.student import Student +from api.models.course import Course +from api.models.assistant import Assistant +from api.models.project import Project +from api.models.group import Group +from authentication.models import Faculty # from datetime import date def populate_db(apps, schema_editor): with transaction.atomic(): - Teacher = apps.get_model("api", "Teacher") - Student = apps.get_model("api", "Student") - Course = apps.get_model("api", "Course") - Assistant = apps.get_model("api", "Assistant") - Project = apps.get_model("api", "Project") - Group = apps.get_model("api", "Group") - Faculty = apps.get_model("authentication", "Faculty") - # Faculteit Letteren en Wijsbegeerte Faculty.objects.create(name="Letteren_Wijsbegeerte") # Faculteit Recht en Criminologie @@ -149,7 +148,7 @@ def populate_db(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ ("api", "0001_initial"), - ("authentication", "0005_alter_user_faculty"), + ("authentication", "0001_initial"), ] operations = [ diff --git a/backend/api/models/student.py b/backend/api/models/student.py index 3d5b948b..8eec4be0 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -7,6 +7,12 @@ class Student(User): It extends the User model from the authentication app with student-specific attributes. """ + # The student's Ghent University ID + student_id = models.CharField( + max_length=8, + null=True, + unique=True + ) # All the courses the student is enrolled in courses = models.ManyToManyField( diff --git a/backend/api/signals.py b/backend/api/signals.py new file mode 100644 index 00000000..7cfd15b2 --- /dev/null +++ b/backend/api/signals.py @@ -0,0 +1,12 @@ +from django.dispatch import receiver +from authentication.signals import user_created +from authentication.models import User +from api.models.student import Student + +@receiver(user_created) +def user_creation(user: User, attributes: dict, **kwargs): + """Upon user creation, auto-populate additional properties""" + student_id = attributes.get("ugentStudentID") + + if student_id: + Student(user_ptr=user,student_id=student_id).save_base(raw=True) \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index 4722cca6..c95373cc 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -53,7 +53,7 @@ checks_view.FileExtensionViewSet, basename='fileExtension') router.register( - r'facultys', + r'faculties', faculty_view.facultyViewSet, basename='faculty') diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index 6ea41806..a86e2591 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.0.2 on 2024-02-25 13:40 +# Generated by Django 5.0.2 on 2024-02-28 21:55 +from django.conf import settings from django.db import migrations, models @@ -16,17 +17,27 @@ class Migration(migrations.Migration): fields=[ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), - ('student_id', models.CharField(max_length=16, null=True, unique=True)), - ('username', models.CharField(max_length=10, unique=True)), + ('username', models.CharField(max_length=12, unique=True)), + ('email', models.EmailField(max_length=254, unique=True)), ('first_name', models.CharField(max_length=50)), ('last_name', models.CharField(max_length=50)), - ('email', models.EmailField(max_length=254, unique=True)), - ('faculty', models.CharField(blank=True, max_length=50, null=True)), - ('last_enrolled', models.CharField(blank=True, max_length=11, null=True)), - ('create_time', models.DateTimeField(auto_now_add=True)), + ('last_enrolled', models.IntegerField(default=1, null=True)), + ('create_time', models.DateTimeField(auto_now=True)), ], options={ 'abstract': False, }, ), + migrations.CreateModel( + name='Faculty', + fields=[ + ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ('user', models.ManyToManyField(blank=True, related_name='users', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='user', + name='faculty', + field=models.ManyToManyField(blank=True, related_name='faculties', to='authentication.faculty'), + ), ] diff --git a/backend/authentication/migrations/0002_faculty.py b/backend/authentication/migrations/0002_faculty.py deleted file mode 100644 index 63b9c8e3..00000000 --- a/backend/authentication/migrations/0002_faculty.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-26 20:33 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Faculty', - fields=[ - ('name', models.CharField(max_length=50, primary_key=True, - serialize=False)), - ('user', models.ForeignKey(null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name='faculties', - to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py b/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py deleted file mode 100644 index 741fab07..00000000 --- a/backend/authentication/migrations/0003_remove_faculty_user_remove_user_faculty_user_faculty.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-27 21:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0002_faculty'), - ] - - operations = [ - migrations.RemoveField( - model_name='faculty', - name='user', - ), - migrations.RemoveField( - model_name='user', - name='faculty', - ), - migrations.AddField( - model_name='user', - name='faculty', - field=models.ManyToManyField(null=True, related_name='faculties', - to='authentication.faculty'), - ), - ] diff --git a/backend/authentication/migrations/0004_alter_user_faculty.py b/backend/authentication/migrations/0004_alter_user_faculty.py deleted file mode 100644 index 70c5f381..00000000 --- a/backend/authentication/migrations/0004_alter_user_faculty.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-27 21:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', - '0003_remove_faculty_user_remove_user_faculty_user_faculty'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='faculty', - field=models.ManyToManyField(related_name='faculties', - to='authentication.faculty'), - ), - ] diff --git a/backend/authentication/migrations/0005_alter_user_faculty.py b/backend/authentication/migrations/0005_alter_user_faculty.py deleted file mode 100644 index 25b0e734..00000000 --- a/backend/authentication/migrations/0005_alter_user_faculty.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-27 21:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0004_alter_user_faculty'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='faculty', - field=models.ManyToManyField(blank=True, related_name='faculties', - to='authentication.faculty'), - ), - ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index ea0e56c4..7bef9b4c 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -17,6 +17,16 @@ class User(AbstractBaseUser): primary_key=True ) + username = CharField( + max_length=12, + unique=True + ) + + email = EmailField( + null=False, + unique=True + ) + first_name = CharField( max_length=50, null=False @@ -27,14 +37,10 @@ class User(AbstractBaseUser): null=False ) - email = EmailField( - null=False, - unique=True - ) - - faculty = CharField( - max_length=50, - null = True + faculty = models.ManyToManyField( + 'Faculty', + related_name='faculties', + blank=True ) last_enrolled = IntegerField( @@ -43,11 +49,11 @@ class User(AbstractBaseUser): ) create_time = DateTimeField( - auto_created=True + auto_now=True ) """Model settings""" - USERNAME_FIELD = "email" + USERNAME_FIELD = "username" EMAIL_FIELD = "email" REQUIRED_FIELDS = [] @@ -60,10 +66,8 @@ class Faculty(models.Model): primary_key=True ) - user = models.ForeignKey( - User, - on_delete=models.DO_NOTHING, - # This is how we can access groups from a project - related_name='faculties', - null=True + user = models.ManyToManyField( + 'User', + related_name='users', + blank=True ) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 2d3f2357..9c7e8173 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -2,7 +2,7 @@ from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings -from authentication.signals import user_created_signal +from authentication.signals import user_created, user_login from authentication.models import User from authentication.cas.client import client @@ -26,24 +26,40 @@ def validate(self, data): # Validation success: create user if it doesn't exist yet. attributes = response.data.get('attributes', dict) + if attributes.get('lastenrolled'): + attributes['lastenrolled'] = int(attributes.get('lastenrolled').split()[0]) + user = UserSerializer(data={ 'id': attributes.get('ugentID'), 'username': attributes.get('uid'), 'email': attributes.get('mail'), 'first_name': attributes.get('givenname'), 'last_name': attributes.get('surname'), - 'faculty': attributes.get('faculty'), 'last_enrolled': attributes.get('lastenrolled') }) if not user.is_valid(): raise ValidationError(user.errors) - user = user.create(user.validated_data) + user, created = user.get_or_create( + user.validated_data + ) + # Update the user's last login. if api_settings.UPDATE_LAST_LOGIN: update_last_login(self, user) + user_login.send(sender=self, + user=user + ) + + # Send signal upon creation. + if created: + user_created.send(sender=self, + attributes=attributes, + user=user + ) + return { 'access': str(AccessToken.for_user(user)), 'refresh': str(RefreshToken.for_user(user)) @@ -66,13 +82,6 @@ class Meta: 'last_enrolled', 'last_login', 'create_time' ] - def create(self, validated_data: dict) -> User: + def get_or_create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" - user, created = User.objects.get_or_create(**validated_data) - - if created: - user_created_signal.send(sender=self, - attributes=validated_data - ) - - return user \ No newline at end of file + return User.objects.get_or_create(**validated_data) \ No newline at end of file diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py index 123f4f26..e584b6bf 100644 --- a/backend/authentication/signals.py +++ b/backend/authentication/signals.py @@ -1,5 +1,5 @@ from django.dispatch import Signal -user_created_signal = Signal() -user_login_signal = Signal() -user_logout_signal = Signal() \ No newline at end of file +user_created = Signal() +user_login = Signal() +user_logout = Signal() \ No newline at end of file diff --git a/backend/fixtures/notification_template.yaml b/backend/notifications/fixtures/notification_template.yaml similarity index 100% rename from backend/fixtures/notification_template.yaml rename to backend/notifications/fixtures/notification_template.yaml diff --git a/backend/notifications/migrations/0001.py b/backend/notifications/migrations/0001.py deleted file mode 100644 index 5d730ba2..00000000 --- a/backend/notifications/migrations/0001.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [("authentication", "0001_initial")] - - operations = [ - migrations.CreateModel( - name="NotificationTemplate", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - ), - ), - ("title_key", models.CharField(max_length=255)), - ("description_key", models.CharField(max_length=511)), - ], - ), - migrations.CreateModel( - name="Notification", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - ), - ), - ( - "user", - models.ForeignKey( - to="authentication.User", - on_delete=models.deletion.CASCADE, - ), - ), - ( - "template_id", - models.ForeignKey( - to="notifications.NotificationTemplate", - on_delete=models.deletion.CASCADE, - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("arguments", models.JSONField(default=dict)), - ("is_read", models.BooleanField(default=False)), - ("is_sent", models.BooleanField(default=False)), - ], - ), - ] diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..5565733d --- /dev/null +++ b/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.2 on 2024-02-28 21:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('title_key', models.CharField(max_length=255)), + ('description_key', models.CharField(max_length=511)), + ], + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('arguments', models.JSONField(default=dict)), + ('is_read', models.BooleanField(default=False)), + ('is_sent', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('template_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtemplate')), + ], + ), + ] diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 8733f6cb..c912fa45 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -36,11 +36,13 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + # Third party "rest_framework_swagger", # Swagger "rest_framework", # Django rest framework "drf_yasg", # Yet Another Swagger generator "sslserver", # Used for local SSL support (needed by CAS) + # First party "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 524a9e8b..6f4771a1 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ # Base API endpoints. - path("/", include("api.urls")), + path("", include("api.urls")), # Authentication endpoints. path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), From 9d27bc4cb8de9be86a7c81366e28bbf641b51756 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 12:55:12 +0100 Subject: [PATCH 106/397] fix: setup script --- backend/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/setup.sh b/backend/setup.sh index a667bf67..6a259706 100755 --- a/backend/setup.sh +++ b/backend/setup.sh @@ -5,7 +5,7 @@ echo "Migrating database..." python manage.py migrate > /dev/null echo "Populating database..." -python manage.py loaddata fixtures/* > /dev/null +python manage.py loaddata notifications/fixtures/* > /dev/null echo "Compiling translations..." django-admin compilemessages > /dev/null From c3ce25985332e8a31e58e8711e7c970331907047 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 29 Feb 2024 13:09:35 +0100 Subject: [PATCH 107/397] chore: support other fixtures --- backend/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/setup.sh b/backend/setup.sh index 6a259706..4fa590be 100755 --- a/backend/setup.sh +++ b/backend/setup.sh @@ -5,7 +5,7 @@ echo "Migrating database..." python manage.py migrate > /dev/null echo "Populating database..." -python manage.py loaddata notifications/fixtures/* > /dev/null +python manage.py loaddata */fixtures/* > /dev/null echo "Compiling translations..." django-admin compilemessages > /dev/null From 88730d95b10805c1dece3aa0183a6116a8c06dd0 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 29 Feb 2024 13:59:59 +0100 Subject: [PATCH 108/397] chore: user hyperLinkedRelatedField --- backend/notifications/apps.py | 6 ++++++ backend/notifications/serializers.py | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index 001b4f98..e5db3f92 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -4,3 +4,9 @@ class NotificationsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'notifications' + + +# TODO: Allow is_sent to be adjusted +# TODO: Signals to send notifications +# TODO: Send emails +# TODO: Think about the required api endpoints \ No newline at end of file diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 080b8555..d5a1697f 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,6 +1,8 @@ import re +from os import read from typing import Dict, List +from authentication.models import User from django.utils.translation import gettext as _ from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -12,7 +14,20 @@ class Meta: fields = "__all__" +class UserHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): + view_name = "user-detail" + queryset = User.objects.all() + + def to_internal_value(self, data): + try: + return self.queryset.get(pk=data) + except User.DoesNotExist: + self.fail("no_match") + + class NotificationSerializer(serializers.ModelSerializer): + user = UserHyperLinkedRelatedField() + message = serializers.SerializerMethodField() class Meta: @@ -64,6 +79,3 @@ def get_message(self, obj): "title": _(obj.template_id.title_key), "description": _(obj.template_id.description_key) % obj.arguments, } - - -# TODO: HyperlinkedModelSerializer From 20b3a719a52450750bc3c470f3bdfa53d858269c Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 29 Feb 2024 14:13:37 +0100 Subject: [PATCH 109/397] chore: start fixtures api + faculty by auto generating --- backend/api/fixtures/admins.yaml | 1 + backend/api/fixtures/assistants.yaml | 10 +++++ backend/api/fixtures/checks.yaml | 1 + backend/api/fixtures/courses.yaml | 14 ++++++ backend/api/fixtures/file_extensions.yaml | 1 + backend/api/fixtures/groups.yaml | 8 ++++ backend/api/fixtures/projects.yaml | 11 +++++ backend/api/fixtures/students.yaml | 11 +++++ backend/api/fixtures/submissions.yaml | 1 + backend/api/fixtures/teachers.yaml | 11 +++++ .../authentication/fixtures/faculties.yaml | 44 +++++++++++++++++++ 11 files changed, 113 insertions(+) create mode 100644 backend/api/fixtures/admins.yaml create mode 100644 backend/api/fixtures/assistants.yaml create mode 100644 backend/api/fixtures/checks.yaml create mode 100644 backend/api/fixtures/courses.yaml create mode 100644 backend/api/fixtures/file_extensions.yaml create mode 100644 backend/api/fixtures/groups.yaml create mode 100644 backend/api/fixtures/projects.yaml create mode 100644 backend/api/fixtures/students.yaml create mode 100644 backend/api/fixtures/submissions.yaml create mode 100644 backend/api/fixtures/teachers.yaml create mode 100644 backend/authentication/fixtures/faculties.yaml diff --git a/backend/api/fixtures/admins.yaml b/backend/api/fixtures/admins.yaml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/backend/api/fixtures/admins.yaml @@ -0,0 +1 @@ +[] diff --git a/backend/api/fixtures/assistants.yaml b/backend/api/fixtures/assistants.yaml new file mode 100644 index 00000000..aa7d31c5 --- /dev/null +++ b/backend/api/fixtures/assistants.yaml @@ -0,0 +1,10 @@ +- model: api.assistant + pk: '235' + fields: + courses: + - 1 +- model: api.assistant + pk: '236' + fields: + courses: + - 2 diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/backend/api/fixtures/checks.yaml @@ -0,0 +1 @@ +[] diff --git a/backend/api/fixtures/courses.yaml b/backend/api/fixtures/courses.yaml new file mode 100644 index 00000000..154f0e0b --- /dev/null +++ b/backend/api/fixtures/courses.yaml @@ -0,0 +1,14 @@ +- model: api.course + pk: 1 + fields: + name: Math + academic_startyear: 2023 + description: Math course + parent_course: null +- model: api.course + pk: 2 + fields: + name: Sel2 + academic_startyear: 2023 + description: Software course + parent_course: null diff --git a/backend/api/fixtures/file_extensions.yaml b/backend/api/fixtures/file_extensions.yaml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/backend/api/fixtures/file_extensions.yaml @@ -0,0 +1 @@ +[] diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml new file mode 100644 index 00000000..b8adc460 --- /dev/null +++ b/backend/api/fixtures/groups.yaml @@ -0,0 +1,8 @@ +- model: api.group + pk: 1 + fields: + project: 123456 + score: null + students: + - '1' + - '2' diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml new file mode 100644 index 00000000..2e42bd30 --- /dev/null +++ b/backend/api/fixtures/projects.yaml @@ -0,0 +1,11 @@ +- model: api.project + pk: 123456 + fields: + name: sel2 + description: make a project + visible: true + archived: false + start_date: 2024-02-26 00:00:00+00:00 + deadline: 2024-02-27 00:00:00+00:00 + checks: null + course: 2 diff --git a/backend/api/fixtures/students.yaml b/backend/api/fixtures/students.yaml new file mode 100644 index 00000000..ac61cbd7 --- /dev/null +++ b/backend/api/fixtures/students.yaml @@ -0,0 +1,11 @@ +- model: api.student + pk: '1' + fields: + student_id: null + courses: + - 1 +- model: api.student + pk: '2' + fields: + student_id: null + courses: [] diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/backend/api/fixtures/submissions.yaml @@ -0,0 +1 @@ +[] diff --git a/backend/api/fixtures/teachers.yaml b/backend/api/fixtures/teachers.yaml new file mode 100644 index 00000000..44b883f2 --- /dev/null +++ b/backend/api/fixtures/teachers.yaml @@ -0,0 +1,11 @@ +- model: api.teacher + pk: '123' + fields: + courses: + - 1 +- model: api.teacher + pk: '124' + fields: + courses: + - 1 + - 2 diff --git a/backend/authentication/fixtures/faculties.yaml b/backend/authentication/fixtures/faculties.yaml new file mode 100644 index 00000000..c46fc982 --- /dev/null +++ b/backend/authentication/fixtures/faculties.yaml @@ -0,0 +1,44 @@ +- model: authentication.faculty + pk: Bio-ingenieurswetenschappen + fields: + user: [] +- model: authentication.faculty + pk: Diergeneeskunde + fields: + user: [] +- model: authentication.faculty + pk: Economie_Bedrijfskunde + fields: + user: [] +- model: authentication.faculty + pk: Farmaceutische_Wetenschappen + fields: + user: [] +- model: authentication.faculty + pk: Geneeskunde_Gezondheidswetenschappen + fields: + user: [] +- model: authentication.faculty + pk: Ingenieurswetenschappen_Architectuur + fields: + user: [] +- model: authentication.faculty + pk: Letteren_Wijsbegeerte + fields: + user: [] +- model: authentication.faculty + pk: Politieke_Sociale_Wetenschappen + fields: + user: [] +- model: authentication.faculty + pk: Psychologie_PedagogischeWetenschappen + fields: + user: [] +- model: authentication.faculty + pk: Recht_Criminologie + fields: + user: [] +- model: authentication.faculty + pk: Wetenschappen + fields: + user: [] From 9156c4e64584ac0cd71104c831c1d932111d24e9 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 29 Feb 2024 14:21:25 +0100 Subject: [PATCH 110/397] fix: faculty model --- ...culty_user_remove_user_faculty_and_more.py | 26 +++++++++++++++++++ backend/authentication/models.py | 15 ++++------- 2 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py diff --git a/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py b/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py new file mode 100644 index 00000000..42ce1147 --- /dev/null +++ b/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.2 on 2024-02-29 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='faculty', + name='user', + ), + migrations.RemoveField( + model_name='user', + name='faculty', + ), + migrations.AddField( + model_name='user', + name='faculties', + field=models.ManyToManyField(blank=True, related_name='users', to='authentication.faculty'), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7bef9b4c..224e05f1 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -37,15 +37,15 @@ class User(AbstractBaseUser): null=False ) - faculty = models.ManyToManyField( + faculties = models.ManyToManyField( 'Faculty', - related_name='faculties', + related_name='users', blank=True ) last_enrolled = IntegerField( - default = datetime.MINYEAR, - null = True + default=datetime.MINYEAR, + null=True ) create_time = DateTimeField( @@ -57,6 +57,7 @@ class User(AbstractBaseUser): EMAIL_FIELD = "email" REQUIRED_FIELDS = [] + class Faculty(models.Model): """This model represents a faculty.""" @@ -65,9 +66,3 @@ class Faculty(models.Model): max_length=50, primary_key=True ) - - user = models.ManyToManyField( - 'User', - related_name='users', - blank=True - ) From e0b5b9b9fbda225da0e13f9e4a22904f6dac7896 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 16:45:01 +0100 Subject: [PATCH 111/397] chore: first test file added #27 --- .../tests/test_authentication.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/authentication/tests/test_authentication.py diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py new file mode 100644 index 00000000..0d66972e --- /dev/null +++ b/backend/authentication/tests/test_authentication.py @@ -0,0 +1,31 @@ +import cas_client +from django.test import TestCase +from unittest.mock import patch + +from ..serializers import CASTokenObtainSerializer, UserSerializer + + +def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = {} + if ticket != "1": + response.error = "This is an error" + else: + response.data = { + "ugentID": 1234, + "uid": 4321, + "mail": "dummy@dummy.be", + "givenname": "Dummy", + "surname": "McDickwad", + "faculty": "Sciences", + "lastenrolled": "" + } + + +class SerializersTests(TestCase): + @patch.object(cas_client.CASClient, 'perform_service_validate', service_validate) + def test_invalid_ticket_generates_error(self): + pass \ No newline at end of file From 0d2cf4d3404bb4f4c06108a192b3537bc28e4dbb Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 18:10:28 +0100 Subject: [PATCH 112/397] fix: requirements.txt sync --- backend/requirements.txt | 47 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index f0eec8cc..9233f72d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,48 @@ +asgiref==3.7.2 +astroid==3.1.0 +cachetools==5.3.2 +cas-client==1.0.0 +certifi==2024.2.2 +cffi==1.16.0 +chardet==5.2.0 +charset-normalizer==3.3.2 +colorama==0.4.6 +coreapi==2.3.3 +coreschema==0.0.4 +cryptography==42.0.5 +dill==0.3.8 +distlib==0.3.8 Django==5.0.2 +django-rest-knox==4.2.0 +django-rest-swagger==2.2.0 django-sslserver==0.22 djangorestframework==3.14.0 -django-rest-swagger==2.2.0 +djangorestframework-simplejwt==5.3.1 drf-yasg==1.21.7 +filelock==3.13.1 +flake8==7.0.0 +flake8-for-pycharm==0.4.1 +idna==3.6 +inflection==0.5.1 +isort==5.13.2 +itypes==1.2.0 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +mccabe==0.7.0 +openapi-codec==1.3.2 +PyJWT==2.8.0 +pylint==3.1.0 +pyproject-api==1.6.1 +pytz==2024.1 +PyYAML==6.0.1 requests==2.31.0 -cas-client==1.0.0 -psycopg2-binary==2.9.9 -djangorestframework-simplejwt==5.3.1 \ No newline at end of file +simplejson==3.19.2 +six==1.16.0 +sqlparse==0.4.4 +tomli==2.0.1 +tomlkit==0.12.4 +tox==4.13.0 +typing_extensions==4.9.0 +uritemplate==4.1.1 +urllib3==2.2.1 +virtualenv==20.25.1 \ No newline at end of file From 9fdffe7f15d0d73d7441338eea9be839c817d25e Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 18:12:10 +0100 Subject: [PATCH 113/397] Revert "fix: requirements.txt sync" This reverts commit 0d2cf4d3404bb4f4c06108a192b3537bc28e4dbb. --- backend/requirements.txt | 47 ++++------------------------------------ 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9233f72d..f0eec8cc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,48 +1,9 @@ -asgiref==3.7.2 -astroid==3.1.0 -cachetools==5.3.2 -cas-client==1.0.0 -certifi==2024.2.2 -cffi==1.16.0 -chardet==5.2.0 -charset-normalizer==3.3.2 -colorama==0.4.6 -coreapi==2.3.3 -coreschema==0.0.4 -cryptography==42.0.5 -dill==0.3.8 -distlib==0.3.8 Django==5.0.2 -django-rest-knox==4.2.0 -django-rest-swagger==2.2.0 django-sslserver==0.22 djangorestframework==3.14.0 -djangorestframework-simplejwt==5.3.1 +django-rest-swagger==2.2.0 drf-yasg==1.21.7 -filelock==3.13.1 -flake8==7.0.0 -flake8-for-pycharm==0.4.1 -idna==3.6 -inflection==0.5.1 -isort==5.13.2 -itypes==1.2.0 -Jinja2==3.1.3 -MarkupSafe==2.1.5 -mccabe==0.7.0 -openapi-codec==1.3.2 -PyJWT==2.8.0 -pylint==3.1.0 -pyproject-api==1.6.1 -pytz==2024.1 -PyYAML==6.0.1 requests==2.31.0 -simplejson==3.19.2 -six==1.16.0 -sqlparse==0.4.4 -tomli==2.0.1 -tomlkit==0.12.4 -tox==4.13.0 -typing_extensions==4.9.0 -uritemplate==4.1.1 -urllib3==2.2.1 -virtualenv==20.25.1 \ No newline at end of file +cas-client==1.0.0 +psycopg2-binary==2.9.9 +djangorestframework-simplejwt==5.3.1 \ No newline at end of file From e35232e49aa3e2bbfaba344561a7133d5f1f8aca Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 16:45:01 +0100 Subject: [PATCH 114/397] chore: first test file added #27 --- .../tests/test_authentication.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/authentication/tests/test_authentication.py diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py new file mode 100644 index 00000000..0d66972e --- /dev/null +++ b/backend/authentication/tests/test_authentication.py @@ -0,0 +1,31 @@ +import cas_client +from django.test import TestCase +from unittest.mock import patch + +from ..serializers import CASTokenObtainSerializer, UserSerializer + + +def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = {} + if ticket != "1": + response.error = "This is an error" + else: + response.data = { + "ugentID": 1234, + "uid": 4321, + "mail": "dummy@dummy.be", + "givenname": "Dummy", + "surname": "McDickwad", + "faculty": "Sciences", + "lastenrolled": "" + } + + +class SerializersTests(TestCase): + @patch.object(cas_client.CASClient, 'perform_service_validate', service_validate) + def test_invalid_ticket_generates_error(self): + pass \ No newline at end of file From a177535c2ddf4669d98d60f03b0c025444146702 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 18:07:53 +0100 Subject: [PATCH 115/397] test: first UserSerializer test + base CASTokenObtain class #27 --- .../tests/test_authentication.py | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 0d66972e..2820f9f5 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -1,31 +1,58 @@ import cas_client from django.test import TestCase +from rest_framework.serializers import ValidationError from unittest.mock import patch from ..serializers import CASTokenObtainSerializer, UserSerializer -def service_validate( - self, - ticket=None, - service_url=None, - headers=None,): - response = {} - if ticket != "1": - response.error = "This is an error" - else: - response.data = { - "ugentID": 1234, - "uid": 4321, - "mail": "dummy@dummy.be", - "givenname": "Dummy", - "surname": "McDickwad", - "faculty": "Sciences", - "lastenrolled": "" - } +def customize_data(ugent_id, uid, mail): + + def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = {} + if ticket != "1": + response.error = "This is an error" + else: + response.data = { + "ugentID": ugent_id, + "uid": uid, + "mail": mail, + "givenname": "Dummy", + "surname": "McDickwad", + "faculty": "Sciences", + "lastenrolled": "2021-05-21", + "lastlogin": "", + "createtime": "" + } + return response + + return service_validate + + +class UserSerializerModelTests(TestCase): + def test_non_string_id_makes_user_serializer_invalid(self): + user = UserSerializer(data={ + "id": 1234 + }) + self.assertFalse(user.is_valid()) class SerializersTests(TestCase): - @patch.object(cas_client.CASClient, 'perform_service_validate', service_validate) + class CASTokenObtain: + def __init__(self, ticket): + self.token = "ABCD" + self.ticket = ticket + + @patch.object(cas_client.CASClient, + 'perform_service_validate', + customize_data("1234", "ddickwd", "dummy@dummy.be")) def test_invalid_ticket_generates_error(self): - pass \ No newline at end of file + """When the wrong ticket is provided, a ValidationError should be raised.""" + # I have set "1" as the correct ticket here + obtain = self.CASTokenObtain("2") + serializer = CASTokenObtainSerializer(obtain) + self.assertRaises(ValidationError, lambda: serializer.validate(serializer.ticket)) From 438ce8afbb9d78cb9e15f2dd0c930cb83b17f2e6 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 19:01:50 +0100 Subject: [PATCH 116/397] test: add more UserSerializerModel tests #27 --- .../tests/test_authentication.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 2820f9f5..b687c0d4 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -35,11 +35,43 @@ def service_validate( class UserSerializerModelTests(TestCase): def test_non_string_id_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's ID is not a string + should return False. + """ user = UserSerializer(data={ "id": 1234 }) self.assertFalse(user.is_valid()) + def test_non_string_username_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's username is not a string + should return False. + """ + user = UserSerializer(data={ + "username": 10 + }) + self.assertFalse(user.is_valid()) + + def test_invalid_email_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's email is not + formatted as an email address should return False. + """ + user = UserSerializer(data={ + "email": "dummy" + }) + user2 = UserSerializer(data={ + "email": "dummy@dummy" + }) + user3 = UserSerializer(data={ + "email": 21 + }) + self.assertFalse(user.is_valid()) + self.assertFalse(user2.is_valid()) + self.assertFalse(user3.is_valid()) + class SerializersTests(TestCase): class CASTokenObtain: From ac0ff10bf209a5e3d4abe3616b0a4df71a40a376 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 20:36:07 +0100 Subject: [PATCH 117/397] hotfix: import errors in Python 3.11 --- backend/api/apps.py | 6 +++++- backend/api/signals.py | 9 ++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/api/apps.py b/backend/api/apps.py index 60def201..6cd16758 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -5,4 +5,8 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): - from . import signals \ No newline at end of file + # Only here we can import from apps. + from authentication.signals import user_created + from api.signals import user_creation + + user_created.connect(user_creation) \ No newline at end of file diff --git a/backend/api/signals.py b/backend/api/signals.py index 7cfd15b2..767bff1e 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -1,12 +1,11 @@ -from django.dispatch import receiver -from authentication.signals import user_created from authentication.models import User -from api.models.student import Student -@receiver(user_created) def user_creation(user: User, attributes: dict, **kwargs): + # With Python 3.11, we need to import Student here. + from api.models.student import Student + """Upon user creation, auto-populate additional properties""" student_id = attributes.get("ugentStudentID") if student_id: - Student(user_ptr=user,student_id=student_id).save_base(raw=True) \ No newline at end of file + Student(user_ptr=user, student_id=student_id).save_base(raw=True) \ No newline at end of file From b5b79fa6182a19d9dac7151640496f4ac892a283 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 29 Feb 2024 20:41:19 +0100 Subject: [PATCH 118/397] fix: update faculty --- backend/api/migrations/0002_populate.py | 13 +++++++------ backend/api/serializers/admin_serializer.py | 9 ++++++++- backend/api/serializers/assistant_serializer.py | 2 +- backend/api/serializers/student_serializer.py | 2 +- backend/api/serializers/teacher_serializer.py | 2 +- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/api/migrations/0002_populate.py b/backend/api/migrations/0002_populate.py index a289fd86..1bc31c0d 100644 --- a/backend/api/migrations/0002_populate.py +++ b/backend/api/migrations/0002_populate.py @@ -47,7 +47,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - teacher1.faculty.add(f_psyPeda) + teacher1.faculties.add(f_psyPeda) assistant1 = Assistant.objects.create( id=235, @@ -58,7 +58,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant1.faculty.add(f_wet) + assistant1.faculties.add(f_wet) assistant2 = Assistant.objects.create( id=236, @@ -69,7 +69,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - assistant2.faculty.add(f_psyPeda) + assistant2.faculties.add(f_psyPeda) teacher2 = Teacher.objects.create( id=124, @@ -80,7 +80,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - teacher2.faculty.add(f_psyPeda) + teacher2.faculties.add(f_psyPeda) student1 = Student.objects.create( id=1, @@ -91,7 +91,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - student1.faculty.add(f_wet) + student1.faculties.add(f_wet) student2 = Student.objects.create( id=2, @@ -102,7 +102,7 @@ def populate_db(apps, schema_editor): create_time="2023-01-01T00:00:00Z", ) - student2.faculty.add(f_genGez) + student2.faculties.add(f_genGez) course = Course.objects.create( name="Math", @@ -149,6 +149,7 @@ class Migration(migrations.Migration): dependencies = [ ("api", "0001_initial"), ("authentication", "0001_initial"), + ("authentication", "0002_remove_faculty_user_remove_user_faculty_and_more") ] operations = [ diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index 3cd2404c..a00348e7 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -3,9 +3,16 @@ class AdminSerializer(serializers.ModelSerializer): + + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Admin fields = [ 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time' + 'faculties', 'last_enrolled', 'create_time' ] diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 378f5322..210f7404 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -13,5 +13,5 @@ class Meta: model = Assistant fields = [ 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' + 'faculties', 'last_enrolled', 'create_time', 'courses' ] diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 4e5b794b..214d637b 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -17,6 +17,6 @@ class StudentSerializer(serializers.ModelSerializer): class Meta: model = Student fields = [ - 'id', 'first_name', 'last_name', 'email', 'faculty', + 'id', 'first_name', 'last_name', 'email', 'faculties', 'last_enrolled', 'create_time', 'courses', 'groups' ] diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index e24b0ed0..c35bea68 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -13,5 +13,5 @@ class Meta: model = Teacher fields = [ 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' + 'faculties', 'last_enrolled', 'create_time', 'courses' ] From fd9ce378d3a363961a122cccf3d081cefc39aa4a Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 16:45:01 +0100 Subject: [PATCH 119/397] chore: first test file added #27 --- .../tests/test_authentication.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/authentication/tests/test_authentication.py diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py new file mode 100644 index 00000000..0d66972e --- /dev/null +++ b/backend/authentication/tests/test_authentication.py @@ -0,0 +1,31 @@ +import cas_client +from django.test import TestCase +from unittest.mock import patch + +from ..serializers import CASTokenObtainSerializer, UserSerializer + + +def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = {} + if ticket != "1": + response.error = "This is an error" + else: + response.data = { + "ugentID": 1234, + "uid": 4321, + "mail": "dummy@dummy.be", + "givenname": "Dummy", + "surname": "McDickwad", + "faculty": "Sciences", + "lastenrolled": "" + } + + +class SerializersTests(TestCase): + @patch.object(cas_client.CASClient, 'perform_service_validate', service_validate) + def test_invalid_ticket_generates_error(self): + pass \ No newline at end of file From 05034e48b90831bdf9c59709e6232b998aaac26c Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 18:07:53 +0100 Subject: [PATCH 120/397] test: first UserSerializer test + base CASTokenObtain class #27 --- .../tests/test_authentication.py | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 0d66972e..2820f9f5 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -1,31 +1,58 @@ import cas_client from django.test import TestCase +from rest_framework.serializers import ValidationError from unittest.mock import patch from ..serializers import CASTokenObtainSerializer, UserSerializer -def service_validate( - self, - ticket=None, - service_url=None, - headers=None,): - response = {} - if ticket != "1": - response.error = "This is an error" - else: - response.data = { - "ugentID": 1234, - "uid": 4321, - "mail": "dummy@dummy.be", - "givenname": "Dummy", - "surname": "McDickwad", - "faculty": "Sciences", - "lastenrolled": "" - } +def customize_data(ugent_id, uid, mail): + + def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = {} + if ticket != "1": + response.error = "This is an error" + else: + response.data = { + "ugentID": ugent_id, + "uid": uid, + "mail": mail, + "givenname": "Dummy", + "surname": "McDickwad", + "faculty": "Sciences", + "lastenrolled": "2021-05-21", + "lastlogin": "", + "createtime": "" + } + return response + + return service_validate + + +class UserSerializerModelTests(TestCase): + def test_non_string_id_makes_user_serializer_invalid(self): + user = UserSerializer(data={ + "id": 1234 + }) + self.assertFalse(user.is_valid()) class SerializersTests(TestCase): - @patch.object(cas_client.CASClient, 'perform_service_validate', service_validate) + class CASTokenObtain: + def __init__(self, ticket): + self.token = "ABCD" + self.ticket = ticket + + @patch.object(cas_client.CASClient, + 'perform_service_validate', + customize_data("1234", "ddickwd", "dummy@dummy.be")) def test_invalid_ticket_generates_error(self): - pass \ No newline at end of file + """When the wrong ticket is provided, a ValidationError should be raised.""" + # I have set "1" as the correct ticket here + obtain = self.CASTokenObtain("2") + serializer = CASTokenObtainSerializer(obtain) + self.assertRaises(ValidationError, lambda: serializer.validate(serializer.ticket)) From 2dcc767d9a5a63758a2fa2f7c0dd872d7b2fba39 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 29 Feb 2024 19:01:50 +0100 Subject: [PATCH 121/397] test: add more UserSerializerModel tests #27 --- .../tests/test_authentication.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 2820f9f5..b687c0d4 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -35,11 +35,43 @@ def service_validate( class UserSerializerModelTests(TestCase): def test_non_string_id_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's ID is not a string + should return False. + """ user = UserSerializer(data={ "id": 1234 }) self.assertFalse(user.is_valid()) + def test_non_string_username_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's username is not a string + should return False. + """ + user = UserSerializer(data={ + "username": 10 + }) + self.assertFalse(user.is_valid()) + + def test_invalid_email_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's email is not + formatted as an email address should return False. + """ + user = UserSerializer(data={ + "email": "dummy" + }) + user2 = UserSerializer(data={ + "email": "dummy@dummy" + }) + user3 = UserSerializer(data={ + "email": 21 + }) + self.assertFalse(user.is_valid()) + self.assertFalse(user2.is_valid()) + self.assertFalse(user3.is_valid()) + class SerializersTests(TestCase): class CASTokenObtain: From 460476475915c338f95404b36686610aa0dfdd2d Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 29 Feb 2024 21:44:34 +0100 Subject: [PATCH 122/397] chore: refactor to fixtures finish --- backend/api/fixtures/admins.yaml | 1 - backend/api/fixtures/checks.yaml | 1 - backend/api/fixtures/file_extensions.yaml | 1 - backend/api/fixtures/submissions.yaml | 1 - backend/api/migrations/0002_populate.py | 157 ------------------ .../authentication/fixtures/faculties.yaml | 33 ++-- backend/authentication/fixtures/users.yaml | 72 ++++++++ 7 files changed, 83 insertions(+), 183 deletions(-) delete mode 100644 backend/api/fixtures/admins.yaml delete mode 100644 backend/api/fixtures/checks.yaml delete mode 100644 backend/api/fixtures/file_extensions.yaml delete mode 100644 backend/api/fixtures/submissions.yaml delete mode 100644 backend/api/migrations/0002_populate.py create mode 100644 backend/authentication/fixtures/users.yaml diff --git a/backend/api/fixtures/admins.yaml b/backend/api/fixtures/admins.yaml deleted file mode 100644 index fe51488c..00000000 --- a/backend/api/fixtures/admins.yaml +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml deleted file mode 100644 index fe51488c..00000000 --- a/backend/api/fixtures/checks.yaml +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/backend/api/fixtures/file_extensions.yaml b/backend/api/fixtures/file_extensions.yaml deleted file mode 100644 index fe51488c..00000000 --- a/backend/api/fixtures/file_extensions.yaml +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml deleted file mode 100644 index fe51488c..00000000 --- a/backend/api/fixtures/submissions.yaml +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/backend/api/migrations/0002_populate.py b/backend/api/migrations/0002_populate.py deleted file mode 100644 index 1bc31c0d..00000000 --- a/backend/api/migrations/0002_populate.py +++ /dev/null @@ -1,157 +0,0 @@ -from django.db import migrations, transaction -from api.models.teacher import Teacher -from api.models.student import Student -from api.models.course import Course -from api.models.assistant import Assistant -from api.models.project import Project -from api.models.group import Group -from authentication.models import Faculty -# from datetime import date - - -def populate_db(apps, schema_editor): - with transaction.atomic(): - # Faculteit Letteren en Wijsbegeerte - Faculty.objects.create(name="Letteren_Wijsbegeerte") - # Faculteit Recht en Criminologie - Faculty.objects.create(name="Recht_Criminologie") - # Faculteit Wetenschappen - f_wet = Faculty.objects.create(name="Wetenschappen") - # Faculteit Geneeskunde en Gezondheidswetenschappen - f_genGez = Faculty.objects.create( - name="Geneeskunde_Gezondheidswetenschappen" - ) - # Faculteit Ingenieurswetenschappen en Architectuur - Faculty.objects.create(name="Ingenieurswetenschappen_Architectuur") - # Faculteit Economie en Bedrijfskunde - Faculty.objects.create(name="Economie_Bedrijfskunde") - # Faculteit Diergeneeskunde - Faculty.objects.create(name="Diergeneeskunde") - # Faculteit Psychologie en Pedagogische Wetenschappen - f_psyPeda = Faculty.objects.create( - name="Psychologie_PedagogischeWetenschappen" - ) - # Faculteit Bio-ingenieurswetenschappen - Faculty.objects.create(name="Bio-ingenieurswetenschappen") - # Faculteit Farmaceutische Wetenschappen - Faculty.objects.create(name="Farmaceutische_Wetenschappen") - # Faculteit Politieke en Sociale Wetenschappen - Faculty.objects.create(name="Politieke_Sociale_Wetenschappen") - - teacher1 = Teacher.objects.create( - id=123, - first_name="Tom", - last_name="Boonen", - email="Tom.Boonen@gmail.be", - username="tboonen", - create_time="2023-01-01T00:00:00Z", - ) - - teacher1.faculties.add(f_psyPeda) - - assistant1 = Assistant.objects.create( - id=235, - first_name="Bart", - last_name="Simpson", - username="bsimpson", - email="Bart.Simpson@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant1.faculties.add(f_wet) - - assistant2 = Assistant.objects.create( - id=236, - first_name="Kim", - last_name="Clijsters", - username="kclijster", - email="Kim.Clijsters@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant2.faculties.add(f_psyPeda) - - teacher2 = Teacher.objects.create( - id=124, - first_name="Peter", - last_name="Sagan", - username="psagan", - email="Peter.Sagan@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - teacher2.faculties.add(f_psyPeda) - - student1 = Student.objects.create( - id=1, - first_name="John", - last_name="Doe", - username="jdoe", - email="John.Doe@hotmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student1.faculties.add(f_wet) - - student2 = Student.objects.create( - id=2, - first_name="Bartje", - last_name="Verhaege", - username="bverhae", - email="Bartje.Verhaege@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student2.faculties.add(f_genGez) - - course = Course.objects.create( - name="Math", - academic_startyear=2023, - description="Math course", - ) - - course2 = Course.objects.create( - name="Sel2", - academic_startyear=2023, - description="Software course", - ) - - project1 = Project.objects.create( - id=123456, - name="sel2", - description="make a project", - visible=True, - archived=False, - # Set the start date as 26th February 2024 - start_date="2024-02-26T00:00:00+00:00", - # Set the deadline as 27th February 2024 - deadline="2024-02-27T00:00:00+00:00", - course=course2 - ) - - group1 = Group.objects.create( - project=project1, - ) - - group1.students.add(student1) - group1.students.add(student2) - - teacher1.courses.add(course) - teacher2.courses.add(course) - student1.courses.add(course) - teacher2.courses.add(course2) - - course.assistants.add(assistant1) - course2.assistants.add(assistant2) - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0001_initial"), - ("authentication", "0001_initial"), - ("authentication", "0002_remove_faculty_user_remove_user_faculty_and_more") - ] - - operations = [ - migrations.RunPython(populate_db), - ] diff --git a/backend/authentication/fixtures/faculties.yaml b/backend/authentication/fixtures/faculties.yaml index c46fc982..e13fd9e5 100644 --- a/backend/authentication/fixtures/faculties.yaml +++ b/backend/authentication/fixtures/faculties.yaml @@ -1,44 +1,33 @@ - model: authentication.faculty pk: Bio-ingenieurswetenschappen - fields: - user: [] + fields: {} - model: authentication.faculty pk: Diergeneeskunde - fields: - user: [] + fields: {} - model: authentication.faculty pk: Economie_Bedrijfskunde - fields: - user: [] + fields: {} - model: authentication.faculty pk: Farmaceutische_Wetenschappen - fields: - user: [] + fields: {} - model: authentication.faculty pk: Geneeskunde_Gezondheidswetenschappen - fields: - user: [] + fields: {} - model: authentication.faculty pk: Ingenieurswetenschappen_Architectuur - fields: - user: [] + fields: {} - model: authentication.faculty pk: Letteren_Wijsbegeerte - fields: - user: [] + fields: {} - model: authentication.faculty pk: Politieke_Sociale_Wetenschappen - fields: - user: [] + fields: {} - model: authentication.faculty pk: Psychologie_PedagogischeWetenschappen - fields: - user: [] + fields: {} - model: authentication.faculty pk: Recht_Criminologie - fields: - user: [] + fields: {} - model: authentication.faculty pk: Wetenschappen - fields: - user: [] + fields: {} diff --git a/backend/authentication/fixtures/users.yaml b/backend/authentication/fixtures/users.yaml new file mode 100644 index 00000000..40922b7d --- /dev/null +++ b/backend/authentication/fixtures/users.yaml @@ -0,0 +1,72 @@ +- model: authentication.user + pk: '1' + fields: + last_login: null + username: jdoe + email: John.Doe@hotmail.com + first_name: John + last_name: Doe + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.690556+00:00 + faculties: + - Wetenschappen +- model: authentication.user + pk: '123' + fields: + last_login: null + username: tboonen + email: Tom.Boonen@gmail.be + first_name: Tom + last_name: Boonen + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.686541+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen +- model: authentication.user + pk: '124' + fields: + last_login: null + username: psagan + email: Peter.Sagan@gmail.com + first_name: Peter + last_name: Sagan + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.689543+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen +- model: authentication.user + pk: '2' + fields: + last_login: null + username: bverhae + email: Bartje.Verhaege@gmail.com + first_name: Bartje + last_name: Verhaege + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.691565+00:00 + faculties: + - Geneeskunde_Gezondheidswetenschappen +- model: authentication.user + pk: '235' + fields: + last_login: null + username: bsimpson + email: Bart.Simpson@gmail.be + first_name: Bart + last_name: Simpson + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.687541+00:00 + faculties: + - Wetenschappen +- model: authentication.user + pk: '236' + fields: + last_login: null + username: kclijster + email: Kim.Clijsters@gmail.be + first_name: Kim + last_name: Clijsters + last_enrolled: 1 + create_time: 2024-02-29 20:35:45.688545+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen From a5f5ed93048c51e77a7db623e3fbb8b15998792c Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 29 Feb 2024 21:50:27 +0100 Subject: [PATCH 123/397] chore: update admin --- backend/api/serializers/admin_serializer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index a00348e7..2468e4ba 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -4,11 +4,6 @@ class AdminSerializer(serializers.ModelSerializer): - faculties = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' - ) class Meta: model = Admin From 6772e9537af7a0b0f1578251994a859cf9bfb3b0 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 29 Feb 2024 21:50:34 +0100 Subject: [PATCH 124/397] chore: mock CAS ticket --- backend/api/migrations/0002_populate.py | 156 ------------------ backend/authentication/models.py | 1 - backend/authentication/serializers.py | 9 +- .../tests/test_authentication.py | 24 +-- backend/ypovoli/settings.py | 2 +- 5 files changed, 13 insertions(+), 179 deletions(-) delete mode 100644 backend/api/migrations/0002_populate.py diff --git a/backend/api/migrations/0002_populate.py b/backend/api/migrations/0002_populate.py deleted file mode 100644 index a289fd86..00000000 --- a/backend/api/migrations/0002_populate.py +++ /dev/null @@ -1,156 +0,0 @@ -from django.db import migrations, transaction -from api.models.teacher import Teacher -from api.models.student import Student -from api.models.course import Course -from api.models.assistant import Assistant -from api.models.project import Project -from api.models.group import Group -from authentication.models import Faculty -# from datetime import date - - -def populate_db(apps, schema_editor): - with transaction.atomic(): - # Faculteit Letteren en Wijsbegeerte - Faculty.objects.create(name="Letteren_Wijsbegeerte") - # Faculteit Recht en Criminologie - Faculty.objects.create(name="Recht_Criminologie") - # Faculteit Wetenschappen - f_wet = Faculty.objects.create(name="Wetenschappen") - # Faculteit Geneeskunde en Gezondheidswetenschappen - f_genGez = Faculty.objects.create( - name="Geneeskunde_Gezondheidswetenschappen" - ) - # Faculteit Ingenieurswetenschappen en Architectuur - Faculty.objects.create(name="Ingenieurswetenschappen_Architectuur") - # Faculteit Economie en Bedrijfskunde - Faculty.objects.create(name="Economie_Bedrijfskunde") - # Faculteit Diergeneeskunde - Faculty.objects.create(name="Diergeneeskunde") - # Faculteit Psychologie en Pedagogische Wetenschappen - f_psyPeda = Faculty.objects.create( - name="Psychologie_PedagogischeWetenschappen" - ) - # Faculteit Bio-ingenieurswetenschappen - Faculty.objects.create(name="Bio-ingenieurswetenschappen") - # Faculteit Farmaceutische Wetenschappen - Faculty.objects.create(name="Farmaceutische_Wetenschappen") - # Faculteit Politieke en Sociale Wetenschappen - Faculty.objects.create(name="Politieke_Sociale_Wetenschappen") - - teacher1 = Teacher.objects.create( - id=123, - first_name="Tom", - last_name="Boonen", - email="Tom.Boonen@gmail.be", - username="tboonen", - create_time="2023-01-01T00:00:00Z", - ) - - teacher1.faculty.add(f_psyPeda) - - assistant1 = Assistant.objects.create( - id=235, - first_name="Bart", - last_name="Simpson", - username="bsimpson", - email="Bart.Simpson@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant1.faculty.add(f_wet) - - assistant2 = Assistant.objects.create( - id=236, - first_name="Kim", - last_name="Clijsters", - username="kclijster", - email="Kim.Clijsters@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant2.faculty.add(f_psyPeda) - - teacher2 = Teacher.objects.create( - id=124, - first_name="Peter", - last_name="Sagan", - username="psagan", - email="Peter.Sagan@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - teacher2.faculty.add(f_psyPeda) - - student1 = Student.objects.create( - id=1, - first_name="John", - last_name="Doe", - username="jdoe", - email="John.Doe@hotmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student1.faculty.add(f_wet) - - student2 = Student.objects.create( - id=2, - first_name="Bartje", - last_name="Verhaege", - username="bverhae", - email="Bartje.Verhaege@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student2.faculty.add(f_genGez) - - course = Course.objects.create( - name="Math", - academic_startyear=2023, - description="Math course", - ) - - course2 = Course.objects.create( - name="Sel2", - academic_startyear=2023, - description="Software course", - ) - - project1 = Project.objects.create( - id=123456, - name="sel2", - description="make a project", - visible=True, - archived=False, - # Set the start date as 26th February 2024 - start_date="2024-02-26T00:00:00+00:00", - # Set the deadline as 27th February 2024 - deadline="2024-02-27T00:00:00+00:00", - course=course2 - ) - - group1 = Group.objects.create( - project=project1, - ) - - group1.students.add(student1) - group1.students.add(student2) - - teacher1.courses.add(course) - teacher2.courses.add(course) - student1.courses.add(course) - teacher2.courses.add(course2) - - course.assistants.add(assistant1) - course2.assistants.add(assistant2) - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0001_initial"), - ("authentication", "0001_initial"), - ] - - operations = [ - migrations.RunPython(populate_db), - ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7bef9b4c..2ba5bb76 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -55,7 +55,6 @@ class User(AbstractBaseUser): """Model settings""" USERNAME_FIELD = "username" EMAIL_FIELD = "email" - REQUIRED_FIELDS = [] class Faculty(models.Model): """This model represents a faculty.""" diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 9c7e8173..86551495 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -10,6 +10,8 @@ class CASTokenObtainSerializer(Serializer): """Serializer for CAS ticket validation This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. + + /auth/token """ token = RefreshToken ticket = CharField(required=True, min_length=49, max_length=49) @@ -75,12 +77,7 @@ class UserSerializer(ModelSerializer): class Meta: model = User - fields = [ - 'id', 'username', 'email', - 'first_name', 'last_name', - 'faculty', - 'last_enrolled', 'last_login', 'create_time' - ] + fields = '__all__' def get_or_create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index b687c0d4..8049b7f1 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -1,9 +1,7 @@ -import cas_client from django.test import TestCase -from rest_framework.serializers import ValidationError from unittest.mock import patch - -from ..serializers import CASTokenObtainSerializer, UserSerializer +from authentication.cas.client import client +from authentication.serializers import CASTokenObtainSerializer, UserSerializer def customize_data(ugent_id, uid, mail): @@ -14,7 +12,7 @@ def service_validate( service_url=None, headers=None,): response = {} - if ticket != "1": + if ticket != "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6": response.error = "This is an error" else: response.data = { @@ -74,17 +72,13 @@ def test_invalid_email_makes_user_serializer_invalid(self): class SerializersTests(TestCase): - class CASTokenObtain: - def __init__(self, ticket): - self.token = "ABCD" - self.ticket = ticket - - @patch.object(cas_client.CASClient, + @patch.object(client, 'perform_service_validate', customize_data("1234", "ddickwd", "dummy@dummy.be")) def test_invalid_ticket_generates_error(self): """When the wrong ticket is provided, a ValidationError should be raised.""" - # I have set "1" as the correct ticket here - obtain = self.CASTokenObtain("2") - serializer = CASTokenObtainSerializer(obtain) - self.assertRaises(ValidationError, lambda: serializer.validate(serializer.ticket)) + # I have set "1" as the correct ticket hereµ + serializer = CASTokenObtainSerializer(data={ + 'token': 'qslmdfjklmqsdfjklmqsjdkf' + }) + self.assertFalse(serializer.is_valid()) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index c912fa45..0cc3c354 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -73,7 +73,7 @@ "ACCESS_TOKEN_LIFETIME": timedelta(days=365), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), "UPDATE_LAST_LOGIN": True, - "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer", + "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer" } AUTH_USER_MODEL = "authentication.User" From 29e1ed09401d83a0315fa5ac0da423742fe2bd58 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 29 Feb 2024 22:56:38 +0100 Subject: [PATCH 125/397] chore: init tests --- backend/api/tests/test_admin.py | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 backend/api/tests/test_admin.py diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py new file mode 100644 index 00000000..6c635311 --- /dev/null +++ b/backend/api/tests/test_admin.py @@ -0,0 +1,40 @@ +import datetime +import json + +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse + +from ..models.admin import Admin + + +def create_admin(id, first_name, last_name, username, email): + """ + Create a Admin with the given arguments. + """ + return Admin.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + +class AdminModelTests(TestCase): + def test_no_admins(self): + """ + able to retrieve no admin before publishing it. + """ + + # print(reverse("api:admin.index")) + response_root = self.client.get("https://localhost:8080/admins") + # print(response.content) + self.assertEqual(response_root.status_code, 301) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) From d47783f1404871bdecc2501d44a9ed0f086810ba Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 29 Feb 2024 23:41:33 +0100 Subject: [PATCH 126/397] chore: test admin --- backend/api/tests/test_admin.py | 118 +++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 6c635311..56517cb2 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -8,18 +8,19 @@ from ..models.admin import Admin -def create_admin(id, first_name, last_name, username, email): +def create_admin(id, first_name, last_name, email): """ Create a Admin with the given arguments. """ + username = f"{first_name}_{last_name}" return Admin.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) class AdminModelTests(TestCase): @@ -28,13 +29,108 @@ def test_no_admins(self): able to retrieve no admin before publishing it. """ - # print(reverse("api:admin.index")) - response_root = self.client.get("https://localhost:8080/admins") + response_root = self.client.get("/admins", follow=True) # print(response.content) - self.assertEqual(response_root.status_code, 301) + self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") # Parse the JSON content from the response content_json = json.loads(response_root.content.decode("utf-8")) # Assert that the parsed JSON is an empty list self.assertEqual(content_json, []) + + def test_admin_exists(self): + """ + Able to retrieve a single admin after creating it. + """ + admin = create_admin( + id=3, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + # Make a GET request to retrieve the admin + response = self.client.get("/admins", follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_admin = content_json[0] + self.assertEqual(int(retrieved_admin["id"]), admin.id) + self.assertEqual(retrieved_admin["first_name"], admin.first_name) + self.assertEqual(retrieved_admin["last_name"], admin.last_name) + self.assertEqual(retrieved_admin["email"], admin.email) + + def test_multiple_admins(self): + """ + Able to retrieve multiple admins after creating them. + """ + # Create multiple admins + admin1 = create_admin(id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com") + admin2 = create_admin(id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com") + + # Make a GET request to retrieve the admins + response = self.client.get("/admins", follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple admins + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created admins + retrieved_admin1, retrieved_admin2 = content_json + self.assertEqual(int(retrieved_admin1["id"]), admin1.id) + self.assertEqual(retrieved_admin1["first_name"], admin1.first_name) + self.assertEqual(retrieved_admin1["last_name"], admin1.last_name) + self.assertEqual(retrieved_admin1["email"], admin1.email) + + self.assertEqual(int(retrieved_admin2["id"]), admin2.id) + self.assertEqual(retrieved_admin2["first_name"], admin2.first_name) + self.assertEqual(retrieved_admin2["last_name"], admin2.last_name) + self.assertEqual(retrieved_admin2["email"], admin2.email) + +""" + def test_admin_detail_view(self): + + # Able to retrieve details of a single admin. + + # Create an admin for testing with the name "Bob Peeters" + admin = create_admin(id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com") + + # Make a GET request to retrieve the admin details + response = self.client.get(reverse("/admins/"+str(admin.id), args=[admin.id]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), admin.id) + self.assertEqual(content_json["first_name"], admin.first_name) + self.assertEqual(content_json["last_name"], admin.last_name) + self.assertEqual(content_json["email"], admin.email) + self.assertEqual(content_json["username"], admin.username) +""" From 4b77d4b850e1fd718dd081165e889eece38bf849 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 00:19:36 +0100 Subject: [PATCH 127/397] test: removing unneeded tests + eliminating all other opportunities for failure in tests #27 --- .../tests/test_authentication.py | 122 +++++++++++------- 1 file changed, 72 insertions(+), 50 deletions(-) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 8049b7f1..8af073d2 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -1,56 +1,21 @@ +from cas_client import CASClient from django.test import TestCase +from rest_framework_simplejwt.tokens import RefreshToken from unittest.mock import patch -from authentication.cas.client import client from authentication.serializers import CASTokenObtainSerializer, UserSerializer -def customize_data(ugent_id, uid, mail): - - def service_validate( - self, - ticket=None, - service_url=None, - headers=None,): - response = {} - if ticket != "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6": - response.error = "This is an error" - else: - response.data = { - "ugentID": ugent_id, - "uid": uid, - "mail": mail, - "givenname": "Dummy", - "surname": "McDickwad", - "faculty": "Sciences", - "lastenrolled": "2021-05-21", - "lastlogin": "", - "createtime": "" - } - return response +TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6" +WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" - return service_validate +ID = "1234" +USERNAME = "ddickwd" +EMAIL = "dummy@dummy.be" +FIRST_NAME = "Dummy" +LAST_NAME = "McDickwad" class UserSerializerModelTests(TestCase): - def test_non_string_id_makes_user_serializer_invalid(self): - """ - The is_valid() method of a UserSerializer whose supplied User's ID is not a string - should return False. - """ - user = UserSerializer(data={ - "id": 1234 - }) - self.assertFalse(user.is_valid()) - - def test_non_string_username_makes_user_serializer_invalid(self): - """ - The is_valid() method of a UserSerializer whose supplied User's username is not a string - should return False. - """ - user = UserSerializer(data={ - "username": 10 - }) - self.assertFalse(user.is_valid()) def test_invalid_email_makes_user_serializer_invalid(self): """ @@ -58,27 +23,84 @@ def test_invalid_email_makes_user_serializer_invalid(self): formatted as an email address should return False. """ user = UserSerializer(data={ - "email": "dummy" + 'id': ID, + 'username': USERNAME, + 'email': 'dummy', + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, }) user2 = UserSerializer(data={ - "email": "dummy@dummy" + 'id': ID, + 'username': USERNAME, + 'email': "dummy@dummy", + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, }) user3 = UserSerializer(data={ - "email": 21 + 'id': ID, + 'username': USERNAME, + 'email': 21, + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, }) self.assertFalse(user.is_valid()) self.assertFalse(user2.is_valid()) self.assertFalse(user3.is_valid()) + def test_valid_id_and_username_and_email_makes_valid_serializer(self): + user = UserSerializer(data={ + 'id': ID, + 'username': USERNAME, + 'email': EMAIL, + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, + }) + self.assertTrue(user.is_valid()) + + +def customize_data(ugent_id, uid, mail): + + class Response: + __slots__ = ('error', 'data') + + def __init__(self): + self.error = None + self.data = {} + + def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = Response() + if ticket != TICKET: + response.error = "This is an error" + else: + response.data['attributes'] = { + 'ugentID': ugent_id, + 'uid': uid, + 'mail': mail, + 'givenname': FIRST_NAME, + 'surname': LAST_NAME, + 'faculty': "Sciences", + 'lastenrolled': "2023 - 2024", + 'lastlogin': "", + 'createtime': "" + } + return response + + return service_validate + class SerializersTests(TestCase): - @patch.object(client, + @patch.object(CASClient, 'perform_service_validate', - customize_data("1234", "ddickwd", "dummy@dummy.be")) + customize_data(ID, USERNAME, EMAIL)) def test_invalid_ticket_generates_error(self): """When the wrong ticket is provided, a ValidationError should be raised.""" # I have set "1" as the correct ticket hereµ serializer = CASTokenObtainSerializer(data={ - 'token': 'qslmdfjklmqsdfjklmqsjdkf' + 'token': RefreshToken(), + 'ticket': WRONG_TICKET }) self.assertFalse(serializer.is_valid()) From 62cd8b2b061b15e92ba16557376fd6631227e702 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 00:25:05 +0100 Subject: [PATCH 128/397] test: valid_id_and_username_and_email to valid_email #27 --- backend/authentication/tests/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py index 8af073d2..72dc80bc 100644 --- a/backend/authentication/tests/test_authentication.py +++ b/backend/authentication/tests/test_authentication.py @@ -47,7 +47,7 @@ def test_invalid_email_makes_user_serializer_invalid(self): self.assertFalse(user2.is_valid()) self.assertFalse(user3.is_valid()) - def test_valid_id_and_username_and_email_makes_valid_serializer(self): + def test_valid_email_makes_valid_serializer(self): user = UserSerializer(data={ 'id': ID, 'username': USERNAME, From 12cd8326b59cb6f4455dff860dceeba488a136ca Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 02:13:02 +0100 Subject: [PATCH 129/397] test: fully added tests for authentication/serializers.py #27 --- .../tests/test_authentication.py | 106 ---------- .../tests/test_authentication_serializer.py | 188 ++++++++++++++++++ 2 files changed, 188 insertions(+), 106 deletions(-) delete mode 100644 backend/authentication/tests/test_authentication.py create mode 100644 backend/authentication/tests/test_authentication_serializer.py diff --git a/backend/authentication/tests/test_authentication.py b/backend/authentication/tests/test_authentication.py deleted file mode 100644 index 72dc80bc..00000000 --- a/backend/authentication/tests/test_authentication.py +++ /dev/null @@ -1,106 +0,0 @@ -from cas_client import CASClient -from django.test import TestCase -from rest_framework_simplejwt.tokens import RefreshToken -from unittest.mock import patch -from authentication.serializers import CASTokenObtainSerializer, UserSerializer - - -TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6" -WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" - -ID = "1234" -USERNAME = "ddickwd" -EMAIL = "dummy@dummy.be" -FIRST_NAME = "Dummy" -LAST_NAME = "McDickwad" - - -class UserSerializerModelTests(TestCase): - - def test_invalid_email_makes_user_serializer_invalid(self): - """ - The is_valid() method of a UserSerializer whose supplied User's email is not - formatted as an email address should return False. - """ - user = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': 'dummy', - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - user2 = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': "dummy@dummy", - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - user3 = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': 21, - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - self.assertFalse(user.is_valid()) - self.assertFalse(user2.is_valid()) - self.assertFalse(user3.is_valid()) - - def test_valid_email_makes_valid_serializer(self): - user = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': EMAIL, - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - self.assertTrue(user.is_valid()) - - -def customize_data(ugent_id, uid, mail): - - class Response: - __slots__ = ('error', 'data') - - def __init__(self): - self.error = None - self.data = {} - - def service_validate( - self, - ticket=None, - service_url=None, - headers=None,): - response = Response() - if ticket != TICKET: - response.error = "This is an error" - else: - response.data['attributes'] = { - 'ugentID': ugent_id, - 'uid': uid, - 'mail': mail, - 'givenname': FIRST_NAME, - 'surname': LAST_NAME, - 'faculty': "Sciences", - 'lastenrolled': "2023 - 2024", - 'lastlogin': "", - 'createtime': "" - } - return response - - return service_validate - - -class SerializersTests(TestCase): - @patch.object(CASClient, - 'perform_service_validate', - customize_data(ID, USERNAME, EMAIL)) - def test_invalid_ticket_generates_error(self): - """When the wrong ticket is provided, a ValidationError should be raised.""" - # I have set "1" as the correct ticket hereµ - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': WRONG_TICKET - }) - self.assertFalse(serializer.is_valid()) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py new file mode 100644 index 00000000..c2d233fa --- /dev/null +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -0,0 +1,188 @@ +from cas_client import CASClient +from django.test import TestCase +from rest_framework_simplejwt.tokens import RefreshToken +from unittest.mock import patch, Mock +from authentication.serializers import CASTokenObtainSerializer, UserSerializer +from authentication.signals import user_created, user_login + + +TICKET = 'ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6' +WRONG_TICKET = 'ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5' + +ID = '1234' +USERNAME = 'ddickwd' +EMAIL = 'dummy@dummy.be' +FIRST_NAME = 'Dummy' +LAST_NAME = 'McDickwad' + + +class UserSerializerModelTests(TestCase): + + def test_invalid_email_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's email is not + formatted as an email address should return False. + """ + user = UserSerializer(data={ + 'id': ID, + 'username': USERNAME, + 'email': 'dummy', + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, + }) + user2 = UserSerializer(data={ + 'id': ID, + 'username': USERNAME, + 'email': 'dummy@dummy', + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, + }) + user3 = UserSerializer(data={ + 'id': ID, + 'username': USERNAME, + 'email': 21, + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, + }) + self.assertFalse(user.is_valid()) + self.assertFalse(user2.is_valid()) + self.assertFalse(user3.is_valid()) + + def test_valid_email_makes_valid_serializer(self): + user = UserSerializer(data={ + 'id': ID, + 'username': USERNAME, + 'email': EMAIL, + 'first_name': FIRST_NAME, + 'last_name': LAST_NAME, + }) + self.assertTrue(user.is_valid()) + + +def customize_data(ugent_id, uid, mail): + + class Response: + __slots__ = ('error', 'data') + + def __init__(self): + self.error = None + self.data = {} + + def service_validate( + self, + ticket=None, + service_url=None, + headers=None,): + response = Response() + if ticket != TICKET: + response.error = 'This is an error' + else: + response.data['attributes'] = { + 'ugentID': ugent_id, + 'uid': uid, + 'mail': mail, + 'givenname': FIRST_NAME, + 'surname': LAST_NAME, + 'faculty': 'Sciences', + 'lastenrolled': '2023 - 2024', + 'lastlogin': '', + 'createtime': '' + } + return response + + return service_validate + + +class SerializersTests(TestCase): + def test_wrong_length_ticket_generates_error(self): + """ + When the provided ticket has the wrong length, a ValidationError should be raised + when validating the serializer. + """ + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': 'ST' + }) + self.assertFalse(serializer.is_valid()) + + @patch.object(CASClient, + 'perform_service_validate', + customize_data(ID, USERNAME, EMAIL)) + def test_wrong_ticket_generates_error(self): + """ + When the wrong ticket is provided, a ValidationError should be raised when trying to validate + the serializer. + """ + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': WRONG_TICKET + }) + self.assertFalse(serializer.is_valid()) + + @patch.object(CASClient, + 'perform_service_validate', + customize_data(ID, USERNAME, "dummy@dummy")) + def test_wrong_user_arguments_generate_error(self): + """ + If the user arguments returned by CAS are not valid, then a ValidationError + should be raised when validating the serializer. + """ + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': TICKET + }) + self.assertFalse(serializer.is_valid()) + + @patch.object(CASClient, + 'perform_service_validate', + customize_data(ID, USERNAME, EMAIL)) + def test_new_user_activates_user_created_signal(self): + """ + If the authenticated user is new to the app, then the user_created signal should + be sent when trying to validate the token.""" + + mock = Mock() + user_created.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': TICKET + }) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 1) + + @patch.object(CASClient, + 'perform_service_validate', + customize_data(ID, USERNAME, EMAIL)) + def test_old_user_does_not_activate_user_created_signal(self): + """ + If the authenticated user is new to the app, then the user_created signal should + be sent when trying to validate the token.""" + + mock = Mock() + user_created.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': TICKET + }) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 0) + + @patch.object(CASClient, + 'perform_service_validate', + customize_data(ID, USERNAME, EMAIL)) + def test_login_signal(self): + """ + When the token is correct and all user data is correct, while trying to validate + the token, then the user_login signal should be sent. + """ + mock = Mock() + user_login.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer(data={ + 'token': RefreshToken(), + 'ticket': TICKET + }) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 1) From 5d30a336df11546ad196466e0dd29a21183f1050 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 15:04:43 +0100 Subject: [PATCH 130/397] test: fully added tests for authentication/views directory #27 --- .../tests/test_authentication_views.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 backend/authentication/tests/test_authentication_views.py diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py new file mode 100644 index 00000000..72421b4d --- /dev/null +++ b/backend/authentication/tests/test_authentication_views.py @@ -0,0 +1,94 @@ +from django.core.serializers import serialize +import json +from rest_framework.request import Request +from rest_framework.reverse import reverse +from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework_simplejwt.tokens import AccessToken +from unittest.mock import patch +from authentication.models import User +from authentication.serializers import UserSerializer +from ypovoli import settings + +USER_DATA = { + 'id': '1234', + 'username': 'ddickwd', + 'email': 'dummy@dummy.com', + 'first_name': 'dummy', + 'last_name': 'McDickwad', +} + + +class TestWhomAmIView(APITestCase): + def setUp(self): + """Create a user and generate a token for that user""" + self.user = User.objects.create(**USER_DATA) + access_token = AccessToken().for_user(self.user) + self.token = f'Bearer {access_token}' + + def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): + """ + WhoAmIView should return the User info when requested if User + exists in database and token is supplied. + """ + self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.get(reverse('auth.whoami')) + self.assertJSONEqual(response.content.decode('utf-8'), UserSerializer(self.user).data) + + def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(self): + """ + WhoAmIView should return that the user was not found if + authenticated user was deleted from the database. + """ + self.user.delete() + self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.get(reverse('auth.whoami')) + self.assertJSONNotEqual(response.content, UserSerializer(self.user).data) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['detail'], 'User not found') + + def test_who_am_i_view_returns_401_when_not_authenticated(self): + """WhoAmIView should return a 401 status code when the user is not authenticated""" + response = self.client.get(reverse('auth.whoami')) + self.assertEqual(response.status_code, 401) + + +class TestLogoutView(APITestCase): + def test_logout_view_authenticated_logout_url(self): + """LogoutView should return a logout url redirect if authenticated user sends a post request.""" + self.user = User.objects.create(**USER_DATA) + access_token = AccessToken().for_user(self.user) + self.token = f'Bearer {access_token}' + self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.post(reverse('auth.logout')) + self.assertEqual(response.status_code, 302) + url = '{server_url}/logout?service={service_url}'.format( + server_url=settings.CAS_ENDPOINT, + service_url=settings.API_ENDPOINT + ) + self.assertEqual(response['Location'], url) + + def test_logout_view_not_authenticated_logout_url(self): + """LogoutView should return a 401 error when trying to access it while not authenticated.""" + response = self.client.post(reverse('auth.logout')) + self.assertEqual(response.status_code, 401) + +class TestLoginView(APITestCase): + def test_login_view_returns_login_url(self): + """LoginView should return a login url redirect if a post request is sent.""" + response = self.client.get(reverse('auth.login')) + self.assertEqual(response.status_code, 302) + url = '{server_url}/login?service={service_url}'.format( + server_url=settings.CAS_ENDPOINT, + service_url=settings.CAS_RESPONSE + ) + self.assertEqual(response['Location'], url) + +class TestTokenEchoView(APITestCase): + def test_token_echo_echoes_token(self): + """TokenEchoView should echo the User's current token""" + ticket = 'This is a ticket.' + response = self.client.get(reverse('auth.echo'), data={'ticket': ticket}) + content = response.rendered_content.decode('utf-8').strip('"') + self.assertEqual(content, ticket) + + From 4e7d131f4372da41ed4de6823eae83a6a3ffdfae Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 1 Mar 2024 15:17:48 +0100 Subject: [PATCH 131/397] chore: user has field notifications + faculties list --- backend/api/serializers/admin_serializer.py | 5 ++++ .../api/serializers/assistant_serializer.py | 6 ++++ backend/api/serializers/student_serializer.py | 6 ++++ backend/api/serializers/teacher_serializer.py | 6 ++++ backend/authentication/serializers.py | 28 +++++++++++++++---- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index 2468e4ba..c7749f87 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -4,6 +4,11 @@ class AdminSerializer(serializers.ModelSerializer): + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) class Meta: model = Admin diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 210f7404..540f9f32 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -9,6 +9,12 @@ class AssistantSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Assistant fields = [ diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 214d637b..3ea93733 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -14,6 +14,12 @@ class StudentSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Student fields = [ diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index c35bea68..8fbb98e9 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -9,6 +9,12 @@ class TeacherSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Teacher fields = [ diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 9c7e8173..f33e6c91 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,8 @@ from django.contrib.auth.models import update_last_login -from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer +from rest_framework.serializers import ( + CharField, EmailField, ModelSerializer, ValidationError, + Serializer, HyperlinkedIdentityField, HyperlinkedRelatedField +) from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings from authentication.signals import user_created, user_login @@ -65,23 +68,36 @@ def validate(self, data): 'refresh': str(RefreshToken.for_user(user)) } + class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ id = CharField() username = CharField() - email = EmailField() - + email = EmailField() + + faculties = HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + + notifications = HyperlinkedIdentityField( + view_name='notification-detail', + read_only=True, + ) + class Meta: model = User fields = [ 'id', 'username', 'email', 'first_name', 'last_name', - 'faculty', - 'last_enrolled', 'last_login', 'create_time' + 'faculties', + 'last_enrolled', 'last_login', 'create_time', + 'notifications' ] def get_or_create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" - return User.objects.get_or_create(**validated_data) \ No newline at end of file + return User.objects.get_or_create(**validated_data) From 3b63c2fccfee464b1a3d2a19b0f125d5bf99f268 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 15:20:25 +0100 Subject: [PATCH 132/397] chore: add admin tests --- backend/api/tests/test_admin.py | 44 +++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 56517cb2..eaddd911 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -1,4 +1,3 @@ -import datetime import json from django.test import TestCase @@ -29,7 +28,7 @@ def test_no_admins(self): able to retrieve no admin before publishing it. """ - response_root = self.client.get("/admins", follow=True) + response_root = self.client.get(reverse("admin-list"), follow=True) # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON @@ -51,17 +50,17 @@ def test_admin_exists(self): ) # Make a GET request to retrieve the admin - response = self.client.get("/admins", follow=True) + response = self.client.get(reverse("admin-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) # Assert that the response is JSON self.assertEqual(response.accepted_media_type, "application/json") - + # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - + # Assert that the parsed JSON is a list with one admin self.assertEqual(len(content_json), 1) @@ -77,11 +76,21 @@ def test_multiple_admins(self): Able to retrieve multiple admins after creating them. """ # Create multiple admins - admin1 = create_admin(id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com") - admin2 = create_admin(id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com") + admin1 = create_admin( + id=1, + first_name="Johny", + last_name="Doeg", + email="john.doe@example.com" + ) + admin2 = create_admin( + id=2, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) # Make a GET request to retrieve the admins - response = self.client.get("/admins", follow=True) + response = self.client.get(reverse("admin-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -107,16 +116,21 @@ def test_multiple_admins(self): self.assertEqual(retrieved_admin2["last_name"], admin2.last_name) self.assertEqual(retrieved_admin2["email"], admin2.email) -""" def test_admin_detail_view(self): - - # Able to retrieve details of a single admin. - + """ + Able to retrieve details of a single admin. + """ # Create an admin for testing with the name "Bob Peeters" - admin = create_admin(id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com") + admin = create_admin( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com" + ) # Make a GET request to retrieve the admin details - response = self.client.get(reverse("/admins/"+str(admin.id), args=[admin.id]), follow=True) + response = self.client.get( + reverse("admin-detail", args=[str(admin.id)]), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -132,5 +146,3 @@ def test_admin_detail_view(self): self.assertEqual(content_json["first_name"], admin.first_name) self.assertEqual(content_json["last_name"], admin.last_name) self.assertEqual(content_json["email"], admin.email) - self.assertEqual(content_json["username"], admin.username) -""" From d814a86602f92d48b11ddf61d478ad8f79c48a29 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 16:07:31 +0100 Subject: [PATCH 133/397] chore: add fileExtensions tests --- backend/api/tests/test_assistant.py | 155 ++++++++++++++++++++++++++++ backend/api/tests/test_checks.py | 148 ++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 backend/api/tests/test_assistant.py create mode 100644 backend/api/tests/test_checks.py diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py new file mode 100644 index 00000000..c2ef3b5e --- /dev/null +++ b/backend/api/tests/test_assistant.py @@ -0,0 +1,155 @@ +import json + +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse + +#from ..models.assistant import Assistant + +""" +def create_assistant(id, first_name, last_name, email): + + # Create a Assistant with the given arguments. + + username = f"{first_name}_{last_name}" + return Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) +""" + + +""" class AssistantModelTests(TestCase): + def test_no_assistant(self): + + # able to retrieve no assistant before publishing it. + + + response_root = self.client.get(reverse("assistant-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_assistant_exists(self): + + # Able to retrieve a single assistant after creating it. + + assistant = create_assistant( + id=3, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_assistant = content_json[0] + self.assertEqual(int(retrieved_assistant["id"]), assistant.id) + self.assertEqual( + retrieved_assistant["first_name"], assistant.first_name) + self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) + self.assertEqual(retrieved_assistant["email"], assistant.email) + + def test_multiple_assistant(self): + + # Able to retrieve multiple assistant after creating them. + + # Create multiple assistant + assistant1 = create_assistant( + id=1, + first_name="Johny", + last_name="Doeg", + email="john.doe@example.com" + ) + assistant2 = create_assistant( + id=2, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple admins + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created admins + retrieved_assistant1, retrieved_assistant2 = content_json + self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) + self.assertEqual( + retrieved_assistant1["first_name"], assistant1.first_name) + self.assertEqual( + retrieved_assistant1["last_name"], assistant1.last_name) + self.assertEqual(retrieved_assistant1["email"], assistant1.email) + + self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) + self.assertEqual( + retrieved_assistant2["first_name"], assistant2.first_name) + self.assertEqual( + retrieved_assistant2["last_name"], assistant2.last_name) + self.assertEqual(retrieved_assistant2["email"], assistant2.email) + + def test_assistant_detail_view(self): + + # Able to retrieve details of a single assistant. + + # Create an admin for testing with the name "Bob Peeters" + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + """ \ No newline at end of file diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py new file mode 100644 index 00000000..47a65e13 --- /dev/null +++ b/backend/api/tests/test_checks.py @@ -0,0 +1,148 @@ +import json + +from django.test import TestCase +from django.urls import reverse + +from ..models.checks import FileExtension + + +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create( + id=id, + extension=extension + ) + +""" +def create_checks(id, allowed_file_extensions, forbidden_file_extensions): + + # Create a Checks with the given arguments. + + return Checks.objects.create( + id=id, + allowed_file_extensions=allowed_file_extensions, + forbidden_file_extensions=forbidden_file_extensions + ) +""" + + +class FileExtensionModelTests(TestCase): + def test_no_fileExtension(self): + """ + able to retrieve no FileExtension before publishing it. + """ + + response_root = self.client.get(reverse("fileExtension-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_fileExtension_exists(self): + """ + Able to retrieve a single fileExtension after creating it. + """ + fileExtension = create_fileExtension( + id=5, + extension=".pdf" + ) + + # Make a GET request to retrieve the fileExtension + response = self.client.get(reverse("fileExtension-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_fileExtension = content_json[0] + self.assertEqual( + int(retrieved_fileExtension["id"]), fileExtension.id) + self.assertEqual( + retrieved_fileExtension["extension"], fileExtension.extension) + + def test_multiple_fileExtension(self): + """ + Able to retrieve multiple fileExtension after creating them. + """ + # Create multiple fileExtension + fileExtension1 = create_fileExtension( + id=1, + extension=".jpg" + ) + fileExtension2 = create_fileExtension( + id=2, + extension=".png" + ) + + # Make a GET request to retrieve the fileExtension + response = self.client.get(reverse("fileExtension-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple admins + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created admins + retrieved_fileExtension1, retrieved_fileExtension2 = content_json + self.assertEqual( + int(retrieved_fileExtension1["id"]), fileExtension1.id) + self.assertEqual( + retrieved_fileExtension1["extension"], fileExtension1.extension) + + self.assertEqual( + int(retrieved_fileExtension2["id"]), fileExtension2.id) + self.assertEqual( + retrieved_fileExtension2["extension"], fileExtension2.extension) + + def test_fileExtension_detail_view(self): + """ + Able to retrieve details of a single fileExtension. + """ + # Create an fileExtension for testing with the name "Bob Peeters" + fileExtension = create_fileExtension( + id=3, + extension=".zip" + ) + + # Make a GET request to retrieve the fileExtension details + response = self.client.get( + reverse( + "fileExtension-detail", + args=[str(fileExtension.id)]), + follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved fileExtension + # match the created fileExtension + self.assertEqual(int(content_json["id"]), fileExtension.id) + self.assertEqual(content_json["extension"], fileExtension.extension) From 81135349ce109019f65fba71986bebb32a9ab719 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 16:49:56 +0100 Subject: [PATCH 134/397] chore: checks tests --- backend/api/apps.py | 3 +- backend/api/serializers/checks_serializer.py | 6 +- backend/api/signals.py | 3 +- backend/api/tests/test_assistant.py | 293 +++++++++---------- backend/api/tests/test_checks.py | 28 +- 5 files changed, 169 insertions(+), 164 deletions(-) diff --git a/backend/api/apps.py b/backend/api/apps.py index 6cd16758..5e092e01 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' @@ -9,4 +10,4 @@ def ready(self): from authentication.signals import user_created from api.signals import user_creation - user_created.connect(user_creation) \ No newline at end of file + user_created.connect(user_creation) diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 51bbfd84..fd1aa13b 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -5,13 +5,13 @@ class ChecksSerializer(serializers.ModelSerializer): class Meta: model = Checks - fields = ['id', 'dockerfile'] + fields = ['id', 'dockerfile', + 'allowed_file_extensions', 'forbidden_file_extensions'] class FileExtensionSerializer(serializers.ModelSerializer): class Meta: model = FileExtension fields = [ - 'id', 'extension', - 'allowed_file_extensions', 'forbidden_file_extensions' + 'id', 'extension' ] diff --git a/backend/api/signals.py b/backend/api/signals.py index 767bff1e..4c1e73a8 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -1,5 +1,6 @@ from authentication.models import User + def user_creation(user: User, attributes: dict, **kwargs): # With Python 3.11, we need to import Student here. from api.models.student import Student @@ -8,4 +9,4 @@ def user_creation(user: User, attributes: dict, **kwargs): student_id = attributes.get("ugentStudentID") if student_id: - Student(user_ptr=user, student_id=student_id).save_base(raw=True) \ No newline at end of file + Student(user_ptr=user, student_id=student_id).save_base(raw=True) diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index c2ef3b5e..a316e574 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -4,152 +4,149 @@ from django.utils import timezone from django.urls import reverse -#from ..models.assistant import Assistant -""" -def create_assistant(id, first_name, last_name, email): - - # Create a Assistant with the given arguments. - - username = f"{first_name}_{last_name}" - return Assistant.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), - ) -""" - - -""" class AssistantModelTests(TestCase): - def test_no_assistant(self): - - # able to retrieve no assistant before publishing it. - - - response_root = self.client.get(reverse("assistant-list"), follow=True) - # print(response.content) - self.assertEqual(response_root.status_code, 200) - # Assert that the response is JSON - self.assertEqual(response_root.accepted_media_type, "application/json") - # Parse the JSON content from the response - content_json = json.loads(response_root.content.decode("utf-8")) - # Assert that the parsed JSON is an empty list - self.assertEqual(content_json, []) - - def test_assistant_exists(self): - - # Able to retrieve a single assistant after creating it. - - assistant = create_assistant( - id=3, - first_name="John", - last_name="Doe", - email="john.doe@example.com" - ) - - # Make a GET request to retrieve the assistant - response = self.client.get(reverse("assistant-list"), follow=True) - - # Check if the response was successful - self.assertEqual(response.status_code, 200) - - # Assert that the response is JSON - self.assertEqual(response.accepted_media_type, "application/json") - - # Parse the JSON content from the response - content_json = json.loads(response.content.decode("utf-8")) - - # Assert that the parsed JSON is a list with one admin - self.assertEqual(len(content_json), 1) - - # Assert the details of the retrieved admin match the created admin - retrieved_assistant = content_json[0] - self.assertEqual(int(retrieved_assistant["id"]), assistant.id) - self.assertEqual( - retrieved_assistant["first_name"], assistant.first_name) - self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) - self.assertEqual(retrieved_assistant["email"], assistant.email) - - def test_multiple_assistant(self): - - # Able to retrieve multiple assistant after creating them. - - # Create multiple assistant - assistant1 = create_assistant( - id=1, - first_name="Johny", - last_name="Doeg", - email="john.doe@example.com" - ) - assistant2 = create_assistant( - id=2, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) - - # Make a GET request to retrieve the assistant - response = self.client.get(reverse("assistant-list"), follow=True) - - # Check if the response was successful - self.assertEqual(response.status_code, 200) - - # Assert that the response is JSON - self.assertEqual(response.accepted_media_type, "application/json") - - # Parse the JSON content from the response - content_json = json.loads(response.content.decode("utf-8")) - - # Assert that the parsed JSON is a list with multiple admins - self.assertEqual(len(content_json), 2) - - # Assert the details of the retrieved admins match the created admins - retrieved_assistant1, retrieved_assistant2 = content_json - self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) - self.assertEqual( - retrieved_assistant1["first_name"], assistant1.first_name) - self.assertEqual( - retrieved_assistant1["last_name"], assistant1.last_name) - self.assertEqual(retrieved_assistant1["email"], assistant1.email) - - self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) - self.assertEqual( - retrieved_assistant2["first_name"], assistant2.first_name) - self.assertEqual( - retrieved_assistant2["last_name"], assistant2.last_name) - self.assertEqual(retrieved_assistant2["email"], assistant2.email) - - def test_assistant_detail_view(self): - - # Able to retrieve details of a single assistant. - - # Create an admin for testing with the name "Bob Peeters" - assistant = create_assistant( - id=5, - first_name="Bob", - last_name="Peeters", - email="bob.peeters@example.com" - ) - - # Make a GET request to retrieve the assistant details - response = self.client.get( - reverse("assistant-detail", args=[str(assistant.id)]), follow=True) - - # Check if the response was successful - self.assertEqual(response.status_code, 200) - - # Assert that the response is JSON - self.assertEqual(response.accepted_media_type, "application/json") - - # Parse the JSON content from the response - content_json = json.loads(response.content.decode("utf-8")) - - # Assert the details of the retrieved admin match the created admin - self.assertEqual(int(content_json["id"]), assistant.id) - self.assertEqual(content_json["first_name"], assistant.first_name) - self.assertEqual(content_json["last_name"], assistant.last_name) - self.assertEqual(content_json["email"], assistant.email) - """ \ No newline at end of file +# from ..models.assistant import Assistant + + +# def create_assistant(id, first_name, last_name, email): +# # Create a Assistant with the given arguments. +# username = f"{first_name}_{last_name}" +# return Assistant.objects.create( +# id=id, +# first_name=first_name, +# last_name=last_name, +# username=username, +# email=email, +# create_time=timezone.now(), +# ) + + +# class AssistantModelTests(TestCase): +# def test_no_assistant(self): +# """ +# able to retrieve no assistant before publishing it. +# """ + +# response_root = self.client.get(reverse("assistant-list"), follow=True) +# # print(response.content) +# self.assertEqual(response_root.status_code, 200) +# # Assert that the response is JSON +# self.assertEqual(response_root.accepted_media_type, "application/json") +# # Parse the JSON content from the response +# content_json = json.loads(response_root.content.decode("utf-8")) +# # Assert that the parsed JSON is an empty list +# self.assertEqual(content_json, []) + +# def test_assistant_exists(self): +# """ +# Able to retrieve a single assistant after creating it. +# """ +# assistant = create_assistant( +# id=3, +# first_name="John", +# last_name="Doe", +# email="john.doe@example.com" +# ) + +# # Make a GET request to retrieve the assistant +# response = self.client.get(reverse("assistant-list"), follow=True) + +# # Check if the response was successful +# self.assertEqual(response.status_code, 200) + +# # Assert that the response is JSON +# self.assertEqual(response.accepted_media_type, "application/json") + +# # Parse the JSON content from the response +# content_json = json.loads(response.content.decode("utf-8")) + +# # Assert that the parsed JSON is a list with one admin +# self.assertEqual(len(content_json), 1) + +# # Assert the details of the retrieved admin match the created admin +# retrieved_assistant = content_json[0] +# self.assertEqual(int(retrieved_assistant["id"]), assistant.id) +# self.assertEqual( +# retrieved_assistant["first_name"], assistant.first_name) +# self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) +# self.assertEqual(retrieved_assistant["email"], assistant.email) + +# def test_multiple_assistant(self): +# """ +# Able to retrieve multiple assistant after creating them. +# """ +# # Create multiple assistant +# assistant1 = create_assistant( +# id=1, +# first_name="Johny", +# last_name="Doeg", +# email="john.doe@example.com" +# ) +# assistant2 = create_assistant( +# id=2, +# first_name="Jane", +# last_name="Doe", +# email="jane.doe@example.com" +# ) + +# # Make a GET request to retrieve the assistant +# response = self.client.get(reverse("assistant-list"), follow=True) + +# # Check if the response was successful +# self.assertEqual(response.status_code, 200) + +# # Assert that the response is JSON +# self.assertEqual(response.accepted_media_type, "application/json") + +# # Parse the JSON content from the response +# content_json = json.loads(response.content.decode("utf-8")) + +# # Assert that the parsed JSON is a list with multiple admins +# self.assertEqual(len(content_json), 2) + +# # Assert the details of the retrieved admins match the created admins +# retrieved_assistant1, retrieved_assistant2 = content_json +# self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) +# self.assertEqual( +# retrieved_assistant1["first_name"], assistant1.first_name) +# self.assertEqual( +# retrieved_assistant1["last_name"], assistant1.last_name) +# self.assertEqual(retrieved_assistant1["email"], assistant1.email) + +# self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) +# self.assertEqual( +# retrieved_assistant2["first_name"], assistant2.first_name) +# self.assertEqual( +# retrieved_assistant2["last_name"], assistant2.last_name) +# self.assertEqual(retrieved_assistant2["email"], assistant2.email) + +# def test_assistant_detail_view(self): +# """ +# Able to retrieve details of a single assistant. +# """ +# # Create an admin for testing with the name "Bob Peeters" +# assistant = create_assistant( +# id=5, +# first_name="Bob", +# last_name="Peeters", +# email="bob.peeters@example.com" +# ) + +# # Make a GET request to retrieve the assistant details +# response = self.client.get( +# reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + +# # Check if the response was successful +# self.assertEqual(response.status_code, 200) + +# # Assert that the response is JSON +# self.assertEqual(response.accepted_media_type, "application/json") + +# # Parse the JSON content from the response +# content_json = json.loads(response.content.decode("utf-8")) + +# # Assert the details of the retrieved admin match the created admin +# self.assertEqual(int(content_json["id"]), assistant.id) +# self.assertEqual(content_json["first_name"], assistant.first_name) +# self.assertEqual(content_json["last_name"], assistant.last_name) +# self.assertEqual(content_json["email"], assistant.email) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 47a65e13..8b359ff8 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -3,7 +3,7 @@ from django.test import TestCase from django.urls import reverse -from ..models.checks import FileExtension +from ..models.checks import FileExtension, Checks def create_fileExtension(id, extension): @@ -15,17 +15,22 @@ def create_fileExtension(id, extension): extension=extension ) -""" + def create_checks(id, allowed_file_extensions, forbidden_file_extensions): # Create a Checks with the given arguments. - return Checks.objects.create( + check = Checks.objects.create( id=id, - allowed_file_extensions=allowed_file_extensions, - forbidden_file_extensions=forbidden_file_extensions ) -""" + + for ext in allowed_file_extensions: + check.allowed_file_extensions.add(ext) + + for ext in forbidden_file_extensions: + check.forbidden_file_extensions.add(ext) + + return check class FileExtensionModelTests(TestCase): @@ -34,7 +39,8 @@ def test_no_fileExtension(self): able to retrieve no FileExtension before publishing it. """ - response_root = self.client.get(reverse("fileExtension-list"), follow=True) + response_root = self.client.get( + reverse("fileExtension-list"), follow=True) # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON @@ -50,7 +56,7 @@ def test_fileExtension_exists(self): """ fileExtension = create_fileExtension( id=5, - extension=".pdf" + extension="pdf" ) # Make a GET request to retrieve the fileExtension @@ -82,11 +88,11 @@ def test_multiple_fileExtension(self): # Create multiple fileExtension fileExtension1 = create_fileExtension( id=1, - extension=".jpg" + extension="jpg" ) fileExtension2 = create_fileExtension( id=2, - extension=".png" + extension="png" ) # Make a GET request to retrieve the fileExtension @@ -123,7 +129,7 @@ def test_fileExtension_detail_view(self): # Create an fileExtension for testing with the name "Bob Peeters" fileExtension = create_fileExtension( id=3, - extension=".zip" + extension="zip" ) # Make a GET request to retrieve the fileExtension details From 04dd9c88fa49fbb5557c4a1582f85196b32c9261 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 1 Mar 2024 15:56:10 +0100 Subject: [PATCH 135/397] fix: model relationships now with direct imports --- backend/api/apps.py | 1 - backend/api/models/assistant.py | 5 +- backend/api/models/group.py | 6 +- backend/api/models/project.py | 10 +- backend/api/models/student.py | 5 +- backend/api/models/submission.py | 46 ++--- backend/api/models/teacher.py | 5 +- backend/api/signals.py | 3 +- backend/api/tests/test_assistant.py | 292 ++++++++++++++-------------- 9 files changed, 188 insertions(+), 185 deletions(-) diff --git a/backend/api/apps.py b/backend/api/apps.py index 5e092e01..a13c8f06 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -6,7 +6,6 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): - # Only here we can import from apps. from authentication.signals import user_created from api.signals import user_creation diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py index 3489a776..a761e8a5 100644 --- a/backend/api/models/assistant.py +++ b/backend/api/models/assistant.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from authentication.models import User +from api.models.course import Course class Assistant(User): @@ -10,7 +11,7 @@ class Assistant(User): # All the courses the assistant is assisting in courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the assistants from the course related_name='assistants', blank=True diff --git a/backend/api/models/group.py b/backend/api/models/group.py index 3a8158c2..b820bcc3 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -1,4 +1,6 @@ from django.db import models +from api.models.project import Project +from api.models.student import Student class Group(models.Model): @@ -7,7 +9,7 @@ class Group(models.Model): # ID should be generated automatically project = models.ForeignKey( - 'Project', + Project, # If the project is deleted, the group should be deleted as well on_delete=models.CASCADE, # This is how we can access groups from a project @@ -18,7 +20,7 @@ class Group(models.Model): # Students that are part of the group students = models.ManyToManyField( - 'Student', + Student, # This is how we can access groups from a student related_name='groups', blank=False, diff --git a/backend/api/models/project.py b/backend/api/models/project.py index a83ffe89..29e4264a 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,5 +1,7 @@ -import datetime +from datetime import datetime from django.db import models +from api.models.checks import Checks +from api.models.course import Course class Project(models.Model): @@ -30,7 +32,7 @@ class Project(models.Model): start_date = models.DateTimeField( # The default value is the current date and time - default=datetime.datetime.now, + default=datetime.now, blank=True, ) @@ -41,7 +43,7 @@ class Project(models.Model): # Check entity that is linked to the project checks = models.ForeignKey( - 'Checks', + Checks, # If the checks are deleted, the project should remain on_delete=models.SET_NULL, blank=True, @@ -50,7 +52,7 @@ class Project(models.Model): # Course that the project belongs to course = models.ForeignKey( - 'Course', + Course, # If the course is deleted, the project should be deleted as well on_delete=models.CASCADE, related_name='projects', diff --git a/backend/api/models/student.py b/backend/api/models/student.py index 8eec4be0..a11fe0f8 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from authentication.models import User +from api.models.course import Course class Student(User): @@ -16,7 +17,7 @@ class Student(User): # All the courses the student is enrolled in courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the students from the course related_name='students', blank=True diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 7adac13a..54a00133 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,26 +1,5 @@ from django.db import models - - -class SubmissionFile(models.Model): - """Model for a file that is part of a submission.""" - - # File ID should be generated automatically - - submission = models.ForeignKey( - 'Submission', - # If the submission is deleted, the file should be deleted as well - on_delete=models.CASCADE, - related_name='files', - blank=False, - null=False - ) - - # TODO - Set the right place to save the file - file = models.FileField( - blank=False, - null=False - ) - +from api.models.group import Group class Submission(models.Model): """Model for submission of a project by a group of students.""" @@ -28,7 +7,7 @@ class Submission(models.Model): # Submission ID should be generated automatically group = models.ForeignKey( - 'Group', + Group, # If the group is deleted, the submission should be deleted as well on_delete=models.CASCADE, related_name='submissions', @@ -50,3 +29,24 @@ class Submission(models.Model): class Meta: # A group can only have one submission with a specific number unique_together = ('group', 'submission_number') + + +class SubmissionFile(models.Model): + """Model for a file that is part of a submission.""" + + # File ID should be generated automatically + + submission = models.ForeignKey( + Submission, + # If the submission is deleted, the file should be deleted as well + on_delete=models.CASCADE, + related_name='files', + blank=False, + null=False + ) + + # TODO - Set the right place to save the file + file = models.FileField( + blank=False, + null=False + ) \ No newline at end of file diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 37bb264b..e2e6260e 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from api.models.course import Course +from authentication.models import User class Teacher(User): @@ -10,7 +11,7 @@ class Teacher(User): # All the courses the teacher is teaching courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the teachers from the course related_name='teachers', blank=True diff --git a/backend/api/signals.py b/backend/api/signals.py index 4c1e73a8..8b50a749 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -1,9 +1,8 @@ from authentication.models import User +from api.models.student import Student def user_creation(user: User, attributes: dict, **kwargs): - # With Python 3.11, we need to import Student here. - from api.models.student import Student """Upon user creation, auto-populate additional properties""" student_id = attributes.get("ugentStudentID") diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index a316e574..381e1a6a 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -3,150 +3,148 @@ from django.test import TestCase from django.utils import timezone from django.urls import reverse - - -# from ..models.assistant import Assistant - - -# def create_assistant(id, first_name, last_name, email): -# # Create a Assistant with the given arguments. -# username = f"{first_name}_{last_name}" -# return Assistant.objects.create( -# id=id, -# first_name=first_name, -# last_name=last_name, -# username=username, -# email=email, -# create_time=timezone.now(), -# ) - - -# class AssistantModelTests(TestCase): -# def test_no_assistant(self): -# """ -# able to retrieve no assistant before publishing it. -# """ - -# response_root = self.client.get(reverse("assistant-list"), follow=True) -# # print(response.content) -# self.assertEqual(response_root.status_code, 200) -# # Assert that the response is JSON -# self.assertEqual(response_root.accepted_media_type, "application/json") -# # Parse the JSON content from the response -# content_json = json.loads(response_root.content.decode("utf-8")) -# # Assert that the parsed JSON is an empty list -# self.assertEqual(content_json, []) - -# def test_assistant_exists(self): -# """ -# Able to retrieve a single assistant after creating it. -# """ -# assistant = create_assistant( -# id=3, -# first_name="John", -# last_name="Doe", -# email="john.doe@example.com" -# ) - -# # Make a GET request to retrieve the assistant -# response = self.client.get(reverse("assistant-list"), follow=True) - -# # Check if the response was successful -# self.assertEqual(response.status_code, 200) - -# # Assert that the response is JSON -# self.assertEqual(response.accepted_media_type, "application/json") - -# # Parse the JSON content from the response -# content_json = json.loads(response.content.decode("utf-8")) - -# # Assert that the parsed JSON is a list with one admin -# self.assertEqual(len(content_json), 1) - -# # Assert the details of the retrieved admin match the created admin -# retrieved_assistant = content_json[0] -# self.assertEqual(int(retrieved_assistant["id"]), assistant.id) -# self.assertEqual( -# retrieved_assistant["first_name"], assistant.first_name) -# self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) -# self.assertEqual(retrieved_assistant["email"], assistant.email) - -# def test_multiple_assistant(self): -# """ -# Able to retrieve multiple assistant after creating them. -# """ -# # Create multiple assistant -# assistant1 = create_assistant( -# id=1, -# first_name="Johny", -# last_name="Doeg", -# email="john.doe@example.com" -# ) -# assistant2 = create_assistant( -# id=2, -# first_name="Jane", -# last_name="Doe", -# email="jane.doe@example.com" -# ) - -# # Make a GET request to retrieve the assistant -# response = self.client.get(reverse("assistant-list"), follow=True) - -# # Check if the response was successful -# self.assertEqual(response.status_code, 200) - -# # Assert that the response is JSON -# self.assertEqual(response.accepted_media_type, "application/json") - -# # Parse the JSON content from the response -# content_json = json.loads(response.content.decode("utf-8")) - -# # Assert that the parsed JSON is a list with multiple admins -# self.assertEqual(len(content_json), 2) - -# # Assert the details of the retrieved admins match the created admins -# retrieved_assistant1, retrieved_assistant2 = content_json -# self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) -# self.assertEqual( -# retrieved_assistant1["first_name"], assistant1.first_name) -# self.assertEqual( -# retrieved_assistant1["last_name"], assistant1.last_name) -# self.assertEqual(retrieved_assistant1["email"], assistant1.email) - -# self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) -# self.assertEqual( -# retrieved_assistant2["first_name"], assistant2.first_name) -# self.assertEqual( -# retrieved_assistant2["last_name"], assistant2.last_name) -# self.assertEqual(retrieved_assistant2["email"], assistant2.email) - -# def test_assistant_detail_view(self): -# """ -# Able to retrieve details of a single assistant. -# """ -# # Create an admin for testing with the name "Bob Peeters" -# assistant = create_assistant( -# id=5, -# first_name="Bob", -# last_name="Peeters", -# email="bob.peeters@example.com" -# ) - -# # Make a GET request to retrieve the assistant details -# response = self.client.get( -# reverse("assistant-detail", args=[str(assistant.id)]), follow=True) - -# # Check if the response was successful -# self.assertEqual(response.status_code, 200) - -# # Assert that the response is JSON -# self.assertEqual(response.accepted_media_type, "application/json") - -# # Parse the JSON content from the response -# content_json = json.loads(response.content.decode("utf-8")) - -# # Assert the details of the retrieved admin match the created admin -# self.assertEqual(int(content_json["id"]), assistant.id) -# self.assertEqual(content_json["first_name"], assistant.first_name) -# self.assertEqual(content_json["last_name"], assistant.last_name) -# self.assertEqual(content_json["email"], assistant.email) +from api.models.assistant import Assistant + + +def create_assistant(id, first_name, last_name, email): + # Create an Assistant with the given arguments. + username = f"{first_name}_{last_name}" + return Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + +class AssistantModelTests(TestCase): + def test_no_assistant(self): + """ + able to retrieve no assistant before publishing it. + """ + + response_root = self.client.get(reverse("assistant-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_assistant_exists(self): + """ + Able to retrieve a single assistant after creating it. + """ + assistant = create_assistant( + id=3, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_assistant = content_json[0] + self.assertEqual(int(retrieved_assistant["id"]), assistant.id) + self.assertEqual( + retrieved_assistant["first_name"], assistant.first_name) + self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) + self.assertEqual(retrieved_assistant["email"], assistant.email) + + def test_multiple_assistant(self): + """ + Able to retrieve multiple assistant after creating them. + """ + # Create multiple assistant + assistant1 = create_assistant( + id=1, + first_name="Johny", + last_name="Doeg", + email="john.doe@example.com" + ) + assistant2 = create_assistant( + id=2, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple admins + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created admins + retrieved_assistant1, retrieved_assistant2 = content_json + self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) + self.assertEqual( + retrieved_assistant1["first_name"], assistant1.first_name) + self.assertEqual( + retrieved_assistant1["last_name"], assistant1.last_name) + self.assertEqual(retrieved_assistant1["email"], assistant1.email) + + self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) + self.assertEqual( + retrieved_assistant2["first_name"], assistant2.first_name) + self.assertEqual( + retrieved_assistant2["last_name"], assistant2.last_name) + self.assertEqual(retrieved_assistant2["email"], assistant2.email) + + def test_assistant_detail_view(self): + """ + Able to retrieve details of a single assistant. + """ + # Create an admin for testing with the name "Bob Peeters" + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) From 6ee8e70b9222fd7cb138815006cdbb84f8c149b3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Fri, 1 Mar 2024 15:59:54 +0100 Subject: [PATCH 136/397] feat(notifications): authentication --- backend/notifications/apps.py | 6 ++---- backend/notifications/serializers.py | 1 - backend/notifications/urls.py | 10 +++------ backend/notifications/views.py | 31 ++++++++++++++++++++++++---- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index e5db3f92..04d5d437 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -2,11 +2,9 @@ class NotificationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'notifications' + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" -# TODO: Allow is_sent to be adjusted # TODO: Signals to send notifications # TODO: Send emails -# TODO: Think about the required api endpoints \ No newline at end of file diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index d5a1697f..6f6e3530 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,5 +1,4 @@ import re -from os import read from typing import Dict, List from authentication.models import User diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index cdd8a247..430a82b5 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,8 +1,4 @@ -from notifications.views import NotificationViewSet -from rest_framework.routers import DefaultRouter +from django.urls import path +from notifications.views import NotificationView -router = DefaultRouter() - -router.register(r"", NotificationViewSet, basename="notification") - -urlpatterns = router.urls +urlpatterns = [path("/", NotificationView.as_view())] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 88bbbb54..9f121a47 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,8 +1,31 @@ from notifications.models import Notification from notifications.serializers import NotificationSerializer -from rest_framework.viewsets import ModelViewSet +from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.views import APIView -class NotificationViewSet(ModelViewSet): - queryset = Notification.objects.all() - serializer_class = NotificationSerializer +# TODO: Give admin access to everything +class NotificationPermission(BasePermission): + def has_permission(self, request: Request, view): + return view.kwargs.get("user_id") == request.user.id + + +class NotificationView(APIView): + permission_classes = [IsAuthenticated, NotificationPermission] + + def get(self, request, user_id): + notifications = Notification.objects.filter(user=user_id) + serializer = NotificationSerializer( + notifications, many=True, context={"request": request} + ) + + return Response(serializer.data) + + def post(self, request, user_id): + notifications = Notification.objects.filter(user=user_id) + notifications.update(is_read=True) + + return Response(status=HTTP_200_OK) From 102f1ceadd01b63a9b2b9465faa6d2bf68a1d740 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 16:49:26 +0100 Subject: [PATCH 137/397] test: factoring out CASClient class from patch.object decorator #27 --- .../tests/test_authentication_serializer.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index c2d233fa..05a65630 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -1,7 +1,7 @@ -from cas_client import CASClient from django.test import TestCase from rest_framework_simplejwt.tokens import RefreshToken from unittest.mock import patch, Mock +from authentication.cas.client import client from authentication.serializers import CASTokenObtainSerializer, UserSerializer from authentication.signals import user_created, user_login @@ -69,7 +69,6 @@ def __init__(self): self.data = {} def service_validate( - self, ticket=None, service_url=None, headers=None,): @@ -105,7 +104,7 @@ def test_wrong_length_ticket_generates_error(self): }) self.assertFalse(serializer.is_valid()) - @patch.object(CASClient, + @patch.object(client, 'perform_service_validate', customize_data(ID, USERNAME, EMAIL)) def test_wrong_ticket_generates_error(self): @@ -119,7 +118,7 @@ def test_wrong_ticket_generates_error(self): }) self.assertFalse(serializer.is_valid()) - @patch.object(CASClient, + @patch.object(client, 'perform_service_validate', customize_data(ID, USERNAME, "dummy@dummy")) def test_wrong_user_arguments_generate_error(self): @@ -133,7 +132,7 @@ def test_wrong_user_arguments_generate_error(self): }) self.assertFalse(serializer.is_valid()) - @patch.object(CASClient, + @patch.object(client, 'perform_service_validate', customize_data(ID, USERNAME, EMAIL)) def test_new_user_activates_user_created_signal(self): @@ -151,7 +150,7 @@ def test_new_user_activates_user_created_signal(self): serializer.is_valid() self.assertEquals(mock.call_count, 1) - @patch.object(CASClient, + @patch.object(client, 'perform_service_validate', customize_data(ID, USERNAME, EMAIL)) def test_old_user_does_not_activate_user_created_signal(self): @@ -169,7 +168,7 @@ def test_old_user_does_not_activate_user_created_signal(self): serializer.is_valid() self.assertEquals(mock.call_count, 0) - @patch.object(CASClient, + @patch.object(client, 'perform_service_validate', customize_data(ID, USERNAME, EMAIL)) def test_login_signal(self): From da964d45a290952291b81eb0bb618c2ff22bf360 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 1 Mar 2024 16:04:09 +0100 Subject: [PATCH 138/397] chore: merge with development --- backend/authentication/fixtures/users.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/authentication/fixtures/users.yaml b/backend/authentication/fixtures/users.yaml index 40922b7d..3e6a5801 100644 --- a/backend/authentication/fixtures/users.yaml +++ b/backend/authentication/fixtures/users.yaml @@ -6,7 +6,7 @@ email: John.Doe@hotmail.com first_name: John last_name: Doe - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.690556+00:00 faculties: - Wetenschappen @@ -18,7 +18,7 @@ email: Tom.Boonen@gmail.be first_name: Tom last_name: Boonen - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.686541+00:00 faculties: - Psychologie_PedagogischeWetenschappen @@ -30,7 +30,7 @@ email: Peter.Sagan@gmail.com first_name: Peter last_name: Sagan - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.689543+00:00 faculties: - Psychologie_PedagogischeWetenschappen @@ -42,7 +42,7 @@ email: Bartje.Verhaege@gmail.com first_name: Bartje last_name: Verhaege - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.691565+00:00 faculties: - Geneeskunde_Gezondheidswetenschappen @@ -54,7 +54,7 @@ email: Bart.Simpson@gmail.be first_name: Bart last_name: Simpson - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.687541+00:00 faculties: - Wetenschappen @@ -66,7 +66,7 @@ email: Kim.Clijsters@gmail.be first_name: Kim last_name: Clijsters - last_enrolled: 1 + last_enrolled: 2023 create_time: 2024-02-29 20:35:45.688545+00:00 faculties: - Psychologie_PedagogischeWetenschappen From 55ff80f8dc21d806a76595001ab92d2898061f59 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Fri, 1 Mar 2024 16:39:02 +0100 Subject: [PATCH 139/397] refactor: add type hinting + comments --- backend/notifications/admin.py | 3 -- backend/notifications/models.py | 24 ++++++------ backend/notifications/serializers.py | 58 +++++++++++++--------------- backend/notifications/views.py | 15 +++++-- 4 files changed, 49 insertions(+), 51 deletions(-) delete mode 100644 backend/notifications/admin.py diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py deleted file mode 100644 index 4185d360..00000000 --- a/backend/notifications/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.contrib import admin - -# Register your models here. diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 235723cf..d2827892 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -4,8 +4,10 @@ class NotificationTemplate(models.Model): id = models.AutoField(auto_created=True, primary_key=True) - title_key = models.CharField(max_length=255) - description_key = models.CharField(max_length=511) + title_key = models.CharField(max_length=255) # Key used to get translated title + description_key = models.CharField( + max_length=511 + ) # Key used to get translated description class Notification(models.Model): @@ -13,14 +15,10 @@ class Notification(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) template_id = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) - arguments = models.JSONField(default=dict) - is_read = models.BooleanField(default=False) - is_sent = models.BooleanField(default=False) - - def read(self): - self.is_read = True - self.save() - - def send(self): - self.is_sent = True - self.save() + arguments = models.JSONField(default=dict) # Arguments to be used in the template + is_read = models.BooleanField( + default=False + ) # Whether the notification has been read + is_sent = models.BooleanField( + default=False + ) # Whether the notification has been sent (email) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 6f6e3530..4a24cd59 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -13,44 +13,26 @@ class Meta: fields = "__all__" -class UserHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "user-detail" - queryset = User.objects.all() - - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except User.DoesNotExist: - self.fail("no_match") - - class NotificationSerializer(serializers.ModelSerializer): - user = UserHyperLinkedRelatedField() + # Hyper linked user field + user = serializers.HyperlinkedRelatedField( + view_name="user-detail", queryset=User.objects.all() + ) + # Translate template and arguments into a message message = serializers.SerializerMethodField() - class Meta: - model = Notification - fields = [ - "id", - "user", - "template_id", - "arguments", - "message", - "created_at", - "is_read", - "is_sent", - ] - - def _get_missing_keys(self, s: str, d: Dict[str, str]) -> List[str]: - required_keys = re.findall(r"%\((\w+)\)", s) - missing_keys = [key for key in required_keys if key not in d] + # Check if the required arguments are present + def _get_missing_keys(self, string: str, arguments: Dict[str, str]) -> List[str]: + required_keys: List[str] = re.findall(r"%\((\w+)\)", string) + missing_keys = [key for key in required_keys if key not in arguments] return missing_keys - def validate(self, data): - data = super().validate(data) + def validate(self, data: Dict[str, str]) -> Dict[str, str]: + data: Dict[str, str] = super().validate(data) + # Validate the arguments if "arguments" not in data: data["arguments"] = {} @@ -73,8 +55,22 @@ def validate(self, data): return data - def get_message(self, obj): + # Get the message from the template and arguments + def get_message(self, obj: Notification) -> Dict[str, str]: return { "title": _(obj.template_id.title_key), "description": _(obj.template_id.description_key) % obj.arguments, } + + class Meta: + model = Notification + fields = [ + "id", + "user", + "template_id", + "arguments", + "message", + "created_at", + "is_read", + "is_sent", + ] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 9f121a47..c02df3c0 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import List + from notifications.models import Notification from notifications.serializers import NotificationSerializer from rest_framework.permissions import BasePermission, IsAuthenticated @@ -9,14 +13,16 @@ # TODO: Give admin access to everything class NotificationPermission(BasePermission): - def has_permission(self, request: Request, view): + # The user can only access their own notifications + # An admin can access all notifications + def has_permission(self, request: Request, view: NotificationView) -> bool: return view.kwargs.get("user_id") == request.user.id class NotificationView(APIView): - permission_classes = [IsAuthenticated, NotificationPermission] + permission_classes: List[BasePermission] = [IsAuthenticated, NotificationPermission] - def get(self, request, user_id): + def get(self, request: Request, user_id: str) -> Response: notifications = Notification.objects.filter(user=user_id) serializer = NotificationSerializer( notifications, many=True, context={"request": request} @@ -24,7 +30,8 @@ def get(self, request, user_id): return Response(serializer.data) - def post(self, request, user_id): + # Mark all notifications as read for the user + def post(self, request: Request, user_id: str) -> Response: notifications = Notification.objects.filter(user=user_id) notifications.update(is_read=True) From 9c582f13100a3ba23503802ac016292a43ef45bf Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 1 Mar 2024 16:45:18 +0100 Subject: [PATCH 140/397] testing: (wip) improving testing logic --- backend/.coverage | Bin 0 -> 53248 bytes .../tests/test_authentication_serializer.py | 17 +++++--- .../tests/test_authentication_views.py | 38 +++++++++--------- backend/authentication/views/auth.py | 2 +- 4 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 backend/.coverage diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..7362567266f8b365d756e208dc63d12e97dfd129 GIT binary patch literal 53248 zcmeI4UyK_^9mjX=_1f$8dVF`J<`gAasnE;m{y0ZlRc%zrHMu6a1k%#9MQVhzw)gHf zwY`_!_5DFTA-9zxfp~x?s#cU2=o?a1@sNNDRi#4nK-CBSN>KF$DiD+ksDuIyznR@V z+exlen@ClX@5=V<&dz-2H=p^0dMzt3Zb4ht8vDOEH+y`}JU)Bh(K$1WGk51r+NP$=S<`b@ z&1Ks&7o4VTI_-v2w*sfVXa-AmoVw%N4L;FP98GmR;k3xl_o_~Vas~DxNi2I#%ktLE z$L;k=ksvB9u-Aexf<$a*vCUJMyQA#mre`nMp53n7erUyRr!k%%f9k9GY5L)@BXsF4?&jgthhDRwZ!FrqdSHZ2L~& zxNXy3v+JF}Zd|c~lN?&S5xoA|kk(0Fp_5_GsH{FZz06n086}T0FOYFVhxmDi4jVk+*+vW;w*{knBbH>Pdi-x&6ZRd2y-fa9y zvzK#HRHN-qn!FO^0iV{@^4%k{L|$pH`KO!oeXDA90#_VYsqw2b^igko+9jVk zEgXg8sn(pp-`vp#oMtn63)kW2xAq&}O9q{U_o8OgbQeUI;knJQambtd`%EQ+gJ+qTKwut)H5iN7-gmEbYGE^x_2 z@X^g*wt^+|a@%<{89tU#%X_zNWjHsXI>SHJryffQlh2F_`Yjst`8z#eGLPw*e8gU# zTCtimnk-w6M_rc2;BW>odBTQ8iLIL3355UhL*br$?&;YZ3iOU+_LOQ|B&Y8Efb07X zO{;v`^6!ab&9NoR=cAB6X4YJ{XzsHpP<* zALfr!r;Ga+?X+4}yAjP-d>Rf_Y3%2nla~_?B9ze_qfY5l7rjI?7LCsG5i8c8UM417 zwPiKJ?#>&fPjPk0zDzEhjSg6W9XKu9iE!ous*Rd@TK8ZopfsI>SHt zDmll$%5`_i+497M-0ei2F&t;AK27Q^D>geiObs~6B`16Sa*PIC`nJ>WC!Ad$4t^Mx zzz4Um1#2ONI92prCqK>?_%gd7(F+?0fB*=900@8p2!H?xfB*=900@A&wfB*=900@8p2!H?xfB*=900>;41PZD24i^8!V-J(l+so090PdQ; zWBT?y)hfl_k=Q%zU)N^^VKoSV00@8p2!H?xfB*=900@8p2!KFLppf1n$5#PT`Sf-z zx(LAU|BvJ+BzBIiu{(x8l%)F)H*74$r5*=^h*=t&v1pD~`R|6S{whgods{h+9eq?JJQ^CXr6Z zYw{{R!AM~vkwVK^^yqOV^fZFXo;`a$Q}yf?J&~ZgVAVU#V7!S2 z5U>A74k_J{WLksk|KWp5cj#(_qV@mKtkTUS(}~vqr46OKkxZd))suAn&yFkIiEB}M z|N6g}yu~kD{~ITi?%reu{{QuVA-OyC7S()Fr6+f%f%SjhQo3{3y0$%2$xj`~=>S3k3Et%jY>;KFFrK=}X>aG8k{YqC$CbGHy zPbVL5qV<1@s$wL&3ekra&;P^B4gw$m0w4eaAOHd&00JNY0w4eaH#C8?oR$oJ|6gYR zk?4gD1V8`;KmY_l00ck)1V8`;KmY_l;07d+P8%A(|3A#=_y7N9@3FVpZ`qsdb@mE7 z%bsE1WE<=htFedKJln_aq(s<200ck)1V8`;KmY_l00ck)1VG>>AdnqaWUVy(2U#1U zolgvXFm=mcawXawF1`HAy&B_@?9e~{ChJAo{bcdm=Py3{Mp`xKplHZL1qwe^_;dRw zIa4Z4O22#g{b8M=Zqr}8>sLoFy!7IG&z=3#30cka!>p{TJXB>h!^4cMDm+x~DW)mhnLhWT|BJid zc;=<7n&R;(FC#0OR+Rbu|6%sD#4fOZvcIxFvbWf8=o-Lz_A2`^`yu-Qdx3qIt_6IH zeVr0v0|5{K0T2KI5C8!X009sH0T2KI5J*gbuiWL~VG#@siJ(*x0b?R47DZqfA}AC@ zpz9*Y=S84tBFN=Lkj;uf6&D5MOhz0jiU`ta5u{QIT>udG|D{r5(~%JbKmY_l00ck) z1V8`;KmY_l00cnbIv~LB|6~1s9W)e71OX5L0T2KI5C8!X009sH0T2Lzt02Jt|Bv None: + self.request = RequestFactory() + def test_invalid_email_makes_user_serializer_invalid(self): """ The is_valid() method of a UserSerializer whose supplied User's email is not @@ -29,21 +36,21 @@ def test_invalid_email_makes_user_serializer_invalid(self): 'email': 'dummy', 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }) + }, context={'context': self.request}) user2 = UserSerializer(data={ 'id': ID, 'username': USERNAME, 'email': 'dummy@dummy', 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }) + }, context={'context': self.request}) user3 = UserSerializer(data={ 'id': ID, 'username': USERNAME, 'email': 21, 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }) + }, context={'context': self.request}) self.assertFalse(user.is_valid()) self.assertFalse(user2.is_valid()) self.assertFalse(user3.is_valid()) @@ -55,7 +62,7 @@ def test_valid_email_makes_valid_serializer(self): 'email': EMAIL, 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }) + }, context={'context': self.request}) self.assertTrue(user.is_valid()) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 72421b4d..0d2820a4 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -1,29 +1,27 @@ -from django.core.serializers import serialize import json -from rest_framework.request import Request + +from rest_framework.test import APIRequestFactory + from rest_framework.reverse import reverse -from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import AccessToken -from unittest.mock import patch + from authentication.models import User from authentication.serializers import UserSerializer from ypovoli import settings -USER_DATA = { - 'id': '1234', - 'username': 'ddickwd', - 'email': 'dummy@dummy.com', - 'first_name': 'dummy', - 'last_name': 'McDickwad', -} - - class TestWhomAmIView(APITestCase): def setUp(self): """Create a user and generate a token for that user""" - self.user = User.objects.create(**USER_DATA) - access_token = AccessToken().for_user(self.user) - self.token = f'Bearer {access_token}' + self.user = User.objects.create(**{ + 'id': '1234', + 'username': 'ddickwd', + 'email': 'dummy@dummy.com', + 'first_name': 'dummy', + 'last_name': 'McDickwad', + }) + self.serialized_user = UserSerializer(self.user).data + self.token = f'Bearer {AccessToken().for_user(self.user)}' def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ @@ -31,8 +29,9 @@ def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): exists in database and token is supplied. """ self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.get(reverse('auth.whoami')) - self.assertJSONEqual(response.content.decode('utf-8'), UserSerializer(self.user).data) + self.assertJSONEqual(response.content.decode('utf-8'), self.serialized_user) def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(self): """ @@ -41,8 +40,11 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(s """ self.user.delete() self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.get(reverse('auth.whoami')) - self.assertJSONNotEqual(response.content, UserSerializer(self.user).data) + serializer = UserSerializer(self.user, context={'request': self.request}) + self.assertJSONNotEqual(response.content, serializer.initial_data) + content = json.loads(response.content.decode('utf-8')) self.assertEqual(content['detail'], 'User not found') diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py index 20730e50..ebd2529b 100644 --- a/backend/authentication/views/auth.py +++ b/backend/authentication/views/auth.py @@ -12,7 +12,7 @@ class WhoAmIView(APIView): def get(self, request: Request) -> Response: """Get the user account data for the current user""" - return Response(UserSerializer(request.user).data) + return Response(UserSerializer(request.user, context={'request': request}).data) class LogoutView(APIView): permission_classes = [IsAuthenticated] From f920fbbe0c9b56a755402587ba7f1b6c81a077cd Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 20:07:15 +0100 Subject: [PATCH 141/397] chore: add checks tests and fix admin tests --- backend/api/serializers/admin_serializer.py | 2 - backend/api/serializers/checks_serializer.py | 3 + backend/api/tests/test_checks.py | 64 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index 2468e4ba..2603278b 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -3,8 +3,6 @@ class AdminSerializer(serializers.ModelSerializer): - - class Meta: model = Admin fields = [ diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index fd1aa13b..d6f45b75 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -3,6 +3,9 @@ class ChecksSerializer(serializers.ModelSerializer): + allowed_file_extensions = serializers.StringRelatedField(many=True) + forbidden_file_extensions = serializers.StringRelatedField(many=True) + class Meta: model = Checks fields = ['id', 'dockerfile', diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 8b359ff8..2d00e8b6 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -152,3 +152,67 @@ def test_fileExtension_detail_view(self): # match the created fileExtension self.assertEqual(int(content_json["id"]), fileExtension.id) self.assertEqual(content_json["extension"], fileExtension.extension) + + +class ChecksModelTests(TestCase): + def test_no_checks(self): + """ + Able to retrieve no Checks before publishing it. + """ + response_root = self.client.get( + reverse("check-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + self.assertEqual(response_root.accepted_media_type, "application/json") + content_json = json.loads(response_root.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_checks_exists(self): + """ + Able to retrieve a single Checks after creating it. + """ + # Create a Checks instance with some file extensions + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") + fileExtension3 = create_fileExtension(id=3, extension="tar") + fileExtension4 = create_fileExtension(id=4, extension="wfp") + checks = create_checks( + id=5, + allowed_file_extensions=[fileExtension1, fileExtension4], + forbidden_file_extensions=[fileExtension2, fileExtension3] + ) + + # Make a GET request to retrieve the Checks + response = self.client.get(reverse("check-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one Checks + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved Checks match the created Checks + retrieved_checks = content_json[0] + self.assertEqual(int(retrieved_checks["id"]), checks.id) + + # Assert the file extensions of the retrieved + # Checks match the created file extensions + retrieved_allowed_file_extensions = retrieved_checks[ + "allowed_file_extensions"] + + self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual( + retrieved_allowed_file_extensions[0], fileExtension1.extension) + self.assertEqual( + retrieved_allowed_file_extensions[1], fileExtension4.extension) + + retrieved_forbidden_file_extensions = retrieved_checks[ + "forbidden_file_extensions"] + self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + self.assertEqual( + retrieved_forbidden_file_extensions[0], fileExtension2.extension) + self.assertEqual( + retrieved_forbidden_file_extensions[1], fileExtension3.extension) From acaca7f7c5c54890306615ac42f8808105b07e04 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 20:22:51 +0100 Subject: [PATCH 142/397] chore: add course tests --- backend/api/tests/test_course.py | 115 +++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 backend/api/tests/test_course.py diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py new file mode 100644 index 00000000..21ff40b7 --- /dev/null +++ b/backend/api/tests/test_course.py @@ -0,0 +1,115 @@ +import json + +from django.test import TestCase +from django.urls import reverse +from ..models.course import Course + + +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +class CourseModelTests(TestCase): + def test_no_courses(self): + """ + Able to retrieve no courses before publishing any. + """ + response_root = self.client.get(reverse("course-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + self.assertEqual(response_root.accepted_media_type, "application/json") + content_json = json.loads(response_root.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_course_exists(self): + """ + Able to retrieve a single course after creating it. + """ + course = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science." + ) + + response = self.client.get(reverse("course-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_course = content_json[0] + self.assertEqual(retrieved_course["name"], course.name) + self.assertEqual( + retrieved_course["academic_startyear"], course.academic_startyear) + self.assertEqual(retrieved_course["description"], course.description) + + def test_multiple_courses(self): + """ + Able to retrieve multiple courses after creating them. + """ + course1 = create_course( + name="Mathematics 101", + academic_startyear=2022, + description="A basic mathematics course." + ) + course2 = create_course( + name="Physics 101", + academic_startyear=2022, + description="An introductory physics course." + ) + + response = self.client.get(reverse("course-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 2) + + retrieved_course1, retrieved_course2 = content_json + self.assertEqual(retrieved_course1["name"], course1.name) + self.assertEqual( + retrieved_course1["academic_startyear"], + course1.academic_startyear) + self.assertEqual(retrieved_course1["description"], course1.description) + + self.assertEqual(retrieved_course2["name"], course2.name) + self.assertEqual( + retrieved_course2["academic_startyear"], + course2.academic_startyear) + self.assertEqual(retrieved_course2["description"], course2.description) + + def test_course_detail_view(self): + """ + Able to retrieve details of a single course. + """ + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course." + ) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) From 905354156d7ab81e1526374786a9642503188ec0 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 20:31:34 +0100 Subject: [PATCH 143/397] test: update WhoAmI test to only compare id for correctness #27 --- .../tests/test_authentication_views.py | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 72421b4d..ca302006 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -1,27 +1,22 @@ -from django.core.serializers import serialize import json -from rest_framework.request import Request from rest_framework.reverse import reverse -from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import AccessToken -from unittest.mock import patch from authentication.models import User -from authentication.serializers import UserSerializer from ypovoli import settings -USER_DATA = { - 'id': '1234', - 'username': 'ddickwd', - 'email': 'dummy@dummy.com', - 'first_name': 'dummy', - 'last_name': 'McDickwad', -} - class TestWhomAmIView(APITestCase): def setUp(self): """Create a user and generate a token for that user""" - self.user = User.objects.create(**USER_DATA) + user_data = { + 'id': '1234', + 'username': 'ddickwd', + 'email': 'dummy@dummy.com', + 'first_name': 'dummy', + 'last_name': 'McDickwad', + } + self.user = User.objects.create(**user_data) access_token = AccessToken().for_user(self.user) self.token = f'Bearer {access_token}' @@ -32,7 +27,9 @@ def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.get(reverse('auth.whoami')) - self.assertJSONEqual(response.content.decode('utf-8'), UserSerializer(self.user).data) + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode('utf-8')) + self.assertEqual(content['id'], self.user.id) def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(self): """ @@ -42,7 +39,7 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(s self.user.delete() self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.get(reverse('auth.whoami')) - self.assertJSONNotEqual(response.content, UserSerializer(self.user).data) + self.assertTrue(response.status_code, 200) content = json.loads(response.content.decode('utf-8')) self.assertEqual(content['detail'], 'User not found') @@ -53,9 +50,18 @@ def test_who_am_i_view_returns_401_when_not_authenticated(self): class TestLogoutView(APITestCase): + def setUp(self): + user_data = { + 'id': '1234', + 'username': 'ddickwd', + 'email': 'dummy@dummy.com', + 'first_name': 'dummy', + 'last_name': 'McDickwad', + } + self.user = User.objects.create(**user_data) + def test_logout_view_authenticated_logout_url(self): """LogoutView should return a logout url redirect if authenticated user sends a post request.""" - self.user = User.objects.create(**USER_DATA) access_token = AccessToken().for_user(self.user) self.token = f'Bearer {access_token}' self.client.credentials(HTTP_AUTHORIZATION=self.token) @@ -72,6 +78,7 @@ def test_logout_view_not_authenticated_logout_url(self): response = self.client.post(reverse('auth.logout')) self.assertEqual(response.status_code, 401) + class TestLoginView(APITestCase): def test_login_view_returns_login_url(self): """LoginView should return a login url redirect if a post request is sent.""" @@ -83,6 +90,7 @@ def test_login_view_returns_login_url(self): ) self.assertEqual(response['Location'], url) + class TestTokenEchoView(APITestCase): def test_token_echo_echoes_token(self): """TokenEchoView should echo the User's current token""" From 54d1eaf64076eaf91e36eddc0f0312bffa10d390 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 21:54:33 +0100 Subject: [PATCH 144/397] chore: add group tests --- backend/api/tests/test_group.py | 156 ++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 backend/api/tests/test_group.py diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py new file mode 100644 index 00000000..e676fce7 --- /dev/null +++ b/backend/api/tests/test_group.py @@ -0,0 +1,156 @@ +import json +from datetime import timedelta +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from ..models.project import Project +from ..models.student import Student +from ..models.group import Group +from ..models.course import Course + + +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +def create_project(name, description, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + return Project.objects.create( + name=name, + description=description, + deadline=deadline, + course=course + ) + + +def create_student(id, first_name, last_name, email): + """Create a Student with the given arguments.""" + username = f"{first_name}_{last_name}" + return Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email + ) + + +class GroupModelTests(TestCase): + def test_no_groups(self): + """Able to retrieve no groups before creating any.""" + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_group_exists(self): + """Able to retrieve a single group after creating it.""" + course = create_course( + name="sel2", + academic_startyear=2023 + ) + + project = create_project( + name="Project 1", + description="Description 1", + days=7, + course=course + ) + + student = create_student( + id=1, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + group = Group.objects.create(project=project, score=80) + group.students.add(student) + + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 1) + + retrieved_group = content_json[0] + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)] + ) + + self.assertEqual(retrieved_group["project"], expected_project_url) + self.assertEqual(int(retrieved_group["id"]), group.id) + self.assertEqual(retrieved_group["score"], group.score) + + def test_multiple_groups(self): + """Able to retrieve multiple groups after creating them.""" + course = create_course( + name="sel2", + academic_startyear=2023 + ) + + project1 = create_project(name="Project 1", description="Description 1", days=7, course=course) + project2 = create_project(name="Project 2", description="Description 2", days=7, course=course) + + student1 = create_student(id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com") + student2 = create_student(id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com") + + group1 = Group.objects.create(project=project1, score=80) + group1.students.add(student1) + + group2 = Group.objects.create(project=project2, score=90) + group2.students.add(student1, student2) + + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 2) + + retrieved_group1, retrieved_group2 = content_json + expected_project_url1 = "http://testserver" + reverse("project-detail", args=[str(project1.id)]) + expected_project_url2 = "http://testserver" + reverse("project-detail", args=[str(project2.id)]) + + self.assertEqual(retrieved_group1["project"], expected_project_url1) + self.assertEqual(int(retrieved_group1["id"]), group1.id) + self.assertEqual(retrieved_group1["score"], group1.score) + + self.assertEqual(retrieved_group2["project"], expected_project_url2) + self.assertEqual(int(retrieved_group2["id"]), group2.id) + self.assertEqual(retrieved_group2["score"], group2.score) + + def test_group_detail_view(self): + """Able to retrieve details of a single group.""" + course = create_course( + name="sel2", + academic_startyear=2023 + ) + + project = create_project(name="Project 1", description="Description 1", days=7, course=course) + student = create_student(id=5, first_name="John", last_name="Doe", email="john.doe@example.com") + + group = Group.objects.create(project=project, score=80) + group.students.add(student) + + response = self.client.get(reverse("group-detail", args=[str(group.id)]), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + expected_project_url = "http://testserver" + reverse("project-detail", args=[str(project.id)]) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["project"], expected_project_url) + self.assertEqual(content_json["score"], group.score) From d39c71bf869a4387c7e41a44d0b5eafb1f86e2c4 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Fri, 1 Mar 2024 22:45:41 +0100 Subject: [PATCH 145/397] test: fix mistake of previous merge #27 --- backend/authentication/tests/test_authentication_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index f1ee2588..cfb54cc2 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -64,7 +64,6 @@ def setUp(self): def test_logout_view_authenticated_logout_url(self): """LogoutView should return a logout url redirect if authenticated user sends a post request.""" - self.user = User.objects.create(**USER_DATA) access_token = AccessToken().for_user(self.user) self.token = f'Bearer {access_token}' self.client.credentials(HTTP_AUTHORIZATION=self.token) From 115ae17fe846099fbdb6e61c1faee549eeb1561d Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 23:08:01 +0100 Subject: [PATCH 146/397] chore: add student test --- backend/api/tests/test_group.py | 51 +++++++-- backend/api/tests/test_project.py | 172 ++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 backend/api/tests/test_project.py diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index e676fce7..5734f50c 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -100,11 +100,29 @@ def test_multiple_groups(self): academic_startyear=2023 ) - project1 = create_project(name="Project 1", description="Description 1", days=7, course=course) - project2 = create_project(name="Project 2", description="Description 2", days=7, course=course) + project1 = create_project( + name="Project 1", + description="Description 1", + days=7, course=course + ) + project2 = create_project( + name="Project 2", + description="Description 2", + days=7, course=course + ) - student1 = create_student(id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com") - student2 = create_student(id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com") + student1 = create_student( + id=2, + first_name="Bart", + last_name="Rex", + email="bart.rex@example.com" + ) + student2 = create_student( + id=3, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) group1 = Group.objects.create(project=project1, score=80) group1.students.add(student1) @@ -119,8 +137,10 @@ def test_multiple_groups(self): self.assertEqual(len(content_json), 2) retrieved_group1, retrieved_group2 = content_json - expected_project_url1 = "http://testserver" + reverse("project-detail", args=[str(project1.id)]) - expected_project_url2 = "http://testserver" + reverse("project-detail", args=[str(project2.id)]) + expected_project_url1 = "http://testserver" + reverse( + "project-detail", args=[str(project1.id)]) + expected_project_url2 = "http://testserver" + reverse( + "project-detail", args=[str(project2.id)]) self.assertEqual(retrieved_group1["project"], expected_project_url1) self.assertEqual(int(retrieved_group1["id"]), group1.id) @@ -137,19 +157,30 @@ def test_group_detail_view(self): academic_startyear=2023 ) - project = create_project(name="Project 1", description="Description 1", days=7, course=course) - student = create_student(id=5, first_name="John", last_name="Doe", email="john.doe@example.com") + project = create_project( + name="Project 1", + description="Description 1", + days=7, course=course + ) + student = create_student( + id=5, + first_name="John", + last_name="Doe", + + email="john.doe@example.com") group = Group.objects.create(project=project, score=80) group.students.add(student) - response = self.client.get(reverse("group-detail", args=[str(group.id)]), follow=True) + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - expected_project_url = "http://testserver" + reverse("project-detail", args=[str(project.id)]) + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)]) self.assertEqual(int(content_json["id"]), group.id) self.assertEqual(content_json["project"], expected_project_url) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py new file mode 100644 index 00000000..16a7d081 --- /dev/null +++ b/backend/api/tests/test_project.py @@ -0,0 +1,172 @@ +import json +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse +from ..models.project import Project +from ..models.course import Course +from ..models.checks import Checks + + +def create_course(id, name, academic_startyear): + return Course.objects.create( + id=id, name=name, academic_startyear=academic_startyear) + + +def create_checks(): + return Checks.objects.create() + + +def create_project( + name, + description, + visible, + archived, + days, + checks, + course +): + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + checks=checks, + course=course + ) + + +class ProjectModelTests(TestCase): + def test_no_projects(self): + """Able to retrieve no projects before creating any.""" + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_project_exists(self): + """ + Able to retrieve a single project after creating it. + """ + course = create_course( + id=1, + name="test course", + academic_startyear=2024 + ) + checks = create_checks() + + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) + + def test_multiple_project(self): + """ + Able to retrieve multiple projects after creating it. + """ + course = create_course( + id=1, + name="test course", + academic_startyear=2024 + ) + + checks = create_checks() + + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course + ) + + project2 = create_project( + name="test project2", + description="test description2", + visible=True, + archived=False, + days=7, + checks=checks, + course=course + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 2) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + # TODO + # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) + + retrieved_project = content_json[1] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project2.name) + self.assertEqual(retrieved_project["description"], project2.description) + self.assertEqual(retrieved_project["visible"], project2.visible) + self.assertEqual(retrieved_project["archived"], project2.archived) + # TODO + # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) From 16f282a912176d6aa36fc8d730fef24c6606adb9 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 23:22:49 +0100 Subject: [PATCH 147/397] chore: add student tests --- backend/api/tests/test_student.py | 150 ++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 backend/api/tests/test_student.py diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py new file mode 100644 index 00000000..57542435 --- /dev/null +++ b/backend/api/tests/test_student.py @@ -0,0 +1,150 @@ +import json + +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse +from ..models.student import Student + + +def create_student(id, first_name, last_name, email): + # Create an Assistant with the given arguments. + username = f"{first_name}_{last_name}" + return Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + +class StudentModelTests(TestCase): + def test_no_student(self): + """ + able to retrieve no student before publishing it. + """ + + response_root = self.client.get(reverse("student-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_student_exists(self): + """ + Able to retrieve a single student after creating it. + """ + student = create_student( + id=3, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + # Make a GET request to retrieve the student + response = self.client.get(reverse("student-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_student = content_json[0] + self.assertEqual(int(retrieved_student["id"]), student.id) + self.assertEqual( + retrieved_student["first_name"], student.first_name) + self.assertEqual(retrieved_student["last_name"], student.last_name) + self.assertEqual(retrieved_student["email"], student.email) + + def test_multiple_students(self): + """ + Able to retrieve multiple students after creating them. + """ + # Create multiple assistant + student1 = create_student( + id=1, + first_name="Johny", + last_name="Doeg", + email="john.doe@example.com" + ) + student2 = create_student( + id=2, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the student + response = self.client.get(reverse("student-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple students + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created students + retrieved_student1, retrieved_student2 = content_json + self.assertEqual(int(retrieved_student1["id"]), student1.id) + self.assertEqual( + retrieved_student1["first_name"], student1.first_name) + self.assertEqual( + retrieved_student1["last_name"], student1.last_name) + self.assertEqual(retrieved_student1["email"], student1.email) + + self.assertEqual(int(retrieved_student1["id"]), student1.id) + self.assertEqual( + retrieved_student1["first_name"], student1.first_name) + self.assertEqual( + retrieved_student1["last_name"], student1.last_name) + self.assertEqual(retrieved_student1["email"], student1.email) + + def test_student_detail_view(self): + """ + Able to retrieve details of a single student. + """ + # Create an admin for testing with the name "Bob Peeters" + student = create_student( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) From a74c1b3851aa30ee83f971b24a5f860f4fd676b0 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 23:38:48 +0100 Subject: [PATCH 148/397] chore: add teacher tests --- backend/api/tests/test_project.py | 3 +- backend/api/tests/test_student.py | 10 +- backend/api/tests/test_teacher.py | 150 ++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 backend/api/tests/test_teacher.py diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 16a7d081..18848e0a 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -164,7 +164,8 @@ def test_multiple_project(self): ) self.assertEqual(retrieved_project["name"], project2.name) - self.assertEqual(retrieved_project["description"], project2.description) + self.assertEqual( + retrieved_project["description"], project2.description) self.assertEqual(retrieved_project["visible"], project2.visible) self.assertEqual(retrieved_project["archived"], project2.archived) # TODO diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index 57542435..688ab855 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -102,7 +102,7 @@ def test_multiple_students(self): # Assert that the parsed JSON is a list with multiple students self.assertEqual(len(content_json), 2) - # Assert the details of the retrieved admins match the created students + # Assert the details of the retrieved students match the created students retrieved_student1, retrieved_student2 = content_json self.assertEqual(int(retrieved_student1["id"]), student1.id) self.assertEqual( @@ -111,12 +111,12 @@ def test_multiple_students(self): retrieved_student1["last_name"], student1.last_name) self.assertEqual(retrieved_student1["email"], student1.email) - self.assertEqual(int(retrieved_student1["id"]), student1.id) + self.assertEqual(int(retrieved_student2["id"]), student2.id) self.assertEqual( - retrieved_student1["first_name"], student1.first_name) + retrieved_student2["first_name"], student2.first_name) self.assertEqual( - retrieved_student1["last_name"], student1.last_name) - self.assertEqual(retrieved_student1["email"], student1.email) + retrieved_student2["last_name"], student2.last_name) + self.assertEqual(retrieved_student2["email"], student2.email) def test_student_detail_view(self): """ diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py new file mode 100644 index 00000000..7e2fdd88 --- /dev/null +++ b/backend/api/tests/test_teacher.py @@ -0,0 +1,150 @@ +import json + +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse +from ..models.teacher import Teacher + + +def create_teacher(id, first_name, last_name, email): + # Create an Teacher with the given arguments. + username = f"{first_name}_{last_name}" + return Teacher.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + +class TeacherModelTests(TestCase): + def test_no_teacher(self): + """ + able to retrieve no teacher before publishing it. + """ + + response_root = self.client.get(reverse("teacher-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_teacher_exists(self): + """ + Able to retrieve a single teacher after creating it. + """ + teacher = create_teacher( + id=3, + first_name="John", + last_name="Doe", + email="john.doe@example.com" + ) + + # Make a GET request to retrieve the teacher + response = self.client.get(reverse("teacher-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one teacher + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved teacher match the created teacher + retrieved_teacher = content_json[0] + self.assertEqual(int(retrieved_teacher["id"]), teacher.id) + self.assertEqual( + retrieved_teacher["first_name"], teacher.first_name) + self.assertEqual(retrieved_teacher["last_name"], teacher.last_name) + self.assertEqual(retrieved_teacher["email"], teacher.email) + + def test_multiple_teachers(self): + """ + Able to retrieve multiple teachers after creating them. + """ + # Create multiple assistant + teacher1 = create_teacher( + id=1, + first_name="Johny", + last_name="Doeg", + email="john.doe@example.com" + ) + teacher2 = create_teacher( + id=2, + first_name="Jane", + last_name="Doe", + email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the teacher + response = self.client.get(reverse("teacher-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teacher + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved teacher match the created teacher + retrieved_teacher1, retrieved_teacher2 = content_json + self.assertEqual(int(retrieved_teacher1["id"]), teacher1.id) + self.assertEqual( + retrieved_teacher1["first_name"], teacher1.first_name) + self.assertEqual( + retrieved_teacher1["last_name"], teacher1.last_name) + self.assertEqual(retrieved_teacher1["email"], teacher1.email) + + self.assertEqual(int(retrieved_teacher2["id"]), teacher2.id) + self.assertEqual( + retrieved_teacher2["first_name"], teacher2.first_name) + self.assertEqual( + retrieved_teacher2["last_name"], teacher2.last_name) + self.assertEqual(retrieved_teacher2["email"], teacher2.email) + + def test_teacher_detail_view(self): + """ + Able to retrieve details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + teacher = create_teacher( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) From 415e9876834ad77629e99714fc8ef5f65d558a3d Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 1 Mar 2024 23:42:23 +0100 Subject: [PATCH 149/397] fix: fix linter warnings and mistakes in comments --- backend/api/tests/test_assistant.py | 13 +++++++------ backend/api/tests/test_student.py | 11 ++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 381e1a6a..e0bc3d9c 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -58,10 +58,10 @@ def test_assistant_exists(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with one admin + # Assert that the parsed JSON is a list with one student self.assertEqual(len(content_json), 1) - # Assert the details of the retrieved admin match the created admin + # Assert the details of the retrieved student match the created student retrieved_assistant = content_json[0] self.assertEqual(int(retrieved_assistant["id"]), assistant.id) self.assertEqual( @@ -99,10 +99,11 @@ def test_multiple_assistant(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with multiple admins + # Assert that the parsed JSON is a list with multiple students self.assertEqual(len(content_json), 2) - # Assert the details of the retrieved admins match the created admins + # Assert the details of the retrieved + # students match the created students retrieved_assistant1, retrieved_assistant2 = content_json self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) self.assertEqual( @@ -122,7 +123,7 @@ def test_assistant_detail_view(self): """ Able to retrieve details of a single assistant. """ - # Create an admin for testing with the name "Bob Peeters" + # Create an student for testing with the name "Bob Peeters" assistant = create_assistant( id=5, first_name="Bob", @@ -143,7 +144,7 @@ def test_assistant_detail_view(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert the details of the retrieved admin match the created admin + # Assert the details of the retrieved student match the created student self.assertEqual(int(content_json["id"]), assistant.id) self.assertEqual(content_json["first_name"], assistant.first_name) self.assertEqual(content_json["last_name"], assistant.last_name) diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index 688ab855..f58f3533 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -58,10 +58,10 @@ def test_student_exists(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with one admin + # Assert that the parsed JSON is a list with one student self.assertEqual(len(content_json), 1) - # Assert the details of the retrieved admin match the created admin + # Assert the details of the retrieved student match the created student retrieved_student = content_json[0] self.assertEqual(int(retrieved_student["id"]), student.id) self.assertEqual( @@ -102,7 +102,8 @@ def test_multiple_students(self): # Assert that the parsed JSON is a list with multiple students self.assertEqual(len(content_json), 2) - # Assert the details of the retrieved students match the created students + # Assert the details of the retrieved students + # match the created students retrieved_student1, retrieved_student2 = content_json self.assertEqual(int(retrieved_student1["id"]), student1.id) self.assertEqual( @@ -122,7 +123,7 @@ def test_student_detail_view(self): """ Able to retrieve details of a single student. """ - # Create an admin for testing with the name "Bob Peeters" + # Create an student for testing with the name "Bob Peeters" student = create_student( id=5, first_name="Bob", @@ -143,7 +144,7 @@ def test_student_detail_view(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert the details of the retrieved admin match the created admin + # Assert the details of the retrieved student match the created student self.assertEqual(int(content_json["id"]), student.id) self.assertEqual(content_json["first_name"], student.first_name) self.assertEqual(content_json["last_name"], student.last_name) From 4adaa072817355ff1390315a6c426e72c6830589 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Sat, 2 Mar 2024 10:17:00 +0100 Subject: [PATCH 150/397] test: add docs to function, remove unnecessary context argument and restyle to flake8 style #27 --- .../tests/test_authentication_serializer.py | 16 ++++++++-------- .../tests/test_authentication_views.py | 2 -- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index a4c8dc2d..1e60698b 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -1,5 +1,4 @@ from django.test import TestCase -from django.test.client import RequestFactory from unittest.mock import patch, Mock @@ -22,9 +21,6 @@ class UserSerializerModelTests(TestCase): - def setUp(self) -> None: - self.request = RequestFactory() - def test_invalid_email_makes_user_serializer_invalid(self): """ The is_valid() method of a UserSerializer whose supplied User's email is not @@ -36,33 +32,37 @@ def test_invalid_email_makes_user_serializer_invalid(self): 'email': 'dummy', 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }, context={'context': self.request}) + }) user2 = UserSerializer(data={ 'id': ID, 'username': USERNAME, 'email': 'dummy@dummy', 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }, context={'context': self.request}) + }) user3 = UserSerializer(data={ 'id': ID, 'username': USERNAME, 'email': 21, 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }, context={'context': self.request}) + }) self.assertFalse(user.is_valid()) self.assertFalse(user2.is_valid()) self.assertFalse(user3.is_valid()) def test_valid_email_makes_valid_serializer(self): + """ + When the serializer is provided with a valid email, the serializer becomes valid, + thus the is_valid() method returns True. + """ user = UserSerializer(data={ 'id': ID, 'username': USERNAME, 'email': EMAIL, 'first_name': FIRST_NAME, 'last_name': LAST_NAME, - }, context={'context': self.request}) + }) self.assertTrue(user.is_valid()) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index cfb54cc2..6f1754fc 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -100,5 +100,3 @@ def test_token_echo_echoes_token(self): response = self.client.get(reverse('auth.echo'), data={'ticket': ticket}) content = response.rendered_content.decode('utf-8').strip('"') self.assertEqual(content, ticket) - - From 0899b23b6ee01bef3a71af4e4a1c166ab9a7eaad Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 11:18:37 +0100 Subject: [PATCH 151/397] chore: add deadline aproaching in and deadline passed functions to project --- backend/api/models/project.py | 12 ++++- backend/api/tests/test_project.py | 83 ++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 29e4264a..f766d82b 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,5 +1,6 @@ -from datetime import datetime +from datetime import timedelta, datetime from django.db import models +from django.utils import timezone from api.models.checks import Checks from api.models.course import Course @@ -59,3 +60,12 @@ class Project(models.Model): blank=False, null=False ) + + def deadline_approaching_in(self, days=7): + now = timezone.now() + approaching_date = now + timezone.timedelta(days=days) + return now <= self.deadline <= approaching_date + + def deadline_passed(self): + now = timezone.now() + return now > self.deadline diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 18848e0a..301d2b7a 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from django.test import TestCase from django.utils import timezone from django.urls import reverse @@ -39,6 +40,76 @@ def create_project( class ProjectModelTests(TestCase): + def test_deadline_approaching_in_with_past_Project(self): + """ + deadline_approaching_in() returns False for Projects whose Deadline + is in the past. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=-10, checks=checks, course=course + ) + self.assertIs(past_project.deadline_approaching_in(), False) + + def test_deadline_approaching_in_with_future_Project_within_time(self): + """ + deadline_approaching_in() returns True for Projects whose Deadline + is in the timerange given. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=6, checks=checks, course=course + ) + self.assertIs(future_project.deadline_approaching_in(days=7), True) + + def test_deadline_approaching_in_with_future_Project_not_within_time(self): + """ + deadline_approaching_in() returns False for Projects whose Deadline + is out of the timerange given. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=8, checks=checks, course=course + ) + self.assertIs(future_project.deadline_approaching_in(days=7), False) + + def test_deadline_passed_with_future_Project(self): + """ + deadline_passed() returns False for Projects whose Deadline + is not passed. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=1, checks=checks, course=course + ) + self.assertIs(future_project.deadline_passed(), False) + + def test_deadline_passed_with_past_Project(self): + """ + deadline_passed() returns True for Projects whose Deadline + is passed. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=-1, checks=checks, course=course + ) + self.assertIs(past_project.deadline_passed(), True) + def test_no_projects(self): """Able to retrieve no projects before creating any.""" response = self.client.get(reverse("group-list"), follow=True) @@ -51,13 +122,13 @@ def test_project_exists(self): """ Able to retrieve a single project after creating it. """ + course = create_course( - id=1, + id=3, name="test course", academic_startyear=2024 - ) + ) checks = create_checks() - project = create_project( name="test project", description="test description", @@ -99,13 +170,11 @@ def test_multiple_project(self): Able to retrieve multiple projects after creating it. """ course = create_course( - id=1, + id=3, name="test course", academic_startyear=2024 - ) - + ) checks = create_checks() - project = create_project( name="test project", description="test description", From 905c8d537e7de72b746b277c7c62708b5857faef Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 2 Mar 2024 11:22:06 +0100 Subject: [PATCH 152/397] chore: update submission serializer --- backend/api/serializers/submision_serializer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py index 3429022d..95d26dbf 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submision_serializer.py @@ -5,7 +5,7 @@ class SubmissionFileSerializer(serializers.ModelSerializer): class Meta: model = SubmissionFile - fields = ['id', 'submission', 'file'] + fields = ['file'] class SubmissionSerializer(serializers.ModelSerializer): @@ -16,6 +16,10 @@ class SubmissionSerializer(serializers.ModelSerializer): view_name='group-detail' ) + files = SubmissionFileSerializer(many=True, read_only=True) + class Meta: model = Submission - fields = ['id', 'group', 'submission_number', 'submission_time'] + fields = ['id', 'group', 'submission_number', 'submission_time', + 'files' + ] From 7e208d74028c6d9d0be8913f449dbbeba3651eb2 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 2 Mar 2024 11:23:34 +0100 Subject: [PATCH 153/397] chore: extend fixtures with submissions + admin --- backend/api/fixtures/admins.yaml | 3 +++ backend/api/fixtures/courses.yaml | 7 +++++++ backend/api/fixtures/groups.yaml | 2 +- backend/api/fixtures/submissions.yaml | 24 ++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backend/api/fixtures/admins.yaml create mode 100644 backend/api/fixtures/submissions.yaml diff --git a/backend/api/fixtures/admins.yaml b/backend/api/fixtures/admins.yaml new file mode 100644 index 00000000..a0ad3113 --- /dev/null +++ b/backend/api/fixtures/admins.yaml @@ -0,0 +1,3 @@ +- model: api.admin + pk: '2' + fields: {} diff --git a/backend/api/fixtures/courses.yaml b/backend/api/fixtures/courses.yaml index 154f0e0b..4a8677c9 100644 --- a/backend/api/fixtures/courses.yaml +++ b/backend/api/fixtures/courses.yaml @@ -11,4 +11,11 @@ name: Sel2 academic_startyear: 2023 description: Software course + parent_course: 3 +- model: api.course + pk: 3 + fields: + name: Sel1 + academic_startyear: 2022 + description: Software course parent_course: null diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml index b8adc460..35b2d571 100644 --- a/backend/api/fixtures/groups.yaml +++ b/backend/api/fixtures/groups.yaml @@ -2,7 +2,7 @@ pk: 1 fields: project: 123456 - score: null + score: 7 students: - '1' - '2' diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml new file mode 100644 index 00000000..0b8f876d --- /dev/null +++ b/backend/api/fixtures/submissions.yaml @@ -0,0 +1,24 @@ +- model: api.submission + pk: 1 + fields: + group: 1 + submission_number: 1 + submission_time: '2021-01-01T00:00:00Z' +- model: api.submission + pk: 2 + fields: + group: 1 + submission_number: 2 + submission_time: '2021-01-02T00:00:00Z' + + +- model: api.submissionfile + pk: 1 + fields: + submission: 1 + file: 'submissions/1/1/1.txt' +- model: api.submissionfile + pk: 2 + fields: + submission: 2 + file: 'submissions/1/2/1.txt' From c5552430582f76a25fa4995cb3bed22e579845cf Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 11:34:19 +0100 Subject: [PATCH 154/397] chore: merge basic_app --- backend/api/fixtures/admins.yaml | 3 ++ backend/api/fixtures/courses.yaml | 7 +++++ backend/api/fixtures/groups.yaml | 2 +- backend/api/fixtures/submissions.yaml | 24 ++++++++++++++++ backend/api/serializers/admin_serializer.py | 7 +++++ .../api/serializers/assistant_serializer.py | 6 ++++ backend/api/serializers/student_serializer.py | 6 ++++ .../api/serializers/submision_serializer.py | 8 ++++-- backend/api/serializers/teacher_serializer.py | 6 ++++ backend/authentication/serializers.py | 28 +++++++++++++++---- 10 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 backend/api/fixtures/admins.yaml create mode 100644 backend/api/fixtures/submissions.yaml diff --git a/backend/api/fixtures/admins.yaml b/backend/api/fixtures/admins.yaml new file mode 100644 index 00000000..a0ad3113 --- /dev/null +++ b/backend/api/fixtures/admins.yaml @@ -0,0 +1,3 @@ +- model: api.admin + pk: '2' + fields: {} diff --git a/backend/api/fixtures/courses.yaml b/backend/api/fixtures/courses.yaml index 154f0e0b..4a8677c9 100644 --- a/backend/api/fixtures/courses.yaml +++ b/backend/api/fixtures/courses.yaml @@ -11,4 +11,11 @@ name: Sel2 academic_startyear: 2023 description: Software course + parent_course: 3 +- model: api.course + pk: 3 + fields: + name: Sel1 + academic_startyear: 2022 + description: Software course parent_course: null diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml index b8adc460..35b2d571 100644 --- a/backend/api/fixtures/groups.yaml +++ b/backend/api/fixtures/groups.yaml @@ -2,7 +2,7 @@ pk: 1 fields: project: 123456 - score: null + score: 7 students: - '1' - '2' diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml new file mode 100644 index 00000000..0b8f876d --- /dev/null +++ b/backend/api/fixtures/submissions.yaml @@ -0,0 +1,24 @@ +- model: api.submission + pk: 1 + fields: + group: 1 + submission_number: 1 + submission_time: '2021-01-01T00:00:00Z' +- model: api.submission + pk: 2 + fields: + group: 1 + submission_number: 2 + submission_time: '2021-01-02T00:00:00Z' + + +- model: api.submissionfile + pk: 1 + fields: + submission: 1 + file: 'submissions/1/1/1.txt' +- model: api.submissionfile + pk: 2 + fields: + submission: 2 + file: 'submissions/1/2/1.txt' diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index 2603278b..c7749f87 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -3,6 +3,13 @@ class AdminSerializer(serializers.ModelSerializer): + + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Admin fields = [ diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 210f7404..540f9f32 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -9,6 +9,12 @@ class AssistantSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Assistant fields = [ diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 214d637b..3ea93733 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -14,6 +14,12 @@ class StudentSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Student fields = [ diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py index 3429022d..95d26dbf 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submision_serializer.py @@ -5,7 +5,7 @@ class SubmissionFileSerializer(serializers.ModelSerializer): class Meta: model = SubmissionFile - fields = ['id', 'submission', 'file'] + fields = ['file'] class SubmissionSerializer(serializers.ModelSerializer): @@ -16,6 +16,10 @@ class SubmissionSerializer(serializers.ModelSerializer): view_name='group-detail' ) + files = SubmissionFileSerializer(many=True, read_only=True) + class Meta: model = Submission - fields = ['id', 'group', 'submission_number', 'submission_time'] + fields = ['id', 'group', 'submission_number', 'submission_time', + 'files' + ] diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index c35bea68..8fbb98e9 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -9,6 +9,12 @@ class TeacherSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + class Meta: model = Teacher fields = [ diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 9c7e8173..f33e6c91 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,8 @@ from django.contrib.auth.models import update_last_login -from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer +from rest_framework.serializers import ( + CharField, EmailField, ModelSerializer, ValidationError, + Serializer, HyperlinkedIdentityField, HyperlinkedRelatedField +) from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings from authentication.signals import user_created, user_login @@ -65,23 +68,36 @@ def validate(self, data): 'refresh': str(RefreshToken.for_user(user)) } + class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ id = CharField() username = CharField() - email = EmailField() - + email = EmailField() + + faculties = HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='faculty-detail' + ) + + notifications = HyperlinkedIdentityField( + view_name='notification-detail', + read_only=True, + ) + class Meta: model = User fields = [ 'id', 'username', 'email', 'first_name', 'last_name', - 'faculty', - 'last_enrolled', 'last_login', 'create_time' + 'faculties', + 'last_enrolled', 'last_login', 'create_time', + 'notifications' ] def get_or_create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" - return User.objects.get_or_create(**validated_data) \ No newline at end of file + return User.objects.get_or_create(**validated_data) From e5b1db6da9106d1d1fab9176ae7d55d23a9121c0 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Sat, 2 Mar 2024 12:03:54 +0100 Subject: [PATCH 155/397] chore: replace auto_now=True with auto_now_add=True for User create_time field #35 --- backend/authentication/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index c7fae247..0bf42d22 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -49,7 +49,7 @@ class User(AbstractBaseUser): ) create_time = DateTimeField( - auto_now=True + auto_now_add=True ) """Model settings""" From e306c8b6691e7f923d09c51776b0cf71df96cc70 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 2 Mar 2024 12:04:14 +0100 Subject: [PATCH 156/397] chore: add checks --- backend/api/fixtures/checks.yaml | 26 +++++++++++++++++++ backend/api/fixtures/projects.yaml | 2 +- backend/api/serializers/checks_serializer.py | 21 ++++++++------- backend/api/serializers/project_serializer.py | 7 +++++ 4 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 backend/api/fixtures/checks.yaml diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml new file mode 100644 index 00000000..5a9a7795 --- /dev/null +++ b/backend/api/fixtures/checks.yaml @@ -0,0 +1,26 @@ +- model: api.checks + pk: 1 + fields: + dockerfile: 'path/to/Dockerfile' + allowed_file_extensions: + - 1 + - 2 + forbidden_file_extensions: + - 3 + - 4 +- model: api.fileextension + pk: 1 + fields: + extension: 'py' +- model: api.fileextension + pk: 2 + fields: + extension: 'js' +- model: api.fileextension + pk: 3 + fields: + extension: 'html' +- model: api.fileextension + pk: 4 + fields: + extension: 'php' diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml index 2e42bd30..5d9692ea 100644 --- a/backend/api/fixtures/projects.yaml +++ b/backend/api/fixtures/projects.yaml @@ -7,5 +7,5 @@ archived: false start_date: 2024-02-26 00:00:00+00:00 deadline: 2024-02-27 00:00:00+00:00 - checks: null + checks: 1 course: 2 diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 51bbfd84..dc312e4b 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -2,16 +2,19 @@ from ..models.checks import Checks, FileExtension -class ChecksSerializer(serializers.ModelSerializer): +class FileExtensionSerializer(serializers.ModelSerializer): class Meta: - model = Checks - fields = ['id', 'dockerfile'] + model = FileExtension + fields = ['extension'] -class FileExtensionSerializer(serializers.ModelSerializer): +class ChecksSerializer(serializers.ModelSerializer): + + allowed_file_extensions = FileExtensionSerializer(many=True) + + forbidden_file_extensions = FileExtensionSerializer(many=True) + class Meta: - model = FileExtension - fields = [ - 'id', 'extension', - 'allowed_file_extensions', 'forbidden_file_extensions' - ] + model = Checks + fields = ['id', 'dockerfile', 'allowed_file_extensions', + 'forbidden_file_extensions'] diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index be2195c5..a04058f0 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -3,12 +3,19 @@ class ProjectSerializer(serializers.ModelSerializer): + course = serializers.HyperlinkedRelatedField( many=False, read_only=True, view_name='course-detail' ) + checks = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name='check-detail' + ) + class Meta: model = Project fields = [ From e78d1c0f5b47b87dfa2aaefa1947f8812e0ef72b Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 12:54:16 +0100 Subject: [PATCH 157/397] chore: add togle functions to project --- backend/api/models/project.py | 8 +++++++ backend/api/models/submission.py | 3 ++- backend/api/tests/test_project.py | 36 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index f766d82b..4c142be1 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -69,3 +69,11 @@ def deadline_approaching_in(self, days=7): def deadline_passed(self): now = timezone.now() return now > self.deadline + + def toggle_visible(self): + self.visible = not (self.visible) + self.save() + + def toggle_archived(self): + self.archived = not (self.archived) + self.save() diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 54a00133..a94c9360 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,6 +1,7 @@ from django.db import models from api.models.group import Group + class Submission(models.Model): """Model for submission of a project by a group of students.""" @@ -49,4 +50,4 @@ class SubmissionFile(models.Model): file = models.FileField( blank=False, null=False - ) \ No newline at end of file + ) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 301d2b7a..02a32ea7 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -40,6 +40,42 @@ def create_project( class ProjectModelTests(TestCase): + def test_toggle_visible(self): + """ + toggle the visible state of a project. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", description="descr", visible=True, archived=False, + days=-10, checks=checks, course=course + ) + self.assertIs(past_project.visible, True) + past_project.toggle_visible() + self.assertIs(past_project.visible, False) + past_project.toggle_visible() + self.assertIs(past_project.visible, True) + + def test_toggle_archived(self): + """ + toggle the archived state of a project. + """ + course = create_course( + id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", description="descr", visible=True, archived=True, + days=-10, checks=checks, course=course + ) + + self.assertIs(past_project.archived, True) + past_project.toggle_archived() + self.assertIs(past_project.archived, False) + past_project.toggle_archived() + self.assertIs(past_project.archived, True) + + def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline From 85ddc09d7415343ef157f86c2c05dbbe6f8c8766 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 14:35:54 +0100 Subject: [PATCH 158/397] chore: add submison checks --- backend/api/tests/test_checks.py | 26 ++-- backend/api/tests/test_group.py | 12 +- backend/api/tests/test_project.py | 10 +- backend/api/tests/test_submision.py | 227 ++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 backend/api/tests/test_submision.py diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 2d00e8b6..2d5b8d15 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -76,8 +76,8 @@ def test_fileExtension_exists(self): # Assert the details of the retrieved admin match the created admin retrieved_fileExtension = content_json[0] - self.assertEqual( - int(retrieved_fileExtension["id"]), fileExtension.id) + # self.assertEqual( + # int(retrieved_fileExtension["id"]), fileExtension.id) self.assertEqual( retrieved_fileExtension["extension"], fileExtension.extension) @@ -112,13 +112,13 @@ def test_multiple_fileExtension(self): # Assert the details of the retrieved admins match the created admins retrieved_fileExtension1, retrieved_fileExtension2 = content_json - self.assertEqual( - int(retrieved_fileExtension1["id"]), fileExtension1.id) + # self.assertEqual( + # int(retrieved_fileExtension1["id"]), fileExtension1.id) self.assertEqual( retrieved_fileExtension1["extension"], fileExtension1.extension) - self.assertEqual( - int(retrieved_fileExtension2["id"]), fileExtension2.id) + # self.assertEqual( + # int(retrieved_fileExtension2["id"]), fileExtension2.id) self.assertEqual( retrieved_fileExtension2["extension"], fileExtension2.extension) @@ -150,7 +150,7 @@ def test_fileExtension_detail_view(self): # Assert the details of the retrieved fileExtension # match the created fileExtension - self.assertEqual(int(content_json["id"]), fileExtension.id) + # self.assertEqual(int(content_json["id"]), fileExtension.id) self.assertEqual(content_json["extension"], fileExtension.extension) @@ -205,14 +205,18 @@ def test_checks_exists(self): self.assertEqual(len(retrieved_allowed_file_extensions), 2) self.assertEqual( - retrieved_allowed_file_extensions[0], fileExtension1.extension) + retrieved_allowed_file_extensions[0]["extension"], + fileExtension1.extension) self.assertEqual( - retrieved_allowed_file_extensions[1], fileExtension4.extension) + retrieved_allowed_file_extensions[1]["extension"], + fileExtension4.extension) retrieved_forbidden_file_extensions = retrieved_checks[ "forbidden_file_extensions"] self.assertEqual(len(retrieved_forbidden_file_extensions), 2) self.assertEqual( - retrieved_forbidden_file_extensions[0], fileExtension2.extension) + retrieved_forbidden_file_extensions[0]["extension"], + fileExtension2.extension) self.assertEqual( - retrieved_forbidden_file_extensions[1], fileExtension3.extension) + retrieved_forbidden_file_extensions[1]["extension"], + fileExtension3.extension) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 5734f50c..c1601c19 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -45,6 +45,10 @@ def create_student(id, first_name, last_name, email): ) +def create_group(project, score): + return Group.objects.create(project=project, score=score) + + class GroupModelTests(TestCase): def test_no_groups(self): """Able to retrieve no groups before creating any.""" @@ -75,7 +79,7 @@ def test_group_exists(self): email="john.doe@example.com" ) - group = Group.objects.create(project=project, score=80) + group = create_group(project=project, score=10) group.students.add(student) response = self.client.get(reverse("group-list"), follow=True) @@ -124,10 +128,10 @@ def test_multiple_groups(self): email="jane.doe@example.com" ) - group1 = Group.objects.create(project=project1, score=80) + group1 = create_group(project=project1, score=10) group1.students.add(student1) - group2 = Group.objects.create(project=project2, score=90) + group2 = create_group(project=project2, score=10) group2.students.add(student1, student2) response = self.client.get(reverse("group-list"), follow=True) @@ -169,7 +173,7 @@ def test_group_detail_view(self): email="john.doe@example.com") - group = Group.objects.create(project=project, score=80) + group = create_group(project=project, score=10) group.students.add(student) response = self.client.get( diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 02a32ea7..ec573519 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -1,5 +1,4 @@ import json -from datetime import datetime from django.test import TestCase from django.utils import timezone from django.urls import reverse @@ -75,7 +74,6 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) - def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -198,7 +196,7 @@ def test_project_exists(self): self.assertEqual(retrieved_project["description"], project.description) self.assertEqual(retrieved_project["visible"], project.visible) self.assertEqual(retrieved_project["archived"], project.archived) - # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) def test_multiple_project(self): @@ -254,8 +252,7 @@ def test_multiple_project(self): self.assertEqual(retrieved_project["description"], project.description) self.assertEqual(retrieved_project["visible"], project.visible) self.assertEqual(retrieved_project["archived"], project.archived) - # TODO - # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) retrieved_project = content_json[1] @@ -273,6 +270,5 @@ def test_multiple_project(self): retrieved_project["description"], project2.description) self.assertEqual(retrieved_project["visible"], project2.visible) self.assertEqual(retrieved_project["archived"], project2.archived) - # TODO - # self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submision.py new file mode 100644 index 00000000..63344a06 --- /dev/null +++ b/backend/api/tests/test_submision.py @@ -0,0 +1,227 @@ +import json +from datetime import timedelta +from django.test import TestCase +from django.utils import timezone +from django.urls import reverse +from ..models.submission import Submission, SubmissionFile +from ..models.project import Project +from ..models.group import Group +from ..models.course import Course + + +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +def create_project(name, description, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + return Project.objects.create( + name=name, + description=description, + deadline=deadline, + course=course + ) + + +def create_group(project, score): + return Group.objects.create(project=project, score=score) + + +def create_submission(group, submission_number): + # Create an Submission with the given arguments. + return Submission.objects.create( + group=group, + submission_number=submission_number, + submission_time=timezone.now() + ) + + +def create_submissionFile(submission, file): + # Create an SubmissionFile with the given arguments. + return SubmissionFile.objects.create( + submission=submission, + file=file + ) + + +class SubmissionModelTests(TestCase): + def test_no_submission(self): + """ + able to retrieve no submission before publishing it. + """ + + response_root = self.client.get( + reverse("submission-list"), follow=True) + # print(response.content) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_submission_exists(self): + """ + Able to retrieve a single submission after creating it. + """ + course = create_course( + name="sel2", + academic_startyear=2023 + ) + project = create_project( + name="Project 1", + description="Description 1", + days=7, + course=course + ) + group = create_group(project=project, score=10) + submission = create_submission( + group=group, submission_number=1 + ) + + # Make a GET request to retrieve the submission + response = self.client.get(reverse("submission-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one submission + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json[0] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_multiple_submission_exists(self): + """ + Able to retrieve multiple submissions after creating them. + """ + course = create_course( + name="sel2", + academic_startyear=2023 + ) + project = create_project( + name="Project 1", + description="Description 1", + days=7, + course=course + ) + group = create_group(project=project, score=10) + submission1 = create_submission( + group=group, submission_number=1 + ) + + submission2 = create_submission( + group=group, submission_number=2 + ) + + # Make a GET request to retrieve the submission + response = self.client.get(reverse("submission-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one submission + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json[0] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission1.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission1.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + retrieved_submission = content_json[1] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission2.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission2.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_submission_detail_view(self): + """ + Able to retrieve details of a single submission. + """ + course = create_course( + name="sel2", + academic_startyear=2023 + ) + project = create_project( + name="Project 1", + description="Description 1", + days=7, + course=course + ) + group = create_group(project=project, score=10) + submission = create_submission( + group=group, submission_number=1 + ) + + # Make a GET request to retrieve the submission + response = self.client.get( + reverse("submission-detail", args=[str(submission.id)]), + follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) \ No newline at end of file From e971b1b3aa7c5b089ca88c004f966f664a00171e Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 14:52:24 +0100 Subject: [PATCH 159/397] fix: fix small comments mistakes --- backend/api/tests/test_admin.py | 1 - backend/api/tests/test_assistant.py | 16 +++++++++------- backend/api/tests/test_checks.py | 26 ++++++++------------------ backend/api/tests/test_group.py | 1 + backend/api/tests/test_project.py | 5 +++++ backend/api/tests/test_student.py | 2 +- backend/api/tests/test_submision.py | 7 ++++--- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index eaddd911..5863c9d2 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -29,7 +29,6 @@ def test_no_admins(self): """ response_root = self.client.get(reverse("admin-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index e0bc3d9c..7e9e3701 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -7,7 +7,7 @@ def create_assistant(id, first_name, last_name, email): - # Create an Assistant with the given arguments. + # Create an assistant with the given arguments. username = f"{first_name}_{last_name}" return Assistant.objects.create( id=id, @@ -58,10 +58,11 @@ def test_assistant_exists(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with one student + # Assert that the parsed JSON is a list with one assistant self.assertEqual(len(content_json), 1) - # Assert the details of the retrieved student match the created student + # Assert the details of the retrieved assistant + # match the created assistant retrieved_assistant = content_json[0] self.assertEqual(int(retrieved_assistant["id"]), assistant.id) self.assertEqual( @@ -99,11 +100,11 @@ def test_multiple_assistant(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with multiple students + # Assert that the parsed JSON is a list with multiple assistant self.assertEqual(len(content_json), 2) # Assert the details of the retrieved - # students match the created students + # assistant match the created assistant retrieved_assistant1, retrieved_assistant2 = content_json self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) self.assertEqual( @@ -123,7 +124,7 @@ def test_assistant_detail_view(self): """ Able to retrieve details of a single assistant. """ - # Create an student for testing with the name "Bob Peeters" + # Create an assistant for testing with the name "Bob Peeters" assistant = create_assistant( id=5, first_name="Bob", @@ -144,7 +145,8 @@ def test_assistant_detail_view(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert the details of the retrieved student match the created student + # Assert the details of the retrieved assistant + # match the created assistant self.assertEqual(int(content_json["id"]), assistant.id) self.assertEqual(content_json["first_name"], assistant.first_name) self.assertEqual(content_json["last_name"], assistant.last_name) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 2d5b8d15..1eb1ce75 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -17,19 +17,15 @@ def create_fileExtension(id, extension): def create_checks(id, allowed_file_extensions, forbidden_file_extensions): - - # Create a Checks with the given arguments. - + """Create a Checks with the given arguments.""" check = Checks.objects.create( id=id, ) for ext in allowed_file_extensions: check.allowed_file_extensions.add(ext) - for ext in forbidden_file_extensions: check.forbidden_file_extensions.add(ext) - return check @@ -38,7 +34,6 @@ def test_no_fileExtension(self): """ able to retrieve no FileExtension before publishing it. """ - response_root = self.client.get( reverse("fileExtension-list"), follow=True) # print(response.content) @@ -71,13 +66,12 @@ def test_fileExtension_exists(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with one admin + # Assert that the parsed JSON is a list with one fileExtension self.assertEqual(len(content_json), 1) - # Assert the details of the retrieved admin match the created admin + # Assert the details of the retrieved fileExtension + # match the created fileExtension retrieved_fileExtension = content_json[0] - # self.assertEqual( - # int(retrieved_fileExtension["id"]), fileExtension.id) self.assertEqual( retrieved_fileExtension["extension"], fileExtension.extension) @@ -107,18 +101,15 @@ def test_multiple_fileExtension(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - # Assert that the parsed JSON is a list with multiple admins + # Assert that the parsed JSON is a list with multiple fileExtension self.assertEqual(len(content_json), 2) - # Assert the details of the retrieved admins match the created admins + # Assert the details of the retrieved fileExtension + # match the created fileExtension retrieved_fileExtension1, retrieved_fileExtension2 = content_json - # self.assertEqual( - # int(retrieved_fileExtension1["id"]), fileExtension1.id) self.assertEqual( retrieved_fileExtension1["extension"], fileExtension1.extension) - # self.assertEqual( - # int(retrieved_fileExtension2["id"]), fileExtension2.id) self.assertEqual( retrieved_fileExtension2["extension"], fileExtension2.extension) @@ -126,7 +117,7 @@ def test_fileExtension_detail_view(self): """ Able to retrieve details of a single fileExtension. """ - # Create an fileExtension for testing with the name "Bob Peeters" + # Create an fileExtension for testing. fileExtension = create_fileExtension( id=3, extension="zip" @@ -150,7 +141,6 @@ def test_fileExtension_detail_view(self): # Assert the details of the retrieved fileExtension # match the created fileExtension - # self.assertEqual(int(content_json["id"]), fileExtension.id) self.assertEqual(content_json["extension"], fileExtension.extension) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c1601c19..c8767ff2 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -46,6 +46,7 @@ def create_student(id, first_name, last_name, email): def create_group(project, score): + """Create a Group with the given arguments.""" return Group.objects.create(project=project, score=score) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index ec573519..af7fb11a 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -8,11 +8,15 @@ def create_course(id, name, academic_startyear): + """ + Create a Course with the given arguments. + """ return Course.objects.create( id=id, name=name, academic_startyear=academic_startyear) def create_checks(): + """Create a Checks with the given arguments.""" return Checks.objects.create() @@ -25,6 +29,7 @@ def create_project( checks, course ): + """Create a Project with the given arguments.""" deadline = timezone.now() + timezone.timedelta(days=days) return Project.objects.create( diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index f58f3533..01620891 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -7,7 +7,7 @@ def create_student(id, first_name, last_name, email): - # Create an Assistant with the given arguments. + # Create an student with the given arguments. username = f"{first_name}_{last_name}" return Student.objects.create( id=id, diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submision.py index 63344a06..ea8e630f 100644 --- a/backend/api/tests/test_submision.py +++ b/backend/api/tests/test_submision.py @@ -34,11 +34,12 @@ def create_project(name, description, days, course): def create_group(project, score): + """Create a Group with the given arguments.""" return Group.objects.create(project=project, score=score) def create_submission(group, submission_number): - # Create an Submission with the given arguments. + """Create an Submission with the given arguments.""" return Submission.objects.create( group=group, submission_number=submission_number, @@ -47,7 +48,7 @@ def create_submission(group, submission_number): def create_submissionFile(submission, file): - # Create an SubmissionFile with the given arguments. + """Create an SubmissionFile with the given arguments.""" return SubmissionFile.objects.create( submission=submission, file=file @@ -224,4 +225,4 @@ def test_submission_detail_view(self): int(retrieved_submission["submission_number"]), submission.submission_number ) - self.assertEqual(retrieved_submission["group"], expected_group_url) \ No newline at end of file + self.assertEqual(retrieved_submission["group"], expected_group_url) From 22dcaa28ed5e4b02d17161d61d5542746d071d10 Mon Sep 17 00:00:00 2001 From: francis Date: Sat, 2 Mar 2024 18:08:30 +0100 Subject: [PATCH 160/397] build: create backend testing workflow --- backend/.github/workflows/backend-tests.yaml | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/.github/workflows/backend-tests.yaml diff --git a/backend/.github/workflows/backend-tests.yaml b/backend/.github/workflows/backend-tests.yaml new file mode 100644 index 00000000..66c0d432 --- /dev/null +++ b/backend/.github/workflows/backend-tests.yaml @@ -0,0 +1,23 @@ +name: backend-tests + +on: + push: + branches: [main, development] + pull_request: + branches: [main, development] + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Execute tests + run: python manage.py test \ No newline at end of file From 637c4fbc107af20949d92083ecfac1529ea48e2c Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 2 Mar 2024 18:24:42 +0100 Subject: [PATCH 161/397] chore: init swagger --- backend/ypovoli/settings.py | 21 +++++++++++++++++++++ backend/ypovoli/urls.py | 17 +++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index c912fa45..7552d92e 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -36,6 +36,7 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + 'django.contrib.staticfiles', # Third party "rest_framework_swagger", # Swagger @@ -109,3 +110,23 @@ USE_I18N = True USE_L10N = False USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ +STATIC_URL = 'static/' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 6f4771a1..f4cf1d40 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -15,6 +15,18 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.urls import path, include +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="YpoVoli API", + default_version='v1',), + public=True, + permission_classes=(permissions.AllowAny,), +) + urlpatterns = [ # Base API endpoints. @@ -22,4 +34,9 @@ # Authentication endpoints. path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), + # Swagger documentation. + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), + name='schema-swagger-ui'), + path('swagger/', schema_view.without_ui(cache_timeout=0), + name='schema-json'), ] From 1ba19f0fce8d070616ef09568174adbd40e22b69 Mon Sep 17 00:00:00 2001 From: francis Date: Sat, 2 Mar 2024 18:45:31 +0100 Subject: [PATCH 162/397] build: temporarily add automated-testing branch for automated testing --- backend/.github/workflows/backend-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.github/workflows/backend-tests.yaml b/backend/.github/workflows/backend-tests.yaml index 66c0d432..b057f67a 100644 --- a/backend/.github/workflows/backend-tests.yaml +++ b/backend/.github/workflows/backend-tests.yaml @@ -2,7 +2,7 @@ name: backend-tests on: push: - branches: [main, development] + branches: [main, development, automated-testing] pull_request: branches: [main, development] From 333d3c953591c15b7b280251d9668aeec037f319 Mon Sep 17 00:00:00 2001 From: francis Date: Sat, 2 Mar 2024 18:48:21 +0100 Subject: [PATCH 163/397] fix: move .github folder to root --- {backend/.github => .github}/workflows/backend-tests.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {backend/.github => .github}/workflows/backend-tests.yaml (100%) diff --git a/backend/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml similarity index 100% rename from backend/.github/workflows/backend-tests.yaml rename to .github/workflows/backend-tests.yaml From c27bdba2538f8e8a2e801e7d22d771e9153d079b Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 2 Mar 2024 18:51:59 +0100 Subject: [PATCH 164/397] fix: typo --- backend/ypovoli/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index f4cf1d40..cb541b25 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -21,7 +21,7 @@ schema_view = get_schema_view( openapi.Info( - title="YpoVoli API", + title="Ypovoli API", default_version='v1',), public=True, permission_classes=(permissions.AllowAny,), From e8687f515bb5bfccaefbef95ef725ee9115d6440 Mon Sep 17 00:00:00 2001 From: francis Date: Sat, 2 Mar 2024 18:56:29 +0100 Subject: [PATCH 165/397] fix: file references --- .github/workflows/backend-tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index b057f67a..29990feb 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -18,6 +18,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r ./backend/requirements.txt - name: Execute tests - run: python manage.py test \ No newline at end of file + run: python ./backend/manage.py test \ No newline at end of file From 055306cde20eb92fa019e501ed4675fac0a5885e Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Sat, 2 Mar 2024 19:23:05 +0100 Subject: [PATCH 166/397] test: fix that test expects the whoami page for a deleted/nonexistent user to be 404 #27 --- backend/authentication/tests/test_authentication_views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 6f1754fc..9f1359f1 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -41,9 +41,7 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(s self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.get(reverse('auth.whoami')) - self.assertTrue(response.status_code, 200) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(content['detail'], 'User not found') + self.assertTrue(response.status_code, 404) def test_who_am_i_view_returns_401_when_not_authenticated(self): """WhoAmIView should return a 401 status code when the user is not authenticated""" From 43550c6d69c4faf843b7466302e9dbec6b43edcb Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Sat, 2 Mar 2024 19:52:36 +0100 Subject: [PATCH 167/397] test: from 404 to 405 from last commit #27 --- backend/authentication/tests/test_authentication_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 9f1359f1..4c2cb756 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -41,7 +41,7 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(s self.client.credentials(HTTP_AUTHORIZATION=self.token) response = self.client.get(reverse('auth.whoami')) - self.assertTrue(response.status_code, 404) + self.assertEqual(response.status_code, 405) def test_who_am_i_view_returns_401_when_not_authenticated(self): """WhoAmIView should return a 401 status code when the user is not authenticated""" From 9d55664d80add7478a8fe16514a2c4f6c0061c9d Mon Sep 17 00:00:00 2001 From: Francis Vauterin <159532420+francisvaut@users.noreply.github.com> Date: Sat, 2 Mar 2024 20:45:34 +0100 Subject: [PATCH 168/397] build: add tests to workflow_dispatch group for testing purposes --- .github/workflows/backend-tests.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 29990feb..0266c44e 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -5,6 +5,7 @@ on: branches: [main, development, automated-testing] pull_request: branches: [main, development] + workflow_dispatch: jobs: test: @@ -20,4 +21,4 @@ jobs: python -m pip install --upgrade pip pip install -r ./backend/requirements.txt - name: Execute tests - run: python ./backend/manage.py test \ No newline at end of file + run: python ./backend/manage.py test From 35e43472997a1b71ce21d782250ea8f326311c11 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 20:45:45 +0100 Subject: [PATCH 169/397] chore: add test for admins faculty --- backend/api/tests/test_admin.py | 84 +++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 5863c9d2..6bddb68b 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -5,21 +5,41 @@ from django.urls import reverse from ..models.admin import Admin +from authentication.models import Faculty +def create_faculty(name): + """ + Create a Faculty with the given arguments.""" + return Faculty.objects.create( + name=name + ) -def create_admin(id, first_name, last_name, email): +def create_admin(id, first_name, last_name, email, faculty=None): """ Create a Admin with the given arguments. """ username = f"{first_name}_{last_name}" - return Admin.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), - ) + if faculty is None: + return Admin.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + else: + admin = Admin.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + for fac in faculty: + admin.faculties.add(fac) + return admin class AdminModelTests(TestCase): @@ -145,3 +165,49 @@ def test_admin_detail_view(self): self.assertEqual(content_json["first_name"], admin.first_name) self.assertEqual(content_json["last_name"], admin.last_name) self.assertEqual(content_json["email"], admin.email) + + def test_admin_faculty(self): + """ + Able to retrieve faculty details of a single admin. + """ + # Create an admin for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + admin = create_admin( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty] + ) + + # Make a GET request to retrieve the admin details + response = self.client.get( + reverse("admin-detail", args=[str(admin.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), admin.id) + self.assertEqual(content_json["first_name"], admin.first_name) + self.assertEqual(content_json["last_name"], admin.last_name) + self.assertEqual(content_json["email"], admin.email) + print(content_json["faculties"]) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) From 434579fc12b7bdc921cde2a3b1f4a3ab73995ea3 Mon Sep 17 00:00:00 2001 From: Francis Vauterin <159532420+francisvaut@users.noreply.github.com> Date: Sat, 2 Mar 2024 20:53:02 +0100 Subject: [PATCH 170/397] chore: remove automated-testing branch from testing workflow --- .github/workflows/backend-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 0266c44e..cd8149f7 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -2,7 +2,7 @@ name: backend-tests on: push: - branches: [main, development, automated-testing] + branches: [main, development] pull_request: branches: [main, development] workflow_dispatch: From 28fa50aea144a01502fcbdd0972b19f7a43dc02a Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 21:07:57 +0100 Subject: [PATCH 171/397] chore: assistant tests endpoints --- backend/api/tests/test_admin.py | 2 + backend/api/tests/test_assistant.py | 181 ++++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 10 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 6bddb68b..963db1cb 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -7,6 +7,7 @@ from ..models.admin import Admin from authentication.models import Faculty + def create_faculty(name): """ Create a Faculty with the given arguments.""" @@ -14,6 +15,7 @@ def create_faculty(name): name=name ) + def create_admin(id, first_name, last_name, email, faculty=None): """ Create a Admin with the given arguments. diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 7e9e3701..11cc49e2 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -4,21 +4,62 @@ from django.utils import timezone from django.urls import reverse from api.models.assistant import Assistant +from api.models.course import Course +from authentication.models import Faculty -def create_assistant(id, first_name, last_name, email): - # Create an assistant with the given arguments. - username = f"{first_name}_{last_name}" - return Assistant.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create( + name=name ) +def create_assistant( + id, + first_name, + last_name, + email, + faculty=None, + courses=None + ): + """ + Create a assistant with the given arguments. + """ + username = f"{first_name}_{last_name}" + assistant = Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + + if faculty is not None: + for fac in faculty: + assistant.faculties.add(fac) + + if courses is not None: + for cours in courses: + assistant.courses.add(cours) + + return assistant + + class AssistantModelTests(TestCase): def test_no_assistant(self): """ @@ -151,3 +192,123 @@ def test_assistant_detail_view(self): self.assertEqual(content_json["first_name"], assistant.first_name) self.assertEqual(content_json["last_name"], assistant.last_name) self.assertEqual(content_json["email"], assistant.email) + + def test_assistant_faculty(self): + """ + Able to retrieve faculty details of a single assistant. + """ + # Create an assistant for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty] + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved assistant + # match the created assistant + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + print(content_json["faculties"]) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_assistant_courses(self): + """ + Able to retrieve courses details of a single assistant. + """ + # Create an assistant for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science." + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science." + ) + + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2] + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved assistant + # match the created assistant + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + print(content_json["faculties"]) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple assistant + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual( + int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual( + int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) From f461709576655fe4473c4540b0d5a2d6f01b71cf Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 21:19:27 +0100 Subject: [PATCH 172/397] chore: add student endpoint tests --- backend/api/tests/test_assistant.py | 3 - backend/api/tests/test_student.py | 180 ++++++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 14 deletions(-) diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 11cc49e2..4e8af6a9 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -67,7 +67,6 @@ def test_no_assistant(self): """ response_root = self.client.get(reverse("assistant-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") @@ -226,7 +225,6 @@ def test_assistant_faculty(self): self.assertEqual(content_json["first_name"], assistant.first_name) self.assertEqual(content_json["last_name"], assistant.last_name) self.assertEqual(content_json["email"], assistant.email) - print(content_json["faculties"]) response = self.client.get(content_json["faculties"][0], follow=True) @@ -283,7 +281,6 @@ def test_assistant_courses(self): self.assertEqual(content_json["first_name"], assistant.first_name) self.assertEqual(content_json["last_name"], assistant.last_name) self.assertEqual(content_json["email"], assistant.email) - print(content_json["faculties"]) response = self.client.get(content_json["courses"], follow=True) diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index 01620891..df03625d 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -4,21 +4,62 @@ from django.utils import timezone from django.urls import reverse from ..models.student import Student +from ..models.course import Course +from authentication.models import Faculty -def create_student(id, first_name, last_name, email): - # Create an student with the given arguments. - username = f"{first_name}_{last_name}" - return Student.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create( + name=name ) +def create_student( + id, + first_name, + last_name, + email, + faculty=None, + courses=None + ): + """ + Create a student with the given arguments. + """ + username = f"{first_name}_{last_name}" + student = Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + + if faculty is not None: + for fac in faculty: + student.faculties.add(fac) + + if courses is not None: + for cours in courses: + student.courses.add(cours) + + return student + + class StudentModelTests(TestCase): def test_no_student(self): """ @@ -26,7 +67,6 @@ def test_no_student(self): """ response_root = self.client.get(reverse("student-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") @@ -149,3 +189,121 @@ def test_student_detail_view(self): self.assertEqual(content_json["first_name"], student.first_name) self.assertEqual(content_json["last_name"], student.last_name) self.assertEqual(content_json["email"], student.email) + + def test_student_faculty(self): + """ + Able to retrieve faculty details of a single student. + """ + # Create an student for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + student = create_student( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty] + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved student + # match the created student + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_student_courses(self): + """ + Able to retrieve courses details of a single student. + """ + # Create an student for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science." + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science." + ) + + student = create_student( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2] + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved student + # match the created student + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple student + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual( + int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual( + int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) From e948ce1a440baaef74111b0d501aee1f7483c804 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 21:23:04 +0100 Subject: [PATCH 173/397] chore: add teacher endpoint tests --- backend/api/tests/test_teacher.py | 179 ++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 10 deletions(-) diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py index 7e2fdd88..5122ab0b 100644 --- a/backend/api/tests/test_teacher.py +++ b/backend/api/tests/test_teacher.py @@ -4,21 +4,62 @@ from django.utils import timezone from django.urls import reverse from ..models.teacher import Teacher +from ..models.course import Course +from authentication.models import Faculty -def create_teacher(id, first_name, last_name, email): - # Create an Teacher with the given arguments. - username = f"{first_name}_{last_name}" - return Teacher.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now(), +def create_course(name, academic_startyear, description=None, + parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create( + name=name ) +def create_teacher( + id, + first_name, + last_name, + email, + faculty=None, + courses=None + ): + """ + Create a teacher with the given arguments. + """ + username = f"{first_name}_{last_name}" + teacher = Teacher.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + + if faculty is not None: + for fac in faculty: + teacher.faculties.add(fac) + + if courses is not None: + for cours in courses: + teacher.courses.add(cours) + + return teacher + + class TeacherModelTests(TestCase): def test_no_teacher(self): """ @@ -148,3 +189,121 @@ def test_teacher_detail_view(self): self.assertEqual(content_json["first_name"], teacher.first_name) self.assertEqual(content_json["last_name"], teacher.last_name) self.assertEqual(content_json["email"], teacher.email) + + def test_teacher_faculty(self): + """ + Able to retrieve faculty details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + teacher = create_teacher( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty] + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher + # match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_teacher_courses(self): + """ + Able to retrieve courses details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science." + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science." + ) + + teacher = create_teacher( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2] + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher + # match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teacher + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual( + int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual( + int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) From 5a6bfec9e80890268e79186e955abd181d311605 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 22:17:25 +0100 Subject: [PATCH 174/397] chore: add course endpoints tests --- backend/api/tests/test_course.py | 353 +++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index 21ff40b7..df05eb90 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -1,8 +1,97 @@ import json from django.test import TestCase +from django.utils import timezone from django.urls import reverse from ..models.course import Course +from ..models.teacher import Teacher +from ..models.assistant import Assistant +from ..models.student import Student +from ..models.project import Project + + +def create_project( + name, + description, + visible, + archived, + days, + course +): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + course=course + ) + + +def create_student( + id, + first_name, + last_name, + email + ): + """ + Create a student with the given arguments. + """ + username = f"{first_name}_{last_name}" + student = Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + return student + + +def create_assistant( + id, + first_name, + last_name, + email + ): + """ + Create a assistant with the given arguments. + """ + username = f"{first_name}_{last_name}" + assistant = Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + return assistant + + +def create_teacher( + id, + first_name, + last_name, + email + ): + """ + Create a teacher with the given arguments. + """ + username = f"{first_name}_{last_name}" + teacher = Teacher.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now() + ) + return teacher def create_course(name, academic_startyear, description=None, @@ -113,3 +202,267 @@ def test_course_detail_view(self): self.assertEqual( content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) + + def test_course_teachers(self): + """ + Able to retrieve teachers details of a single course. + """ + teacher1 = create_teacher( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be" + ) + + teacher2 = create_teacher( + id=6, + first_name="Ronny", + last_name="Deila", + email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course." + ) + course.teachers.add(teacher1) + course.teachers.add(teacher2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["teachers"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teachers + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), teacher1.id) + self.assertEqual(content["first_name"], teacher1.first_name) + self.assertEqual(content["last_name"], teacher1.last_name) + self.assertEqual(content["email"], teacher1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), teacher2.id) + self.assertEqual(content["first_name"], teacher2.first_name) + self.assertEqual(content["last_name"], teacher2.last_name) + self.assertEqual(content["email"], teacher2.email) + + def test_course_assistant(self): + """ + Able to retrieve assistant details of a single course. + """ + assistant1 = create_assistant( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be" + ) + + assistant2 = create_assistant( + id=6, + first_name="Ronny", + last_name="Deila", + email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course." + ) + course.assistants.add(assistant1) + course.assistants.add(assistant2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["assistants"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teachers + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), assistant1.id) + self.assertEqual(content["first_name"], assistant1.first_name) + self.assertEqual(content["last_name"], assistant1.last_name) + self.assertEqual(content["email"], assistant1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), assistant2.id) + self.assertEqual(content["first_name"], assistant2.first_name) + self.assertEqual(content["last_name"], assistant2.last_name) + self.assertEqual(content["email"], assistant2.email) + + def test_course_student(self): + """ + Able to retrieve student details of a single course. + """ + student1 = create_student( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be" + ) + + student2 = create_student( + id=6, + first_name="Ronny", + last_name="Deila", + email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course." + ) + course.students.add(student1) + course.students.add(student2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["students"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple student + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), student1.id) + self.assertEqual(content["first_name"], student1.first_name) + self.assertEqual(content["last_name"], student1.last_name) + self.assertEqual(content["email"], student1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), student2.id) + self.assertEqual(content["first_name"], student2.first_name) + self.assertEqual(content["last_name"], student2.last_name) + self.assertEqual(content["email"], student2.email) + + def test_course_project(self): + """ + Able to retrieve project details of a single course. + """ + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course." + ) + + project1 = create_project( + name="become champions", + description="win the jpl", + visible=True, + archived=False, + days=50, + course=course + ) + + project2 = create_project( + name="become european champion", + description="win the cfl", + visible=True, + archived=False, + days=50, + course=course + ) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["projects"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple projects + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), project1.id) + self.assertEqual(content["name"], project1.name) + self.assertEqual(content["description"], project1.description) + self.assertEqual(content["visible"], project1.visible) + self.assertEqual(content["archived"], project1.archived) + + content = content_json[1] + self.assertEqual(int(content["id"]), project2.id) + self.assertEqual(content["name"], project2.name) + self.assertEqual(content["description"], project2.description) + self.assertEqual(content["visible"], project2.visible) + self.assertEqual(content["archived"], project2.archived) From 84520f9a8fcfea66bf050738418c0ea2ee4c136a Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 22:30:09 +0100 Subject: [PATCH 175/397] chore: add endpoint tests submision --- backend/api/tests/test_submision.py | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submision.py index ea8e630f..07e904c6 100644 --- a/backend/api/tests/test_submision.py +++ b/backend/api/tests/test_submision.py @@ -226,3 +226,63 @@ def test_submission_detail_view(self): submission.submission_number ) self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_submission_group(self): + """ + Able to retrieve group of a single submission. + """ + course = create_course( + name="sel2", + academic_startyear=2023 + ) + project = create_project( + name="Project 1", + description="Description 1", + days=7, + course=course + ) + group = create_group(project=project, score=10) + submission = create_submission( + group=group, submission_number=1 + ) + + # Make a GET request to retrieve the submission + response = self.client.get( + reverse("submission-detail", args=[str(submission.id)]), + follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission.submission_number + ) + + response = self.client.get(content_json["group"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)]) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["project"], expected_project_url) + self.assertEqual(content_json["score"], group.score) From 6d9a54eb0dbb9d333a295d37e69920c0185d59e8 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 22:48:21 +0100 Subject: [PATCH 176/397] chore: add group api endpoint --- backend/api/tests/test_group.py | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c8767ff2..e0320a44 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -190,3 +190,118 @@ def test_group_detail_view(self): self.assertEqual(int(content_json["id"]), group.id) self.assertEqual(content_json["project"], expected_project_url) self.assertEqual(content_json["score"], group.score) + + def test_group_project(self): + """Able to retrieve details of a single group.""" + course = create_course( + name="sel2", + academic_startyear=2023 + ) + + project = create_project( + name="Project 1", + description="Description 1", + days=7, course=course + ) + student = create_student( + id=5, + first_name="John", + last_name="Doe", + + email="john.doe@example.com") + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["score"], group.score) + + response = self.client.get(content_json["project"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(content_json["name"], project.name) + self.assertEqual(content_json["description"], project.description) + self.assertEqual(content_json["visible"], project.visible) + self.assertEqual(content_json["archived"], project.archived) + self.assertEqual(content_json["course"], expected_course_url) + + def test_group_students(self): + """Able to retrieve students details of a group.""" + course = create_course( + name="sel2", + academic_startyear=2023 + ) + + project = create_project( + name="Project 1", + description="Description 1", + days=7, course=course + ) + student1 = create_student( + id=5, + first_name="John", + last_name="Doe", + email="john.doe@example.com") + + student2 = create_student( + id=6, + first_name="kom", + last_name="mor_up", + email="kom.mor_up@example.com") + + group = create_group(project=project, score=10) + group.students.add(student1) + group.students.add(student2) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["score"], group.score) + + response = self.client.get(content_json["students"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), student1.id) + self.assertEqual(content["first_name"], student1.first_name) + self.assertEqual(content["last_name"], student1.last_name) + self.assertEqual(content["email"], student1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), student2.id) + self.assertEqual(content["first_name"], student2.first_name) + self.assertEqual(content["last_name"], student2.last_name) + self.assertEqual(content["email"], student2.email) From c09f5e0e15af40bd203c32652ad23d354574542b Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 23:11:20 +0100 Subject: [PATCH 177/397] chore: add api tests project endpoints --- backend/api/tests/test_project.py | 176 +++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index af7fb11a..b4e4058d 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -4,7 +4,7 @@ from django.urls import reverse from ..models.project import Project from ..models.course import Course -from ..models.checks import Checks +from ..models.checks import Checks, FileExtension def create_course(id, name, academic_startyear): @@ -15,9 +15,35 @@ def create_course(id, name, academic_startyear): id=id, name=name, academic_startyear=academic_startyear) -def create_checks(): +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create( + id=id, + extension=extension + ) + + +def create_checks( + id=None, + allowed_file_extensions=None, + forbidden_file_extensions=None): """Create a Checks with the given arguments.""" - return Checks.objects.create() + if id is None and allowed_file_extensions is None: + # extra if to make line shorter + if forbidden_file_extensions is None: + return Checks.objects.create() + + check = Checks.objects.create( + id=id, + ) + + for ext in allowed_file_extensions: + check.allowed_file_extensions.add(ext) + for ext in forbidden_file_extensions: + check.forbidden_file_extensions.add(ext) + return check def create_project( @@ -277,3 +303,147 @@ def test_multiple_project(self): self.assertEqual(retrieved_project["archived"], project2.archived) self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) + + def test_project_course(self): + """ + Able to retrieve a course of a project after creating it. + """ + + course = create_course( + id=3, + name="test course", + academic_startyear=2024 + ) + checks = create_checks() + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["checks"], expected_checks_url) + + response = self.client.get(retrieved_project["course"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual( + content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + def test_project_checks(self): + """ + Able to retrieve a check of a project after creating it. + """ + + course = create_course( + id=3, + name="test course", + academic_startyear=2024 + ) + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") + fileExtension3 = create_fileExtension(id=3, extension="tar") + fileExtension4 = create_fileExtension(id=4, extension="wfp") + checks = create_checks( + id=5, + allowed_file_extensions=[fileExtension1, fileExtension4], + forbidden_file_extensions=[fileExtension2, fileExtension3] + ) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["course"], expected_course_url) + + response = self.client.get(retrieved_project["checks"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), checks.id) + + # Assert the file extensions of the retrieved + # Checks match the created file extensions + retrieved_allowed_file_extensions = content_json[ + "allowed_file_extensions"] + + self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual( + retrieved_allowed_file_extensions[0]["extension"], + fileExtension1.extension) + self.assertEqual( + retrieved_allowed_file_extensions[1]["extension"], + fileExtension4.extension) + + retrieved_forbidden_file_extensions = content_json[ + "forbidden_file_extensions"] + self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + self.assertEqual( + retrieved_forbidden_file_extensions[0]["extension"], + fileExtension2.extension) + self.assertEqual( + retrieved_forbidden_file_extensions[1]["extension"], + fileExtension3.extension) From ebed9ed03903a62e79695d726fe686810e0f3475 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 2 Mar 2024 23:36:06 +0100 Subject: [PATCH 178/397] fix: removed prints --- backend/api/tests/test_admin.py | 1 - backend/api/tests/test_checks.py | 1 - backend/api/tests/test_submision.py | 1 - backend/api/tests/test_teacher.py | 1 - 4 files changed, 4 deletions(-) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 963db1cb..292e1f21 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -200,7 +200,6 @@ def test_admin_faculty(self): self.assertEqual(content_json["first_name"], admin.first_name) self.assertEqual(content_json["last_name"], admin.last_name) self.assertEqual(content_json["email"], admin.email) - print(content_json["faculties"]) response = self.client.get(content_json["faculties"][0], follow=True) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 1eb1ce75..e4247956 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -36,7 +36,6 @@ def test_no_fileExtension(self): """ response_root = self.client.get( reverse("fileExtension-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submision.py index 07e904c6..119415ef 100644 --- a/backend/api/tests/test_submision.py +++ b/backend/api/tests/test_submision.py @@ -63,7 +63,6 @@ def test_no_submission(self): response_root = self.client.get( reverse("submission-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py index 5122ab0b..2e3d8c6e 100644 --- a/backend/api/tests/test_teacher.py +++ b/backend/api/tests/test_teacher.py @@ -67,7 +67,6 @@ def test_no_teacher(self): """ response_root = self.client.get(reverse("teacher-list"), follow=True) - # print(response.content) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") From af95e0f562761c1d010f52ea570164759f5bdbea Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 4 Mar 2024 10:12:50 +0100 Subject: [PATCH 179/397] build: add default linting checks --- .github/workflows/backend-tests.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index cd8149f7..9144dd55 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 @@ -19,6 +19,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install flake8 pip install -r ./backend/requirements.txt - name: Execute tests run: python ./backend/manage.py test + - name: Execute linting checks + run: flake8 ./backend From 2315ef96519d988624fe9cbc47d14ffb5dfd0628 Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 4 Mar 2024 10:16:46 +0100 Subject: [PATCH 180/397] build: seperate testing and linting workflow --- .github/workflows/backend-linting.yaml | 25 +++++++++++++++++++++++++ .github/workflows/backend-tests.yaml | 2 -- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/backend-linting.yaml diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml new file mode 100644 index 00000000..cbd8e9f5 --- /dev/null +++ b/.github/workflows/backend-linting.yaml @@ -0,0 +1,25 @@ +name: backend-linting + +on: + push: + branches: [main, development] + pull_request: + branches: [main, development] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + pip install -r ./backend/requirements.txt + - name: Execute linting checks + run: flake8 ./backend diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 9144dd55..85ba4f2e 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -23,5 +23,3 @@ jobs: pip install -r ./backend/requirements.txt - name: Execute tests run: python ./backend/manage.py test - - name: Execute linting checks - run: flake8 ./backend From 5656ce396c9fde00d002e63634b3d22e0e707269 Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 4 Mar 2024 10:28:51 +0100 Subject: [PATCH 181/397] build: flake8 config --- .github/workflows/backend-linting.yaml | 2 +- backend/.flake8 | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 backend/.flake8 diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml index cbd8e9f5..e3a7c8e5 100644 --- a/.github/workflows/backend-linting.yaml +++ b/.github/workflows/backend-linting.yaml @@ -22,4 +22,4 @@ jobs: pip install flake8 pip install -r ./backend/requirements.txt - name: Execute linting checks - run: flake8 ./backend + run: flake8 --config ./backend/.flake8 ./backend diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..ee3e436d --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,15 @@ +[flake8] + +# Ignore unused imports +ignore = F401 + +max-line-length = 119 + +max-complexity = 10 + +exclude = .git, + __pycache__, + .venv, + venv, + migrations + From 8057562d71abb7b6ab43acd6a216ee7b771ddd1a Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 4 Mar 2024 16:33:13 +0100 Subject: [PATCH 182/397] chore: format code --- backend/.flake8 | 14 + backend/api/apps.py | 4 +- backend/api/migrations/0001_initial.py | 297 ++++++++++++++---- backend/api/models/assistant.py | 4 +- backend/api/models/checks.py | 13 +- backend/api/models/course.py | 22 +- backend/api/models/group.py | 11 +- backend/api/models/project.py | 30 +- backend/api/models/student.py | 11 +- backend/api/models/submission.py | 24 +- backend/api/models/teacher.py | 4 +- backend/api/serializers/admin_serializer.py | 16 +- .../api/serializers/assistant_serializer.py | 19 +- backend/api/serializers/checks_serializer.py | 11 +- backend/api/serializers/course_serializer.py | 26 +- backend/api/serializers/faculty_serializer.py | 4 +- backend/api/serializers/group_serializer.py | 10 +- backend/api/serializers/project_serializer.py | 22 +- backend/api/serializers/student_serializer.py | 22 +- .../api/serializers/submision_serializer.py | 11 +- backend/api/serializers/teacher_serializer.py | 19 +- backend/api/signals.py | 1 - backend/api/tests/test_admin.py | 42 +-- backend/api/tests/test_assistant.py | 100 +++--- backend/api/tests/test_checks.py | 70 ++--- backend/api/tests/test_course.py | 161 ++++------ backend/api/tests/test_group.py | 145 +++------ backend/api/tests/test_project.py | 215 +++++++------ backend/api/tests/test_student.py | 100 +++--- backend/api/tests/test_submision.py | 140 +++------ backend/api/tests/test_teacher.py | 100 +++--- backend/api/urls.py | 59 +--- backend/api/views/assistant_view.py | 10 +- backend/api/views/checks_view.py | 4 +- backend/api/views/course_view.py | 36 ++- backend/api/views/group_view.py | 9 +- backend/api/views/student_view.py | 18 +- backend/api/views/submision_view.py | 3 +- backend/api/views/teacher_view.py | 9 +- backend/authentication/apps.py | 4 +- backend/authentication/cas/client.py | 4 +- .../authentication/migrations/0001_initial.py | 54 ++-- ...culty_user_remove_user_faculty_and_more.py | 19 +- .../migrations/0003_alter_user_create_time.py | 17 + backend/authentication/models.py | 64 ++-- backend/authentication/serializers.py | 70 ++--- backend/authentication/services/users.py | 56 ++-- backend/authentication/signals.py | 2 +- .../tests/test_authentication_serializer.py | 183 +++++------ .../tests/test_authentication_views.py | 66 ++-- backend/authentication/urls.py | 26 +- backend/authentication/views/auth.py | 8 +- backend/authentication/views/users.py | 3 +- backend/checks/apps.py | 4 +- backend/manage.py | 4 +- backend/notifications/apps.py | 6 +- .../notifications/migrations/0001_initial.py | 47 ++- backend/ypovoli/asgi.py | 2 +- backend/ypovoli/settings.py | 26 +- backend/ypovoli/urls.py | 15 +- backend/ypovoli/wsgi.py | 2 +- 61 files changed, 1229 insertions(+), 1269 deletions(-) create mode 100644 backend/.flake8 create mode 100644 backend/authentication/migrations/0003_alter_user_create_time.py diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..b92dcdfc --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,14 @@ +[flake8] + +# Ignore unused imports +ignore = F401 + +max-line-length = 119 + +max-complexity = 10 + +exclude = .git, + __pycache__, + .venv, + venv, + migrations \ No newline at end of file diff --git a/backend/api/apps.py b/backend/api/apps.py index a13c8f06..55a607c6 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -2,8 +2,8 @@ class ApiConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'api' + default_auto_field = "django.db.models.BigAutoField" + name = "api" def ready(self): from authentication.signals import user_created diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py index bb01d604..6b842090 100644 --- a/backend/api/migrations/0001_initial.py +++ b/backend/api/migrations/0001_initial.py @@ -7,125 +7,300 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('authentication', '0001_initial'), + ("authentication", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Admin', + name="Admin", fields=[ - ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ( + "user_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('authentication.user',), + bases=("authentication.user",), ), migrations.CreateModel( - name='FileExtension', + name="FileExtension", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extension', models.CharField(max_length=10, unique=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("extension", models.CharField(max_length=10, unique=True)), ], ), migrations.CreateModel( - name='Course', + name="Course", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('academic_startyear', models.IntegerField()), - ('description', models.TextField(blank=True, null=True)), - ('parent_course', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_course', to='api.course')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("academic_startyear", models.IntegerField()), + ("description", models.TextField(blank=True, null=True)), + ( + "parent_course", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="child_course", + to="api.course", + ), + ), ], ), migrations.CreateModel( - name='Assistant', + name="Assistant", fields=[ - ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), - ('courses', models.ManyToManyField(blank=True, related_name='assistants', to='api.course')), + ( + "user_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "courses", + models.ManyToManyField( + blank=True, related_name="assistants", to="api.course" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('authentication.user',), + bases=("authentication.user",), ), migrations.CreateModel( - name='Checks', + name="Checks", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('dockerfile', models.FileField(blank=True, null=True, upload_to='')), - ('allowed_file_extensions', models.ManyToManyField(blank=True, related_name='checks_allowed', to='api.fileextension')), - ('forbidden_file_extensions', models.ManyToManyField(blank=True, related_name='checks_forbidden', to='api.fileextension')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("dockerfile", models.FileField(blank=True, null=True, upload_to="")), + ( + "allowed_file_extensions", + models.ManyToManyField( + blank=True, + related_name="checks_allowed", + to="api.fileextension", + ), + ), + ( + "forbidden_file_extensions", + models.ManyToManyField( + blank=True, + related_name="checks_forbidden", + to="api.fileextension", + ), + ), ], ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('description', models.TextField(blank=True, null=True)), - ('visible', models.BooleanField(default=True)), - ('archived', models.BooleanField(default=False)), - ('start_date', models.DateTimeField(blank=True, default=datetime.datetime.now)), - ('deadline', models.DateTimeField()), - ('checks', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.checks')), - ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("description", models.TextField(blank=True, null=True)), + ("visible", models.BooleanField(default=True)), + ("archived", models.BooleanField(default=False)), + ( + "start_date", + models.DateTimeField(blank=True, default=datetime.datetime.now), + ), + ("deadline", models.DateTimeField()), + ( + "checks", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="api.checks", + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="projects", + to="api.course", + ), + ), ], ), migrations.CreateModel( - name='Student', + name="Student", fields=[ - ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), - ('student_id', models.CharField(max_length=8, null=True, unique=True)), - ('courses', models.ManyToManyField(blank=True, related_name='students', to='api.course')), + ( + "user_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ("student_id", models.CharField(max_length=8, null=True, unique=True)), + ( + "courses", + models.ManyToManyField( + blank=True, related_name="students", to="api.course" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('authentication.user',), + bases=("authentication.user",), ), migrations.CreateModel( - name='Group', + name="Group", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('score', models.FloatField(blank=True, null=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='api.project')), - ('students', models.ManyToManyField(related_name='groups', to='api.student')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("score", models.FloatField(blank=True, null=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="groups", + to="api.project", + ), + ), + ( + "students", + models.ManyToManyField(related_name="groups", to="api.student"), + ), ], ), migrations.CreateModel( - name='Submission', + name="Submission", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('submission_number', models.PositiveIntegerField()), - ('submission_time', models.DateTimeField(auto_now_add=True)), - ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='api.group')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("submission_number", models.PositiveIntegerField()), + ("submission_time", models.DateTimeField(auto_now_add=True)), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="api.group", + ), + ), ], options={ - 'unique_together': {('group', 'submission_number')}, + "unique_together": {("group", "submission_number")}, }, ), migrations.CreateModel( - name='SubmissionFile', + name="SubmissionFile", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('file', models.FileField(upload_to='')), - ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.submission')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("file", models.FileField(upload_to="")), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + to="api.submission", + ), + ), ], ), migrations.CreateModel( - name='Teacher', + name="Teacher", fields=[ - ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), - ('courses', models.ManyToManyField(blank=True, related_name='teachers', to='api.course')), + ( + "user_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "courses", + models.ManyToManyField( + blank=True, related_name="teachers", to="api.course" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('authentication.user',), + bases=("authentication.user",), ), ] diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py index a761e8a5..4c6d9f19 100644 --- a/backend/api/models/assistant.py +++ b/backend/api/models/assistant.py @@ -13,6 +13,6 @@ class Assistant(User): courses = models.ManyToManyField( Course, # Allows us to access the assistants from the course - related_name='assistants', - blank=True + related_name="assistants", + blank=True, ) diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index db11e6e9..ef0595ba 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -17,21 +17,14 @@ class Checks(models.Model): # ID check should be generated automatically - dockerfile = models.FileField( - blank=True, - null=True - ) + dockerfile = models.FileField(blank=True, null=True) # Link to the file extensions that are allowed allowed_file_extensions = models.ManyToManyField( - FileExtension, - related_name='checks_allowed', - blank=True + FileExtension, related_name="checks_allowed", blank=True ) # Link to the file extensions that are forbidden forbidden_file_extensions = models.ManyToManyField( - FileExtension, - related_name='checks_forbidden', - blank=True + FileExtension, related_name="checks_forbidden", blank=True ) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index f0804caf..f4ec41f2 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -7,33 +7,23 @@ class Course(models.Model): # ID of the course should automatically be generated - name = models.CharField( - max_length=100, - blank=False, - null=False - ) + name = models.CharField(max_length=100, blank=False, null=False) # Begin year of the academic year - academic_startyear = models.IntegerField( - blank=False, - null=False - ) + academic_startyear = models.IntegerField(blank=False, null=False) - description = models.TextField( - blank=True, - null=True - ) + description = models.TextField(blank=True, null=True) # OneToOneField is used to represent a one-to-one relationship # with the course of the previous academic year parent_course = models.OneToOneField( - 'self', + "self", # If the old course is deleted, the child course should remain on_delete=models.SET_NULL, # Allows us to access the child course from the parent course - related_name='child_course', + related_name="child_course", blank=True, - null=True + null=True, ) def __str__(self) -> str: diff --git a/backend/api/models/group.py b/backend/api/models/group.py index b820bcc3..b963aa89 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -13,21 +13,18 @@ class Group(models.Model): # If the project is deleted, the group should be deleted as well on_delete=models.CASCADE, # This is how we can access groups from a project - related_name='groups', + related_name="groups", blank=False, - null=False + null=False, ) # Students that are part of the group students = models.ManyToManyField( Student, # This is how we can access groups from a student - related_name='groups', + related_name="groups", blank=False, ) # Score of the group - score = models.FloatField( - blank=True, - null=True - ) + score = models.FloatField(blank=True, null=True) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 4c142be1..ec16ed77 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -10,26 +10,15 @@ class Project(models.Model): # ID should be generated automatically - name = models.CharField( - max_length=100, - blank=False, - null=False - ) + name = models.CharField(max_length=100, blank=False, null=False) - description = models.TextField( - blank=True, - null=True - ) + description = models.TextField(blank=True, null=True) # Project already visible to students - visible = models.BooleanField( - default=True - ) + visible = models.BooleanField(default=True) # Project archived - archived = models.BooleanField( - default=False - ) + archived = models.BooleanField(default=False) start_date = models.DateTimeField( # The default value is the current date and time @@ -37,10 +26,7 @@ class Project(models.Model): blank=True, ) - deadline = models.DateTimeField( - blank=False, - null=False - ) + deadline = models.DateTimeField(blank=False, null=False) # Check entity that is linked to the project checks = models.ForeignKey( @@ -48,7 +34,7 @@ class Project(models.Model): # If the checks are deleted, the project should remain on_delete=models.SET_NULL, blank=True, - null=True + null=True, ) # Course that the project belongs to @@ -56,9 +42,9 @@ class Project(models.Model): Course, # If the course is deleted, the project should be deleted as well on_delete=models.CASCADE, - related_name='projects', + related_name="projects", blank=False, - null=False + null=False, ) def deadline_approaching_in(self, days=7): diff --git a/backend/api/models/student.py b/backend/api/models/student.py index a11fe0f8..c619d924 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -8,17 +8,14 @@ class Student(User): It extends the User model from the authentication app with student-specific attributes. """ + # The student's Ghent University ID - student_id = models.CharField( - max_length=8, - null=True, - unique=True - ) + student_id = models.CharField(max_length=8, null=True, unique=True) # All the courses the student is enrolled in courses = models.ManyToManyField( Course, # Allows us to access the students from the course - related_name='students', - blank=True + related_name="students", + blank=True, ) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index a94c9360..8f41018c 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -11,25 +11,20 @@ class Submission(models.Model): Group, # If the group is deleted, the submission should be deleted as well on_delete=models.CASCADE, - related_name='submissions', + related_name="submissions", blank=False, - null=False + null=False, ) # Multiple submissions can be made by a group - submission_number = models.PositiveIntegerField( - blank=False, - null=False - ) + submission_number = models.PositiveIntegerField(blank=False, null=False) # Automatically set the submission time to the current time - submission_time = models.DateTimeField( - auto_now_add=True - ) + submission_time = models.DateTimeField(auto_now_add=True) class Meta: # A group can only have one submission with a specific number - unique_together = ('group', 'submission_number') + unique_together = ("group", "submission_number") class SubmissionFile(models.Model): @@ -41,13 +36,10 @@ class SubmissionFile(models.Model): Submission, # If the submission is deleted, the file should be deleted as well on_delete=models.CASCADE, - related_name='files', + related_name="files", blank=False, - null=False + null=False, ) # TODO - Set the right place to save the file - file = models.FileField( - blank=False, - null=False - ) + file = models.FileField(blank=False, null=False) diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index e2e6260e..89f3d471 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -13,6 +13,6 @@ class Teacher(User): courses = models.ManyToManyField( Course, # Allows us to access the teachers from the course - related_name='teachers', - blank=True + related_name="teachers", + blank=True, ) diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py index c7749f87..7b060e69 100644 --- a/backend/api/serializers/admin_serializer.py +++ b/backend/api/serializers/admin_serializer.py @@ -3,16 +3,18 @@ class AdminSerializer(serializers.ModelSerializer): - faculties = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' + many=True, read_only=True, view_name="faculty-detail" ) class Meta: model = Admin fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculties', 'last_enrolled', 'create_time' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + ] diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 540f9f32..16e26206 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -3,21 +3,24 @@ class AssistantSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='assistant-courses', + view_name="assistant-courses", read_only=True, ) faculties = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' + many=True, read_only=True, view_name="faculty-detail" ) class Meta: model = Assistant fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculties', 'last_enrolled', 'create_time', 'courses' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index dc312e4b..01254ec0 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -5,16 +5,19 @@ class FileExtensionSerializer(serializers.ModelSerializer): class Meta: model = FileExtension - fields = ['extension'] + fields = ["extension"] class ChecksSerializer(serializers.ModelSerializer): - allowed_file_extensions = FileExtensionSerializer(many=True) forbidden_file_extensions = FileExtensionSerializer(many=True) class Meta: model = Checks - fields = ['id', 'dockerfile', 'allowed_file_extensions', - 'forbidden_file_extensions'] + fields = [ + "id", + "dockerfile", + "allowed_file_extensions", + "forbidden_file_extensions", + ] diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 3ff5e1c7..4d6edede 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -3,36 +3,40 @@ class CourseSerializer(serializers.ModelSerializer): - teachers = serializers.HyperlinkedIdentityField( - view_name='course-teachers', + view_name="course-teachers", read_only=True, ) assistants = serializers.HyperlinkedIdentityField( - view_name='course-assistants', + view_name="course-assistants", read_only=True, ) students = serializers.HyperlinkedIdentityField( - view_name='course-students', + view_name="course-students", read_only=True, ) projects = serializers.HyperlinkedIdentityField( - view_name='course-projects', + view_name="course-projects", read_only=True, ) parent_course = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='course-detail' + many=False, read_only=True, view_name="course-detail" ) class Meta: model = Course fields = [ - 'id', 'name', 'academic_startyear', 'description', - 'parent_course', 'teachers', 'assistants', 'students', 'projects' - ] + "id", + "name", + "academic_startyear", + "description", + "parent_course", + "teachers", + "assistants", + "students", + "projects", + ] diff --git a/backend/api/serializers/faculty_serializer.py b/backend/api/serializers/faculty_serializer.py index 9c22e1ce..eab4a48d 100644 --- a/backend/api/serializers/faculty_serializer.py +++ b/backend/api/serializers/faculty_serializer.py @@ -5,6 +5,4 @@ class facultySerializer(serializers.ModelSerializer): class Meta: model = Faculty - fields = [ - 'name' - ] + fields = ["name"] diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 9a9e1604..d3b0ecfa 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -4,16 +4,14 @@ class GroupSerializer(serializers.ModelSerializer): project = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='project-detail' + many=False, read_only=True, view_name="project-detail" ) students = serializers.HyperlinkedIdentityField( - view_name='group-students', - read_only=True, + view_name="group-students", + read_only=True, ) class Meta: model = Group - fields = ['id', 'project', 'students', 'score'] + fields = ["id", "project", "students", "score"] diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index a04058f0..c0e36a9a 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -3,22 +3,24 @@ class ProjectSerializer(serializers.ModelSerializer): - course = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='course-detail' + many=False, read_only=True, view_name="course-detail" ) checks = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='check-detail' + many=False, read_only=True, view_name="check-detail" ) class Meta: model = Project fields = [ - 'id', 'name', 'description', 'visible', 'archived', - 'start_date', 'deadline', 'checks', 'course' - ] + "id", + "name", + "description", + "visible", + "archived", + "start_date", + "deadline", + "checks", + "course", + ] diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 3ea93733..fddbd8d2 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -3,26 +3,30 @@ class StudentSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='student-courses', + view_name="student-courses", read_only=True, ) groups = serializers.HyperlinkedIdentityField( - view_name='student-groups', + view_name="student-groups", read_only=True, ) faculties = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' + many=True, read_only=True, view_name="faculty-detail" ) class Meta: model = Student fields = [ - 'id', 'first_name', 'last_name', 'email', 'faculties', - 'last_enrolled', 'create_time', 'courses', 'groups' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + "courses", + "groups", + ] diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py index 95d26dbf..da7458b8 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submision_serializer.py @@ -5,21 +5,16 @@ class SubmissionFileSerializer(serializers.ModelSerializer): class Meta: model = SubmissionFile - fields = ['file'] + fields = ["file"] class SubmissionSerializer(serializers.ModelSerializer): - group = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='group-detail' + many=False, read_only=True, view_name="group-detail" ) files = SubmissionFileSerializer(many=True, read_only=True) class Meta: model = Submission - fields = ['id', 'group', 'submission_number', 'submission_time', - 'files' - ] + fields = ["id", "group", "submission_number", "submission_time", "files"] diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index 8fbb98e9..fcfa35e1 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -3,21 +3,24 @@ class TeacherSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='teacher-courses', + view_name="teacher-courses", read_only=True, ) faculties = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' + many=True, read_only=True, view_name="faculty-detail" ) class Meta: model = Teacher fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculties', 'last_enrolled', 'create_time', 'courses' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] diff --git a/backend/api/signals.py b/backend/api/signals.py index 8b50a749..85f94211 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -3,7 +3,6 @@ def user_creation(user: User, attributes: dict, **kwargs): - """Upon user creation, auto-populate additional properties""" student_id = attributes.get("ugentStudentID") diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index 292e1f21..d6d44888 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -11,9 +11,7 @@ def create_faculty(name): """ Create a Faculty with the given arguments.""" - return Faculty.objects.create( - name=name - ) + return Faculty.objects.create(name=name) def create_admin(id, first_name, last_name, email, faculty=None): @@ -28,7 +26,7 @@ def create_admin(id, first_name, last_name, email, faculty=None): last_name=last_name, username=username, email=email, - create_time=timezone.now() + create_time=timezone.now(), ) else: admin = Admin.objects.create( @@ -64,10 +62,7 @@ def test_admin_exists(self): Able to retrieve a single admin after creating it. """ admin = create_admin( - id=3, - first_name="John", - last_name="Doe", - email="john.doe@example.com" + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" ) # Make a GET request to retrieve the admin @@ -98,17 +93,11 @@ def test_multiple_admins(self): """ # Create multiple admins admin1 = create_admin( - id=1, - first_name="Johny", - last_name="Doeg", - email="john.doe@example.com" - ) + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) admin2 = create_admin( - id=2, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) # Make a GET request to retrieve the admins response = self.client.get(reverse("admin-list"), follow=True) @@ -143,15 +132,13 @@ def test_admin_detail_view(self): """ # Create an admin for testing with the name "Bob Peeters" admin = create_admin( - id=5, - first_name="Bob", - last_name="Peeters", - email="bob.peeters@example.com" - ) + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) # Make a GET request to retrieve the admin details response = self.client.get( - reverse("admin-detail", args=[str(admin.id)]), follow=True) + reverse("admin-detail", args=[str(admin.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -179,12 +166,13 @@ def test_admin_faculty(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - faculty=[faculty] - ) + faculty=[faculty], + ) # Make a GET request to retrieve the admin details response = self.client.get( - reverse("admin-detail", args=[str(admin.id)]), follow=True) + reverse("admin-detail", args=[str(admin.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 4e8af6a9..15d53aa1 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -8,8 +8,7 @@ from authentication.models import Faculty -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -17,37 +16,28 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) def create_faculty(name): """Create a Faculty with the given arguments.""" - return Faculty.objects.create( - name=name - ) + return Faculty.objects.create(name=name) -def create_assistant( - id, - first_name, - last_name, - email, - faculty=None, - courses=None - ): +def create_assistant(id, first_name, last_name, email, faculty=None, courses=None): """ Create a assistant with the given arguments. """ username = f"{first_name}_{last_name}" assistant = Assistant.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) if faculty is not None: for fac in faculty: @@ -80,10 +70,7 @@ def test_assistant_exists(self): Able to retrieve a single assistant after creating it. """ assistant = create_assistant( - id=3, - first_name="John", - last_name="Doe", - email="john.doe@example.com" + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" ) # Make a GET request to retrieve the assistant @@ -105,8 +92,7 @@ def test_assistant_exists(self): # match the created assistant retrieved_assistant = content_json[0] self.assertEqual(int(retrieved_assistant["id"]), assistant.id) - self.assertEqual( - retrieved_assistant["first_name"], assistant.first_name) + self.assertEqual(retrieved_assistant["first_name"], assistant.first_name) self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) self.assertEqual(retrieved_assistant["email"], assistant.email) @@ -116,17 +102,11 @@ def test_multiple_assistant(self): """ # Create multiple assistant assistant1 = create_assistant( - id=1, - first_name="Johny", - last_name="Doeg", - email="john.doe@example.com" - ) + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) assistant2 = create_assistant( - id=2, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) # Make a GET request to retrieve the assistant response = self.client.get(reverse("assistant-list"), follow=True) @@ -147,17 +127,13 @@ def test_multiple_assistant(self): # assistant match the created assistant retrieved_assistant1, retrieved_assistant2 = content_json self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) - self.assertEqual( - retrieved_assistant1["first_name"], assistant1.first_name) - self.assertEqual( - retrieved_assistant1["last_name"], assistant1.last_name) + self.assertEqual(retrieved_assistant1["first_name"], assistant1.first_name) + self.assertEqual(retrieved_assistant1["last_name"], assistant1.last_name) self.assertEqual(retrieved_assistant1["email"], assistant1.email) self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) - self.assertEqual( - retrieved_assistant2["first_name"], assistant2.first_name) - self.assertEqual( - retrieved_assistant2["last_name"], assistant2.last_name) + self.assertEqual(retrieved_assistant2["first_name"], assistant2.first_name) + self.assertEqual(retrieved_assistant2["last_name"], assistant2.last_name) self.assertEqual(retrieved_assistant2["email"], assistant2.email) def test_assistant_detail_view(self): @@ -166,15 +142,13 @@ def test_assistant_detail_view(self): """ # Create an assistant for testing with the name "Bob Peeters" assistant = create_assistant( - id=5, - first_name="Bob", - last_name="Peeters", - email="bob.peeters@example.com" - ) + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) # Make a GET request to retrieve the assistant details response = self.client.get( - reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -203,12 +177,13 @@ def test_assistant_faculty(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - faculty=[faculty] - ) + faculty=[faculty], + ) # Make a GET request to retrieve the assistant details response = self.client.get( - reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -246,12 +221,12 @@ def test_assistant_courses(self): course1 = create_course( name="Introduction to Computer Science", academic_startyear=2022, - description="An introductory course on computer science." + description="An introductory course on computer science.", ) course2 = create_course( name="Intermediate to Computer Science", academic_startyear=2023, - description="An second course on computer science." + description="An second course on computer science.", ) assistant = create_assistant( @@ -259,12 +234,13 @@ def test_assistant_courses(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - courses=[course1, course2] - ) + courses=[course1, course2], + ) # Make a GET request to retrieve the assistant details response = self.client.get( - reverse("assistant-detail", args=[str(assistant.id)]), follow=True) + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -299,13 +275,11 @@ def test_assistant_courses(self): content = content_json[0] self.assertEqual(int(content["id"]), course1.id) self.assertEqual(content["name"], course1.name) - self.assertEqual( - int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) self.assertEqual(content["description"], course1.description) content = content_json[1] self.assertEqual(int(content["id"]), course2.id) self.assertEqual(content["name"], course2.name) - self.assertEqual( - int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) self.assertEqual(content["description"], course2.description) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index e4247956..f7273144 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -10,10 +10,7 @@ def create_fileExtension(id, extension): """ Create a FileExtension with the given arguments. """ - return FileExtension.objects.create( - id=id, - extension=extension - ) + return FileExtension.objects.create(id=id, extension=extension) def create_checks(id, allowed_file_extensions, forbidden_file_extensions): @@ -34,8 +31,7 @@ def test_no_fileExtension(self): """ able to retrieve no FileExtension before publishing it. """ - response_root = self.client.get( - reverse("fileExtension-list"), follow=True) + response_root = self.client.get(reverse("fileExtension-list"), follow=True) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") @@ -48,10 +44,7 @@ def test_fileExtension_exists(self): """ Able to retrieve a single fileExtension after creating it. """ - fileExtension = create_fileExtension( - id=5, - extension="pdf" - ) + fileExtension = create_fileExtension(id=5, extension="pdf") # Make a GET request to retrieve the fileExtension response = self.client.get(reverse("fileExtension-list"), follow=True) @@ -71,22 +64,15 @@ def test_fileExtension_exists(self): # Assert the details of the retrieved fileExtension # match the created fileExtension retrieved_fileExtension = content_json[0] - self.assertEqual( - retrieved_fileExtension["extension"], fileExtension.extension) + self.assertEqual(retrieved_fileExtension["extension"], fileExtension.extension) def test_multiple_fileExtension(self): """ Able to retrieve multiple fileExtension after creating them. """ # Create multiple fileExtension - fileExtension1 = create_fileExtension( - id=1, - extension="jpg" - ) - fileExtension2 = create_fileExtension( - id=2, - extension="png" - ) + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") # Make a GET request to retrieve the fileExtension response = self.client.get(reverse("fileExtension-list"), follow=True) @@ -107,27 +93,24 @@ def test_multiple_fileExtension(self): # match the created fileExtension retrieved_fileExtension1, retrieved_fileExtension2 = content_json self.assertEqual( - retrieved_fileExtension1["extension"], fileExtension1.extension) + retrieved_fileExtension1["extension"], fileExtension1.extension + ) self.assertEqual( - retrieved_fileExtension2["extension"], fileExtension2.extension) + retrieved_fileExtension2["extension"], fileExtension2.extension + ) def test_fileExtension_detail_view(self): """ Able to retrieve details of a single fileExtension. """ # Create an fileExtension for testing. - fileExtension = create_fileExtension( - id=3, - extension="zip" - ) + fileExtension = create_fileExtension(id=3, extension="zip") # Make a GET request to retrieve the fileExtension details response = self.client.get( - reverse( - "fileExtension-detail", - args=[str(fileExtension.id)]), - follow=True) + reverse("fileExtension-detail", args=[str(fileExtension.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -148,8 +131,7 @@ def test_no_checks(self): """ Able to retrieve no Checks before publishing it. """ - response_root = self.client.get( - reverse("check-list"), follow=True) + response_root = self.client.get(reverse("check-list"), follow=True) self.assertEqual(response_root.status_code, 200) self.assertEqual(response_root.accepted_media_type, "application/json") content_json = json.loads(response_root.content.decode("utf-8")) @@ -167,8 +149,8 @@ def test_checks_exists(self): checks = create_checks( id=5, allowed_file_extensions=[fileExtension1, fileExtension4], - forbidden_file_extensions=[fileExtension2, fileExtension3] - ) + forbidden_file_extensions=[fileExtension2, fileExtension3], + ) # Make a GET request to retrieve the Checks response = self.client.get(reverse("check-list"), follow=True) @@ -189,23 +171,25 @@ def test_checks_exists(self): # Assert the file extensions of the retrieved # Checks match the created file extensions - retrieved_allowed_file_extensions = retrieved_checks[ - "allowed_file_extensions"] + retrieved_allowed_file_extensions = retrieved_checks["allowed_file_extensions"] self.assertEqual(len(retrieved_allowed_file_extensions), 2) self.assertEqual( - retrieved_allowed_file_extensions[0]["extension"], - fileExtension1.extension) + retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + ) self.assertEqual( - retrieved_allowed_file_extensions[1]["extension"], - fileExtension4.extension) + retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + ) retrieved_forbidden_file_extensions = retrieved_checks[ - "forbidden_file_extensions"] + "forbidden_file_extensions" + ] self.assertEqual(len(retrieved_forbidden_file_extensions), 2) self.assertEqual( retrieved_forbidden_file_extensions[0]["extension"], - fileExtension2.extension) + fileExtension2.extension, + ) self.assertEqual( retrieved_forbidden_file_extensions[1]["extension"], - fileExtension3.extension) + fileExtension3.extension, + ) diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index df05eb90..97de9259 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -10,14 +10,7 @@ from ..models.project import Project -def create_project( - name, - description, - visible, - archived, - days, - course -): +def create_project(name, description, visible, archived, days, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timezone.timedelta(days=days) @@ -27,75 +20,59 @@ def create_project( visible=visible, archived=archived, deadline=deadline, - course=course + course=course, ) -def create_student( - id, - first_name, - last_name, - email - ): +def create_student(id, first_name, last_name, email): """ Create a student with the given arguments. """ username = f"{first_name}_{last_name}" student = Student.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) return student -def create_assistant( - id, - first_name, - last_name, - email - ): +def create_assistant(id, first_name, last_name, email): """ Create a assistant with the given arguments. """ username = f"{first_name}_{last_name}" assistant = Assistant.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) return assistant -def create_teacher( - id, - first_name, - last_name, - email - ): +def create_teacher(id, first_name, last_name, email): """ Create a teacher with the given arguments. """ username = f"{first_name}_{last_name}" teacher = Teacher.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) return teacher -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -103,7 +80,7 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) @@ -125,7 +102,7 @@ def test_course_exists(self): course = create_course( name="Introduction to Computer Science", academic_startyear=2022, - description="An introductory course on computer science." + description="An introductory course on computer science.", ) response = self.client.get(reverse("course-list"), follow=True) @@ -140,7 +117,8 @@ def test_course_exists(self): retrieved_course = content_json[0] self.assertEqual(retrieved_course["name"], course.name) self.assertEqual( - retrieved_course["academic_startyear"], course.academic_startyear) + retrieved_course["academic_startyear"], course.academic_startyear + ) self.assertEqual(retrieved_course["description"], course.description) def test_multiple_courses(self): @@ -150,12 +128,12 @@ def test_multiple_courses(self): course1 = create_course( name="Mathematics 101", academic_startyear=2022, - description="A basic mathematics course." + description="A basic mathematics course.", ) course2 = create_course( name="Physics 101", academic_startyear=2022, - description="An introductory physics course." + description="An introductory physics course.", ) response = self.client.get(reverse("course-list"), follow=True) @@ -170,14 +148,14 @@ def test_multiple_courses(self): retrieved_course1, retrieved_course2 = content_json self.assertEqual(retrieved_course1["name"], course1.name) self.assertEqual( - retrieved_course1["academic_startyear"], - course1.academic_startyear) + retrieved_course1["academic_startyear"], course1.academic_startyear + ) self.assertEqual(retrieved_course1["description"], course1.description) self.assertEqual(retrieved_course2["name"], course2.name) self.assertEqual( - retrieved_course2["academic_startyear"], - course2.academic_startyear) + retrieved_course2["academic_startyear"], course2.academic_startyear + ) self.assertEqual(retrieved_course2["description"], course2.description) def test_course_detail_view(self): @@ -187,11 +165,12 @@ def test_course_detail_view(self): course = create_course( name="Chemistry 101", academic_startyear=2022, - description="An introductory chemistry course." + description="An introductory chemistry course.", ) response = self.client.get( - reverse("course-detail", args=[str(course.id)]), follow=True) + reverse("course-detail", args=[str(course.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -199,8 +178,7 @@ def test_course_detail_view(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) def test_course_teachers(self): @@ -211,26 +189,24 @@ def test_course_teachers(self): id=5, first_name="Simon", last_name="Mignolet", - email="simon.mignolet@ugent.be" + email="simon.mignolet@ugent.be", ) teacher2 = create_teacher( - id=6, - first_name="Ronny", - last_name="Deila", - email="ronny.deila@brugge.be" + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" ) course = create_course( name="Chemistry 101", academic_startyear=2022, - description="An introductory chemistry course." + description="An introductory chemistry course.", ) course.teachers.add(teacher1) course.teachers.add(teacher2) response = self.client.get( - reverse("course-detail", args=[str(course.id)]), follow=True) + reverse("course-detail", args=[str(course.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -238,8 +214,7 @@ def test_course_teachers(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) response = self.client.get(content_json["teachers"], follow=True) @@ -276,26 +251,24 @@ def test_course_assistant(self): id=5, first_name="Simon", last_name="Mignolet", - email="simon.mignolet@ugent.be" + email="simon.mignolet@ugent.be", ) assistant2 = create_assistant( - id=6, - first_name="Ronny", - last_name="Deila", - email="ronny.deila@brugge.be" + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" ) course = create_course( name="Chemistry 101", academic_startyear=2022, - description="An introductory chemistry course." + description="An introductory chemistry course.", ) course.assistants.add(assistant1) course.assistants.add(assistant2) response = self.client.get( - reverse("course-detail", args=[str(course.id)]), follow=True) + reverse("course-detail", args=[str(course.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -303,8 +276,7 @@ def test_course_assistant(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) response = self.client.get(content_json["assistants"], follow=True) @@ -341,26 +313,24 @@ def test_course_student(self): id=5, first_name="Simon", last_name="Mignolet", - email="simon.mignolet@ugent.be" + email="simon.mignolet@ugent.be", ) student2 = create_student( - id=6, - first_name="Ronny", - last_name="Deila", - email="ronny.deila@brugge.be" + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" ) course = create_course( name="Chemistry 101", academic_startyear=2022, - description="An introductory chemistry course." + description="An introductory chemistry course.", ) course.students.add(student1) course.students.add(student2) response = self.client.get( - reverse("course-detail", args=[str(course.id)]), follow=True) + reverse("course-detail", args=[str(course.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -368,8 +338,7 @@ def test_course_student(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) response = self.client.get(content_json["students"], follow=True) @@ -405,7 +374,7 @@ def test_course_project(self): course = create_course( name="Chemistry 101", academic_startyear=2022, - description="An introductory chemistry course." + description="An introductory chemistry course.", ) project1 = create_project( @@ -414,7 +383,7 @@ def test_course_project(self): visible=True, archived=False, days=50, - course=course + course=course, ) project2 = create_project( @@ -423,11 +392,12 @@ def test_course_project(self): visible=True, archived=False, days=50, - course=course + course=course, ) response = self.client.get( - reverse("course-detail", args=[str(course.id)]), follow=True) + reverse("course-detail", args=[str(course.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -435,8 +405,7 @@ def test_course_project(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) response = self.client.get(content_json["projects"], follow=True) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index e0320a44..dafbc1a8 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -9,8 +9,7 @@ from ..models.course import Course -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -18,7 +17,7 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) @@ -26,10 +25,7 @@ def create_project(name, description, days, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, - description=description, - deadline=deadline, - course=course + name=name, description=description, deadline=deadline, course=course ) @@ -41,7 +37,7 @@ def create_student(id, first_name, last_name, email): first_name=first_name, last_name=last_name, username=username, - email=email + email=email, ) @@ -61,23 +57,14 @@ def test_no_groups(self): def test_group_exists(self): """Able to retrieve a single group after creating it.""" - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, - course=course - ) + name="Project 1", description="Description 1", days=7, course=course + ) student = create_student( - id=1, - first_name="John", - last_name="Doe", - email="john.doe@example.com" + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" ) group = create_group(project=project, score=10) @@ -91,8 +78,8 @@ def test_group_exists(self): retrieved_group = content_json[0] expected_project_url = "http://testserver" + reverse( - "project-detail", args=[str(project.id)] - ) + "project-detail", args=[str(project.id)] + ) self.assertEqual(retrieved_group["project"], expected_project_url) self.assertEqual(int(retrieved_group["id"]), group.id) @@ -100,34 +87,21 @@ def test_group_exists(self): def test_multiple_groups(self): """Able to retrieve multiple groups after creating them.""" - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project1 = create_project( - name="Project 1", - description="Description 1", - days=7, course=course - ) + name="Project 1", description="Description 1", days=7, course=course + ) project2 = create_project( - name="Project 2", - description="Description 2", - days=7, course=course - ) + name="Project 2", description="Description 2", days=7, course=course + ) student1 = create_student( - id=2, - first_name="Bart", - last_name="Rex", - email="bart.rex@example.com" - ) + id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com" + ) student2 = create_student( - id=3, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) + id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) group1 = create_group(project=project1, score=10) group1.students.add(student1) @@ -143,9 +117,11 @@ def test_multiple_groups(self): retrieved_group1, retrieved_group2 = content_json expected_project_url1 = "http://testserver" + reverse( - "project-detail", args=[str(project1.id)]) + "project-detail", args=[str(project1.id)] + ) expected_project_url2 = "http://testserver" + reverse( - "project-detail", args=[str(project2.id)]) + "project-detail", args=[str(project2.id)] + ) self.assertEqual(retrieved_group1["project"], expected_project_url1) self.assertEqual(int(retrieved_group1["id"]), group1.id) @@ -157,35 +133,29 @@ def test_multiple_groups(self): def test_group_detail_view(self): """Able to retrieve details of a single group.""" - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, course=course - ) + name="Project 1", description="Description 1", days=7, course=course + ) student = create_student( - id=5, - first_name="John", - last_name="Doe", - - email="john.doe@example.com") + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) group = create_group(project=project, score=10) group.students.add(student) response = self.client.get( - reverse("group-detail", args=[str(group.id)]), follow=True) + reverse("group-detail", args=[str(group.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) expected_project_url = "http://testserver" + reverse( - "project-detail", args=[str(project.id)]) + "project-detail", args=[str(project.id)] + ) self.assertEqual(int(content_json["id"]), group.id) self.assertEqual(content_json["project"], expected_project_url) @@ -193,28 +163,21 @@ def test_group_detail_view(self): def test_group_project(self): """Able to retrieve details of a single group.""" - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, course=course - ) + name="Project 1", description="Description 1", days=7, course=course + ) student = create_student( - id=5, - first_name="John", - last_name="Doe", - - email="john.doe@example.com") + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) group = create_group(project=project, score=10) group.students.add(student) response = self.client.get( - reverse("group-detail", args=[str(group.id)]), follow=True) + reverse("group-detail", args=[str(group.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") @@ -235,8 +198,8 @@ def test_group_project(self): content_json = json.loads(response.content.decode("utf-8")) expected_course_url = "http://testserver" + reverse( - "course-detail", args=[str(course.id)] - ) + "course-detail", args=[str(course.id)] + ) self.assertEqual(content_json["name"], project.name) self.assertEqual(content_json["description"], project.description) @@ -246,34 +209,26 @@ def test_group_project(self): def test_group_students(self): """Able to retrieve students details of a group.""" - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, course=course - ) + name="Project 1", description="Description 1", days=7, course=course + ) student1 = create_student( - id=5, - first_name="John", - last_name="Doe", - email="john.doe@example.com") + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) student2 = create_student( - id=6, - first_name="kom", - last_name="mor_up", - email="kom.mor_up@example.com") + id=6, first_name="kom", last_name="mor_up", email="kom.mor_up@example.com" + ) group = create_group(project=project, score=10) group.students.add(student1) group.students.add(student2) response = self.client.get( - reverse("group-detail", args=[str(group.id)]), follow=True) + reverse("group-detail", args=[str(group.id)]), follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b4e4058d..72f3fbef 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -12,23 +12,20 @@ def create_course(id, name, academic_startyear): Create a Course with the given arguments. """ return Course.objects.create( - id=id, name=name, academic_startyear=academic_startyear) + id=id, name=name, academic_startyear=academic_startyear + ) def create_fileExtension(id, extension): """ Create a FileExtension with the given arguments. """ - return FileExtension.objects.create( - id=id, - extension=extension - ) + return FileExtension.objects.create(id=id, extension=extension) def create_checks( - id=None, - allowed_file_extensions=None, - forbidden_file_extensions=None): + id=None, allowed_file_extensions=None, forbidden_file_extensions=None +): """Create a Checks with the given arguments.""" if id is None and allowed_file_extensions is None: # extra if to make line shorter @@ -46,15 +43,7 @@ def create_checks( return check -def create_project( - name, - description, - visible, - archived, - days, - checks, - course -): +def create_project(name, description, visible, archived, days, checks, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timezone.timedelta(days=days) @@ -65,7 +54,7 @@ def create_project( archived=archived, deadline=deadline, checks=checks, - course=course + course=course, ) @@ -74,12 +63,16 @@ def test_toggle_visible(self): """ toggle the visible state of a project. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() past_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=-10, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + checks=checks, + course=course, ) self.assertIs(past_project.visible, True) past_project.toggle_visible() @@ -91,12 +84,16 @@ def test_toggle_archived(self): """ toggle the archived state of a project. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() past_project = create_project( - name="test", description="descr", visible=True, archived=True, - days=-10, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=True, + days=-10, + checks=checks, + course=course, ) self.assertIs(past_project.archived, True) @@ -110,12 +107,16 @@ def test_deadline_approaching_in_with_past_Project(self): deadline_approaching_in() returns False for Projects whose Deadline is in the past. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() past_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=-10, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + checks=checks, + course=course, ) self.assertIs(past_project.deadline_approaching_in(), False) @@ -124,12 +125,16 @@ def test_deadline_approaching_in_with_future_Project_within_time(self): deadline_approaching_in() returns True for Projects whose Deadline is in the timerange given. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() future_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=6, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=6, + checks=checks, + course=course, ) self.assertIs(future_project.deadline_approaching_in(days=7), True) @@ -138,12 +143,16 @@ def test_deadline_approaching_in_with_future_Project_not_within_time(self): deadline_approaching_in() returns False for Projects whose Deadline is out of the timerange given. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() future_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=8, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=8, + checks=checks, + course=course, ) self.assertIs(future_project.deadline_approaching_in(days=7), False) @@ -152,12 +161,16 @@ def test_deadline_passed_with_future_Project(self): deadline_passed() returns False for Projects whose Deadline is not passed. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() future_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=1, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=1, + checks=checks, + course=course, ) self.assertIs(future_project.deadline_passed(), False) @@ -166,12 +179,16 @@ def test_deadline_passed_with_past_Project(self): deadline_passed() returns True for Projects whose Deadline is passed. """ - course = create_course( - id=3, name="test course", academic_startyear=2024) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() past_project = create_project( - name="test", description="descr", visible=True, archived=False, - days=-1, checks=checks, course=course + name="test", + description="descr", + visible=True, + archived=False, + days=-1, + checks=checks, + course=course, ) self.assertIs(past_project.deadline_passed(), True) @@ -188,11 +205,7 @@ def test_project_exists(self): Able to retrieve a single project after creating it. """ - course = create_course( - id=3, - name="test course", - academic_startyear=2024 - ) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() project = create_project( name="test project", @@ -201,8 +214,8 @@ def test_project_exists(self): archived=False, days=7, checks=checks, - course=course - ) + course=course, + ) response = self.client.get(reverse("project-list"), follow=True) @@ -216,12 +229,12 @@ def test_project_exists(self): retrieved_project = content_json[0] expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) + "check-detail", args=[str(checks.id)] + ) expected_course_url = "http://testserver" + reverse( - "course-detail", args=[str(course.id)] - ) + "course-detail", args=[str(course.id)] + ) self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -234,11 +247,7 @@ def test_multiple_project(self): """ Able to retrieve multiple projects after creating it. """ - course = create_course( - id=3, - name="test course", - academic_startyear=2024 - ) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() project = create_project( name="test project", @@ -247,8 +256,8 @@ def test_multiple_project(self): archived=False, days=7, checks=checks, - course=course - ) + course=course, + ) project2 = create_project( name="test project2", @@ -257,8 +266,8 @@ def test_multiple_project(self): archived=False, days=7, checks=checks, - course=course - ) + course=course, + ) response = self.client.get(reverse("project-list"), follow=True) @@ -272,12 +281,12 @@ def test_multiple_project(self): retrieved_project = content_json[0] expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) + "check-detail", args=[str(checks.id)] + ) expected_course_url = "http://testserver" + reverse( - "course-detail", args=[str(course.id)] - ) + "course-detail", args=[str(course.id)] + ) self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -289,16 +298,15 @@ def test_multiple_project(self): retrieved_project = content_json[1] expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) + "check-detail", args=[str(checks.id)] + ) expected_course_url = "http://testserver" + reverse( - "course-detail", args=[str(course.id)] - ) + "course-detail", args=[str(course.id)] + ) self.assertEqual(retrieved_project["name"], project2.name) - self.assertEqual( - retrieved_project["description"], project2.description) + self.assertEqual(retrieved_project["description"], project2.description) self.assertEqual(retrieved_project["visible"], project2.visible) self.assertEqual(retrieved_project["archived"], project2.archived) self.assertEqual(retrieved_project["checks"], expected_checks_url) @@ -309,11 +317,7 @@ def test_project_course(self): Able to retrieve a course of a project after creating it. """ - course = create_course( - id=3, - name="test course", - academic_startyear=2024 - ) + course = create_course(id=3, name="test course", academic_startyear=2024) checks = create_checks() project = create_project( name="test project", @@ -322,8 +326,8 @@ def test_project_course(self): archived=False, days=7, checks=checks, - course=course - ) + course=course, + ) response = self.client.get(reverse("project-list"), follow=True) @@ -337,8 +341,8 @@ def test_project_course(self): retrieved_project = content_json[0] expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) + "check-detail", args=[str(checks.id)] + ) self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -358,8 +362,7 @@ def test_project_course(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["name"], course.name) - self.assertEqual( - content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) def test_project_checks(self): @@ -367,11 +370,7 @@ def test_project_checks(self): Able to retrieve a check of a project after creating it. """ - course = create_course( - id=3, - name="test course", - academic_startyear=2024 - ) + course = create_course(id=3, name="test course", academic_startyear=2024) fileExtension1 = create_fileExtension(id=1, extension="jpg") fileExtension2 = create_fileExtension(id=2, extension="png") fileExtension3 = create_fileExtension(id=3, extension="tar") @@ -379,8 +378,8 @@ def test_project_checks(self): checks = create_checks( id=5, allowed_file_extensions=[fileExtension1, fileExtension4], - forbidden_file_extensions=[fileExtension2, fileExtension3] - ) + forbidden_file_extensions=[fileExtension2, fileExtension3], + ) project = create_project( name="test project", description="test description", @@ -388,8 +387,8 @@ def test_project_checks(self): archived=False, days=7, checks=checks, - course=course - ) + course=course, + ) response = self.client.get(reverse("project-list"), follow=True) @@ -403,8 +402,8 @@ def test_project_checks(self): retrieved_project = content_json[0] expected_course_url = "http://testserver" + reverse( - "course-detail", args=[str(course.id)] - ) + "course-detail", args=[str(course.id)] + ) self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -427,23 +426,23 @@ def test_project_checks(self): # Assert the file extensions of the retrieved # Checks match the created file extensions - retrieved_allowed_file_extensions = content_json[ - "allowed_file_extensions"] + retrieved_allowed_file_extensions = content_json["allowed_file_extensions"] self.assertEqual(len(retrieved_allowed_file_extensions), 2) self.assertEqual( - retrieved_allowed_file_extensions[0]["extension"], - fileExtension1.extension) + retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + ) self.assertEqual( - retrieved_allowed_file_extensions[1]["extension"], - fileExtension4.extension) + retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + ) - retrieved_forbidden_file_extensions = content_json[ - "forbidden_file_extensions"] + retrieved_forbidden_file_extensions = content_json["forbidden_file_extensions"] self.assertEqual(len(retrieved_forbidden_file_extensions), 2) self.assertEqual( retrieved_forbidden_file_extensions[0]["extension"], - fileExtension2.extension) + fileExtension2.extension, + ) self.assertEqual( retrieved_forbidden_file_extensions[1]["extension"], - fileExtension3.extension) + fileExtension3.extension, + ) diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index df03625d..c43a89e7 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -8,8 +8,7 @@ from authentication.models import Faculty -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -17,37 +16,28 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) def create_faculty(name): """Create a Faculty with the given arguments.""" - return Faculty.objects.create( - name=name - ) + return Faculty.objects.create(name=name) -def create_student( - id, - first_name, - last_name, - email, - faculty=None, - courses=None - ): +def create_student(id, first_name, last_name, email, faculty=None, courses=None): """ Create a student with the given arguments. """ username = f"{first_name}_{last_name}" student = Student.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) if faculty is not None: for fac in faculty: @@ -80,10 +70,7 @@ def test_student_exists(self): Able to retrieve a single student after creating it. """ student = create_student( - id=3, - first_name="John", - last_name="Doe", - email="john.doe@example.com" + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" ) # Make a GET request to retrieve the student @@ -104,8 +91,7 @@ def test_student_exists(self): # Assert the details of the retrieved student match the created student retrieved_student = content_json[0] self.assertEqual(int(retrieved_student["id"]), student.id) - self.assertEqual( - retrieved_student["first_name"], student.first_name) + self.assertEqual(retrieved_student["first_name"], student.first_name) self.assertEqual(retrieved_student["last_name"], student.last_name) self.assertEqual(retrieved_student["email"], student.email) @@ -115,17 +101,11 @@ def test_multiple_students(self): """ # Create multiple assistant student1 = create_student( - id=1, - first_name="Johny", - last_name="Doeg", - email="john.doe@example.com" - ) + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) student2 = create_student( - id=2, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) # Make a GET request to retrieve the student response = self.client.get(reverse("student-list"), follow=True) @@ -146,17 +126,13 @@ def test_multiple_students(self): # match the created students retrieved_student1, retrieved_student2 = content_json self.assertEqual(int(retrieved_student1["id"]), student1.id) - self.assertEqual( - retrieved_student1["first_name"], student1.first_name) - self.assertEqual( - retrieved_student1["last_name"], student1.last_name) + self.assertEqual(retrieved_student1["first_name"], student1.first_name) + self.assertEqual(retrieved_student1["last_name"], student1.last_name) self.assertEqual(retrieved_student1["email"], student1.email) self.assertEqual(int(retrieved_student2["id"]), student2.id) - self.assertEqual( - retrieved_student2["first_name"], student2.first_name) - self.assertEqual( - retrieved_student2["last_name"], student2.last_name) + self.assertEqual(retrieved_student2["first_name"], student2.first_name) + self.assertEqual(retrieved_student2["last_name"], student2.last_name) self.assertEqual(retrieved_student2["email"], student2.email) def test_student_detail_view(self): @@ -165,15 +141,13 @@ def test_student_detail_view(self): """ # Create an student for testing with the name "Bob Peeters" student = create_student( - id=5, - first_name="Bob", - last_name="Peeters", - email="bob.peeters@example.com" - ) + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) # Make a GET request to retrieve the student details response = self.client.get( - reverse("student-detail", args=[str(student.id)]), follow=True) + reverse("student-detail", args=[str(student.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -201,12 +175,13 @@ def test_student_faculty(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - faculty=[faculty] - ) + faculty=[faculty], + ) # Make a GET request to retrieve the student details response = self.client.get( - reverse("student-detail", args=[str(student.id)]), follow=True) + reverse("student-detail", args=[str(student.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -244,12 +219,12 @@ def test_student_courses(self): course1 = create_course( name="Introduction to Computer Science", academic_startyear=2022, - description="An introductory course on computer science." + description="An introductory course on computer science.", ) course2 = create_course( name="Intermediate to Computer Science", academic_startyear=2023, - description="An second course on computer science." + description="An second course on computer science.", ) student = create_student( @@ -257,12 +232,13 @@ def test_student_courses(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - courses=[course1, course2] - ) + courses=[course1, course2], + ) # Make a GET request to retrieve the student details response = self.client.get( - reverse("student-detail", args=[str(student.id)]), follow=True) + reverse("student-detail", args=[str(student.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -297,13 +273,11 @@ def test_student_courses(self): content = content_json[0] self.assertEqual(int(content["id"]), course1.id) self.assertEqual(content["name"], course1.name) - self.assertEqual( - int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) self.assertEqual(content["description"], course1.description) content = content_json[1] self.assertEqual(int(content["id"]), course2.id) self.assertEqual(content["name"], course2.name) - self.assertEqual( - int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) self.assertEqual(content["description"], course2.description) diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submision.py index 119415ef..51b571ea 100644 --- a/backend/api/tests/test_submision.py +++ b/backend/api/tests/test_submision.py @@ -9,8 +9,7 @@ from ..models.course import Course -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -18,7 +17,7 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) @@ -26,10 +25,7 @@ def create_project(name, description, days, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, - description=description, - deadline=deadline, - course=course + name=name, description=description, deadline=deadline, course=course ) @@ -41,18 +37,13 @@ def create_group(project, score): def create_submission(group, submission_number): """Create an Submission with the given arguments.""" return Submission.objects.create( - group=group, - submission_number=submission_number, - submission_time=timezone.now() + group=group, submission_number=submission_number, submission_time=timezone.now() ) def create_submissionFile(submission, file): """Create an SubmissionFile with the given arguments.""" - return SubmissionFile.objects.create( - submission=submission, - file=file - ) + return SubmissionFile.objects.create(submission=submission, file=file) class SubmissionModelTests(TestCase): @@ -61,8 +52,7 @@ def test_no_submission(self): able to retrieve no submission before publishing it. """ - response_root = self.client.get( - reverse("submission-list"), follow=True) + response_root = self.client.get(reverse("submission-list"), follow=True) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") @@ -75,20 +65,12 @@ def test_submission_exists(self): """ Able to retrieve a single submission after creating it. """ - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, - course=course - ) - group = create_group(project=project, score=10) - submission = create_submission( - group=group, submission_number=1 + name="Project 1", description="Description 1", days=7, course=course ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) # Make a GET request to retrieve the submission response = self.client.get(reverse("submission-list"), follow=True) @@ -109,37 +91,26 @@ def test_submission_exists(self): # match the created submission retrieved_submission = content_json[0] expected_group_url = "http://testserver" + reverse( - "group-detail", args=[str(group.id)] - ) + "group-detail", args=[str(group.id)] + ) self.assertEqual(int(retrieved_submission["id"]), submission.id) self.assertEqual( - int(retrieved_submission["submission_number"]), - submission.submission_number - ) + int(retrieved_submission["submission_number"]), submission.submission_number + ) self.assertEqual(retrieved_submission["group"], expected_group_url) def test_multiple_submission_exists(self): """ Able to retrieve multiple submissions after creating them. """ - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, - course=course - ) - group = create_group(project=project, score=10) - submission1 = create_submission( - group=group, submission_number=1 + name="Project 1", description="Description 1", days=7, course=course ) + group = create_group(project=project, score=10) + submission1 = create_submission(group=group, submission_number=1) - submission2 = create_submission( - group=group, submission_number=2 - ) + submission2 = create_submission(group=group, submission_number=2) # Make a GET request to retrieve the submission response = self.client.get(reverse("submission-list"), follow=True) @@ -160,49 +131,41 @@ def test_multiple_submission_exists(self): # match the created submission retrieved_submission = content_json[0] expected_group_url = "http://testserver" + reverse( - "group-detail", args=[str(group.id)] - ) + "group-detail", args=[str(group.id)] + ) self.assertEqual(int(retrieved_submission["id"]), submission1.id) self.assertEqual( int(retrieved_submission["submission_number"]), - submission1.submission_number - ) + submission1.submission_number, + ) self.assertEqual(retrieved_submission["group"], expected_group_url) retrieved_submission = content_json[1] expected_group_url = "http://testserver" + reverse( - "group-detail", args=[str(group.id)] - ) + "group-detail", args=[str(group.id)] + ) self.assertEqual(int(retrieved_submission["id"]), submission2.id) self.assertEqual( int(retrieved_submission["submission_number"]), - submission2.submission_number - ) + submission2.submission_number, + ) self.assertEqual(retrieved_submission["group"], expected_group_url) def test_submission_detail_view(self): """ Able to retrieve details of a single submission. """ - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, - course=course - ) - group = create_group(project=project, score=10) - submission = create_submission( - group=group, submission_number=1 + name="Project 1", description="Description 1", days=7, course=course ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) # Make a GET request to retrieve the submission response = self.client.get( - reverse("submission-detail", args=[str(submission.id)]), - follow=True) + reverse("submission-detail", args=[str(submission.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -217,38 +180,29 @@ def test_submission_detail_view(self): # match the created submission retrieved_submission = content_json expected_group_url = "http://testserver" + reverse( - "group-detail", args=[str(group.id)] - ) + "group-detail", args=[str(group.id)] + ) self.assertEqual(int(retrieved_submission["id"]), submission.id) self.assertEqual( - int(retrieved_submission["submission_number"]), - submission.submission_number - ) + int(retrieved_submission["submission_number"]), submission.submission_number + ) self.assertEqual(retrieved_submission["group"], expected_group_url) def test_submission_group(self): """ Able to retrieve group of a single submission. """ - course = create_course( - name="sel2", - academic_startyear=2023 - ) + course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", - description="Description 1", - days=7, - course=course - ) - group = create_group(project=project, score=10) - submission = create_submission( - group=group, submission_number=1 + name="Project 1", description="Description 1", days=7, course=course ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) # Make a GET request to retrieve the submission response = self.client.get( - reverse("submission-detail", args=[str(submission.id)]), - follow=True) + reverse("submission-detail", args=[str(submission.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -264,9 +218,8 @@ def test_submission_group(self): retrieved_submission = content_json self.assertEqual(int(retrieved_submission["id"]), submission.id) self.assertEqual( - int(retrieved_submission["submission_number"]), - submission.submission_number - ) + int(retrieved_submission["submission_number"]), submission.submission_number + ) response = self.client.get(content_json["group"], follow=True) @@ -280,7 +233,8 @@ def test_submission_group(self): content_json = json.loads(response.content.decode("utf-8")) expected_project_url = "http://testserver" + reverse( - "project-detail", args=[str(project.id)]) + "project-detail", args=[str(project.id)] + ) self.assertEqual(int(content_json["id"]), group.id) self.assertEqual(content_json["project"], expected_project_url) diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py index 2e3d8c6e..dc07da70 100644 --- a/backend/api/tests/test_teacher.py +++ b/backend/api/tests/test_teacher.py @@ -8,8 +8,7 @@ from authentication.models import Faculty -def create_course(name, academic_startyear, description=None, - parent_course=None): +def create_course(name, academic_startyear, description=None, parent_course=None): """ Create a Course with the given arguments. """ @@ -17,37 +16,28 @@ def create_course(name, academic_startyear, description=None, name=name, academic_startyear=academic_startyear, description=description, - parent_course=parent_course + parent_course=parent_course, ) def create_faculty(name): """Create a Faculty with the given arguments.""" - return Faculty.objects.create( - name=name - ) + return Faculty.objects.create(name=name) -def create_teacher( - id, - first_name, - last_name, - email, - faculty=None, - courses=None - ): +def create_teacher(id, first_name, last_name, email, faculty=None, courses=None): """ Create a teacher with the given arguments. """ username = f"{first_name}_{last_name}" teacher = Teacher.objects.create( - id=id, - first_name=first_name, - last_name=last_name, - username=username, - email=email, - create_time=timezone.now() - ) + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) if faculty is not None: for fac in faculty: @@ -80,10 +70,7 @@ def test_teacher_exists(self): Able to retrieve a single teacher after creating it. """ teacher = create_teacher( - id=3, - first_name="John", - last_name="Doe", - email="john.doe@example.com" + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" ) # Make a GET request to retrieve the teacher @@ -104,8 +91,7 @@ def test_teacher_exists(self): # Assert the details of the retrieved teacher match the created teacher retrieved_teacher = content_json[0] self.assertEqual(int(retrieved_teacher["id"]), teacher.id) - self.assertEqual( - retrieved_teacher["first_name"], teacher.first_name) + self.assertEqual(retrieved_teacher["first_name"], teacher.first_name) self.assertEqual(retrieved_teacher["last_name"], teacher.last_name) self.assertEqual(retrieved_teacher["email"], teacher.email) @@ -115,17 +101,11 @@ def test_multiple_teachers(self): """ # Create multiple assistant teacher1 = create_teacher( - id=1, - first_name="Johny", - last_name="Doeg", - email="john.doe@example.com" - ) + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) teacher2 = create_teacher( - id=2, - first_name="Jane", - last_name="Doe", - email="jane.doe@example.com" - ) + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) # Make a GET request to retrieve the teacher response = self.client.get(reverse("teacher-list"), follow=True) @@ -145,17 +125,13 @@ def test_multiple_teachers(self): # Assert the details of the retrieved teacher match the created teacher retrieved_teacher1, retrieved_teacher2 = content_json self.assertEqual(int(retrieved_teacher1["id"]), teacher1.id) - self.assertEqual( - retrieved_teacher1["first_name"], teacher1.first_name) - self.assertEqual( - retrieved_teacher1["last_name"], teacher1.last_name) + self.assertEqual(retrieved_teacher1["first_name"], teacher1.first_name) + self.assertEqual(retrieved_teacher1["last_name"], teacher1.last_name) self.assertEqual(retrieved_teacher1["email"], teacher1.email) self.assertEqual(int(retrieved_teacher2["id"]), teacher2.id) - self.assertEqual( - retrieved_teacher2["first_name"], teacher2.first_name) - self.assertEqual( - retrieved_teacher2["last_name"], teacher2.last_name) + self.assertEqual(retrieved_teacher2["first_name"], teacher2.first_name) + self.assertEqual(retrieved_teacher2["last_name"], teacher2.last_name) self.assertEqual(retrieved_teacher2["email"], teacher2.email) def test_teacher_detail_view(self): @@ -164,15 +140,13 @@ def test_teacher_detail_view(self): """ # Create an teacher for testing with the name "Bob Peeters" teacher = create_teacher( - id=5, - first_name="Bob", - last_name="Peeters", - email="bob.peeters@example.com" - ) + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) # Make a GET request to retrieve the teacher details response = self.client.get( - reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -200,12 +174,13 @@ def test_teacher_faculty(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - faculty=[faculty] - ) + faculty=[faculty], + ) # Make a GET request to retrieve the teacher details response = self.client.get( - reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -243,12 +218,12 @@ def test_teacher_courses(self): course1 = create_course( name="Introduction to Computer Science", academic_startyear=2022, - description="An introductory course on computer science." + description="An introductory course on computer science.", ) course2 = create_course( name="Intermediate to Computer Science", academic_startyear=2023, - description="An second course on computer science." + description="An second course on computer science.", ) teacher = create_teacher( @@ -256,12 +231,13 @@ def test_teacher_courses(self): first_name="Bob", last_name="Peeters", email="bob.peeters@example.com", - courses=[course1, course2] - ) + courses=[course1, course2], + ) # Make a GET request to retrieve the teacher details response = self.client.get( - reverse("teacher-detail", args=[str(teacher.id)]), follow=True) + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -296,13 +272,11 @@ def test_teacher_courses(self): content = content_json[0] self.assertEqual(int(content["id"]), course1.id) self.assertEqual(content["name"], course1.name) - self.assertEqual( - int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) self.assertEqual(content["description"], course1.description) content = content_json[1] self.assertEqual(int(content["id"]), course2.id) self.assertEqual(content["name"], course2.name) - self.assertEqual( - int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) self.assertEqual(content["description"], course2.description) diff --git a/backend/api/urls.py b/backend/api/urls.py index c95373cc..450301ca 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -12,51 +12,20 @@ from rest_framework.routers import DefaultRouter router = DefaultRouter() -router.register( - r'teachers', - teacher_view.TeacherViewSet, - basename='teacher') -router.register( - r'admins', - admin_view.AdminViewSet, - basename='admin') -router.register( - r'assistants', - assistant_view.AssistantViewSet, - basename='assistant') -router.register( - r'students', - student_view.StudentViewSet, - basename='student') -router.register( - r'projects', - project_view.ProjectViewSet, - basename='project') -router.register( - r'groups', - group_view.GroupViewSet, - basename='group') -router.register( - r'courses', - course_view.CourseViewSet, - basename='course') -router.register( - r'submissions', - submision_view.SubmissionViewSet, - basename='submission') -router.register( - r'checks', - checks_view.ChecksViewSet, - basename='check') -router.register( - r'fileExtensions', - checks_view.FileExtensionViewSet, - basename='fileExtension') -router.register( - r'faculties', - faculty_view.facultyViewSet, - basename='faculty') +router.register(r"teachers", teacher_view.TeacherViewSet, basename="teacher") +router.register(r"admins", admin_view.AdminViewSet, basename="admin") +router.register(r"assistants", assistant_view.AssistantViewSet, basename="assistant") +router.register(r"students", student_view.StudentViewSet, basename="student") +router.register(r"projects", project_view.ProjectViewSet, basename="project") +router.register(r"groups", group_view.GroupViewSet, basename="group") +router.register(r"courses", course_view.CourseViewSet, basename="course") +router.register(r"submissions", submision_view.SubmissionViewSet, basename="submission") +router.register(r"checks", checks_view.ChecksViewSet, basename="check") +router.register( + r"fileExtensions", checks_view.FileExtensionViewSet, basename="fileExtension" +) +router.register(r"faculties", faculty_view.facultyViewSet, basename="faculty") urlpatterns = [ - path('', include(router.urls)), + path("", include(router.urls)), ] diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 3d7d8ba8..ea75fc8b 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -10,7 +10,7 @@ class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given assistant""" @@ -20,11 +20,13 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Assistant.DoesNotExist: # Invalid assistant ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Assistant not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, + data={"message": "Assistant not found"}, + ) diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 9d136f23..654eb1f1 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,8 +1,6 @@ from rest_framework import viewsets from ..models.checks import Checks, FileExtension -from ..serializers.checks_serializer import ( - ChecksSerializer, FileExtensionSerializer -) +from ..serializers.checks_serializer import ChecksSerializer, FileExtensionSerializer class ChecksViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 85012452..54b1fcf2 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -13,7 +13,7 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def teachers(self, request, pk=None): """Returns a list of teachers for the given course""" @@ -23,16 +23,17 @@ def teachers(self, request, pk=None): # Serialize the teacher objects serializer = TeacherSerializer( - teachers, many=True, context={'request': request} + teachers, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def assistants(self, request, pk=None): """Returns a list of assistants for the given course""" @@ -42,16 +43,17 @@ def assistants(self, request, pk=None): # Serialize the assistant objects serializer = AssistantSerializer( - assistants, many=True, context={'request': request} + assistants, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given course""" @@ -61,16 +63,17 @@ def students(self, request, pk=None): # Serialize the student objects serializer = StudentSerializer( - students, many=True, context={'request': request} + students, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def projects(self, request, pk=None): """Returns a list of projects for the given course""" @@ -80,11 +83,12 @@ def projects(self, request, pk=None): # Serialize the project objects serializer = ProjectSerializer( - projects, many=True, context={'request': request} + projects, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 809fb893..0402f198 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -10,7 +10,7 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given group""" @@ -20,11 +20,12 @@ def students(self, request, pk=None): # Serialize the student objects serializer = StudentSerializer( - students, many=True, context={'request': request} + students, many=True, context={"request": request} ) return Response(serializer.data) except Group.DoesNotExist: # Invalid group ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Group not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Group not found"} + ) diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index b5f87e82..4fe6f92c 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -11,7 +11,7 @@ class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given student""" @@ -21,16 +21,17 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Student.DoesNotExist: # Invalid student ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Student not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def groups(self, request, pk=None): """Returns a list of groups for the given student""" @@ -40,11 +41,12 @@ def groups(self, request, pk=None): # Serialize the group objects serializer = GroupSerializer( - groups, many=True, context={'request': request} + groups, many=True, context={"request": request} ) return Response(serializer.data) except Student.DoesNotExist: # Invalid student ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Student not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} + ) diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index 72c95e45..8e0de7ad 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,7 +1,8 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile from ..serializers.submision_serializer import ( - SubmissionSerializer, SubmissionFileSerializer + SubmissionSerializer, + SubmissionFileSerializer, ) diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 9f26361b..49038133 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -10,7 +10,7 @@ class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" @@ -20,11 +20,12 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Teacher.DoesNotExist: # Invalid teacher ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Teacher not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} + ) diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py index 8bab8df0..c65f1d28 100644 --- a/backend/authentication/apps.py +++ b/backend/authentication/apps.py @@ -2,5 +2,5 @@ class AuthenticationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'authentication' + default_auto_field = "django.db.models.BigAutoField" + name = "authentication" diff --git a/backend/authentication/cas/client.py b/backend/authentication/cas/client.py index e9133f75..8388c978 100644 --- a/backend/authentication/cas/client.py +++ b/backend/authentication/cas/client.py @@ -2,7 +2,5 @@ from ypovoli import settings client = CASClient( - server_url=settings.CAS_ENDPOINT, - service_url=settings.CAS_RESPONSE, - auth_prefix='' + server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE, auth_prefix="" ) diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index a86e2591..8895c9c4 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -5,39 +5,55 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), - ('username', models.CharField(max_length=12, unique=True)), - ('email', models.EmailField(max_length=254, unique=True)), - ('first_name', models.CharField(max_length=50)), - ('last_name', models.CharField(max_length=50)), - ('last_enrolled', models.IntegerField(default=1, null=True)), - ('create_time', models.DateTimeField(auto_now=True)), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "id", + models.CharField(max_length=12, primary_key=True, serialize=False), + ), + ("username", models.CharField(max_length=12, unique=True)), + ("email", models.EmailField(max_length=254, unique=True)), + ("first_name", models.CharField(max_length=50)), + ("last_name", models.CharField(max_length=50)), + ("last_enrolled", models.IntegerField(default=1, null=True)), + ("create_time", models.DateTimeField(auto_now=True)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Faculty', + name="Faculty", fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), - ('user', models.ManyToManyField(blank=True, related_name='users', to=settings.AUTH_USER_MODEL)), + ( + "name", + models.CharField(max_length=50, primary_key=True, serialize=False), + ), + ( + "user", + models.ManyToManyField( + blank=True, related_name="users", to=settings.AUTH_USER_MODEL + ), + ), ], ), migrations.AddField( - model_name='user', - name='faculty', - field=models.ManyToManyField(blank=True, related_name='faculties', to='authentication.faculty'), + model_name="user", + name="faculty", + field=models.ManyToManyField( + blank=True, related_name="faculties", to="authentication.faculty" + ), ), ] diff --git a/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py b/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py index 42ce1147..6323b90c 100644 --- a/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py +++ b/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py @@ -4,23 +4,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('authentication', '0001_initial'), + ("authentication", "0001_initial"), ] operations = [ migrations.RemoveField( - model_name='faculty', - name='user', + model_name="faculty", + name="user", ), migrations.RemoveField( - model_name='user', - name='faculty', + model_name="user", + name="faculty", ), migrations.AddField( - model_name='user', - name='faculties', - field=models.ManyToManyField(blank=True, related_name='users', to='authentication.faculty'), + model_name="user", + name="faculties", + field=models.ManyToManyField( + blank=True, related_name="users", to="authentication.faculty" + ), ), ] diff --git a/backend/authentication/migrations/0003_alter_user_create_time.py b/backend/authentication/migrations/0003_alter_user_create_time.py new file mode 100644 index 00000000..f65d50cf --- /dev/null +++ b/backend/authentication/migrations/0003_alter_user_create_time.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.2 on 2024-03-04 15:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentication", "0002_remove_faculty_user_remove_user_faculty_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="create_time", + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 0bf42d22..a9cce277 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -4,53 +4,30 @@ from django.db.models import CharField, EmailField, IntegerField, DateTimeField from django.contrib.auth.models import AbstractBaseUser + class User(AbstractBaseUser): """This model represents a single authenticatable user. It extends the built-in Django user model with CAS-specific attributes. """ """Model fields""" - password = None # We don't use passwords for our user model. - - id = CharField( - max_length=12, - primary_key=True - ) - - username = CharField( - max_length=12, - unique=True - ) - - email = EmailField( - null=False, - unique=True - ) - - first_name = CharField( - max_length=50, - null=False - ) - - last_name = CharField( - max_length=50, - null=False - ) - - faculties = models.ManyToManyField( - 'Faculty', - related_name='users', - blank=True - ) - - last_enrolled = IntegerField( - default=datetime.MINYEAR, - null=True - ) - - create_time = DateTimeField( - auto_now_add=True - ) + password = None # We don't use passwords for our user model. + + id = CharField(max_length=12, primary_key=True) + + username = CharField(max_length=12, unique=True) + + email = EmailField(null=False, unique=True) + + first_name = CharField(max_length=50, null=False) + + last_name = CharField(max_length=50, null=False) + + faculties = models.ManyToManyField("Faculty", related_name="users", blank=True) + + last_enrolled = IntegerField(default=datetime.MINYEAR, null=True) + + create_time = DateTimeField(auto_now_add=True) """Model settings""" USERNAME_FIELD = "username" @@ -61,7 +38,4 @@ class Faculty(models.Model): """This model represents a faculty.""" """Model fields""" - name = CharField( - max_length=50, - primary_key=True - ) + name = CharField(max_length=50, primary_key=True) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index cd1d2f97..1ec9b9a0 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,7 +1,12 @@ from django.contrib.auth.models import update_last_login from rest_framework.serializers import ( - CharField, EmailField, ModelSerializer, ValidationError, - Serializer, HyperlinkedIdentityField, HyperlinkedRelatedField + CharField, + EmailField, + ModelSerializer, + ValidationError, + Serializer, + HyperlinkedIdentityField, + HyperlinkedRelatedField, ) from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings @@ -9,87 +14,82 @@ from authentication.models import User from authentication.cas.client import client + class CASTokenObtainSerializer(Serializer): """Serializer for CAS ticket validation This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. """ + token = RefreshToken ticket = CharField(required=True, min_length=49, max_length=49) def validate(self, data): """Validate a ticket using CAS client""" - response = client.perform_service_validate( - ticket=data['ticket'] - ) + response = client.perform_service_validate(ticket=data["ticket"]) if response.error: raise ValidationError(response.error) # Validation success: create user if it doesn't exist yet. - attributes = response.data.get('attributes', dict) - - if attributes.get('lastenrolled'): - attributes['lastenrolled'] = int(attributes.get('lastenrolled').split()[0]) - - user = UserSerializer(data={ - 'id': attributes.get('ugentID'), - 'username': attributes.get('uid'), - 'email': attributes.get('mail'), - 'first_name': attributes.get('givenname'), - 'last_name': attributes.get('surname'), - 'last_enrolled': attributes.get('lastenrolled') - }) + attributes = response.data.get("attributes", dict) + + if attributes.get("lastenrolled"): + attributes["lastenrolled"] = int(attributes.get("lastenrolled").split()[0]) + + user = UserSerializer( + data={ + "id": attributes.get("ugentID"), + "username": attributes.get("uid"), + "email": attributes.get("mail"), + "first_name": attributes.get("givenname"), + "last_name": attributes.get("surname"), + "last_enrolled": attributes.get("lastenrolled"), + } + ) if not user.is_valid(): raise ValidationError(user.errors) - user, created = user.get_or_create( - user.validated_data - ) + user, created = user.get_or_create(user.validated_data) # Update the user's last login. if api_settings.UPDATE_LAST_LOGIN: update_last_login(self, user) - user_login.send(sender=self, - user=user - ) + user_login.send(sender=self, user=user) # Send signal upon creation. if created: - user_created.send(sender=self, - attributes=attributes, - user=user - ) + user_created.send(sender=self, attributes=attributes, user=user) return { - 'access': str(AccessToken.for_user(user)), - 'refresh': str(RefreshToken.for_user(user)) + "access": str(AccessToken.for_user(user)), + "refresh": str(RefreshToken.for_user(user)), } + class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ + id = CharField() username = CharField() email = EmailField() faculties = HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='faculty-detail' + many=True, read_only=True, view_name="faculty-detail" ) notifications = HyperlinkedIdentityField( - view_name='notification-detail', + view_name="notification-detail", read_only=True, ) class Meta: model = User - fields = '__all__' + fields = "__all__" def get_or_create(self, validated_data: dict) -> User: """Create or fetch the user based on the validated data.""" diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py index 0b621fc5..dcafcdf7 100644 --- a/backend/authentication/services/users.py +++ b/backend/authentication/services/users.py @@ -1,54 +1,70 @@ from authentication.models import User + def exists(user_id: str) -> bool: """Check if a user exists""" - return User.objects.filter(id = user_id).exists() + return User.objects.filter(id=user_id).exists() + -def get_by_id(user_id: str) -> User|None: +def get_by_id(user_id: str) -> User | None: """Get a user by its user id""" return User.objects.filter(id=user_id).first() + def get_by_username(username: str) -> User: """Get a user by its username""" return User.objects.filter(username=username).first() + def create( - user_id: str, username: str, email: str, - first_name: str, last_name: str, + user_id: str, + username: str, + email: str, + first_name: str, + last_name: str, faculty: str = None, last_enrolled: str = None, - student_id: str = None + student_id: str = None, ) -> User: """Create a new user Note: this does not assign specific user classes. This should be handled by consumers of this package. """ return User.objects.create( - id = user_id, - student_id = student_id, - username = username, - email = email, - first_name = first_name, - last_name = last_name, - faculty = faculty, - last_enrolled = last_enrolled + id=user_id, + student_id=student_id, + username=username, + email=email, + first_name=first_name, + last_name=last_name, + faculty=faculty, + last_enrolled=last_enrolled, ) + def get_or_create( - user_id: str, username: str, email: str, - first_name: str, last_name: str, + user_id: str, + username: str, + email: str, + first_name: str, + last_name: str, faculty: str = None, last_enrolled: str = None, - student_id: str = None + student_id: str = None, ) -> User: """Get a user by ID, or create if it doesn't exist""" user = get_by_id(user_id) if user is None: return create( - user_id, username, email, - first_name, last_name, - faculty, last_enrolled, student_id + user_id, + username, + email, + first_name, + last_name, + faculty, + last_enrolled, + student_id, ) - return user \ No newline at end of file + return user diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py index e584b6bf..40c89941 100644 --- a/backend/authentication/signals.py +++ b/backend/authentication/signals.py @@ -2,4 +2,4 @@ user_created = Signal() user_login = Signal() -user_logout = Signal() \ No newline at end of file +user_logout = Signal() diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index 1e60698b..736ed2f0 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -9,44 +9,49 @@ from authentication.signals import user_created, user_login -TICKET = 'ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6' -WRONG_TICKET = 'ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5' +TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6" +WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" -ID = '1234' -USERNAME = 'ddickwd' -EMAIL = 'dummy@dummy.be' -FIRST_NAME = 'Dummy' -LAST_NAME = 'McDickwad' +ID = "1234" +USERNAME = "ddickwd" +EMAIL = "dummy@dummy.be" +FIRST_NAME = "Dummy" +LAST_NAME = "McDickwad" class UserSerializerModelTests(TestCase): - def test_invalid_email_makes_user_serializer_invalid(self): """ The is_valid() method of a UserSerializer whose supplied User's email is not formatted as an email address should return False. """ - user = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': 'dummy', - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - user2 = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': 'dummy@dummy', - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) - user3 = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': 21, - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) + user = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": "dummy", + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + user2 = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": "dummy@dummy", + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + user3 = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": 21, + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) self.assertFalse(user.is_valid()) self.assertFalse(user2.is_valid()) self.assertFalse(user3.is_valid()) @@ -56,43 +61,45 @@ def test_valid_email_makes_valid_serializer(self): When the serializer is provided with a valid email, the serializer becomes valid, thus the is_valid() method returns True. """ - user = UserSerializer(data={ - 'id': ID, - 'username': USERNAME, - 'email': EMAIL, - 'first_name': FIRST_NAME, - 'last_name': LAST_NAME, - }) + user = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": EMAIL, + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) self.assertTrue(user.is_valid()) def customize_data(ugent_id, uid, mail): - class Response: - __slots__ = ('error', 'data') + __slots__ = ("error", "data") def __init__(self): self.error = None self.data = {} def service_validate( - ticket=None, - service_url=None, - headers=None,): + ticket=None, + service_url=None, + headers=None, + ): response = Response() if ticket != TICKET: - response.error = 'This is an error' + response.error = "This is an error" else: - response.data['attributes'] = { - 'ugentID': ugent_id, - 'uid': uid, - 'mail': mail, - 'givenname': FIRST_NAME, - 'surname': LAST_NAME, - 'faculty': 'Sciences', - 'lastenrolled': '2023 - 2024', - 'lastlogin': '', - 'createtime': '' + response.data["attributes"] = { + "ugentID": ugent_id, + "uid": uid, + "mail": mail, + "givenname": FIRST_NAME, + "surname": LAST_NAME, + "faculty": "Sciences", + "lastenrolled": "2023 - 2024", + "lastlogin": "", + "createtime": "", } return response @@ -105,43 +112,40 @@ def test_wrong_length_ticket_generates_error(self): When the provided ticket has the wrong length, a ValidationError should be raised when validating the serializer. """ - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': 'ST' - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": "ST"} + ) self.assertFalse(serializer.is_valid()) - @patch.object(client, - 'perform_service_validate', - customize_data(ID, USERNAME, EMAIL)) + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) def test_wrong_ticket_generates_error(self): """ When the wrong ticket is provided, a ValidationError should be raised when trying to validate the serializer. """ - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': WRONG_TICKET - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": WRONG_TICKET} + ) self.assertFalse(serializer.is_valid()) - @patch.object(client, - 'perform_service_validate', - customize_data(ID, USERNAME, "dummy@dummy")) + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, "dummy@dummy") + ) def test_wrong_user_arguments_generate_error(self): """ If the user arguments returned by CAS are not valid, then a ValidationError should be raised when validating the serializer. """ - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': TICKET - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) self.assertFalse(serializer.is_valid()) - @patch.object(client, - 'perform_service_validate', - customize_data(ID, USERNAME, EMAIL)) + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) def test_new_user_activates_user_created_signal(self): """ If the authenticated user is new to the app, then the user_created signal should @@ -149,17 +153,16 @@ def test_new_user_activates_user_created_signal(self): mock = Mock() user_created.connect(mock, dispatch_uid="STDsAllAround") - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': TICKET - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) # this next line triggers the retrieval of User information and logs in the user serializer.is_valid() self.assertEquals(mock.call_count, 1) - @patch.object(client, - 'perform_service_validate', - customize_data(ID, USERNAME, EMAIL)) + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) def test_old_user_does_not_activate_user_created_signal(self): """ If the authenticated user is new to the app, then the user_created signal should @@ -167,17 +170,16 @@ def test_old_user_does_not_activate_user_created_signal(self): mock = Mock() user_created.connect(mock, dispatch_uid="STDsAllAround") - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': TICKET - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) # this next line triggers the retrieval of User information and logs in the user serializer.is_valid() self.assertEquals(mock.call_count, 0) - @patch.object(client, - 'perform_service_validate', - customize_data(ID, USERNAME, EMAIL)) + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) def test_login_signal(self): """ When the token is correct and all user data is correct, while trying to validate @@ -185,10 +187,9 @@ def test_login_signal(self): """ mock = Mock() user_login.connect(mock, dispatch_uid="STDsAllAround") - serializer = CASTokenObtainSerializer(data={ - 'token': RefreshToken(), - 'ticket': TICKET - }) + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) # this next line triggers the retrieval of User information and logs in the user serializer.is_valid() self.assertEquals(mock.call_count, 1) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 4c2cb756..ce1ad7e5 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -10,15 +10,15 @@ class TestWhomAmIView(APITestCase): def setUp(self): """Create a user and generate a token for that user""" user_data = { - 'id': '1234', - 'username': 'ddickwd', - 'email': 'dummy@dummy.com', - 'first_name': 'dummy', - 'last_name': 'McDickwad', + "id": "1234", + "username": "ddickwd", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "McDickwad", } self.user = User.objects.create(**user_data) access_token = AccessToken().for_user(self.user) - self.token = f'Bearer {access_token}' + self.token = f"Bearer {access_token}" def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ @@ -27,12 +27,14 @@ def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.get(reverse('auth.whoami')) + response = self.client.get(reverse("auth.whoami")) self.assertEqual(response.status_code, 200) - content = json.loads(response.content.decode('utf-8')) - self.assertEqual(content['id'], self.user.id) + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["id"], self.user.id) - def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(self): + def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated( + self, + ): """ WhoAmIView should return that the user was not found if authenticated user was deleted from the database. @@ -40,61 +42,59 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated(s self.user.delete() self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.get(reverse('auth.whoami')) - self.assertEqual(response.status_code, 405) + response = self.client.get(reverse("auth.whoami")) + self.assertEqual(response.status_code, 401) def test_who_am_i_view_returns_401_when_not_authenticated(self): """WhoAmIView should return a 401 status code when the user is not authenticated""" - response = self.client.get(reverse('auth.whoami')) + response = self.client.get(reverse("auth.whoami")) self.assertEqual(response.status_code, 401) class TestLogoutView(APITestCase): def setUp(self): user_data = { - 'id': '1234', - 'username': 'ddickwd', - 'email': 'dummy@dummy.com', - 'first_name': 'dummy', - 'last_name': 'McDickwad', + "id": "1234", + "username": "ddickwd", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "McDickwad", } self.user = User.objects.create(**user_data) def test_logout_view_authenticated_logout_url(self): """LogoutView should return a logout url redirect if authenticated user sends a post request.""" access_token = AccessToken().for_user(self.user) - self.token = f'Bearer {access_token}' + self.token = f"Bearer {access_token}" self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.post(reverse('auth.logout')) + response = self.client.post(reverse("auth.logout")) self.assertEqual(response.status_code, 302) - url = '{server_url}/logout?service={service_url}'.format( - server_url=settings.CAS_ENDPOINT, - service_url=settings.API_ENDPOINT + url = "{server_url}/logout?service={service_url}".format( + server_url=settings.CAS_ENDPOINT, service_url=settings.API_ENDPOINT ) - self.assertEqual(response['Location'], url) + self.assertEqual(response["Location"], url) def test_logout_view_not_authenticated_logout_url(self): """LogoutView should return a 401 error when trying to access it while not authenticated.""" - response = self.client.post(reverse('auth.logout')) + response = self.client.post(reverse("auth.logout")) self.assertEqual(response.status_code, 401) class TestLoginView(APITestCase): def test_login_view_returns_login_url(self): """LoginView should return a login url redirect if a post request is sent.""" - response = self.client.get(reverse('auth.login')) + response = self.client.get(reverse("auth.login")) self.assertEqual(response.status_code, 302) - url = '{server_url}/login?service={service_url}'.format( - server_url=settings.CAS_ENDPOINT, - service_url=settings.CAS_RESPONSE + url = "{server_url}/login?service={service_url}".format( + server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE ) - self.assertEqual(response['Location'], url) + self.assertEqual(response["Location"], url) class TestTokenEchoView(APITestCase): def test_token_echo_echoes_token(self): """TokenEchoView should echo the User's current token""" - ticket = 'This is a ticket.' - response = self.client.get(reverse('auth.echo'), data={'ticket': ticket}) - content = response.rendered_content.decode('utf-8').strip('"') + ticket = "This is a ticket." + response = self.client.get(reverse("auth.echo"), data={"ticket": ticket}) + content = response.rendered_content.decode("utf-8").strip('"') self.assertEqual(content, ticket) diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 4bdc45cd..4e53f3d0 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,22 +1,26 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) from authentication.views.auth import WhoAmIView, LoginView, LogoutView, TokenEchoView from authentication.views.users import UsersView router = DefaultRouter() -router.register('users', UsersView, basename='user') +router.register("users", UsersView, basename="user") urlpatterns = [ # USER endpoints. - path('', include(router.urls)), + path("", include(router.urls)), # AUTH endpoints. - path('login', LoginView.as_view(), name='auth.login'), - path('logout', LogoutView.as_view(), name='auth.logout'), - path('whoami', WhoAmIView.as_view(), name='auth.whoami'), - path('echo', TokenEchoView.as_view(), name='auth.echo'), + path("login", LoginView.as_view(), name="auth.login"), + path("logout", LogoutView.as_view(), name="auth.logout"), + path("whoami", WhoAmIView.as_view(), name="auth.whoami"), + path("echo", TokenEchoView.as_view(), name="auth.echo"), # TOKEN endpoints. - path('token', TokenObtainPairView.as_view(), name='auth.token'), - path('token/refresh', TokenRefreshView.as_view(), name='auth.token.refresh'), - path('token/verify', TokenVerifyView.as_view(), name='auth.token.verify') -] \ No newline at end of file + path("token", TokenObtainPairView.as_view(), name="auth.token"), + path("token/refresh", TokenRefreshView.as_view(), name="auth.token.refresh"), + path("token/verify", TokenVerifyView.as_view(), name="auth.token.verify"), +] diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py index ebd2529b..fea0ded0 100644 --- a/backend/authentication/views/auth.py +++ b/backend/authentication/views/auth.py @@ -7,12 +7,14 @@ from authentication.cas.client import client from ypovoli import settings + class WhoAmIView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: """Get the user account data for the current user""" - return Response(UserSerializer(request.user, context={'request': request}).data) + return Response(UserSerializer(request.user, context={"request": request}).data) + class LogoutView(APIView): permission_classes = [IsAuthenticated] @@ -21,11 +23,13 @@ def post(self, request: Request) -> Response: """Attempt to log out. Redirect to our single CAS endpoint.""" return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) + class LoginView(APIView): def get(self, request: Request): """Attempt to log in. Redirect to our single CAS endpoint.""" return redirect(client.get_login_url()) + class TokenEchoView(APIView): def get(self, request: Request) -> Response: - return Response(request.query_params.get('ticket')) \ No newline at end of file + return Response(request.query_params.get("ticket")) diff --git a/backend/authentication/views/users.py b/backend/authentication/views/users.py index 4c6c4b2b..cea6e4a9 100644 --- a/backend/authentication/views/users.py +++ b/backend/authentication/views/users.py @@ -3,6 +3,7 @@ from authentication.models import User from authentication.serializers import UserSerializer + class UsersView(ListModelMixin, RetrieveModelMixin, GenericViewSet): queryset = User.objects.all() - serializer_class = UserSerializer \ No newline at end of file + serializer_class = UserSerializer diff --git a/backend/checks/apps.py b/backend/checks/apps.py index 28a74284..5fa5cda6 100644 --- a/backend/checks/apps.py +++ b/backend/checks/apps.py @@ -2,5 +2,5 @@ class ChecksConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'checks' + default_auto_field = "django.db.models.BigAutoField" + name = "checks" diff --git a/backend/manage.py b/backend/manage.py index f2b51f89..75478bbb 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index e5db3f92..e81be476 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -2,11 +2,11 @@ class NotificationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'notifications' + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" # TODO: Allow is_sent to be adjusted # TODO: Signals to send notifications # TODO: Send emails -# TODO: Think about the required api endpoints \ No newline at end of file +# TODO: Think about the required api endpoints diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py index 5565733d..c0a67c04 100644 --- a/backend/notifications/migrations/0001_initial.py +++ b/backend/notifications/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,23 +14,45 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='NotificationTemplate', + name="NotificationTemplate", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('title_key', models.CharField(max_length=255)), - ('description_key', models.CharField(max_length=511)), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("title_key", models.CharField(max_length=255)), + ("description_key", models.CharField(max_length=511)), ], ), migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('arguments', models.JSONField(default=dict)), - ('is_read', models.BooleanField(default=False)), - ('is_sent', models.BooleanField(default=False)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('template_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtemplate')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("arguments", models.JSONField(default=dict)), + ("is_read", models.BooleanField(default=False)), + ("is_sent", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="notifications.notificationtemplate", + ), + ), ], ), ] diff --git a/backend/ypovoli/asgi.py b/backend/ypovoli/asgi.py index 70cd7d09..ac8466f7 100644 --- a/backend/ypovoli/asgi.py +++ b/backend/ypovoli/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") application = get_asgi_application() diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 3c966165..6663866c 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -36,14 +36,12 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - 'django.contrib.staticfiles', - + "django.contrib.staticfiles", # Third party "rest_framework_swagger", # Swagger "rest_framework", # Django rest framework "drf_yasg", # Yet Another Swagger generator "sslserver", # Used for local SSL support (needed by CAS) - # First party "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application @@ -74,7 +72,7 @@ "ACCESS_TOKEN_LIFETIME": timedelta(days=365), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), "UPDATE_LAST_LOGIN": True, - "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer" + "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer", } AUTH_USER_MODEL = "authentication.User" @@ -113,19 +111,19 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index cb541b25..4dc43b32 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -22,7 +22,8 @@ schema_view = get_schema_view( openapi.Info( title="Ypovoli API", - default_version='v1',), + default_version="v1", + ), public=True, permission_classes=(permissions.AllowAny,), ) @@ -35,8 +36,12 @@ path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), # Swagger documentation. - path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), - name='schema-swagger-ui'), - path('swagger/', schema_view.without_ui(cache_timeout=0), - name='schema-json'), + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" + ), ] diff --git a/backend/ypovoli/wsgi.py b/backend/ypovoli/wsgi.py index c617cd31..0495fc95 100644 --- a/backend/ypovoli/wsgi.py +++ b/backend/ypovoli/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") application = get_wsgi_application() From 6740434204d2c3510ee379e608b4001407d4b56d Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 4 Mar 2024 22:11:39 +0100 Subject: [PATCH 183/397] fix: change github actions server to self-hosted --- .github/workflows/backend-linting.yaml | 2 +- .github/workflows/backend-tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml index e3a7c8e5..d145985d 100644 --- a/.github/workflows/backend-linting.yaml +++ b/.github/workflows/backend-linting.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 85ba4f2e..48768a80 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 From 3ecbecf4cccfbf2f47635a97f20817bc2450382e Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 5 Mar 2024 11:24:44 +0100 Subject: [PATCH 184/397] chore: add max_score + group_size to project --- backend/api/fixtures/projects.yaml | 2 ++ ...02_project_group_size_project_max_score.py | 23 ++++++++++++++++++ backend/api/models/project.py | 24 ++++++++++++++++--- backend/api/serializers/project_serializer.py | 2 ++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 backend/api/migrations/0002_project_group_size_project_max_score.py diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml index 5d9692ea..8efb2064 100644 --- a/backend/api/fixtures/projects.yaml +++ b/backend/api/fixtures/projects.yaml @@ -7,5 +7,7 @@ archived: false start_date: 2024-02-26 00:00:00+00:00 deadline: 2024-02-27 00:00:00+00:00 + group_size: 3 + max_score: 20 checks: 1 course: 2 diff --git a/backend/api/migrations/0002_project_group_size_project_max_score.py b/backend/api/migrations/0002_project_group_size_project_max_score.py new file mode 100644 index 00000000..17da27d0 --- /dev/null +++ b/backend/api/migrations/0002_project_group_size_project_max_score.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-03-05 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='group_size', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AddField( + model_name='project', + name='max_score', + field=models.PositiveSmallIntegerField(default=100), + ), + ] diff --git a/backend/api/models/project.py b/backend/api/models/project.py index ec16ed77..eb32c77e 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,4 +1,4 @@ -from datetime import timedelta, datetime +from datetime import datetime from django.db import models from django.utils import timezone from api.models.checks import Checks @@ -28,6 +28,20 @@ class Project(models.Model): deadline = models.DateTimeField(blank=False, null=False) + # Max score that can be achieved in the project + max_score = models.PositiveSmallIntegerField( + blank=False, + null=False, + default=100 + ) + + # Size of the groups than can be formed + group_size = models.PositiveSmallIntegerField( + blank=False, + null=False, + default=1 + ) + # Check entity that is linked to the project checks = models.ForeignKey( Checks, @@ -48,18 +62,22 @@ class Project(models.Model): ) def deadline_approaching_in(self, days=7): + """Returns True if the deadline is approaching in the next days.""" now = timezone.now() approaching_date = now + timezone.timedelta(days=days) return now <= self.deadline <= approaching_date def deadline_passed(self): + """Returns True if the deadline has passed.""" now = timezone.now() return now > self.deadline def toggle_visible(self): - self.visible = not (self.visible) + """Toggles the visibility of the project.""" + self.visible = not self.visible self.save() def toggle_archived(self): - self.archived = not (self.archived) + """Toggles the archived status of the project.""" + self.archived = not self.archived self.save() diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index c0e36a9a..839d3d74 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -21,6 +21,8 @@ class Meta: "archived", "start_date", "deadline", + "max_score", + "group_size", "checks", "course", ] From c05cc952742e79ffb901ff2ba9ea255cdda5555e Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 5 Mar 2024 14:13:25 +0100 Subject: [PATCH 185/397] chore: add groups to project --- backend/api/serializers/project_serializer.py | 6 +++++ backend/api/views/project_view.py | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 839d3d74..4a26d9ac 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -11,6 +11,11 @@ class ProjectSerializer(serializers.ModelSerializer): many=False, read_only=True, view_name="check-detail" ) + groups = serializers.HyperlinkedIdentityField( + view_name="project-groups", + read_only=True + ) + class Meta: model = Project fields = [ @@ -25,4 +30,5 @@ class Meta: "group_size", "checks", "course", + "groups" ] diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index c8e6dc83..1b2c37d6 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,8 +1,31 @@ -from rest_framework import viewsets +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer +from ..serializers.group_serializer import GroupSerializer class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer + + @action(detail=True, methods=["get"]) + def groups(self, request, pk=None): + """Returns a list of groups for the given project""" + + try: + queryset = Project.objects.get(id=pk) + groups = queryset.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) + return Response(serializer.data) + + except Project.DoesNotExist: + # Invalid project ID + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Project not found"} + ) From 7092a2a367cfddbfaf5d32c867fdc307ca77e20d Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 4 Mar 2024 18:28:52 +0100 Subject: [PATCH 186/397] feat: moved admin class as class field for user --- backend/api/models/admin.py | 10 --- backend/api/serializers/admin_serializer.py | 20 ------ backend/api/urls.py | 6 +- backend/api/views/admin_view.py | 9 ++- .../views/users.py => api/views/user_view.py} | 2 +- backend/authentication/models.py | 6 +- backend/authentication/permissions.py | 6 ++ backend/authentication/services/__init__.py | 0 backend/authentication/services/users.py | 70 ------------------- backend/authentication/urls.py | 24 ++----- backend/authentication/views.py | 38 ++++++++++ backend/authentication/views/__init__.py | 0 backend/authentication/views/auth.py | 35 ---------- backend/ypovoli/settings.py | 5 +- 14 files changed, 67 insertions(+), 164 deletions(-) delete mode 100644 backend/api/models/admin.py delete mode 100644 backend/api/serializers/admin_serializer.py rename backend/{authentication/views/users.py => api/views/user_view.py} (78%) create mode 100644 backend/authentication/permissions.py delete mode 100644 backend/authentication/services/__init__.py create mode 100644 backend/authentication/views.py delete mode 100644 backend/authentication/views/__init__.py delete mode 100644 backend/authentication/views/auth.py diff --git a/backend/api/models/admin.py b/backend/api/models/admin.py deleted file mode 100644 index f577ae9b..00000000 --- a/backend/api/models/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -from authentication.models import User - - -class Admin(User): - """This model represents the admin. - It extends the User model from the authentication app with - admin-specific attributes. - """ - - # At the moment, there are no additional attributes for the Admin model. diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py deleted file mode 100644 index 7b060e69..00000000 --- a/backend/api/serializers/admin_serializer.py +++ /dev/null @@ -1,20 +0,0 @@ -from rest_framework import serializers -from ..models.admin import Admin - - -class AdminSerializer(serializers.ModelSerializer): - faculties = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="faculty-detail" - ) - - class Meta: - model = Admin - fields = [ - "id", - "first_name", - "last_name", - "email", - "faculties", - "last_enrolled", - "create_time", - ] diff --git a/backend/api/urls.py b/backend/api/urls.py index 450301ca..64b83a23 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,4 +1,5 @@ from django.urls import include, path +from api.views import user_view from api.views import teacher_view from api.views import admin_view from api.views import assistant_view @@ -12,6 +13,7 @@ from rest_framework.routers import DefaultRouter router = DefaultRouter() +router.register(r"users", user_view.UserViewSet, basename="user") router.register(r"teachers", teacher_view.TeacherViewSet, basename="teacher") router.register(r"admins", admin_view.AdminViewSet, basename="admin") router.register(r"assistants", assistant_view.AssistantViewSet, basename="assistant") @@ -21,9 +23,7 @@ router.register(r"courses", course_view.CourseViewSet, basename="course") router.register(r"submissions", submision_view.SubmissionViewSet, basename="submission") router.register(r"checks", checks_view.ChecksViewSet, basename="check") -router.register( - r"fileExtensions", checks_view.FileExtensionViewSet, basename="fileExtension" -) +router.register(r"fileExtensions", checks_view.FileExtensionViewSet, basename="fileExtension") router.register(r"faculties", faculty_view.facultyViewSet, basename="faculty") urlpatterns = [ diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index f44fdcdb..5c4fabe3 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,7 @@ from rest_framework import viewsets -from ..models.admin import Admin -from ..serializers.admin_serializer import AdminSerializer - +from authentication.serializers import UserSerializer +from authentication.models import User class AdminViewSet(viewsets.ModelViewSet): - queryset = Admin.objects.all() - serializer_class = AdminSerializer + queryset = User.objects.filter(is_staff=True) + serializer_class = UserSerializer diff --git a/backend/authentication/views/users.py b/backend/api/views/user_view.py similarity index 78% rename from backend/authentication/views/users.py rename to backend/api/views/user_view.py index cea6e4a9..870243da 100644 --- a/backend/authentication/views/users.py +++ b/backend/api/views/user_view.py @@ -4,6 +4,6 @@ from authentication.serializers import UserSerializer -class UsersView(ListModelMixin, RetrieveModelMixin, GenericViewSet): +class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer diff --git a/backend/authentication/models.py b/backend/authentication/models.py index a9cce277..7142b631 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,8 +1,8 @@ import datetime from django.db import models -from django.db.models import CharField, EmailField, IntegerField, DateTimeField -from django.contrib.auth.models import AbstractBaseUser +from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField +from django.contrib.auth.models import AbstractBaseUser, AbstractUser class User(AbstractBaseUser): @@ -17,6 +17,8 @@ class User(AbstractBaseUser): username = CharField(max_length=12, unique=True) + is_staff = BooleanField(default=False, null=False) + email = EmailField(null=False, unique=True) first_name = CharField(max_length=50, null=False) diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py new file mode 100644 index 00000000..ae7293b5 --- /dev/null +++ b/backend/authentication/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission, DjangoModelPermissions + + +class CASPermission(BasePermission): + def has_permission(self, request, view): + pass \ No newline at end of file diff --git a/backend/authentication/services/__init__.py b/backend/authentication/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py index dcafcdf7..e69de29b 100644 --- a/backend/authentication/services/users.py +++ b/backend/authentication/services/users.py @@ -1,70 +0,0 @@ -from authentication.models import User - - -def exists(user_id: str) -> bool: - """Check if a user exists""" - return User.objects.filter(id=user_id).exists() - - -def get_by_id(user_id: str) -> User | None: - """Get a user by its user id""" - return User.objects.filter(id=user_id).first() - - -def get_by_username(username: str) -> User: - """Get a user by its username""" - return User.objects.filter(username=username).first() - - -def create( - user_id: str, - username: str, - email: str, - first_name: str, - last_name: str, - faculty: str = None, - last_enrolled: str = None, - student_id: str = None, -) -> User: - """Create a new user - Note: this does not assign specific user classes. - This should be handled by consumers of this package. - """ - return User.objects.create( - id=user_id, - student_id=student_id, - username=username, - email=email, - first_name=first_name, - last_name=last_name, - faculty=faculty, - last_enrolled=last_enrolled, - ) - - -def get_or_create( - user_id: str, - username: str, - email: str, - first_name: str, - last_name: str, - faculty: str = None, - last_enrolled: str = None, - student_id: str = None, -) -> User: - """Get a user by ID, or create if it doesn't exist""" - user = get_by_id(user_id) - - if user is None: - return create( - user_id, - username, - email, - first_name, - last_name, - faculty, - last_enrolled, - student_id, - ) - - return user diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 4e53f3d0..4365ca54 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,26 +1,16 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - TokenVerifyView, -) -from authentication.views.auth import WhoAmIView, LoginView, LogoutView, TokenEchoView -from authentication.views.users import UsersView +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView +from authentication.views import CASViewSet router = DefaultRouter() -router.register("users", UsersView, basename="user") +router.register(f"cas", CASViewSet,"cas") urlpatterns = [ - # USER endpoints. - path("", include(router.urls)), # AUTH endpoints. - path("login", LoginView.as_view(), name="auth.login"), - path("logout", LogoutView.as_view(), name="auth.logout"), - path("whoami", WhoAmIView.as_view(), name="auth.whoami"), - path("echo", TokenEchoView.as_view(), name="auth.echo"), + path("", include(router.urls)), # TOKEN endpoints. - path("token", TokenObtainPairView.as_view(), name="auth.token"), - path("token/refresh", TokenRefreshView.as_view(), name="auth.token.refresh"), - path("token/verify", TokenVerifyView.as_view(), name="auth.token.verify"), + path("token", TokenObtainPairView.as_view(), name="token"), + path("token/refresh", TokenRefreshView.as_view(), name="token-refresh"), + path("token/verify", TokenVerifyView.as_view(), name="token-verify") ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 00000000..b5993489 --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,38 @@ +from django.shortcuts import redirect +from rest_framework.decorators import action +from rest_framework.viewsets import ViewSet +from rest_framework.request import Request +from rest_framework.response import Response +from authentication.serializers import UserSerializer +from authentication.cas.client import client +from ypovoli import settings + +class CASViewSet(ViewSet): + + @action(detail=False, methods=['get']) + def login(self, _: Request) -> Response: + """Attempt to log in. Redirect to our single CAS endpoint.""" + return redirect(client.get_login_url()) + + @action(detail=False, methods=['get']) + def logout(self, _: Request) -> Response: + """Attempt to log out. Redirect to our single CAS endpoint. + Normally would only allow POST requests to a logout endpoint. + Since the CAS logout location handles the actual logout, we should accept GET requests. + """ + return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) + + @action(detail=False, methods=['get'], url_path='whoami') + def who_am_i(self, request: Request) -> Response: + """Get the user account data for the logged-in user. + The logged-in user is determined by the provided access token in the + Authorization HTTP header. + """ + return Response( + UserSerializer(request.user).data + ) + + @action(detail=False, methods=['get']) + def echo(self, request: Request) -> Response: + """Echo the obtained CAS token for development and testing.""" + return Response(request.query_params.get('ticket')) \ No newline at end of file diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py deleted file mode 100644 index fea0ded0..00000000 --- a/backend/authentication/views/auth.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.shortcuts import redirect -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework.permissions import IsAuthenticated -from authentication.serializers import UserSerializer -from authentication.cas.client import client -from ypovoli import settings - - -class WhoAmIView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request) -> Response: - """Get the user account data for the current user""" - return Response(UserSerializer(request.user, context={"request": request}).data) - - -class LogoutView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request: Request) -> Response: - """Attempt to log out. Redirect to our single CAS endpoint.""" - return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) - - -class LoginView(APIView): - def get(self, request: Request): - """Attempt to log in. Redirect to our single CAS endpoint.""" - return redirect(client.get_login_url()) - - -class TokenEchoView(APIView): - def get(self, request: Request) -> Response: - return Response(request.query_params.get("ticket")) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 6663866c..3582b40b 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -66,6 +66,9 @@ "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication" ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ] } SIMPLE_JWT = { @@ -84,7 +87,7 @@ # Application endpoints CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/auth/echo" +CAS_RESPONSE = "https://localhost:8080/auth/cas/echo" API_ENDPOINT = "https://localhost:8080" # Database From 5a1fff12ab9625b8f1a2eecc7a758fa41dfbd859 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 4 Mar 2024 20:52:55 +0100 Subject: [PATCH 187/397] fix: test authentication (wip) --- backend/api/migrations/0002_delete_admin.py | 16 +++++++++++++ backend/api/tests/test_admin.py | 16 +++++++++---- ...03_user_is_staff_alter_user_create_time.py | 23 +++++++++++++++++++ backend/authentication/models.py | 14 +++++++---- backend/authentication/permissions.py | 11 ++++++--- backend/authentication/views.py | 8 +++++-- backend/ypovoli/settings.py | 2 +- 7 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 backend/api/migrations/0002_delete_admin.py create mode 100644 backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py diff --git a/backend/api/migrations/0002_delete_admin.py b/backend/api/migrations/0002_delete_admin.py new file mode 100644 index 00000000..036781ac --- /dev/null +++ b/backend/api/migrations/0002_delete_admin.py @@ -0,0 +1,16 @@ +# Generated by Django 5.0.2 on 2024-03-04 18:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Admin', + ), + ] diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index d6d44888..ac4b3768 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -4,8 +4,7 @@ from django.utils import timezone from django.urls import reverse -from ..models.admin import Admin -from authentication.models import Faculty +from authentication.models import Faculty, User def create_faculty(name): @@ -20,29 +19,38 @@ def create_admin(id, first_name, last_name, email, faculty=None): """ username = f"{first_name}_{last_name}" if faculty is None: - return Admin.objects.create( + return User.objects.create( id=id, first_name=first_name, last_name=last_name, username=username, email=email, + is_staff=True, create_time=timezone.now(), ) else: - admin = Admin.objects.create( + admin = User.objects.create( id=id, first_name=first_name, last_name=last_name, username=username, email=email, + is_staff=True, create_time=timezone.now(), ) + for fac in faculty: admin.faculties.add(fac) + return admin class AdminModelTests(TestCase): + def setUp(self): + self.client.force_login( + create_admin('admin', 'admin', 'admin', 'admin@admin.com') + ) + def test_no_admins(self): """ able to retrieve no admin before publishing it. diff --git a/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py b/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py new file mode 100644 index 00000000..6e1a9080 --- /dev/null +++ b/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-03-04 18:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_remove_faculty_user_remove_user_faculty_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_staff', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='create_time', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7142b631..6e487258 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,8 +1,8 @@ -import datetime - +from datetime import MINYEAR +from typing import Self, Type from django.db import models -from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField -from django.contrib.auth.models import AbstractBaseUser, AbstractUser +from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField, Model +from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin class User(AbstractBaseUser): @@ -35,6 +35,12 @@ class User(AbstractBaseUser): USERNAME_FIELD = "username" EMAIL_FIELD = "email" + def has_role(self, model: Type[Self]): + """Simple generic implementation of roles. + This function looks if there exists a model (inheriting from User) with the same ID. + """ + model.objects.exists(self.id) + class Faculty(models.Model): """This model represents a faculty.""" diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py index ae7293b5..5056df97 100644 --- a/backend/authentication/permissions.py +++ b/backend/authentication/permissions.py @@ -1,6 +1,11 @@ -from rest_framework.permissions import BasePermission, DjangoModelPermissions +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet class CASPermission(BasePermission): - def has_permission(self, request, view): - pass \ No newline at end of file + def has_permission(self, request: Request, view: ViewSet): + """Check whether a user has permission in the CAS flow context.""" + return request.user.is_authenticated or view.action not in [ + 'logout', 'whoami' + ] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index b5993489..db2e7484 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -3,13 +3,17 @@ from rest_framework.viewsets import ViewSet from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated from authentication.serializers import UserSerializer from authentication.cas.client import client from ypovoli import settings class CASViewSet(ViewSet): + # The IsAuthenticated class is applied by default, + # but it's good to be verbose when it comes to security. + authentication_classes = [IsAuthenticated] - @action(detail=False, methods=['get']) + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) def login(self, _: Request) -> Response: """Attempt to log in. Redirect to our single CAS endpoint.""" return redirect(client.get_login_url()) @@ -32,7 +36,7 @@ def who_am_i(self, request: Request) -> Response: UserSerializer(request.user).data ) - @action(detail=False, methods=['get']) + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) def echo(self, request: Request) -> Response: """Echo the obtained CAS token for development and testing.""" return Response(request.query_params.get('ticket')) \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 3582b40b..880f2f80 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -67,7 +67,7 @@ "rest_framework_simplejwt.authentication.JWTAuthentication" ], 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', + 'rest_framework.permissions.IsAuthenticated' ] } From 588a450dcaf6d23971faef80b62e10fdbf2e5539 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 15:12:08 +0100 Subject: [PATCH 188/397] chore: fixed test with authorization --- backend/api/tests/test_admin.py | 10 ++++---- backend/api/tests/test_assistant.py | 12 ++++++---- backend/api/tests/test_checks.py | 21 ++++++++++++----- backend/api/tests/test_course.py | 21 ++++++++++------- backend/api/tests/test_group.py | 18 ++++++++++----- backend/api/tests/test_project.py | 16 +++++++++---- backend/api/tests/test_student.py | 17 +++++++++----- .../{test_submision.py => test_submission.py} | 19 ++++++++++----- backend/api/tests/test_teacher.py | 16 ++++++++----- backend/authentication/models.py | 11 +++++++++ .../tests/test_authentication_serializer.py | 1 - .../tests/test_authentication_views.py | 23 +++++++++---------- backend/authentication/views.py | 16 ++++++++----- backend/ypovoli/settings.py | 2 +- 14 files changed, 130 insertions(+), 73 deletions(-) rename backend/api/tests/{test_submision.py => test_submission.py} (95%) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py index ac4b3768..5de600b2 100644 --- a/backend/api/tests/test_admin.py +++ b/backend/api/tests/test_admin.py @@ -1,9 +1,7 @@ import json - -from django.test import TestCase from django.utils import timezone from django.urls import reverse - +from rest_framework.test import APITestCase from authentication.models import Faculty, User @@ -45,10 +43,10 @@ def create_admin(id, first_name, last_name, email, faculty=None): return admin -class AdminModelTests(TestCase): +class AdminModelTests(APITestCase): def setUp(self): - self.client.force_login( - create_admin('admin', 'admin', 'admin', 'admin@admin.com') + self.client.force_authenticate( + User.get_dummy_admin() ) def test_no_admins(self): diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py index 15d53aa1..81332915 100644 --- a/backend/api/tests/test_assistant.py +++ b/backend/api/tests/test_assistant.py @@ -1,11 +1,10 @@ import json - -from django.test import TestCase from django.utils import timezone from django.urls import reverse +from rest_framework.test import APITestCase from api.models.assistant import Assistant from api.models.course import Course -from authentication.models import Faculty +from authentication.models import Faculty, User def create_course(name, academic_startyear, description=None, parent_course=None): @@ -50,7 +49,12 @@ def create_assistant(id, first_name, last_name, email, faculty=None, courses=Non return assistant -class AssistantModelTests(TestCase): +class AssistantModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_assistant(self): """ able to retrieve no assistant before publishing it. diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index f7273144..b47fe651 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -1,9 +1,8 @@ import json - -from django.test import TestCase from django.urls import reverse - -from ..models.checks import FileExtension, Checks +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.checks import FileExtension, Checks def create_fileExtension(id, extension): @@ -26,7 +25,12 @@ def create_checks(id, allowed_file_extensions, forbidden_file_extensions): return check -class FileExtensionModelTests(TestCase): +class FileExtensionModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_fileExtension(self): """ able to retrieve no FileExtension before publishing it. @@ -126,7 +130,12 @@ def test_fileExtension_detail_view(self): self.assertEqual(content_json["extension"], fileExtension.extension) -class ChecksModelTests(TestCase): +class ChecksModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_checks(self): """ Able to retrieve no Checks before publishing it. diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index 97de9259..a2c3d165 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -1,13 +1,13 @@ import json - -from django.test import TestCase from django.utils import timezone from django.urls import reverse -from ..models.course import Course -from ..models.teacher import Teacher -from ..models.assistant import Assistant -from ..models.student import Student -from ..models.project import Project +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.course import Course +from api.models.teacher import Teacher +from api.models.assistant import Assistant +from api.models.student import Student +from api.models.project import Project def create_project(name, description, visible, archived, days, course): @@ -84,7 +84,12 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -class CourseModelTests(TestCase): +class CourseModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_courses(self): """ Able to retrieve no courses before publishing any. diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index dafbc1a8..f10e87d4 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -1,12 +1,13 @@ import json from datetime import timedelta -from django.test import TestCase from django.urls import reverse from django.utils import timezone -from ..models.project import Project -from ..models.student import Student -from ..models.group import Group -from ..models.course import Course +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.project import Project +from api.models.student import Student +from api.models.group import Group +from api.models.course import Course def create_course(name, academic_startyear, description=None, parent_course=None): @@ -46,7 +47,12 @@ def create_group(project, score): return Group.objects.create(project=project, score=score) -class GroupModelTests(TestCase): +class GroupModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_groups(self): """Able to retrieve no groups before creating any.""" response = self.client.get(reverse("group-list"), follow=True) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 72f3fbef..ae9f2efb 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -1,10 +1,11 @@ import json -from django.test import TestCase from django.utils import timezone from django.urls import reverse -from ..models.project import Project -from ..models.course import Course -from ..models.checks import Checks, FileExtension +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.project import Project +from api.models.course import Course +from api.models.checks import Checks, FileExtension def create_course(id, name, academic_startyear): @@ -58,7 +59,12 @@ def create_project(name, description, visible, archived, days, checks, course): ) -class ProjectModelTests(TestCase): +class ProjectModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_toggle_visible(self): """ toggle the visible state of a project. diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py index c43a89e7..1fced767 100644 --- a/backend/api/tests/test_student.py +++ b/backend/api/tests/test_student.py @@ -1,11 +1,10 @@ import json - -from django.test import TestCase from django.utils import timezone from django.urls import reverse -from ..models.student import Student -from ..models.course import Course -from authentication.models import Faculty +from rest_framework.test import APITestCase +from api.models.student import Student +from api.models.course import Course +from authentication.models import Faculty, User def create_course(name, academic_startyear, description=None, parent_course=None): @@ -50,7 +49,13 @@ def create_student(id, first_name, last_name, email, faculty=None, courses=None) return student -class StudentModelTests(TestCase): +class StudentModelTests(APITestCase): + + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_student(self): """ able to retrieve no student before publishing it. diff --git a/backend/api/tests/test_submision.py b/backend/api/tests/test_submission.py similarity index 95% rename from backend/api/tests/test_submision.py rename to backend/api/tests/test_submission.py index 51b571ea..fa7a4386 100644 --- a/backend/api/tests/test_submision.py +++ b/backend/api/tests/test_submission.py @@ -1,12 +1,13 @@ import json from datetime import timedelta -from django.test import TestCase from django.utils import timezone from django.urls import reverse -from ..models.submission import Submission, SubmissionFile -from ..models.project import Project -from ..models.group import Group -from ..models.course import Course +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.submission import Submission, SubmissionFile +from api.models.project import Project +from api.models.group import Group +from api.models.course import Course def create_course(name, academic_startyear, description=None, parent_course=None): @@ -46,7 +47,13 @@ def create_submissionFile(submission, file): return SubmissionFile.objects.create(submission=submission, file=file) -class SubmissionModelTests(TestCase): +class SubmissionModelTests(APITestCase): + + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_submission(self): """ able to retrieve no submission before publishing it. diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py index dc07da70..ec58ec95 100644 --- a/backend/api/tests/test_teacher.py +++ b/backend/api/tests/test_teacher.py @@ -1,11 +1,10 @@ import json - -from django.test import TestCase from django.utils import timezone from django.urls import reverse -from ..models.teacher import Teacher -from ..models.course import Course -from authentication.models import Faculty +from rest_framework.test import APITestCase +from api.models.teacher import Teacher +from api.models.course import Course +from authentication.models import Faculty, User def create_course(name, academic_startyear, description=None, parent_course=None): @@ -50,7 +49,12 @@ def create_teacher(id, first_name, last_name, email, faculty=None, courses=None) return teacher -class TeacherModelTests(TestCase): +class TeacherModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + def test_no_teacher(self): """ able to retrieve no teacher before publishing it. diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6e487258..a2dea074 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -41,6 +41,17 @@ def has_role(self, model: Type[Self]): """ model.objects.exists(self.id) + @staticmethod + def get_dummy_admin(): + return User( + id="admin", + first_name="Nikkus", + last_name="Derdinus", + username="nderdinus", + email="nikkus@ypovoli.be", + is_staff=True + ) + class Faculty(models.Model): """This model represents a faculty.""" diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index 736ed2f0..b5ef46c4 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -18,7 +18,6 @@ FIRST_NAME = "Dummy" LAST_NAME = "McDickwad" - class UserSerializerModelTests(TestCase): def test_invalid_email_makes_user_serializer_invalid(self): """ diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index ce1ad7e5..068e1d93 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -9,16 +9,15 @@ class TestWhomAmIView(APITestCase): def setUp(self): """Create a user and generate a token for that user""" - user_data = { + user_data = User.objects.create(**{ "id": "1234", "username": "ddickwd", "email": "dummy@dummy.com", "first_name": "dummy", "last_name": "McDickwad", - } - self.user = User.objects.create(**user_data) - access_token = AccessToken().for_user(self.user) - self.token = f"Bearer {access_token}" + }) + + self.token = f'Bearer {AccessToken().for_user(self.user)}' def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ @@ -27,7 +26,7 @@ def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): """ self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.get(reverse("auth.whoami")) + response = self.client.get(reverse("cas-whoami")) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["id"], self.user.id) @@ -42,12 +41,12 @@ def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated( self.user.delete() self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.get(reverse("auth.whoami")) + response = self.client.get(reverse("cas-whoami")) self.assertEqual(response.status_code, 401) def test_who_am_i_view_returns_401_when_not_authenticated(self): """WhoAmIView should return a 401 status code when the user is not authenticated""" - response = self.client.get(reverse("auth.whoami")) + response = self.client.get(reverse("cas-whoami")) self.assertEqual(response.status_code, 401) @@ -67,7 +66,7 @@ def test_logout_view_authenticated_logout_url(self): access_token = AccessToken().for_user(self.user) self.token = f"Bearer {access_token}" self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.post(reverse("auth.logout")) + response = self.client.post(reverse("cas-logout")) self.assertEqual(response.status_code, 302) url = "{server_url}/logout?service={service_url}".format( server_url=settings.CAS_ENDPOINT, service_url=settings.API_ENDPOINT @@ -76,14 +75,14 @@ def test_logout_view_authenticated_logout_url(self): def test_logout_view_not_authenticated_logout_url(self): """LogoutView should return a 401 error when trying to access it while not authenticated.""" - response = self.client.post(reverse("auth.logout")) + response = self.client.post(reverse("cas-logout")) self.assertEqual(response.status_code, 401) class TestLoginView(APITestCase): def test_login_view_returns_login_url(self): """LoginView should return a login url redirect if a post request is sent.""" - response = self.client.get(reverse("auth.login")) + response = self.client.get(reverse("cas-login")) self.assertEqual(response.status_code, 302) url = "{server_url}/login?service={service_url}".format( server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE @@ -95,6 +94,6 @@ class TestTokenEchoView(APITestCase): def test_token_echo_echoes_token(self): """TokenEchoView should echo the User's current token""" ticket = "This is a ticket." - response = self.client.get(reverse("auth.echo"), data={"ticket": ticket}) + response = self.client.get(reverse("cas-echo"), data={"ticket": ticket}) content = response.rendered_content.decode("utf-8").strip('"') self.assertEqual(content, ticket) diff --git a/backend/authentication/views.py b/backend/authentication/views.py index db2e7484..1b66d8d2 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -11,14 +11,14 @@ class CASViewSet(ViewSet): # The IsAuthenticated class is applied by default, # but it's good to be verbose when it comes to security. - authentication_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated] - @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) def login(self, _: Request) -> Response: """Attempt to log in. Redirect to our single CAS endpoint.""" return redirect(client.get_login_url()) - @action(detail=False, methods=['get']) + @action(detail=False, methods=['GET']) def logout(self, _: Request) -> Response: """Attempt to log out. Redirect to our single CAS endpoint. Normally would only allow POST requests to a logout endpoint. @@ -26,17 +26,21 @@ def logout(self, _: Request) -> Response: """ return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) - @action(detail=False, methods=['get'], url_path='whoami') + @action(detail=False, methods=['GET'], url_path='whoami', url_name='whoami') def who_am_i(self, request: Request) -> Response: """Get the user account data for the logged-in user. The logged-in user is determined by the provided access token in the Authorization HTTP header. """ + user_serializer = UserSerializer(request.user, context={ + 'request': request + }) + return Response( - UserSerializer(request.user).data + user_serializer.data ) - @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) def echo(self, request: Request) -> Response: """Echo the obtained CAS token for development and testing.""" return Response(request.query_params.get('ticket')) \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 880f2f80..e5f197c1 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -110,7 +110,7 @@ TIME_ZONE = "UTC" USE_I18N = True USE_L10N = False -USE_TZ = True +USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ From f1c18b3247247079e1fa4a51d719efae6f9e8735 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 15:26:47 +0100 Subject: [PATCH 189/397] chore: merge --- backend/api/migrations/0001_initial.py | 301 ++++-------------- backend/api/migrations/0002_delete_admin.py | 16 - .../authentication/migrations/0001_initial.py | 61 ++-- ...culty_user_remove_user_faculty_and_more.py | 27 -- .../migrations/0003_alter_user_create_time.py | 17 - ...03_user_is_staff_alter_user_create_time.py | 23 -- backend/authentication/models.py | 2 +- .../tests/test_authentication_views.py | 6 +- 8 files changed, 82 insertions(+), 371 deletions(-) delete mode 100644 backend/api/migrations/0002_delete_admin.py delete mode 100644 backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py delete mode 100644 backend/authentication/migrations/0003_alter_user_create_time.py delete mode 100644 backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py index 6b842090..1254180c 100644 --- a/backend/api/migrations/0001_initial.py +++ b/backend/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-28 21:55 +# Generated by Django 5.0.2 on 2024-03-05 14:25 import datetime import django.db.models.deletion @@ -7,300 +7,115 @@ class Migration(migrations.Migration): + initial = True dependencies = [ - ("authentication", "0001_initial"), + ('authentication', '0001_initial'), ] operations = [ migrations.CreateModel( - name="Admin", - fields=[ - ( - "user_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - bases=("authentication.user",), - ), - migrations.CreateModel( - name="FileExtension", + name='FileExtension', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("extension", models.CharField(max_length=10, unique=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extension', models.CharField(max_length=10, unique=True)), ], ), migrations.CreateModel( - name="Course", + name='Course', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100)), - ("academic_startyear", models.IntegerField()), - ("description", models.TextField(blank=True, null=True)), - ( - "parent_course", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="child_course", - to="api.course", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('academic_startyear', models.IntegerField()), + ('description', models.TextField(blank=True, null=True)), + ('parent_course', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_course', to='api.course')), ], ), migrations.CreateModel( - name="Assistant", + name='Assistant', fields=[ - ( - "user_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "courses", - models.ManyToManyField( - blank=True, related_name="assistants", to="api.course" - ), - ), + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('courses', models.ManyToManyField(blank=True, related_name='assistants', to='api.course')), ], options={ - "abstract": False, + 'abstract': False, }, - bases=("authentication.user",), + bases=('authentication.user',), ), migrations.CreateModel( - name="Checks", + name='Checks', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("dockerfile", models.FileField(blank=True, null=True, upload_to="")), - ( - "allowed_file_extensions", - models.ManyToManyField( - blank=True, - related_name="checks_allowed", - to="api.fileextension", - ), - ), - ( - "forbidden_file_extensions", - models.ManyToManyField( - blank=True, - related_name="checks_forbidden", - to="api.fileextension", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dockerfile', models.FileField(blank=True, null=True, upload_to='')), + ('allowed_file_extensions', models.ManyToManyField(blank=True, related_name='checks_allowed', to='api.fileextension')), + ('forbidden_file_extensions', models.ManyToManyField(blank=True, related_name='checks_forbidden', to='api.fileextension')), ], ), migrations.CreateModel( - name="Project", + name='Project', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100)), - ("description", models.TextField(blank=True, null=True)), - ("visible", models.BooleanField(default=True)), - ("archived", models.BooleanField(default=False)), - ( - "start_date", - models.DateTimeField(blank=True, default=datetime.datetime.now), - ), - ("deadline", models.DateTimeField()), - ( - "checks", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="api.checks", - ), - ), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="projects", - to="api.course", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('visible', models.BooleanField(default=True)), + ('archived', models.BooleanField(default=False)), + ('start_date', models.DateTimeField(blank=True, default=datetime.datetime.now)), + ('deadline', models.DateTimeField()), + ('checks', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.checks')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course')), ], ), migrations.CreateModel( - name="Student", + name='Student', fields=[ - ( - "user_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to=settings.AUTH_USER_MODEL, - ), - ), - ("student_id", models.CharField(max_length=8, null=True, unique=True)), - ( - "courses", - models.ManyToManyField( - blank=True, related_name="students", to="api.course" - ), - ), + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('student_id', models.CharField(max_length=8, null=True, unique=True)), + ('courses', models.ManyToManyField(blank=True, related_name='students', to='api.course')), ], options={ - "abstract": False, + 'abstract': False, }, - bases=("authentication.user",), + bases=('authentication.user',), ), migrations.CreateModel( - name="Group", + name='Group', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("score", models.FloatField(blank=True, null=True)), - ( - "project", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="groups", - to="api.project", - ), - ), - ( - "students", - models.ManyToManyField(related_name="groups", to="api.student"), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.FloatField(blank=True, null=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='api.project')), + ('students', models.ManyToManyField(related_name='groups', to='api.student')), ], ), migrations.CreateModel( - name="Submission", + name='Submission', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("submission_number", models.PositiveIntegerField()), - ("submission_time", models.DateTimeField(auto_now_add=True)), - ( - "group", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="submissions", - to="api.group", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submission_number', models.PositiveIntegerField()), + ('submission_time', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='api.group')), ], options={ - "unique_together": {("group", "submission_number")}, + 'unique_together': {('group', 'submission_number')}, }, ), migrations.CreateModel( - name="SubmissionFile", + name='SubmissionFile', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("file", models.FileField(upload_to="")), - ( - "submission", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="files", - to="api.submission", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.submission')), ], ), migrations.CreateModel( - name="Teacher", + name='Teacher', fields=[ - ( - "user_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "courses", - models.ManyToManyField( - blank=True, related_name="teachers", to="api.course" - ), - ), + ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('courses', models.ManyToManyField(blank=True, related_name='teachers', to='api.course')), ], options={ - "abstract": False, + 'abstract': False, }, - bases=("authentication.user",), + bases=('authentication.user',), ), ] diff --git a/backend/api/migrations/0002_delete_admin.py b/backend/api/migrations/0002_delete_admin.py deleted file mode 100644 index 036781ac..00000000 --- a/backend/api/migrations/0002_delete_admin.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-04 18:06 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0001_initial'), - ] - - operations = [ - migrations.DeleteModel( - name='Admin', - ), - ] diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index 8895c9c4..3265f487 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -1,59 +1,38 @@ -# Generated by Django 5.0.2 on 2024-02-28 21:55 +# Generated by Django 5.0.2 on 2024-03-05 14:25 -from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="User", + name='Faculty', fields=[ - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "id", - models.CharField(max_length=12, primary_key=True, serialize=False), - ), - ("username", models.CharField(max_length=12, unique=True)), - ("email", models.EmailField(max_length=254, unique=True)), - ("first_name", models.CharField(max_length=50)), - ("last_name", models.CharField(max_length=50)), - ("last_enrolled", models.IntegerField(default=1, null=True)), - ("create_time", models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), ], - options={ - "abstract": False, - }, ), migrations.CreateModel( - name="Faculty", + name='User', fields=[ - ( - "name", - models.CharField(max_length=50, primary_key=True, serialize=False), - ), - ( - "user", - models.ManyToManyField( - blank=True, related_name="users", to=settings.AUTH_USER_MODEL - ), - ), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('username', models.CharField(max_length=12, unique=True)), + ('is_staff', models.BooleanField(default=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('first_name', models.CharField(max_length=50)), + ('last_name', models.CharField(max_length=50)), + ('last_enrolled', models.IntegerField(default=1, null=True)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('faculties', models.ManyToManyField(blank=True, related_name='users', to='authentication.faculty')), ], - ), - migrations.AddField( - model_name="user", - name="faculty", - field=models.ManyToManyField( - blank=True, related_name="faculties", to="authentication.faculty" - ), + options={ + 'abstract': False, + }, ), ] diff --git a/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py b/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py deleted file mode 100644 index 6323b90c..00000000 --- a/backend/authentication/migrations/0002_remove_faculty_user_remove_user_faculty_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-29 13:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("authentication", "0001_initial"), - ] - - operations = [ - migrations.RemoveField( - model_name="faculty", - name="user", - ), - migrations.RemoveField( - model_name="user", - name="faculty", - ), - migrations.AddField( - model_name="user", - name="faculties", - field=models.ManyToManyField( - blank=True, related_name="users", to="authentication.faculty" - ), - ), - ] diff --git a/backend/authentication/migrations/0003_alter_user_create_time.py b/backend/authentication/migrations/0003_alter_user_create_time.py deleted file mode 100644 index f65d50cf..00000000 --- a/backend/authentication/migrations/0003_alter_user_create_time.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-04 15:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("authentication", "0002_remove_faculty_user_remove_user_faculty_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="user", - name="create_time", - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py b/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py deleted file mode 100644 index 6e1a9080..00000000 --- a/backend/authentication/migrations/0003_user_is_staff_alter_user_create_time.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-04 18:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0002_remove_faculty_user_remove_user_faculty_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='is_staff', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='user', - name='create_time', - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index a2dea074..8a8787f4 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -27,7 +27,7 @@ class User(AbstractBaseUser): faculties = models.ManyToManyField("Faculty", related_name="users", blank=True) - last_enrolled = IntegerField(default=datetime.MINYEAR, null=True) + last_enrolled = IntegerField(default=MINYEAR, null=True) create_time = DateTimeField(auto_now_add=True) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 068e1d93..960e689d 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -9,7 +9,7 @@ class TestWhomAmIView(APITestCase): def setUp(self): """Create a user and generate a token for that user""" - user_data = User.objects.create(**{ + self.user = User.objects.create(**{ "id": "1234", "username": "ddickwd", "email": "dummy@dummy.com", @@ -66,7 +66,7 @@ def test_logout_view_authenticated_logout_url(self): access_token = AccessToken().for_user(self.user) self.token = f"Bearer {access_token}" self.client.credentials(HTTP_AUTHORIZATION=self.token) - response = self.client.post(reverse("cas-logout")) + response = self.client.get(reverse("cas-logout")) self.assertEqual(response.status_code, 302) url = "{server_url}/logout?service={service_url}".format( server_url=settings.CAS_ENDPOINT, service_url=settings.API_ENDPOINT @@ -75,7 +75,7 @@ def test_logout_view_authenticated_logout_url(self): def test_logout_view_not_authenticated_logout_url(self): """LogoutView should return a 401 error when trying to access it while not authenticated.""" - response = self.client.post(reverse("cas-logout")) + response = self.client.get(reverse("cas-logout")) self.assertEqual(response.status_code, 401) From fe100d8d9a29b875ac4b62f513bd35370e201d15 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 15:32:03 +0100 Subject: [PATCH 190/397] chore: fixed linting errors --- backend/api/views/admin_view.py | 1 + backend/authentication/permissions.py | 2 +- backend/authentication/tests/test_authentication_serializer.py | 1 + backend/authentication/urls.py | 2 +- backend/authentication/views.py | 3 ++- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 5c4fabe3..63fdab43 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -2,6 +2,7 @@ from authentication.serializers import UserSerializer from authentication.models import User + class AdminViewSet(viewsets.ModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py index 5056df97..d852d767 100644 --- a/backend/authentication/permissions.py +++ b/backend/authentication/permissions.py @@ -8,4 +8,4 @@ def has_permission(self, request: Request, view: ViewSet): """Check whether a user has permission in the CAS flow context.""" return request.user.is_authenticated or view.action not in [ 'logout', 'whoami' - ] \ No newline at end of file + ] diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index b5ef46c4..736ed2f0 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -18,6 +18,7 @@ FIRST_NAME = "Dummy" LAST_NAME = "McDickwad" + class UserSerializerModelTests(TestCase): def test_invalid_email_makes_user_serializer_invalid(self): """ diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 4365ca54..2214cc67 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -4,7 +4,7 @@ from authentication.views import CASViewSet router = DefaultRouter() -router.register(f"cas", CASViewSet,"cas") +router.register("cas", CASViewSet, "cas") urlpatterns = [ # AUTH endpoints. diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 1b66d8d2..c5f85e29 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -8,6 +8,7 @@ from authentication.cas.client import client from ypovoli import settings + class CASViewSet(ViewSet): # The IsAuthenticated class is applied by default, # but it's good to be verbose when it comes to security. @@ -43,4 +44,4 @@ def who_am_i(self, request: Request) -> Response: @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) def echo(self, request: Request) -> Response: """Echo the obtained CAS token for development and testing.""" - return Response(request.query_params.get('ticket')) \ No newline at end of file + return Response(request.query_params.get('ticket')) From b16c1fed345c71e0c1ef216f4e5e9eb86845533c Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 15:36:46 +0100 Subject: [PATCH 191/397] test: linting warning? --- backend/authentication/tests/test_authentication_serializer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index 736ed2f0..b5ef46c4 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -18,7 +18,6 @@ FIRST_NAME = "Dummy" LAST_NAME = "McDickwad" - class UserSerializerModelTests(TestCase): def test_invalid_email_makes_user_serializer_invalid(self): """ From 237d32dc5d9769ba712e3ca04133e80b4fd1a306 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 15:37:08 +0100 Subject: [PATCH 192/397] test: linting warning --- backend/authentication/tests/test_authentication_serializer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index b5ef46c4..736ed2f0 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -18,6 +18,7 @@ FIRST_NAME = "Dummy" LAST_NAME = "McDickwad" + class UserSerializerModelTests(TestCase): def test_invalid_email_makes_user_serializer_invalid(self): """ From 73d04b111f648e932ef787ab9bdeb293a950e730 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 17:09:19 +0100 Subject: [PATCH 193/397] feat: session authentication for smooth browser-specific experence --- backend/authentication/permissions.py | 10 ++- backend/authentication/serializers.py | 64 ++++++++++++------- backend/authentication/services/users.py | 0 .../tests/test_authentication_views.py | 8 --- backend/authentication/views.py | 25 ++++++-- backend/ypovoli/settings.py | 5 +- backend/ypovoli/urls.py | 5 +- 7 files changed, 70 insertions(+), 47 deletions(-) delete mode 100644 backend/authentication/services/users.py diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py index d852d767..b9ff5906 100644 --- a/backend/authentication/permissions.py +++ b/backend/authentication/permissions.py @@ -1,11 +1,9 @@ from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.viewsets import ViewSet +from ypovoli import settings -class CASPermission(BasePermission): - def has_permission(self, request: Request, view: ViewSet): - """Check whether a user has permission in the CAS flow context.""" - return request.user.is_authenticated or view.action not in [ - 'logout', 'whoami' - ] +class IsDebug(BasePermission): + def has_permission(self, request: Request, view: ViewSet) -> bool: + return settings.DEBUG diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 1ec9b9a0..1ce1b446 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,4 +1,6 @@ +from typing import Tuple from django.contrib.auth.models import update_last_login +from django.contrib.auth import login from rest_framework.serializers import ( CharField, EmailField, @@ -20,20 +22,53 @@ class CASTokenObtainSerializer(Serializer): This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. """ - - token = RefreshToken ticket = CharField(required=True, min_length=49, max_length=49) def validate(self, data): """Validate a ticket using CAS client""" - response = client.perform_service_validate(ticket=data["ticket"]) + # Validate the ticket and get CAS attributes. + attributes = self._validate_ticket(data["ticket"]) + + # Fetch a user model from the CAS attributes. + user, created = self._fetch_user_from_cas(attributes) + + # Update the user's last login. + if api_settings.UPDATE_LAST_LOGIN: + update_last_login(self, user) + + # Login and send authentication signals. + if "request" in self.context: + login(self.context["request"], user) + + user_login.send( + sender=self, user=user + ) + + if created: + user_created.send( + sender=self, attributes=attributes, user=user + ) + + # Return access tokens for the now logged-in user. + return { + "access": str( + AccessToken.for_user(user) + ), + "refresh": str( + RefreshToken.for_user(user) + ), + } + + def _validate_ticket(self, ticket: str) -> dict: + """Validate a CAS ticket using the CAS client""" + response = client.perform_service_validate(ticket=ticket) if response.error: raise ValidationError(response.error) - # Validation success: create user if it doesn't exist yet. - attributes = response.data.get("attributes", dict) + return response.data.get("attributes", dict) + def _fetch_user_from_cas(self, attributes: dict) -> Tuple[User, bool]: if attributes.get("lastenrolled"): attributes["lastenrolled"] = int(attributes.get("lastenrolled").split()[0]) @@ -51,22 +86,7 @@ def validate(self, data): if not user.is_valid(): raise ValidationError(user.errors) - user, created = user.get_or_create(user.validated_data) - - # Update the user's last login. - if api_settings.UPDATE_LAST_LOGIN: - update_last_login(self, user) - - user_login.send(sender=self, user=user) - - # Send signal upon creation. - if created: - user_created.send(sender=self, attributes=attributes, user=user) - - return { - "access": str(AccessToken.for_user(user)), - "refresh": str(RefreshToken.for_user(user)), - } + return user.get_or_create(user.validated_data) class UserSerializer(ModelSerializer): @@ -91,6 +111,6 @@ class Meta: model = User fields = "__all__" - def get_or_create(self, validated_data: dict) -> User: + def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 960e689d..4eecc815 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -89,11 +89,3 @@ def test_login_view_returns_login_url(self): ) self.assertEqual(response["Location"], url) - -class TestTokenEchoView(APITestCase): - def test_token_echo_echoes_token(self): - """TokenEchoView should echo the User's current token""" - ticket = "This is a ticket." - response = self.client.get(reverse("cas-echo"), data={"ticket": ticket}) - content = response.rendered_content.decode("utf-8").strip('"') - self.assertEqual(content, ticket) diff --git a/backend/authentication/views.py b/backend/authentication/views.py index c5f85e29..cc39aa74 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,10 +1,13 @@ from django.shortcuts import redirect +from django.contrib.auth import login, logout from rest_framework.decorators import action from rest_framework.viewsets import ViewSet from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import AllowAny, IsAuthenticated -from authentication.serializers import UserSerializer +from authentication.permissions import IsDebug +from authentication.serializers import UserSerializer, CASTokenObtainSerializer from authentication.cas.client import client from ypovoli import settings @@ -20,12 +23,16 @@ def login(self, _: Request) -> Response: return redirect(client.get_login_url()) @action(detail=False, methods=['GET']) - def logout(self, _: Request) -> Response: + def logout(self, request: Request) -> Response: """Attempt to log out. Redirect to our single CAS endpoint. Normally would only allow POST requests to a logout endpoint. Since the CAS logout location handles the actual logout, we should accept GET requests. """ - return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) + logout(request) + + return redirect( + client.get_logout_url(service_url=settings.API_ENDPOINT) + ) @action(detail=False, methods=['GET'], url_path='whoami', url_name='whoami') def who_am_i(self, request: Request) -> Response: @@ -41,7 +48,15 @@ def who_am_i(self, request: Request) -> Response: user_serializer.data ) - @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) + @action(detail=False, methods=['GET'], permission_classes=[IsDebug]) def echo(self, request: Request) -> Response: """Echo the obtained CAS token for development and testing.""" - return Response(request.query_params.get('ticket')) + token_serializer = CASTokenObtainSerializer(data=request.query_params, context={ + 'request': request + }) + + if token_serializer.is_valid(): + return Response(token_serializer.validated_data) + + raise AuthenticationFailed(token_serializer.errors) + diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index e5f197c1..43fa8513 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -64,7 +64,8 @@ "rest_framework.renderers.JSONRenderer", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication" + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication" ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated' @@ -79,9 +80,7 @@ } AUTH_USER_MODEL = "authentication.User" - ROOT_URLCONF = "ypovoli.urls" - WSGI_APPLICATION = "ypovoli.wsgi.application" # Application endpoints diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 4dc43b32..1a1ca5c0 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -25,7 +25,7 @@ default_version="v1", ), public=True, - permission_classes=(permissions.AllowAny,), + permission_classes=[permissions.AllowAny,], ) @@ -36,8 +36,7 @@ path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), # Swagger documentation. - path( - "swagger/", + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui", ), From 771cd9c4fca00bbede0cb050569268fbc0b747f6 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 17:16:55 +0100 Subject: [PATCH 194/397] fix: linting issues --- .../authentication/tests/test_authentication_views.py | 1 - backend/authentication/views.py | 3 +-- backend/ypovoli/urls.py | 9 ++------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 4eecc815..8e7a4155 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -88,4 +88,3 @@ def test_login_view_returns_login_url(self): server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE ) self.assertEqual(response["Location"], url) - diff --git a/backend/authentication/views.py b/backend/authentication/views.py index cc39aa74..f029defd 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,5 +1,5 @@ from django.shortcuts import redirect -from django.contrib.auth import login, logout +from django.contrib.auth import logout from rest_framework.decorators import action from rest_framework.viewsets import ViewSet from rest_framework.request import Request @@ -59,4 +59,3 @@ def echo(self, request: Request) -> Response: return Response(token_serializer.validated_data) raise AuthenticationFailed(token_serializer.errors) - diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 1a1ca5c0..25e30a72 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -36,11 +36,6 @@ path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), # Swagger documentation. - path("swagger/", - schema_view.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - path( - "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" - ), + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), + path("swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json"), ] From 634bdc7ad5917ca599a4581d196702cfc79e4929 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 17:20:59 +0100 Subject: [PATCH 195/397] fix: admin fixtures and student fields --- backend/api/fixtures/admins.yaml | 3 --- backend/api/serializers/student_serializer.py | 12 +----------- backend/ypovoli/settings.py | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 backend/api/fixtures/admins.yaml diff --git a/backend/api/fixtures/admins.yaml b/backend/api/fixtures/admins.yaml deleted file mode 100644 index a0ad3113..00000000 --- a/backend/api/fixtures/admins.yaml +++ /dev/null @@ -1,3 +0,0 @@ -- model: api.admin - pk: '2' - fields: {} diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index fddbd8d2..9040fe1c 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -19,14 +19,4 @@ class StudentSerializer(serializers.ModelSerializer): class Meta: model = Student - fields = [ - "id", - "first_name", - "last_name", - "email", - "faculties", - "last_enrolled", - "create_time", - "courses", - "groups", - ] + fields = '__all__' diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 43fa8513..32355200 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -109,7 +109,7 @@ TIME_ZONE = "UTC" USE_I18N = True USE_L10N = False -USE_TZ = False +USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ From ec5e2b39502b3bf19fba0ad34285a7e7293f766b Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 17:50:08 +0100 Subject: [PATCH 196/397] chore: faculties as list of names instead of links --- backend/api/serializers/student_serializer.py | 6 +----- backend/authentication/serializers.py | 8 +++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 9040fe1c..4430d3f7 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from ..models.student import Student +from api.models.student import Student class StudentSerializer(serializers.ModelSerializer): @@ -13,10 +13,6 @@ class StudentSerializer(serializers.ModelSerializer): read_only=True, ) - faculties = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="faculty-detail" - ) - class Meta: model = Student fields = '__all__' diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 1ce1b446..0eefe7a6 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -13,7 +13,7 @@ from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings from authentication.signals import user_created, user_login -from authentication.models import User +from authentication.models import User, Faculty from authentication.cas.client import client @@ -114,3 +114,9 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) + + +class FacultySerializer(ModelSerializer): + class Meta: + model = Faculty + fields = "__all__" From a4a5a1e9d258edda0056d0eab357879300d62b61 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 5 Mar 2024 17:57:20 +0100 Subject: [PATCH 197/397] fix: revert student serializer --- backend/api/serializers/student_serializer.py | 4 ++++ backend/authentication/serializers.py | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 4430d3f7..9cd1f245 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -13,6 +13,10 @@ class StudentSerializer(serializers.ModelSerializer): read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + class Meta: model = Student fields = '__all__' diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 0eefe7a6..60771277 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -114,9 +114,3 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) - - -class FacultySerializer(ModelSerializer): - class Meta: - model = Faculty - fields = "__all__" From 47c8675979d2acd8dff27f1a114d2f3625d0b48e Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 5 Mar 2024 18:06:01 +0100 Subject: [PATCH 198/397] fix: execute tests in backend folder --- .github/workflows/backend-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 48768a80..c8ec08da 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -22,4 +22,4 @@ jobs: pip install flake8 pip install -r ./backend/requirements.txt - name: Execute tests - run: python ./backend/manage.py test + run: cd backend; python manage.py test From ecc5cb140ece5afea19b049e3f5229105bec9926 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 09:36:59 +0100 Subject: [PATCH 199/397] chore: refactor checks --- backend/api/models/checks.py | 60 +++++++++++++++++++++++--------- backend/api/models/extensions.py | 23 ++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 backend/api/models/extensions.py diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index ef0595ba..cd2917c0 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -1,30 +1,58 @@ from django.db import models +from api.models.project import Project +from api.models.extensions import ObligatedExtension, BlockedExtension -class FileExtension(models.Model): - """Model that represents a file extension.""" +class StructureCheck(models.Model): + """Model that represents a structure check for a project. + This means that the structure of a submission is checked. + These checks are obligated to pass.""" - # ID check should be generated automatically + # ID should be generated automatically - extension = models.CharField(max_length=10, unique=True) + # Name of the structure check + name = models.CharField( + max_length=100, + blank=False, + null=False + ) - def __str__(self) -> str: - return str(self.extension) + # Link to the project + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="structure_checks" + ) + # Obligated extensions + obligated_extensions = models.ManyToManyField( + ObligatedExtension, + blank=True + ) + + # Blocked extensions + blocked_extensions = models.ManyToManyField( + BlockedExtension, + blank=True + ) -class Checks(models.Model): - """Model that represents checks for a project.""" - # ID check should be generated automatically +class ExtraCheck(models.Model): + """Model that represents an extra check for a project. + These checks are not obligated to pass.""" - dockerfile = models.FileField(blank=True, null=True) + # ID should be generated automatically - # Link to the file extensions that are allowed - allowed_file_extensions = models.ManyToManyField( - FileExtension, related_name="checks_allowed", blank=True + # Link to the project + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="extra_checks" ) - # Link to the file extensions that are forbidden - forbidden_file_extensions = models.ManyToManyField( - FileExtension, related_name="checks_forbidden", blank=True + # Run script + # TODO set upload_to + run_script = models.FileField( + blank=False, + null=False ) diff --git a/backend/api/models/extensions.py b/backend/api/models/extensions.py new file mode 100644 index 00000000..306af433 --- /dev/null +++ b/backend/api/models/extensions.py @@ -0,0 +1,23 @@ +from django.db import models + + +class BlockedExtension(models.Model): + """Model that represents a file extension that is blocked.""" + + # ID should be generated automatically + + extension = models.CharField( + max_length=10, + unique=True + ) + + +class ObligatedExtension(models.Model): + """Model that represents a file extension that is obligated.""" + + # ID should be generated automatically + + extension = models.CharField( + max_length=10, + unique=True + ) From 7d56998c3b0446f0ee42540758ae246976e7d0b1 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 09:56:49 +0100 Subject: [PATCH 200/397] chore: refactor checks to one model --- backend/api/fixtures/checks.yaml | 32 ++++++++++------ backend/api/fixtures/projects.yaml | 1 - backend/api/models/checks.py | 6 +-- backend/api/models/extension.py | 12 ++++++ backend/api/models/extensions.py | 23 ----------- backend/api/models/project.py | 10 ----- backend/api/serializers/checks_serializer.py | 40 ++++++++++++++++---- 7 files changed, 67 insertions(+), 57 deletions(-) create mode 100644 backend/api/models/extension.py delete mode 100644 backend/api/models/extensions.py diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml index 5a9a7795..ac162764 100644 --- a/backend/api/fixtures/checks.yaml +++ b/backend/api/fixtures/checks.yaml @@ -1,26 +1,34 @@ -- model: api.checks +- model: api.structurecheck pk: 1 fields: - dockerfile: 'path/to/Dockerfile' - allowed_file_extensions: - - 1 - - 2 - forbidden_file_extensions: - - 3 - - 4 + name: '.' + project: 123456 + obligated_extensions: + - 3 + - 4 + blocked_extensions: + - 1 + - 2 + +- model: api.extracheck + pk: 1 + fields: + project: 123456 + run_script: 'scripts/run.sh' + - model: api.fileextension pk: 1 fields: - extension: 'py' + extension: 'class' - model: api.fileextension pk: 2 fields: - extension: 'js' + extension: 'png' - model: api.fileextension pk: 3 fields: - extension: 'html' + extension: 'java' - model: api.fileextension pk: 4 fields: - extension: 'php' + extension: 'py' diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml index 8efb2064..2b7eca2b 100644 --- a/backend/api/fixtures/projects.yaml +++ b/backend/api/fixtures/projects.yaml @@ -9,5 +9,4 @@ deadline: 2024-02-27 00:00:00+00:00 group_size: 3 max_score: 20 - checks: 1 course: 2 diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index cd2917c0..848a8052 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -1,6 +1,6 @@ from django.db import models from api.models.project import Project -from api.models.extensions import ObligatedExtension, BlockedExtension +from api.models.extension import FileExtension class StructureCheck(models.Model): @@ -26,13 +26,13 @@ class StructureCheck(models.Model): # Obligated extensions obligated_extensions = models.ManyToManyField( - ObligatedExtension, + FileExtension, blank=True ) # Blocked extensions blocked_extensions = models.ManyToManyField( - BlockedExtension, + FileExtension, blank=True ) diff --git a/backend/api/models/extension.py b/backend/api/models/extension.py new file mode 100644 index 00000000..08238c0c --- /dev/null +++ b/backend/api/models/extension.py @@ -0,0 +1,12 @@ +from django.db import models + + +class FileExtension(models.Model): + """Model that represents a file extension.""" + + # ID should be generated automatically + + extension = models.CharField( + max_length=10, + unique=True + ) diff --git a/backend/api/models/extensions.py b/backend/api/models/extensions.py deleted file mode 100644 index 306af433..00000000 --- a/backend/api/models/extensions.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import models - - -class BlockedExtension(models.Model): - """Model that represents a file extension that is blocked.""" - - # ID should be generated automatically - - extension = models.CharField( - max_length=10, - unique=True - ) - - -class ObligatedExtension(models.Model): - """Model that represents a file extension that is obligated.""" - - # ID should be generated automatically - - extension = models.CharField( - max_length=10, - unique=True - ) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index eb32c77e..777f28f7 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,7 +1,6 @@ from datetime import datetime from django.db import models from django.utils import timezone -from api.models.checks import Checks from api.models.course import Course @@ -42,15 +41,6 @@ class Project(models.Model): default=1 ) - # Check entity that is linked to the project - checks = models.ForeignKey( - Checks, - # If the checks are deleted, the project should remain - on_delete=models.SET_NULL, - blank=True, - null=True, - ) - # Course that the project belongs to course = models.ForeignKey( Course, diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 01254ec0..4a876809 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -1,5 +1,6 @@ from rest_framework import serializers -from ..models.checks import Checks, FileExtension +from ..models.extension import FileExtension +from ..models.checks import StructureCheck, ExtraCheck class FileExtensionSerializer(serializers.ModelSerializer): @@ -8,16 +9,39 @@ class Meta: fields = ["extension"] -class ChecksSerializer(serializers.ModelSerializer): - allowed_file_extensions = FileExtensionSerializer(many=True) +class StructureCheckSerializer(serializers.ModelSerializer): - forbidden_file_extensions = FileExtensionSerializer(many=True) + project = serializers.HyperlinkedRelatedField( + view_name="project-detail", + read_only=True + ) + + obligated_extensions = FileExtensionSerializer(many=True) + + blocked_extensions = FileExtensionSerializer(many=True) + + class Meta: + model = StructureCheck + fields = [ + "id", + "name", + "project", + "obligated_extensions", + "blocked_extensions" + ] + + +class ExtraCheckSerializer(serializers.ModelSerializer): + + project = serializers.HyperlinkedRelatedField( + view_name="project-detail", + read_only=True + ) class Meta: - model = Checks + model = ExtraCheck fields = [ "id", - "dockerfile", - "allowed_file_extensions", - "forbidden_file_extensions", + "project", + "run_script" ] From 61a20f2de22a35102e7cf1377c62dd58a4ddb349 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 10:51:10 +0100 Subject: [PATCH 201/397] chore: refactor view + serializer for checks --- ...ecks_extracheck_structurecheck_and_more.py | 39 +++++++++++++++ backend/api/models/checks.py | 2 + backend/api/serializers/project_serializer.py | 11 ++-- backend/api/urls.py | 50 ++++++++++--------- backend/api/views/checks_view.py | 18 +++++-- 5 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py diff --git a/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py b/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py new file mode 100644 index 00000000..8d5426a7 --- /dev/null +++ b/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.2 on 2024-03-06 09:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_project_group_size_project_max_score'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='checks', + ), + migrations.CreateModel( + name='ExtraCheck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('run_script', models.FileField(upload_to='')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extra_checks', to='api.project')), + ], + ), + migrations.CreateModel( + name='StructureCheck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('blocked_extensions', models.ManyToManyField(blank=True, related_name='blocked_extensions', to='api.fileextension')), + ('obligated_extensions', models.ManyToManyField(blank=True, related_name='obligated_extensions', to='api.fileextension')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='structure_checks', to='api.project')), + ], + ), + migrations.DeleteModel( + name='Checks', + ), + ] diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index 848a8052..0c5e20c3 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -27,12 +27,14 @@ class StructureCheck(models.Model): # Obligated extensions obligated_extensions = models.ManyToManyField( FileExtension, + related_name="obligated_extensions", blank=True ) # Blocked extensions blocked_extensions = models.ManyToManyField( FileExtension, + related_name="blocked_extensions", blank=True ) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 4a26d9ac..2dbe9c79 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -7,8 +7,12 @@ class ProjectSerializer(serializers.ModelSerializer): many=False, read_only=True, view_name="course-detail" ) - checks = serializers.HyperlinkedRelatedField( - many=False, read_only=True, view_name="check-detail" + structure_checks = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="structure_check-detail" + ) + + extra_checks = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="extra_check-detail" ) groups = serializers.HyperlinkedIdentityField( @@ -28,7 +32,8 @@ class Meta: "deadline", "max_score", "group_size", - "checks", + "structure_checks", + "extra_checks", "course", "groups" ] diff --git a/backend/api/urls.py b/backend/api/urls.py index 64b83a23..8f0f6079 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,30 +1,34 @@ from django.urls import include, path -from api.views import user_view -from api.views import teacher_view -from api.views import admin_view -from api.views import assistant_view -from api.views import student_view -from api.views import project_view -from api.views import group_view -from api.views import course_view -from api.views import submision_view -from api.views import checks_view -from api.views import faculty_view from rest_framework.routers import DefaultRouter +from api.views.user_view import UserViewSet +from api.views.teacher_view import TeacherViewSet +from api.views.admin_view import AdminViewSet +from api.views.assistant_view import AssistantViewSet +from api.views.student_view import StudentViewSet +from api.views.project_view import ProjectViewSet +from api.views.group_view import GroupViewSet +from api.views.course_view import CourseViewSet +from api.views.submision_view import SubmissionViewSet +from api.views.faculty_view import facultyViewSet +from api.views.checks_view import ( + ExtraCheckViewSet, FileExtensionViewSet, StructureCheckViewSet +) + router = DefaultRouter() -router.register(r"users", user_view.UserViewSet, basename="user") -router.register(r"teachers", teacher_view.TeacherViewSet, basename="teacher") -router.register(r"admins", admin_view.AdminViewSet, basename="admin") -router.register(r"assistants", assistant_view.AssistantViewSet, basename="assistant") -router.register(r"students", student_view.StudentViewSet, basename="student") -router.register(r"projects", project_view.ProjectViewSet, basename="project") -router.register(r"groups", group_view.GroupViewSet, basename="group") -router.register(r"courses", course_view.CourseViewSet, basename="course") -router.register(r"submissions", submision_view.SubmissionViewSet, basename="submission") -router.register(r"checks", checks_view.ChecksViewSet, basename="check") -router.register(r"fileExtensions", checks_view.FileExtensionViewSet, basename="fileExtension") -router.register(r"faculties", faculty_view.facultyViewSet, basename="faculty") +router.register(r"users", UserViewSet, basename="user") +router.register(r"teachers", TeacherViewSet, basename="teacher") +router.register(r"admins", AdminViewSet, basename="admin") +router.register(r"assistants", AssistantViewSet, basename="assistant") +router.register(r"students", StudentViewSet, basename="student") +router.register(r"projects", ProjectViewSet, basename="project") +router.register(r"groups", GroupViewSet, basename="group") +router.register(r"courses", CourseViewSet, basename="course") +router.register(r"submissions", SubmissionViewSet, basename="submission") +router.register(r"structure_checks", StructureCheckViewSet, basename="structure_check") +router.register(r"extra_checks", ExtraCheckViewSet, basename="extra_check") +router.register(r"fileExtensions", FileExtensionViewSet, basename="fileExtension") +router.register(r"faculties", facultyViewSet, basename="faculty") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 654eb1f1..eaf18757 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,11 +1,19 @@ from rest_framework import viewsets -from ..models.checks import Checks, FileExtension -from ..serializers.checks_serializer import ChecksSerializer, FileExtensionSerializer +from ..models.extension import FileExtension +from ..models.checks import StructureCheck, ExtraCheck +from ..serializers.checks_serializer import ( + StructureCheckSerializer, ExtraCheckSerializer, FileExtensionSerializer +) -class ChecksViewSet(viewsets.ModelViewSet): - queryset = Checks.objects.all() - serializer_class = ChecksSerializer +class StructureCheckViewSet(viewsets.ModelViewSet): + queryset = StructureCheck.objects.all() + serializer_class = StructureCheckSerializer + + +class ExtraCheckViewSet(viewsets.ModelViewSet): + queryset = ExtraCheck.objects.all() + serializer_class = ExtraCheckSerializer class FileExtensionViewSet(viewsets.ModelViewSet): From c2a5b9fff282bbfe0a85a4b6fcb234922a756fa4 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 12:36:55 +0100 Subject: [PATCH 202/397] chore: refactor checks tests --- backend/api/tests/test_checks.py | 153 +++++++++++++++++++++++----- backend/api/tests/test_project.py | 163 +++++++++++++++--------------- 2 files changed, 208 insertions(+), 108 deletions(-) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index b47fe651..49781271 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -1,8 +1,12 @@ import json +from django.utils import timezone from django.urls import reverse from rest_framework.test import APITestCase from authentication.models import User -from api.models.checks import FileExtension, Checks +from api.models.checks import StructureCheck, ExtraCheck +from api.models.extension import FileExtension +from api.models.project import Project +from api.models.course import Course def create_fileExtension(id, extension): @@ -12,17 +16,67 @@ def create_fileExtension(id, extension): return FileExtension.objects.create(id=id, extension=extension) -def create_checks(id, allowed_file_extensions, forbidden_file_extensions): - """Create a Checks with the given arguments.""" - check = Checks.objects.create( +def create_structure_check(id, name, project, obligated_extensions, blocked_extensions): + """ + Create a StructureCheck with the given arguments. + """ + check = StructureCheck.objects.create(id=id, name=name, project=project) + + for ext in obligated_extensions: + check.obligated_extensions.add(ext) + for ext in blocked_extensions: + check.blocked_extensions.add(ext) + + return check + + +def create_extra_check(id, project, run_script): + """ + Create an ExtraCheck with the given arguments. + """ + return ExtraCheck.objects.create(id=id, project=project, run_script=run_script) + + +def create_project(id, name, description, visible, archived, days, course, max_score, group_size): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( id=id, + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + course=course, + max_score=max_score, + group_size=group_size, ) - for ext in allowed_file_extensions: - check.allowed_file_extensions.add(ext) - for ext in forbidden_file_extensions: - check.forbidden_file_extensions.add(ext) - return check + +def create_course(id, name, academic_startyear): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + id=id, name=name, academic_startyear=academic_startyear + ) + + +def get_project(): + course = create_course(id=1, name="Course", academic_startyear=2021) + project = create_project( + id=1, + name="Project", + description="Description", + visible=True, + archived=False, + days=5, + course=course, + max_score=10, + group_size=2, + ) + return project class FileExtensionModelTests(APITestCase): @@ -33,7 +87,7 @@ def setUp(self) -> None: def test_no_fileExtension(self): """ - able to retrieve no FileExtension before publishing it. + Able to retrieve no FileExtension before publishing it. """ response_root = self.client.get(reverse("fileExtension-list"), follow=True) self.assertEqual(response_root.status_code, 200) @@ -130,7 +184,7 @@ def test_fileExtension_detail_view(self): self.assertEqual(content_json["extension"], fileExtension.extension) -class ChecksModelTests(APITestCase): +class StructureCheckModelTests(APITestCase): def setUp(self) -> None: self.client.force_authenticate( User.get_dummy_admin() @@ -140,13 +194,13 @@ def test_no_checks(self): """ Able to retrieve no Checks before publishing it. """ - response_root = self.client.get(reverse("check-list"), follow=True) + response_root = self.client.get(reverse("structure_check-list"), follow=True) self.assertEqual(response_root.status_code, 200) self.assertEqual(response_root.accepted_media_type, "application/json") content_json = json.loads(response_root.content.decode("utf-8")) self.assertEqual(content_json, []) - def test_checks_exists(self): + def test_structure_checks_exists(self): """ Able to retrieve a single Checks after creating it. """ @@ -155,14 +209,16 @@ def test_checks_exists(self): fileExtension2 = create_fileExtension(id=2, extension="png") fileExtension3 = create_fileExtension(id=3, extension="tar") fileExtension4 = create_fileExtension(id=4, extension="wfp") - checks = create_checks( - id=5, - allowed_file_extensions=[fileExtension1, fileExtension4], - forbidden_file_extensions=[fileExtension2, fileExtension3], + checks = create_structure_check( + id=1, + name=".", + project=get_project(), + obligated_extensions=[fileExtension1, fileExtension4], + blocked_extensions=[fileExtension2, fileExtension3], ) # Make a GET request to retrieve the Checks - response = self.client.get(reverse("check-list"), follow=True) + response = self.client.get(reverse("structure_check-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -180,25 +236,68 @@ def test_checks_exists(self): # Assert the file extensions of the retrieved # Checks match the created file extensions - retrieved_allowed_file_extensions = retrieved_checks["allowed_file_extensions"] + retrieved_obligated_file_extensions = retrieved_checks["obligated_extensions"] - self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual(len(retrieved_obligated_file_extensions), 2) self.assertEqual( - retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + retrieved_obligated_file_extensions[0]["extension"], fileExtension1.extension ) self.assertEqual( - retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + retrieved_obligated_file_extensions[1]["extension"], fileExtension4.extension ) - retrieved_forbidden_file_extensions = retrieved_checks[ - "forbidden_file_extensions" + retrieved_blocked_file_extensions = retrieved_checks[ + "blocked_extensions" ] - self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + self.assertEqual(len(retrieved_blocked_file_extensions), 2) self.assertEqual( - retrieved_forbidden_file_extensions[0]["extension"], + retrieved_blocked_file_extensions[0]["extension"], fileExtension2.extension, ) self.assertEqual( - retrieved_forbidden_file_extensions[1]["extension"], + retrieved_blocked_file_extensions[1]["extension"], fileExtension3.extension, ) + + +class ExtraCheckModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_checks(self): + """ + Able to retrieve no Checks before publishing it. + """ + response_root = self.client.get(reverse("extra_check-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + self.assertEqual(response_root.accepted_media_type, "application/json") + content_json = json.loads(response_root.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_extra_checks_exists(self): + """ + Able to retrieve a single Checks after creating it. + """ + checks = create_extra_check( + id=1, project=get_project(), run_script="test.sh" + ) + + # Make a GET request to retrieve the Checks + response = self.client.get(reverse("extra_check-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one Checks + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved Checks match the created Checks + retrieved_checks = content_json[0] + self.assertEqual(int(retrieved_checks["id"]), checks.id) + self.assertEqual(retrieved_checks["run_script"], "http://testserver" + checks.run_script.url) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index ae9f2efb..14381296 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,7 +5,8 @@ from authentication.models import User from api.models.project import Project from api.models.course import Course -from api.models.checks import Checks, FileExtension +from api.models.checks import StructureCheck, ExtraCheck +from api.models.extension import FileExtension def create_course(id, name, academic_startyear): @@ -24,27 +25,7 @@ def create_fileExtension(id, extension): return FileExtension.objects.create(id=id, extension=extension) -def create_checks( - id=None, allowed_file_extensions=None, forbidden_file_extensions=None -): - """Create a Checks with the given arguments.""" - if id is None and allowed_file_extensions is None: - # extra if to make line shorter - if forbidden_file_extensions is None: - return Checks.objects.create() - - check = Checks.objects.create( - id=id, - ) - - for ext in allowed_file_extensions: - check.allowed_file_extensions.add(ext) - for ext in forbidden_file_extensions: - check.forbidden_file_extensions.add(ext) - return check - - -def create_project(name, description, visible, archived, days, checks, course): +def create_project(name, description, visible, archived, days, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timezone.timedelta(days=days) @@ -54,11 +35,24 @@ def create_project(name, description, visible, archived, days, checks, course): visible=visible, archived=archived, deadline=deadline, - checks=checks, course=course, ) +def create_structure_check(id, name, project, obligated_extensions, blocked_extensions): + """ + Create a StructureCheck with the given arguments. + """ + check = StructureCheck.objects.create(id=id, name=name, project=project) + + for ext in obligated_extensions: + check.obligated_extensions.add(ext) + for ext in blocked_extensions: + check.blocked_extensions.add(ext) + + return check + + class ProjectModelTests(APITestCase): def setUp(self) -> None: self.client.force_authenticate( @@ -70,14 +64,12 @@ def test_toggle_visible(self): toggle the visible state of a project. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() past_project = create_project( name="test", description="descr", visible=True, archived=False, days=-10, - checks=checks, course=course, ) self.assertIs(past_project.visible, True) @@ -91,14 +83,12 @@ def test_toggle_archived(self): toggle the archived state of a project. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() past_project = create_project( name="test", description="descr", visible=True, archived=True, days=-10, - checks=checks, course=course, ) @@ -114,14 +104,12 @@ def test_deadline_approaching_in_with_past_Project(self): is in the past. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() past_project = create_project( name="test", description="descr", visible=True, archived=False, days=-10, - checks=checks, course=course, ) self.assertIs(past_project.deadline_approaching_in(), False) @@ -132,14 +120,12 @@ def test_deadline_approaching_in_with_future_Project_within_time(self): is in the timerange given. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() future_project = create_project( name="test", description="descr", visible=True, archived=False, days=6, - checks=checks, course=course, ) self.assertIs(future_project.deadline_approaching_in(days=7), True) @@ -150,14 +136,12 @@ def test_deadline_approaching_in_with_future_Project_not_within_time(self): is out of the timerange given. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() future_project = create_project( name="test", description="descr", visible=True, archived=False, days=8, - checks=checks, course=course, ) self.assertIs(future_project.deadline_approaching_in(days=7), False) @@ -168,14 +152,12 @@ def test_deadline_passed_with_future_Project(self): is not passed. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() future_project = create_project( name="test", description="descr", visible=True, archived=False, days=1, - checks=checks, course=course, ) self.assertIs(future_project.deadline_passed(), False) @@ -186,14 +168,12 @@ def test_deadline_passed_with_past_Project(self): is passed. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() past_project = create_project( name="test", description="descr", visible=True, archived=False, days=-1, - checks=checks, course=course, ) self.assertIs(past_project.deadline_passed(), True) @@ -212,14 +192,12 @@ def test_project_exists(self): """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() project = create_project( name="test project", description="test description", visible=True, archived=False, days=7, - checks=checks, course=course, ) @@ -234,10 +212,6 @@ def test_project_exists(self): retrieved_project = content_json[0] - expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) - expected_course_url = "http://testserver" + reverse( "course-detail", args=[str(course.id)] ) @@ -246,7 +220,6 @@ def test_project_exists(self): self.assertEqual(retrieved_project["description"], project.description) self.assertEqual(retrieved_project["visible"], project.visible) self.assertEqual(retrieved_project["archived"], project.archived) - self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) def test_multiple_project(self): @@ -254,14 +227,12 @@ def test_multiple_project(self): Able to retrieve multiple projects after creating it. """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() project = create_project( name="test project", description="test description", visible=True, archived=False, days=7, - checks=checks, course=course, ) @@ -271,7 +242,6 @@ def test_multiple_project(self): visible=True, archived=False, days=7, - checks=checks, course=course, ) @@ -286,10 +256,6 @@ def test_multiple_project(self): retrieved_project = content_json[0] - expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) - expected_course_url = "http://testserver" + reverse( "course-detail", args=[str(course.id)] ) @@ -298,15 +264,10 @@ def test_multiple_project(self): self.assertEqual(retrieved_project["description"], project.description) self.assertEqual(retrieved_project["visible"], project.visible) self.assertEqual(retrieved_project["archived"], project.archived) - self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) retrieved_project = content_json[1] - expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) - expected_course_url = "http://testserver" + reverse( "course-detail", args=[str(course.id)] ) @@ -315,7 +276,6 @@ def test_multiple_project(self): self.assertEqual(retrieved_project["description"], project2.description) self.assertEqual(retrieved_project["visible"], project2.visible) self.assertEqual(retrieved_project["archived"], project2.archived) - self.assertEqual(retrieved_project["checks"], expected_checks_url) self.assertEqual(retrieved_project["course"], expected_course_url) def test_project_course(self): @@ -324,14 +284,12 @@ def test_project_course(self): """ course = create_course(id=3, name="test course", academic_startyear=2024) - checks = create_checks() project = create_project( name="test project", description="test description", visible=True, archived=False, days=7, - checks=checks, course=course, ) @@ -346,15 +304,10 @@ def test_project_course(self): retrieved_project = content_json[0] - expected_checks_url = "http://testserver" + reverse( - "check-detail", args=[str(checks.id)] - ) - self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) self.assertEqual(retrieved_project["visible"], project.visible) self.assertEqual(retrieved_project["archived"], project.archived) - self.assertEqual(retrieved_project["checks"], expected_checks_url) response = self.client.get(retrieved_project["course"], follow=True) @@ -371,9 +324,9 @@ def test_project_course(self): self.assertEqual(content_json["academic_startyear"], course.academic_startyear) self.assertEqual(content_json["description"], course.description) - def test_project_checks(self): + def test_project_structure_checks(self): """ - Able to retrieve a check of a project after creating it. + Able to retrieve a structure check of a project after creating it. """ course = create_course(id=3, name="test course", academic_startyear=2024) @@ -381,20 +334,21 @@ def test_project_checks(self): fileExtension2 = create_fileExtension(id=2, extension="png") fileExtension3 = create_fileExtension(id=3, extension="tar") fileExtension4 = create_fileExtension(id=4, extension="wfp") - checks = create_checks( - id=5, - allowed_file_extensions=[fileExtension1, fileExtension4], - forbidden_file_extensions=[fileExtension2, fileExtension3], - ) project = create_project( name="test project", description="test description", visible=True, archived=False, days=7, - checks=checks, course=course, ) + checks = create_structure_check( + id=5, + name=".", + project=project, + obligated_extensions=[fileExtension1, fileExtension4], + blocked_extensions=[fileExtension2, fileExtension3], + ) response = self.client.get(reverse("project-list"), follow=True) @@ -417,7 +371,7 @@ def test_project_checks(self): self.assertEqual(retrieved_project["archived"], project.archived) self.assertEqual(retrieved_project["course"], expected_course_url) - response = self.client.get(retrieved_project["checks"], follow=True) + response = self.client.get(retrieved_project["structure_checks"][0], follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -432,23 +386,70 @@ def test_project_checks(self): # Assert the file extensions of the retrieved # Checks match the created file extensions - retrieved_allowed_file_extensions = content_json["allowed_file_extensions"] + retrieved_obligated_extensions = content_json["obligated_extensions"] - self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual(len(retrieved_obligated_extensions), 2) self.assertEqual( - retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + retrieved_obligated_extensions[0]["extension"], fileExtension1.extension ) self.assertEqual( - retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + retrieved_obligated_extensions[1]["extension"], fileExtension4.extension ) - retrieved_forbidden_file_extensions = content_json["forbidden_file_extensions"] - self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + retrieved_blocked_file_extensions = content_json["blocked_extensions"] + self.assertEqual(len(retrieved_blocked_file_extensions), 2) self.assertEqual( - retrieved_forbidden_file_extensions[0]["extension"], + retrieved_blocked_file_extensions[0]["extension"], fileExtension2.extension, ) self.assertEqual( - retrieved_forbidden_file_extensions[1]["extension"], + retrieved_blocked_file_extensions[1]["extension"], fileExtension3.extension, ) + + def test_project_extra_checks(self): + """ + Able to retrieve a extra check of a project after creating it. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + checks = ExtraCheck.objects.create( + id=5, + project=project, + run_script="testscript.sh", + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + response = self.client.get(retrieved_project["extra_checks"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), checks.id) + self.assertEqual(content_json["project"], "http://testserver" + reverse( + "project-detail", args=[str(project.id)] + )) + self.assertEqual(content_json["run_script"], "http://testserver" + checks.run_script.url) From 2c8ebd9ffe3748764683c282b8ac5151bfa8d25d Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 13:21:50 +0100 Subject: [PATCH 203/397] chore: refactor submission to have status of checks after submission --- ...ructure_checks_passed_extrachecksresult.py | 28 ++++++++++++++ backend/api/models/submission.py | 38 +++++++++++++++++++ ...serializer.py => submission_serializer.py} | 16 +++++++- backend/api/views/submision_view.py | 2 +- 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py rename backend/api/serializers/{submision_serializer.py => submission_serializer.py} (57%) diff --git a/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py b/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py new file mode 100644 index 00000000..5c1abdab --- /dev/null +++ b/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-03-06 11:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_remove_project_checks_extracheck_structurecheck_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='structure_checks_passed', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='ExtraChecksResult', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('passed', models.BooleanField(default=False)), + ('extra_check', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.extracheck')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extra_checks_results', to='api.submission')), + ], + ), + ] diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 8f41018c..531f07a0 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,5 +1,6 @@ from django.db import models from api.models.group import Group +from api.models.checks import ExtraCheck class Submission(models.Model): @@ -22,6 +23,13 @@ class Submission(models.Model): # Automatically set the submission time to the current time submission_time = models.DateTimeField(auto_now_add=True) + # True if submission passed the structure checks + structure_checks_passed = models.BooleanField( + blank=False, + null=False, + default=False + ) + class Meta: # A group can only have one submission with a specific number unique_together = ("group", "submission_number") @@ -43,3 +51,33 @@ class SubmissionFile(models.Model): # TODO - Set the right place to save the file file = models.FileField(blank=False, null=False) + + +class ExtraChecksResult(models.Model): + """Model for the result of extra checks on a submission.""" + + # Result ID should be generated automatically + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="extra_checks_results", + blank=False, + null=False, + ) + + # Link to the extra checks that were performed + extra_check = models.ForeignKey( + ExtraCheck, + on_delete=models.CASCADE, + related_name="results", + blank=False, + null=False, + ) + + # True if the submission passed the extra checks + passed = models.BooleanField( + blank=False, + null=False, + default=False + ) diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submission_serializer.py similarity index 57% rename from backend/api/serializers/submision_serializer.py rename to backend/api/serializers/submission_serializer.py index da7458b8..cf964e1c 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -15,6 +15,20 @@ class SubmissionSerializer(serializers.ModelSerializer): files = SubmissionFileSerializer(many=True, read_only=True) + extra_checks_results = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name="extra_checks-detail" + ) + class Meta: model = Submission - fields = ["id", "group", "submission_number", "submission_time", "files"] + fields = [ + "id", + "group", + "submission_number", + "submission_time", + "files", + "structure_checks_passed", + "extra_checks_results" + ] diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index 8e0de7ad..3644d619 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile -from ..serializers.submision_serializer import ( +from ..serializers.submission_serializer import ( SubmissionSerializer, SubmissionFileSerializer, ) From 6a24fa1aae69470f9716661514f49a73b7c2724c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 6 Mar 2024 13:44:10 +0100 Subject: [PATCH 204/397] feat(notification): Added events --- backend/notifications/serializers.py | 16 ++++++++++--- backend/notifications/signals.py | 34 ++++++++++++++++++++++++++++ backend/notifications/urls.py | 4 +++- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 backend/notifications/signals.py diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 4a24cd59..52482ca9 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -13,11 +13,21 @@ class Meta: fields = "__all__" +# Hyper linked user field that returns a hyper link but expects an id +class UserHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): + view_name = "user-detail" + queryset = User.objects.all() + + def to_internal_value(self, data): + try: + return self.queryset.get(pk=data) + except User.DoesNotExist: + self.fail("no_match") + + class NotificationSerializer(serializers.ModelSerializer): # Hyper linked user field - user = serializers.HyperlinkedRelatedField( - view_name="user-detail", queryset=User.objects.all() - ) + user = UserHyperLinkedRelatedField() # Translate template and arguments into a message message = serializers.SerializerMethodField() diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py new file mode 100644 index 00000000..6abe6304 --- /dev/null +++ b/backend/notifications/signals.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import io +from enum import Enum +from typing import Dict + +from authentication.models import User +from django.dispatch import Signal, receiver +from notifications.models import Notification, NotificationTemplate +from notifications.serializers import NotificationSerializer +from rest_framework.parsers import JSONParser + +notification_create = Signal() + + +@receiver(notification_create) +def notification_creation( + type: NotificationType, user: User, arguments: Dict[str, str], **kwargs +) -> bool: + serializer = NotificationSerializer( + data={"template_id": type.value, "user": user.id, "arguments": arguments} + ) + + if not serializer.is_valid(): + return False + + serializer.save() + + return True + + +class NotificationType(Enum): + SCORE_ADDED = 1 + SCORE_UPDATED = 2 diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index 430a82b5..142fde10 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,4 +1,6 @@ from django.urls import path from notifications.views import NotificationView -urlpatterns = [path("/", NotificationView.as_view())] +urlpatterns = [ + path("/", NotificationView.as_view()), +] From 1dcea4160e030c245deff10679ad37e293a4f214 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 13:51:10 +0100 Subject: [PATCH 205/397] chore: link extra checks to submission --- backend/api/fixtures/submissions.yaml | 16 +++++++++++- .../api/serializers/submission_serializer.py | 25 ++++++++++++++----- backend/api/urls.py | 2 +- .../{submision_view.py => submission_view.py} | 5 +--- 4 files changed, 36 insertions(+), 12 deletions(-) rename backend/api/views/{submision_view.py => submission_view.py} (77%) diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml index 0b8f876d..af3627eb 100644 --- a/backend/api/fixtures/submissions.yaml +++ b/backend/api/fixtures/submissions.yaml @@ -4,13 +4,14 @@ group: 1 submission_number: 1 submission_time: '2021-01-01T00:00:00Z' + structure_checks_passed: True - model: api.submission pk: 2 fields: group: 1 submission_number: 2 submission_time: '2021-01-02T00:00:00Z' - + structure_checks_passed: True - model: api.submissionfile pk: 1 @@ -22,3 +23,16 @@ fields: submission: 2 file: 'submissions/1/2/1.txt' + +- model: api.extrachecksresult + pk: 1 + fields: + submission: 1 + extra_check: 1 + passed: False +- model: api.extrachecksresult + pk: 2 + fields: + submission: 2 + extra_check: 1 + passed: True diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index cf964e1c..00ff8312 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from ..models.submission import Submission, SubmissionFile +from ..models.submission import Submission, SubmissionFile, ExtraChecksResult class SubmissionFileSerializer(serializers.ModelSerializer): @@ -8,18 +8,31 @@ class Meta: fields = ["file"] +class ExtraChecksResultSerializer(serializers.ModelSerializer): + + extra_check = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name="extra_check-detail" + ) + + class Meta: + model = ExtraChecksResult + fields = [ + "extra_check", + "passed" + ] + + class SubmissionSerializer(serializers.ModelSerializer): + group = serializers.HyperlinkedRelatedField( many=False, read_only=True, view_name="group-detail" ) files = SubmissionFileSerializer(many=True, read_only=True) - extra_checks_results = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name="extra_checks-detail" - ) + extra_checks_results = ExtraChecksResultSerializer(many=True, read_only=True) class Meta: model = Submission diff --git a/backend/api/urls.py b/backend/api/urls.py index 8f0f6079..d16f56fb 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -8,7 +8,7 @@ from api.views.project_view import ProjectViewSet from api.views.group_view import GroupViewSet from api.views.course_view import CourseViewSet -from api.views.submision_view import SubmissionViewSet +from api.views.submission_view import SubmissionViewSet from api.views.faculty_view import facultyViewSet from api.views.checks_view import ( ExtraCheckViewSet, FileExtensionViewSet, StructureCheckViewSet diff --git a/backend/api/views/submision_view.py b/backend/api/views/submission_view.py similarity index 77% rename from backend/api/views/submision_view.py rename to backend/api/views/submission_view.py index 3644d619..279f105b 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submission_view.py @@ -1,9 +1,6 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile -from ..serializers.submission_serializer import ( - SubmissionSerializer, - SubmissionFileSerializer, -) +from ..serializers.submission_serializer import SubmissionSerializer, SubmissionFileSerializer class SubmissionFileViewSet(viewsets.ModelViewSet): From 8bbd2edd67ac0b0a8fccb2fc7dee8c2dc54b2fe3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 6 Mar 2024 13:52:39 +0100 Subject: [PATCH 206/397] chore: NotificationType argument hints --- backend/notifications/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 6abe6304..52b0a84e 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -30,5 +30,5 @@ def notification_creation( class NotificationType(Enum): - SCORE_ADDED = 1 - SCORE_UPDATED = 2 + SCORE_ADDED = 1 # Arguments: {"score": int} + SCORE_UPDATED = 2 # Arguments: {"score": int} From 53447e272625aa0acef5198b4d47ce29ca8062eb Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 6 Mar 2024 14:20:53 +0100 Subject: [PATCH 207/397] chore: change user hyperlink field --- backend/notifications/serializers.py | 16 +++------------- backend/notifications/signals.py | 14 ++++++++------ backend/notifications/views.py | 2 +- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 52482ca9..4a24cd59 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -13,21 +13,11 @@ class Meta: fields = "__all__" -# Hyper linked user field that returns a hyper link but expects an id -class UserHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "user-detail" - queryset = User.objects.all() - - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except User.DoesNotExist: - self.fail("no_match") - - class NotificationSerializer(serializers.ModelSerializer): # Hyper linked user field - user = UserHyperLinkedRelatedField() + user = serializers.HyperlinkedRelatedField( + view_name="user-detail", queryset=User.objects.all() + ) # Translate template and arguments into a message message = serializers.SerializerMethodField() diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 52b0a84e..2ded382f 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -1,14 +1,12 @@ from __future__ import annotations -import io from enum import Enum from typing import Dict from authentication.models import User from django.dispatch import Signal, receiver -from notifications.models import Notification, NotificationTemplate +from django.urls import reverse from notifications.serializers import NotificationSerializer -from rest_framework.parsers import JSONParser notification_create = Signal() @@ -18,7 +16,11 @@ def notification_creation( type: NotificationType, user: User, arguments: Dict[str, str], **kwargs ) -> bool: serializer = NotificationSerializer( - data={"template_id": type.value, "user": user.id, "arguments": arguments} + data={ + "template_id": type.value, + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } ) if not serializer.is_valid(): @@ -30,5 +32,5 @@ def notification_creation( class NotificationType(Enum): - SCORE_ADDED = 1 # Arguments: {"score": int} - SCORE_UPDATED = 2 # Arguments: {"score": int} + SCORE_ADDED = 1 # Arguments: {"score": int} + SCORE_UPDATED = 2 # Arguments: {"score": int} diff --git a/backend/notifications/views.py b/backend/notifications/views.py index c02df3c0..5f4dd772 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -16,7 +16,7 @@ class NotificationPermission(BasePermission): # The user can only access their own notifications # An admin can access all notifications def has_permission(self, request: Request, view: NotificationView) -> bool: - return view.kwargs.get("user_id") == request.user.id + return view.kwargs.get("user_id") == request.user.id or request.user.is_staff class NotificationView(APIView): From 46697ae34211fecc13f20950615871d4fd586352 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 6 Mar 2024 14:37:07 +0100 Subject: [PATCH 208/397] chore: fix test --- backend/authentication/serializers.py | 35 +++++++++++---------------- backend/notifications/urls.py | 2 +- backend/ypovoli/urls.py | 23 ++++++++++++------ 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 60771277..7c4f1d92 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,20 +1,20 @@ from typing import Tuple -from django.contrib.auth.models import update_last_login + +from authentication.cas.client import client +from authentication.models import User +from authentication.signals import user_created, user_login from django.contrib.auth import login +from django.contrib.auth.models import update_last_login from rest_framework.serializers import ( CharField, EmailField, + HyperlinkedRelatedField, ModelSerializer, - ValidationError, Serializer, - HyperlinkedIdentityField, - HyperlinkedRelatedField, + ValidationError, ) -from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings -from authentication.signals import user_created, user_login -from authentication.models import User, Faculty -from authentication.cas.client import client +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken class CASTokenObtainSerializer(Serializer): @@ -22,6 +22,7 @@ class CASTokenObtainSerializer(Serializer): This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. """ + ticket = CharField(required=True, min_length=49, max_length=49) def validate(self, data): @@ -40,23 +41,15 @@ def validate(self, data): if "request" in self.context: login(self.context["request"], user) - user_login.send( - sender=self, user=user - ) + user_login.send(sender=self, user=user) if created: - user_created.send( - sender=self, attributes=attributes, user=user - ) + user_created.send(sender=self, attributes=attributes, user=user) # Return access tokens for the now logged-in user. return { - "access": str( - AccessToken.for_user(user) - ), - "refresh": str( - RefreshToken.for_user(user) - ), + "access": str(AccessToken.for_user(user)), + "refresh": str(RefreshToken.for_user(user)), } def _validate_ticket(self, ticket: str) -> dict: @@ -102,7 +95,7 @@ class UserSerializer(ModelSerializer): many=True, read_only=True, view_name="faculty-detail" ) - notifications = HyperlinkedIdentityField( + notifications = HyperlinkedRelatedField( view_name="notification-detail", read_only=True, ) diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index 142fde10..e80acd66 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -2,5 +2,5 @@ from notifications.views import NotificationView urlpatterns = [ - path("/", NotificationView.as_view()), + path("/", NotificationView.as_view(), name="notification-detail"), ] diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 25e30a72..f3093cdc 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -14,10 +14,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import path, include -from rest_framework import permissions -from drf_yasg.views import get_schema_view + +from django.urls import include, path from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions schema_view = get_schema_view( openapi.Info( @@ -25,7 +26,9 @@ default_version="v1", ), public=True, - permission_classes=[permissions.AllowAny,], + permission_classes=[ + permissions.AllowAny, + ], ) @@ -34,8 +37,14 @@ path("", include("api.urls")), # Authentication endpoints. path("auth/", include("authentication.urls")), - path("notifications/", include("notifications.urls")), + path("notifications/", include("notifications.urls"), name="notifications"), # Swagger documentation. - path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - path("swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json"), + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" + ), ] From d0882e144433d6e2fa5f370283836b3077365f51 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 14:43:27 +0100 Subject: [PATCH 209/397] chore: fix tests submission --- backend/api/tests/test_submission.py | 49 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index fa7a4386..ac0b52ab 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -4,10 +4,11 @@ from django.urls import reverse from rest_framework.test import APITestCase from authentication.models import User -from api.models.submission import Submission, SubmissionFile +from api.models.submission import Submission, SubmissionFile, ExtraChecksResult from api.models.project import Project from api.models.group import Group from api.models.course import Course +from api.models.checks import ExtraCheck def create_course(name, academic_startyear, description=None, parent_course=None): @@ -38,7 +39,7 @@ def create_group(project, score): def create_submission(group, submission_number): """Create an Submission with the given arguments.""" return Submission.objects.create( - group=group, submission_number=submission_number, submission_time=timezone.now() + group=group, submission_number=submission_number, submission_time=timezone.now(), structure_checks_passed=True ) @@ -105,6 +106,7 @@ def test_submission_exists(self): int(retrieved_submission["submission_number"]), submission.submission_number ) self.assertEqual(retrieved_submission["group"], expected_group_url) + self.assertEqual(retrieved_submission["structure_checks_passed"], submission.structure_checks_passed) def test_multiple_submission_exists(self): """ @@ -246,3 +248,46 @@ def test_submission_group(self): self.assertEqual(int(content_json["id"]), group.id) self.assertEqual(content_json["project"], expected_project_url) self.assertEqual(content_json["score"], group.score) + + def test_submission_extra_checks(self): + """ + Able to retrieve extra checks of a single submission. + """ + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) + extra_check = ExtraCheck.objects.create( + project=project, run_script="test.py" + ) + extra_check_result = ExtraChecksResult.objects.create( + submission=submission, extra_check=extra_check, passed=True + ) + + # Make a GET request to retrieve the submission + response = self.client.get( + reverse("submission-detail", args=[str(submission.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json + self.assertEqual(int(retrieved_submission["id"]), submission.id) + + # Extra check that is part of the project + retrieved_extra_check = content_json["extra_checks_results"][0] + + self.assertEqual( + retrieved_extra_check["passed"], extra_check_result.passed + ) From 898219c2e884704a7824f34feb93f3b0022c8d5e Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 15:11:59 +0100 Subject: [PATCH 210/397] fix: linting --- backend/api/tests/test_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 14381296..03dfe7cd 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -410,7 +410,7 @@ def test_project_structure_checks(self): def test_project_extra_checks(self): """ Able to retrieve a extra check of a project after creating it. - """ + """ course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test project", From f3593dace12776a7d1c6f58c491338112d3bd9f9 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 15:29:19 +0100 Subject: [PATCH 211/397] fix: typo --- backend/api/urls.py | 4 ++-- backend/api/views/faculty_view.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/urls.py b/backend/api/urls.py index d16f56fb..4aa067ca 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,7 +9,7 @@ from api.views.group_view import GroupViewSet from api.views.course_view import CourseViewSet from api.views.submission_view import SubmissionViewSet -from api.views.faculty_view import facultyViewSet +from api.views.faculty_view import FacultyViewSet from api.views.checks_view import ( ExtraCheckViewSet, FileExtensionViewSet, StructureCheckViewSet ) @@ -28,7 +28,7 @@ router.register(r"structure_checks", StructureCheckViewSet, basename="structure_check") router.register(r"extra_checks", ExtraCheckViewSet, basename="extra_check") router.register(r"fileExtensions", FileExtensionViewSet, basename="fileExtension") -router.register(r"faculties", facultyViewSet, basename="faculty") +router.register(r"faculties", FacultyViewSet, basename="faculty") urlpatterns = [ path("", include(router.urls)), diff --git a/backend/api/views/faculty_view.py b/backend/api/views/faculty_view.py index 92975d0f..446ac45b 100644 --- a/backend/api/views/faculty_view.py +++ b/backend/api/views/faculty_view.py @@ -3,6 +3,6 @@ from ..serializers.faculty_serializer import facultySerializer -class facultyViewSet(viewsets.ModelViewSet): +class FacultyViewSet(viewsets.ModelViewSet): queryset = Faculty.objects.all() serializer_class = facultySerializer From b59f565b82c916f8e712017cebfbcd80b4123725 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 6 Mar 2024 15:34:27 +0100 Subject: [PATCH 212/397] fix: update default score project --- .../migrations/0005_alter_project_max_score.py | 18 ++++++++++++++++++ backend/api/models/project.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 backend/api/migrations/0005_alter_project_max_score.py diff --git a/backend/api/migrations/0005_alter_project_max_score.py b/backend/api/migrations/0005_alter_project_max_score.py new file mode 100644 index 00000000..faec3a5f --- /dev/null +++ b/backend/api/migrations/0005_alter_project_max_score.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-03-06 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_submission_structure_checks_passed_extrachecksresult'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='max_score', + field=models.PositiveSmallIntegerField(default=20), + ), + ] diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 777f28f7..88e587fc 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -31,7 +31,7 @@ class Project(models.Model): max_score = models.PositiveSmallIntegerField( blank=False, null=False, - default=100 + default=20 ) # Size of the groups than can be formed From 79caead723c817ff938bca3979e8366aab8f4459 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 7 Mar 2024 10:00:37 +0100 Subject: [PATCH 213/397] fix: - instead of _ --- backend/api/serializers/project_serializer.py | 4 ++-- backend/api/serializers/submission_serializer.py | 2 +- backend/api/tests/test_checks.py | 16 ++++++++-------- backend/api/urls.py | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 2dbe9c79..f9c458d5 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -8,11 +8,11 @@ class ProjectSerializer(serializers.ModelSerializer): ) structure_checks = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="structure_check-detail" + many=True, read_only=True, view_name="structure-check-detail" ) extra_checks = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="extra_check-detail" + many=True, read_only=True, view_name="extra-check-detail" ) groups = serializers.HyperlinkedIdentityField( diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 00ff8312..744cd4ac 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -13,7 +13,7 @@ class ExtraChecksResultSerializer(serializers.ModelSerializer): extra_check = serializers.HyperlinkedRelatedField( many=False, read_only=True, - view_name="extra_check-detail" + view_name="extra-check-detail" ) class Meta: diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index 49781271..b66251b8 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -89,7 +89,7 @@ def test_no_fileExtension(self): """ Able to retrieve no FileExtension before publishing it. """ - response_root = self.client.get(reverse("fileExtension-list"), follow=True) + response_root = self.client.get(reverse("file-extension-list"), follow=True) self.assertEqual(response_root.status_code, 200) # Assert that the response is JSON self.assertEqual(response_root.accepted_media_type, "application/json") @@ -105,7 +105,7 @@ def test_fileExtension_exists(self): fileExtension = create_fileExtension(id=5, extension="pdf") # Make a GET request to retrieve the fileExtension - response = self.client.get(reverse("fileExtension-list"), follow=True) + response = self.client.get(reverse("file-extension-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -133,7 +133,7 @@ def test_multiple_fileExtension(self): fileExtension2 = create_fileExtension(id=2, extension="png") # Make a GET request to retrieve the fileExtension - response = self.client.get(reverse("fileExtension-list"), follow=True) + response = self.client.get(reverse("file-extension-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -167,7 +167,7 @@ def test_fileExtension_detail_view(self): # Make a GET request to retrieve the fileExtension details response = self.client.get( - reverse("fileExtension-detail", args=[str(fileExtension.id)]), follow=True + reverse("file-extension-detail", args=[str(fileExtension.id)]), follow=True ) # Check if the response was successful @@ -194,7 +194,7 @@ def test_no_checks(self): """ Able to retrieve no Checks before publishing it. """ - response_root = self.client.get(reverse("structure_check-list"), follow=True) + response_root = self.client.get(reverse("structure-check-list"), follow=True) self.assertEqual(response_root.status_code, 200) self.assertEqual(response_root.accepted_media_type, "application/json") content_json = json.loads(response_root.content.decode("utf-8")) @@ -218,7 +218,7 @@ def test_structure_checks_exists(self): ) # Make a GET request to retrieve the Checks - response = self.client.get(reverse("structure_check-list"), follow=True) + response = self.client.get(reverse("structure-check-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -270,7 +270,7 @@ def test_no_checks(self): """ Able to retrieve no Checks before publishing it. """ - response_root = self.client.get(reverse("extra_check-list"), follow=True) + response_root = self.client.get(reverse("extra-check-list"), follow=True) self.assertEqual(response_root.status_code, 200) self.assertEqual(response_root.accepted_media_type, "application/json") content_json = json.loads(response_root.content.decode("utf-8")) @@ -285,7 +285,7 @@ def test_extra_checks_exists(self): ) # Make a GET request to retrieve the Checks - response = self.client.get(reverse("extra_check-list"), follow=True) + response = self.client.get(reverse("extra-check-list"), follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) diff --git a/backend/api/urls.py b/backend/api/urls.py index 4aa067ca..094e2fce 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -25,9 +25,9 @@ router.register(r"groups", GroupViewSet, basename="group") router.register(r"courses", CourseViewSet, basename="course") router.register(r"submissions", SubmissionViewSet, basename="submission") -router.register(r"structure_checks", StructureCheckViewSet, basename="structure_check") -router.register(r"extra_checks", ExtraCheckViewSet, basename="extra_check") -router.register(r"fileExtensions", FileExtensionViewSet, basename="fileExtension") +router.register(r"structure-checks", StructureCheckViewSet, basename="structure-check") +router.register(r"extra-checks", ExtraCheckViewSet, basename="extra-check") +router.register(r"file-extensions", FileExtensionViewSet, basename="file-extension") router.register(r"faculties", FacultyViewSet, basename="faculty") urlpatterns = [ From d2e2e2f4d666bfd2b4d83aac88898714ae254469 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 7 Mar 2024 13:56:08 +0100 Subject: [PATCH 214/397] chore: translation of extensions allowed/forbidden fields #27 --- backend/api/models/checks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index ef0595ba..de2fbff7 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ class FileExtension(models.Model): @@ -21,10 +22,10 @@ class Checks(models.Model): # Link to the file extensions that are allowed allowed_file_extensions = models.ManyToManyField( - FileExtension, related_name="checks_allowed", blank=True + FileExtension, related_name="checks_allowed", blank=True, verbose_name=_('Allowed extensions') ) # Link to the file extensions that are forbidden forbidden_file_extensions = models.ManyToManyField( - FileExtension, related_name="checks_forbidden", blank=True + FileExtension, related_name="checks_forbidden", blank=True, verbose_name=_('Forbidden extensions') ) From 22b7e327207336ef496f9fa1a77a539171155749 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 7 Mar 2024 14:57:25 +0100 Subject: [PATCH 215/397] chore: translate 404 errors to dutch #57 --- backend/ypovoli/handlers.py | 14 ++++++++ .../ypovoli/locale/nl/LC_MESSAGES/django.po | 35 +++++++++++++++++++ backend/ypovoli/settings.py | 5 ++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 backend/ypovoli/handlers.py create mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py new file mode 100644 index 00000000..1a027b7a --- /dev/null +++ b/backend/ypovoli/handlers.py @@ -0,0 +1,14 @@ +from rest_framework.views import exception_handler +from django.utils.translation import gettext_lazy as _ + + +def translate_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response.status_code == 401: + response.data['detail'] = _('Given token not valid for any token type') + + if response.status_code == 404: + response.data['detail'] = _('Not found.') + + return response diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..6e497baf --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,35 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-07 14:34+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ypovoli/handlers.py:9 +msgid "Given token not valid for any token type" +msgstr "Gegeven token is niet geldig voor eender welk token type" + +#: ypovoli/handlers.py:12 +msgid "Not found." +msgstr "Niet gevonden." + +#: ypovoli/settings.py:113 +msgid "English" +msgstr "Engels" + +#: ypovoli/settings.py:113 +msgid "Dutch" +msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 32355200..c7d3e698 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +from django.utils.translation import gettext_lazy as _ from datetime import timedelta from pathlib import Path @@ -69,7 +70,8 @@ ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated' - ] + ], + 'EXCEPTION_HANDLERE': 'ypovoli' } SIMPLE_JWT = { @@ -108,6 +110,7 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True +LANGUAGES = [('en', _('English')), ('nl', _('Dutch'))] USE_L10N = False USE_TZ = True From f01a03d747a151d59b0291f333bbd2d9cc7f2204 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 15:47:48 +0100 Subject: [PATCH 216/397] chore: startup of file structure checks --- backend/api/views/check_folder_structure.py | 137 ++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 backend/api/views/check_folder_structure.py diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py new file mode 100644 index 00000000..9c9e5279 --- /dev/null +++ b/backend/api/views/check_folder_structure.py @@ -0,0 +1,137 @@ +import zipfile +import os + +data_directory = "../../../data" + + +def get_parent_directories(dir_path): + components = dir_path.split('/') + parents = set() + current_path = "" + for component in components: + if current_path != "": + current_path = current_path + "/" + component + else: + current_path = component + parents.add(current_path) + return parents + + +def list_zip_directories(zip_file_path): + """ + List all directories in a zip file. + :param zip_file_path: Path where the zip file is located. + :return: List of directory names. + """ + dirs = set() + with zipfile.ZipFile(zip_file_path, 'r') as zip_ref: + info_list = zip_ref.infolist() + for file in info_list: + if file.is_dir(): + dir_path = file.filename[:-1] + parent_dirs = get_parent_directories(dir_path) + dirs.update(parent_dirs) + return dirs + + +def get_zip_structure(root_path): + directory_structure = {} + base, _ = os.path.splitext(root_path) + inhoud = list_zip_directories(root_path) + inhoud.add(".") + # print("inhoud") + # print(inhoud) + for inh in inhoud: + directory_structure[inh] = { + 'obligated_extensions': set(), + 'blocked_extensions': set() + } + with zipfile.ZipFile(root_path, 'r') as zip_file: + file_names = zip_file.namelist() + for file_name in file_names: + # print(file_name) + parts = file_name.rsplit('/', 1) # You can also use '\\' if needed + if len(parts) == 2: + map, file = parts + _, file_extension = os.path.splitext(file) + file_extension = file_extension[1:] + if not file_extension == "": + directory_structure[map]["obligated_extensions"].add(file_extension) + else: + _, file_extension = os.path.splitext(file_name) + file_extension = file_extension[1:] + directory_structure["."]["obligated_extensions"].add(file_extension) + return directory_structure + + +def check_zip_content(root_path, dir_path, obligated_extensions, blocked_extensions): + """ + Check the content of a directory without traversing subdirectories. + :param dir_path: The path of the zip we need to check. + :param obligated_extensions: The file extensions that are obligated to be present. + :param blocked_extensions: The file extensions that are forbidden to be present. + :return: + True: All checks pass. + False: At least 1 blocked extension is found or 1 obligated extension is not found. + """ + dir_path = dir_path.replace('\\', '/') + # print(f"looking in the {dir_path} subdirectory") + with zipfile.ZipFile(root_path, 'r') as zip_file: + zip_contents = set(zip_file.namelist()) + #print(zip_contents) + found_obligated = set() # To track found obligated extensions + if dir_path == ".": + files_in_subdirectory = [file for file in zip_contents if "/" not in file] + else: + files_in_subdirectory = [file[len(dir_path)+1:] for file in zip_contents if (file.startswith(dir_path) and '/' not in file[len(dir_path)+1:] and file[len(dir_path)+1:] != "")] + # print(files_in_subdirectory) + for file in files_in_subdirectory: + _, file_extension = os.path.splitext(file) + file_extension = file_extension[1:] # file_extension[1:] to remove the . + # print(file_extension) + if file_extension in blocked_extensions: + print(f"Error: {file_extension} found in '{dir_path}' is not allowed.") + return False + elif file_extension in obligated_extensions: + found_obligated.add(file_extension) + return set(obligated_extensions) <= found_obligated + + +def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders=False): + """ + Check the structure of a zip file. + :param zip_file_path: Path to the zip file. + :param folder_structure: Dictionary representing the expected folder structure. + :return: + True: Zip file structure matches the expected structure. + False: Zip file structure does not match the expected structure. + """ + base, _ = os.path.splitext(zip_file_path) + struc = [f for f in folder_structure.keys() if not f == "."] + # print(struc) + dirs = list_zip_directories(zip_file_path) + for dir in struc: + if dir not in dirs: + print(f"Error: Directory '{dir}' not defined.") + return False + + with zipfile.ZipFile(zip_file_path, 'r') as zip_file: + # zip_contents = set(zip_file.namelist()) + # print(f"all contents in the zip are {zip_contents}") + for directory, info in folder_structure.items(): + # base_name, _ = os.path.splitext(zip_file_path) + obligated_extensions = info.get('obligated_extensions', set()) + blocked_extensions = info.get('blocked_extensions', set()) + + result = check_zip_content(zip_file_path, directory, obligated_extensions, blocked_extensions) + if not result: + return False + # Check for any directories not present in the folder structure dictionary + # print(struc) + # print(dirs) + if restrict_extra_folders: + for actual_directory in dirs: + if actual_directory not in struc: + print(f"Error: Directory '{actual_directory}' not defined in the folder structure dictionary.") + return False + return True From b75c47b815c8a0ea9f6c9a51cde77be30fc62a21 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 17:29:41 +0100 Subject: [PATCH 217/397] chore: fxed get structure --- backend/api/views/check_folder_structure.py | 25 ++++++++++++++++++--- backend/api/views/project_view.py | 1 + 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 9c9e5279..3c878932 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -1,7 +1,28 @@ import zipfile import os +from api.models.checks import StructureCheck +from api.models.project import Project +from api.models.extension import FileExtension + +data_directory = "../../../data" # ../data\structures\zip_struct1.zip + + +def parseZipFile(project, dir_path): + struct = get_zip_structure(dir_path) + for key, value in struct.items(): + check = StructureCheck.objects.create( + name=key, + project=project + ) + for ext in value["obligated_extensions"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + print(extensie) + check.obligated_extensions.add(extensie.id) + project.structure_checks.add(check) + print(key, value) -data_directory = "../../../data" def get_parent_directories(dir_path): @@ -39,8 +60,6 @@ def get_zip_structure(root_path): base, _ = os.path.splitext(root_path) inhoud = list_zip_directories(root_path) inhoud.add(".") - # print("inhoud") - # print(inhoud) for inh in inhoud: directory_structure[inh] = { 'obligated_extensions': set(), diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 1b2c37d6..d14111b0 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,6 +1,7 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from api.views.check_folder_structure import get_zip_structure, check_zip_structure, data_directory, parseZipFile from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer From 8adbcfdcad01d468b4b4a83aa17f6316e47acbe4 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 7 Mar 2024 19:41:02 +0100 Subject: [PATCH 218/397] feat: IsStudent permission class and ability to join courses (#60) --- backend/api/permissions/__init__.py | 0 backend/api/permissions/role_permissions.py | 9 +++++ backend/api/views/course_view.py | 38 ++++++++++++++++----- backend/authentication/models.py | 6 ---- 4 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 backend/api/permissions/__init__.py create mode 100644 backend/api/permissions/role_permissions.py diff --git a/backend/api/permissions/__init__.py b/backend/api/permissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py new file mode 100644 index 00000000..dcd85686 --- /dev/null +++ b/backend/api/permissions/role_permissions.py @@ -0,0 +1,9 @@ +from rest_framework.permissions import IsAuthenticated +from api.models.student import Student + +class IsStudent(IsAuthenticated): + def has_permission(self, request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and \ + Student.objects.filter(id=request.user.id).exists() \ No newline at end of file diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 54b1fcf2..05614ced 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,12 +1,16 @@ +from django.utils.translation import gettext from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from ..models.course import Course -from ..serializers.course_serializer import CourseSerializer -from ..serializers.teacher_serializer import TeacherSerializer -from ..serializers.assistant_serializer import AssistantSerializer -from ..serializers.student_serializer import StudentSerializer -from ..serializers.project_serializer import ProjectSerializer +from rest_framework.exceptions import NotFound +from api.models.student import Student +from api.models.course import Course +from api.permissions.role_permissions import IsStudent +from api.serializers.course_serializer import CourseSerializer +from api.serializers.teacher_serializer import TeacherSerializer +from api.serializers.assistant_serializer import AssistantSerializer +from api.serializers.student_serializer import StudentSerializer +from api.serializers.project_serializer import ProjectSerializer class CourseViewSet(viewsets.ModelViewSet): @@ -89,6 +93,24 @@ def projects(self, request, pk=None): except Course.DoesNotExist: # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + raise NotFound(gettext("courses.errors.not_found")) + + @action(detail=True, methods=['get'], permission_classes=[IsStudent]) + def join(self, request, pk=None): + try: + # Add the course to the student's enrollment list. + Student.objects.get(id=request.user.id).courses.add( + Course.objects.get(id=pk) ) + + return Response({ + "message": gettext("courses.messages.successful_join") + }) + + except Course.DoesNotExist: + # Invalid course ID + raise NotFound(gettext("courses.errors.not_found")) + except Student.DoesNotExist: + # Invalid student user, this should not happen + # since the IsStudent permission class already checks this. + raise NotFound(gettext("students.errors.not_found")) \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 8a8787f4..dcf28b4d 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -35,12 +35,6 @@ class User(AbstractBaseUser): USERNAME_FIELD = "username" EMAIL_FIELD = "email" - def has_role(self, model: Type[Self]): - """Simple generic implementation of roles. - This function looks if there exists a model (inheriting from User) with the same ID. - """ - model.objects.exists(self.id) - @staticmethod def get_dummy_admin(): return User( From 620b906f1fb8d3346803884835583bb050ae9b97 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 20:44:54 +0100 Subject: [PATCH 219/397] chore: fixed reading zip files --- backend/api/fixtures/checks.yaml | 12 ------------ backend/api/views/check_folder_structure.py | 1 - 2 files changed, 13 deletions(-) diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml index ac162764..134302e7 100644 --- a/backend/api/fixtures/checks.yaml +++ b/backend/api/fixtures/checks.yaml @@ -1,15 +1,3 @@ -- model: api.structurecheck - pk: 1 - fields: - name: '.' - project: 123456 - obligated_extensions: - - 3 - - 4 - blocked_extensions: - - 1 - - 2 - - model: api.extracheck pk: 1 fields: diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 3c878932..43634c57 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -18,7 +18,6 @@ def parseZipFile(project, dir_path): extensie, _ = FileExtension.objects.get_or_create( extension=ext ) - print(extensie) check.obligated_extensions.add(extensie.id) project.structure_checks.add(check) print(key, value) From 72a0ee2c3496baa0dd6a4718eb3cf4aee64cd88f Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 7 Mar 2024 21:00:35 +0100 Subject: [PATCH 220/397] chore: one link for checks in project --- backend/api/serializers/project_serializer.py | 10 +++-- backend/api/views/project_view.py | 43 +++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index f9c458d5..5f4ee39f 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -7,12 +7,14 @@ class ProjectSerializer(serializers.ModelSerializer): many=False, read_only=True, view_name="course-detail" ) - structure_checks = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="structure-check-detail" + structure_checks = serializers.HyperlinkedIdentityField( + view_name="project-structure-checks", + read_only=True ) - extra_checks = serializers.HyperlinkedRelatedField( - many=True, read_only=True, view_name="extra-check-detail" + extra_checks = serializers.HyperlinkedIdentityField( + view_name="project-extra-checks", + read_only=True ) groups = serializers.HyperlinkedIdentityField( diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index d14111b0..99d2eca6 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,10 +1,13 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.exceptions import NotFound +from django.utils.translation import gettext_lazy as _ from api.views.check_folder_structure import get_zip_structure, check_zip_structure, data_directory, parseZipFile from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer +from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer class ProjectViewSet(viewsets.ModelViewSet): @@ -27,6 +30,40 @@ def groups(self, request, pk=None): except Project.DoesNotExist: # Invalid project ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Project not found"} + raise NotFound(_('project.status.not_found')) + + @action(detail=True, methods=["get"]) + def structure_checks(self, request, pk=None): + """Returns the structure checks for the given project""" + + try: + queryset = Project.objects.get(id=pk) + checks = queryset.structure_checks.all() + + # Serialize the check objects + serializer = StructureCheckSerializer( + checks, many=True, context={"request": request} ) + return Response(serializer.data) + + except Project.DoesNotExist: + # Invalid project ID + raise NotFound(_('project.status.not_found')) + + @action(detail=True, methods=["get"]) + def extra_checks(self, request, pk=None): + """Returns the extra checks for the given project""" + + try: + queryset = Project.objects.get(id=pk) + checks = queryset.extra_checks.all() + + # Serialize the check objects + serializer = ExtraCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) + + except Project.DoesNotExist: + # Invalid project ID + raise NotFound(_('project.status.not_found')) From 196e195eb90406ce06afda054cba044710ff6cc1 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 21:01:37 +0100 Subject: [PATCH 221/397] chore: added retrieving of structure checks for project --- backend/api/views/check_folder_structure.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 43634c57..e580e95c 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -20,8 +20,21 @@ def parseZipFile(project, dir_path): ) check.obligated_extensions.add(extensie.id) project.structure_checks.add(check) - print(key, value) - + # print(key, value) + + +def checkZipFile(project, dir_path): + project_structure_checks = StructureCheck.objects.filter(project=project.id) + for struct in project_structure_checks: + print(struct.id) + print(struct.name) + print("obligated_extensions") + for ext in struct.obligated_extensions.all(): + print(" ", ext.extension) + print("blocked_extensions") + for ext in struct.blocked_extensions.all(): + print(" ", ext.extension) + print("=========================") def get_parent_directories(dir_path): From b1dbb18012951c69a446fbec604081559852189a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Thu, 7 Mar 2024 21:08:09 +0100 Subject: [PATCH 222/397] fix: update tests --- backend/api/tests/test_project.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 03dfe7cd..126e4257 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -371,7 +371,7 @@ def test_project_structure_checks(self): self.assertEqual(retrieved_project["archived"], project.archived) self.assertEqual(retrieved_project["course"], expected_course_url) - response = self.client.get(retrieved_project["structure_checks"][0], follow=True) + response = self.client.get(retrieved_project["structure_checks"], follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -380,7 +380,7 @@ def test_project_structure_checks(self): self.assertEqual(response.accepted_media_type, "application/json") # Parse the JSON content from the response - content_json = json.loads(response.content.decode("utf-8")) + content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) @@ -437,7 +437,7 @@ def test_project_extra_checks(self): retrieved_project = content_json[0] - response = self.client.get(retrieved_project["extra_checks"][0], follow=True) + response = self.client.get(retrieved_project["extra_checks"], follow=True) # Check if the response was successful self.assertEqual(response.status_code, 200) @@ -446,7 +446,7 @@ def test_project_extra_checks(self): self.assertEqual(response.accepted_media_type, "application/json") # Parse the JSON content from the response - content_json = json.loads(response.content.decode("utf-8")) + content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) self.assertEqual(content_json["project"], "http://testserver" + reverse( From aee9ad8f16028dabc1555ccc462cd1933ab82cc1 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 21:31:55 +0100 Subject: [PATCH 223/397] chore: finalized initialisation of file checks and added data --- backend/api/views/check_folder_structure.py | 18 ++++++++++++++++-- data/structures/mixed_template.zip | Bin 0 -> 822 bytes data/structures/only_files_template.zip | Bin 0 -> 474 bytes data/structures/only_folders_template.zip | Bin 0 -> 638 bytes data/structures/remake.zip | Bin 0 -> 3234 bytes data/structures/root.zip | Bin 0 -> 226 bytes data/structures/zip_struct1.zip | Bin 0 -> 2136 bytes data/tests/mixed.zip | Bin 0 -> 1660 bytes data/tests/only_files.zip | Bin 0 -> 618 bytes data/tests/only_folders.zip | Bin 0 -> 990 bytes data/tests/test_zip1struct1.zip | Bin 0 -> 2136 bytes data/tests/test_zip2struct1.zip | Bin 0 -> 1988 bytes data/tests/test_zip3struct1.zip | Bin 0 -> 2258 bytes data/tests/test_zip4struct1.zip | Bin 0 -> 1822 bytes 14 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 data/structures/mixed_template.zip create mode 100644 data/structures/only_files_template.zip create mode 100644 data/structures/only_folders_template.zip create mode 100644 data/structures/remake.zip create mode 100644 data/structures/root.zip create mode 100644 data/structures/zip_struct1.zip create mode 100644 data/tests/mixed.zip create mode 100644 data/tests/only_files.zip create mode 100644 data/tests/only_folders.zip create mode 100644 data/tests/test_zip1struct1.zip create mode 100644 data/tests/test_zip2struct1.zip create mode 100644 data/tests/test_zip3struct1.zip create mode 100644 data/tests/test_zip4struct1.zip diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index e580e95c..742788b3 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -23,8 +23,9 @@ def parseZipFile(project, dir_path): # print(key, value) -def checkZipFile(project, dir_path): +def checkZipFile(project, dir_path, restrict_extra_folders=False): project_structure_checks = StructureCheck.objects.filter(project=project.id) + """ for struct in project_structure_checks: print(struct.id) print(struct.name) @@ -35,6 +36,19 @@ def checkZipFile(project, dir_path): for ext in struct.blocked_extensions.all(): print(" ", ext.extension) print("=========================") + """ + + structuur = {} + for struct in project_structure_checks: + structuur[struct.name] = { + 'obligated_extensions': set(), + 'blocked_extensions': set() + } + for ext in struct.obligated_extensions.all(): + structuur[struct.name]["obligated_extensions"].add(ext.extension) + for ext in struct.blocked_extensions.all(): + structuur[struct.name]["blocked_extensions"].add(ext.extension) + return check_zip_structure(structuur, dir_path, restrict_extra_folders=restrict_extra_folders) def get_parent_directories(dir_path): @@ -109,7 +123,7 @@ def check_zip_content(root_path, dir_path, obligated_extensions, blocked_extensi # print(f"looking in the {dir_path} subdirectory") with zipfile.ZipFile(root_path, 'r') as zip_file: zip_contents = set(zip_file.namelist()) - #print(zip_contents) + # print(zip_contents) found_obligated = set() # To track found obligated extensions if dir_path == ".": files_in_subdirectory = [file for file in zip_contents if "/" not in file] diff --git a/data/structures/mixed_template.zip b/data/structures/mixed_template.zip new file mode 100644 index 0000000000000000000000000000000000000000..456c258b7a948ec1e68ae92e48baa5437412adde GIT binary patch literal 822 zcmWIWW@h1H0D-^BX%S!sl;B{HVMxo*Nl7g-)DI2eWMI|}d6D`$_(f`I1vdjD%L`@( z1~3tT(*$886UtJHigOav^$Jqb;D&(=#A(=LoX#q9(d ztPAxTGtea<^KiNb-7IOWW|b$Fq!#I=XF|LPG8BX{3}s}JW5yNc641~WV0h~YVj^OI z6%qp^m?q2sGmU{^Nuv{zX(+Kl*kH`KfEf&OWw>PZU#n{ z7t9O{U?Ko+jxL%xoIrCja}(23^$POR!A7(JwSzEDBecMSO(WoE hbkk5>c>&2ZQ0|N^X`Y})LL8nLpm^1YecoxR!lv3JKiw)%eF`_An9-tL2X;~K%wohRLW z{xJTzL0TlGN&2)q=!KN6B`F&Q=~{d5@l{fPzcr#~P{X6^q}CYK3E@AxMzvLgS{N}J zqyu)m!w&ox(FtYVVUi;dV?=Bz4Yv%4WcaM_$K%E?LGg`EbyHDYaj3qeEa~|NUN{Jj zc;BBv9n0AFeT8t_K}cyo?)h*O!ul_u{b3sYjmr8t$wQtP2XR=&anxg2cHlC@;;_s8 z!V|$V0s_S^`#G-*m;Qm`g-z90R81FD+@AF|z6$rE9!&Hn^>EMxPJ6oh>>2m~%qFBlVkfI60upxRS~a1zC^G~;7F z1KNaDBGhJX)lGZ;=6K`Jk>8v6sNeccvGu1=UL~0_A#Y!88!uGI8aWrS?eiDUqpHWehToC_HZ=ZPG&e0Rh90xa#yHnTQz@9?O; zR%YeYEHT_@_1Kmt(06C5nab0{eS+oeD0=$Z3d1A;f_4c@C(AjK5zxs$4uX;@fYT<5 z#Hj-GHH%eWs z^EnEKp$?QtA)q)`YdM25%kVB-R-hCT32@pplKz$RsEAu=+Wo_!WI6#&n@p0XTDX#1 bh{K&$pvj~%S=pS${F#IAJ9zp9nuPoZU*!%e literal 0 HcmV?d00001 diff --git a/data/structures/root.zip b/data/structures/root.zip new file mode 100644 index 0000000000000000000000000000000000000000..4e703157f5a0929e00ca8b7dd224e0f3e31150ce GIT binary patch literal 226 zcmWIWW@Zs#0D+R2vO?J3$a1~ hY7?3*2(8%cL1@)Rbs)%ARyL3{6A+dG=`aw70RSa#9%=vp literal 0 HcmV?d00001 diff --git a/data/structures/zip_struct1.zip b/data/structures/zip_struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..bd156cd2a3301c7dd2b28cc7520b8b7447a1ddb4 GIT binary patch literal 2136 zcma)-y-ve05XaLGSYfEdz(Dwz7!ulUAQmK8I?#~~g~V+m5*pbl(C!R80(}D}76cLx zz{<=6u(BZ-v1`b=t}l(F8}k84(18XDOW05}_|S^lSkZ<{i5JAgi37ak zDxS@#uate$%A#U5T#+bd$?=l}D!*$0!^FvXkpMn8=fvr$gjbC)r-f0N@R3c1&_ZFo zo5~OW)~cd?H(frg=}{OF=A=v+lTVGXhG{MdBD>uxm1`Ehs+_=&J$V*gTp*fskf>!I z4*0EE0W2~O5vweZfL!q#fn@v0MFyN{wI$Bx;i(8N1OG2N&`qlj^lAxhc6#vvXj**$ z9?l0&i~xA~pX3JP31a|=$r_-k7aXD(1<>@CgjL8RKJX7tlg0rclXXat%|pG6h_4$7 z08Lf`K$;I!j0K1$YXRb8J`s!tfVz0%-%%bR-WLWG#sd(O^+<@#!*&G`ZpMg!XtE+8 XM)^c(Obd%5VToUNRm-~PDVFsMDkRQV literal 0 HcmV?d00001 diff --git a/data/tests/mixed.zip b/data/tests/mixed.zip new file mode 100644 index 0000000000000000000000000000000000000000..509911007ed853f9249aa3ede21c717e5d25b976 GIT binary patch literal 1660 zcmb8wPfo%>6bA5z1*5nTFvbXB>5dYL9zeIcAV!ICCDMWz3ojTb(&}VVmy9#fj%LY`SHyZlEq*1@T*aW9`J`WCX zJ-uu}COdZk!b{64loVkWOdf}wzB~4N0wd6h{aWLAEY2DJy0MB-5OKWvY~TkY_hEb( zh9QcUHB7D>pG{Gr*o?edG^J~Fj+R-DX6+lTZfEM=kAr@guZM2@^!40TF%BB;m(h>cDSZ#g zGVwjoGj`VaY^YkO8LJ=@>t!g$61Npitr@y0ZP4AaaB0#XMpvnv?E$%UJW`Oy?+E`m Ievkq91EnNEO#lD@ literal 0 HcmV?d00001 diff --git a/data/tests/only_files.zip b/data/tests/only_files.zip new file mode 100644 index 0000000000000000000000000000000000000000..55a7402c58fd5025e0827be9af1f98886c1802d0 GIT binary patch literal 618 zcmWIWW@h1H0D;9xX%S!sl;C5KVaQ0$$;mIzFUm>L%StQ?4dG;9UKscy6@*JGxEUB( zUNAE-fQbOOIcw0&;RKqKnVXoNs#lPg4mM&lP&)|YG-3{#5gb4xit|$o^hzp9z$Q!w z>IPw)CQL&!fg5N-S!z*nPGY)VK}s6fkZzy>AdF!MBa<96t}u~+2hoy75EDIwSRo;V z$4t!dK{nF^XeLr<0gb~NQV1tvh841L-9Y0IAqO-KYp5Yi!wffM)6N1-LkUHofmlP3 Ul?|wpfdvSi7#SFZfS7>+00f?N$p8QV literal 0 HcmV?d00001 diff --git a/data/tests/only_folders.zip b/data/tests/only_folders.zip new file mode 100644 index 0000000000000000000000000000000000000000..012a030475b2e794d5b13dfec96d37dce82581db GIT binary patch literal 990 zcmb8t%?g4*5C`xvHLNb-sYCb%B6;j)bcrqo1<@%YJq1P|(6J}znR=S=(mOPxtGnr% zrtGp8e=|FO{kf`jfu7?oTqg&%88Cz}I_!hf$XP8rpiUQYFvfMg^+3^L6(HICTsRj5 zQxKSByQwuphrGs?CAD(ZW<8QitI~}c^vEm^9a@#vy&-s`pP1{UcXP9w zkwvT2jncBzssslt%Ran4I`TaAG|wMeIZaWU2wI*3W{a+L{=CbgYk}xKq;6gznMQq$ za>`5q5J)@# zD>Dzk%7$RXt|8~TzBI0q>vF%lobUf{w<^_|B~Im_dph~^_uAsIJBR{8J1nI`Kdrk# zNQs{g=;(y@yo+#5sMBZdjIi68Cala7*zhd&lDdm0t>Z~4iId1n2~*PV_NQy^6iLrO zj=)_*GtU}r6Q^Ag$|A4l1Os0IJZON8`G6$oLIZ_I*idHhp%t~Uq79c4FNlc~2T*cP zyR}t3n^9jW`=*seht+UJqSz(JPZFs7t^o`iC-+4H_~M)sr>_!THNu=0#$m!&HW@(+ z`p2Uy+f+XMTdRup-E{e~rpIAKn3FPPO+Gck8a8uD5ZUcksa&&gsd558_T*huxIi@R zokT73aL6Cc3P8v>L`Yd40lDH20?G8qhYUEAv?b2w;i(8N1OE#h=q9NHy;?$>ofbX- zP0|P8(R|><2!NM=B{vvP7z02|)&Nbt;1I|f`4$DG!6intV4oq9_nRe z__~n*&}1b5r1?O_Sb%7<79c+66TxTzs1HwAjXYv_Ul>pr4?s-TBOx{q+ZAMRGe!hN alNA9m$|p)=T38ebOZ<0RwXA!dVp+et6wGG; literal 0 HcmV?d00001 diff --git a/data/tests/test_zip2struct1.zip b/data/tests/test_zip2struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..fbce9306ed2d477e01496bd8c176376a7840c84d GIT binary patch literal 1988 zcmb7_u};G<5QbA)7!gAy1_nZ5Vn}Ga!G;739q7mgA#qEjgj%)>v^y`sOE4gT!~?MM z6i7_0Fk;t`bB(VJ7bTbFzPp_7zjQm*THRAt-Rqr9UVa{1()R{gLf9!6Y#57Xn52xv zV!%entRG#bW5R+9-Yp1?vuVQWUjiSV=Mim&c+dtOBuGxPNDyv>-|bH4+$j@YfC7cP zfnuI4+9poBB8+EIKS&0#0l2pSjrjnY^q_#!Eo>P*c(aoFSkhL=NR;Fx$P+x}8XnDA zY@~g)(xT03g(^_ZQV>%LnP2_~7^hSY9326mbhp|@*`HQc$DHdaZHIEy#9^8d9tdt6 z@`puO$2z77D!SXLEYv+*Xp$h!pFGRT4v4NkXw-^y`|`Gg3^K$aN{HGNao)Hq+d$1=DA{3}zSyMzk#S_Lh3TDAam2^W9|^MMl?18??`++sW>Yk;_f4MjcU z5JTobvrAG!*QO5m3#Vz>1IQ(O4f3UHE+Eaf*8l)rivWOZHW1bV0PRmzeWsu%Mj1|1~c=ijsO4v literal 0 HcmV?d00001 diff --git a/data/tests/test_zip3struct1.zip b/data/tests/test_zip3struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..315fd52e9e46cda79198cf42847465798587c850 GIT binary patch literal 2258 zcma)+y-ve05P;Jkm^)NrU?BWV3<+(SfC&i}20C_wkhpC`LL(;y+MSo+4VYLENIU>5 zGfOu%#D-wRt|8~RzBH~9cZk2cT<*U1QKecp_^Rx7JJUaZw+*&;2Z2ZENu1E3n>1|C zr^HPLbaYI6&V@fF)au9Wl+ZcN5|(BO@$f8kqK1thTE!0~B#Z(lA+dD)b|)KitH|*b zWO3XzB=e-u)^XY;p>g2!EN|dSfO`e7HXjgqT}YsC37b+4-j$#x7PM(o;&>skLJtqQ zjGvCFD}{Yk!lK1$+Wa8zCCiN>i2SAiG#w}UMF3dioD-`r6J8X;oD{}>#9B5QK?;TI z-I8AXTZxM1-LhG;CWn4NVk?QIHu+QtD_G41fuDw)KlZXMkB-?k%ehpp8~A*)uxy#r zd(mkJqON!XRb$&eyA*%G^y3gd|6*~-6}u2fI>ec`)cHW*Otw81!KL7TJ3z8JKLEX2 zLX*wf0)RSS0PtWwaB2_W<$sbJjK^&P5S>pTsb?G_*#&57O587E@elli(}ZmRr1Omc znQUv6k>acN0ie!50Hpaq$wolb`3Q&)`9!c20M+90Sr>~J?+XKRTLDDpD*-Xtwpl?6 rH)Ag#>ih-7AfG5~1~8q^1dPAq9R}leTUg`|*4S@SHH-;+)G&Sl8(!uQ literal 0 HcmV?d00001 diff --git a/data/tests/test_zip4struct1.zip b/data/tests/test_zip4struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..9acfea0241389ce71f27778ac5980ed9b7272931 GIT binary patch literal 1822 zcma)+F;9a)6o6?_Hxq{@4w^{R#7RioO*aRZ4l=qa!cj&tHBY`y$5EaYX0^<8&7Ct{0`0gnUZp zXLJ}`rwc;eg!z92epx1L{wJ{6)g;Ke9wa>-m6P`Hq#HunI2gLoG`s`AqXyWm2V_wU z`Hj2o6}nz(MIEeY&!Z%WCd8dYc*!k1o6)dP_Ejs34y)(MM0tE}m}OA;O#>JXSsjrA zh_f0IHz^WcG(t@ai!>8oNj8TT8qd35*!Y)L745t4al-g%Iws8Jtnit9YJ?qZ=87P* z{XwhUv2Z&CK`bYKHgyGvra36oh6pF(*6ag!0AjL7K^zfoaY)%E0Gez9;B!4NcMEvc zyZpv@(lvmXY=fo_Tga$N?g342N!q8D9Q+8UDHj1`vQdE?5gw{R;3fb~b^>r&4J^0{ zM3b#RJZPhnlY0es0jLj8{^Qk>!%HxrbQvHfn-#eiOTiWHJPw3 Mt`FO?w#98(KeNV#0RR91 literal 0 HcmV?d00001 From 20f6ad5870f4f28140afdf66e4f9ac912820a832 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 22:28:08 +0100 Subject: [PATCH 224/397] chore: add i18n for errors --- backend/api/views/check_folder_structure.py | 46 +++++++-------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 742788b3..4faa40d9 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -3,6 +3,7 @@ from api.models.checks import StructureCheck from api.models.project import Project from api.models.extension import FileExtension +from django.utils.translation import gettext data_directory = "../../../data" # ../data\structures\zip_struct1.zip @@ -20,24 +21,10 @@ def parseZipFile(project, dir_path): ) check.obligated_extensions.add(extensie.id) project.structure_checks.add(check) - # print(key, value) def checkZipFile(project, dir_path, restrict_extra_folders=False): project_structure_checks = StructureCheck.objects.filter(project=project.id) - """ - for struct in project_structure_checks: - print(struct.id) - print(struct.name) - print("obligated_extensions") - for ext in struct.obligated_extensions.all(): - print(" ", ext.extension) - print("blocked_extensions") - for ext in struct.blocked_extensions.all(): - print(" ", ext.extension) - print("=========================") - """ - structuur = {} for struct in project_structure_checks: structuur[struct.name] = { @@ -120,26 +107,27 @@ def check_zip_content(root_path, dir_path, obligated_extensions, blocked_extensi False: At least 1 blocked extension is found or 1 obligated extension is not found. """ dir_path = dir_path.replace('\\', '/') - # print(f"looking in the {dir_path} subdirectory") with zipfile.ZipFile(root_path, 'r') as zip_file: zip_contents = set(zip_file.namelist()) - # print(zip_contents) found_obligated = set() # To track found obligated extensions if dir_path == ".": files_in_subdirectory = [file for file in zip_contents if "/" not in file] else: files_in_subdirectory = [file[len(dir_path)+1:] for file in zip_contents if (file.startswith(dir_path) and '/' not in file[len(dir_path)+1:] and file[len(dir_path)+1:] != "")] - # print(files_in_subdirectory) + for file in files_in_subdirectory: _, file_extension = os.path.splitext(file) file_extension = file_extension[1:] # file_extension[1:] to remove the . - # print(file_extension) + if file_extension in blocked_extensions: - print(f"Error: {file_extension} found in '{dir_path}' is not allowed.") - return False + # print(f"Error: {file_extension} found in '{dir_path}' is not allowed.") TODO + return False, gettext('zip.errors.invalid_structure.blocked_extension_found') elif file_extension in obligated_extensions: found_obligated.add(file_extension) - return set(obligated_extensions) <= found_obligated + if set(obligated_extensions) <= found_obligated: + return True, gettext('zip.success') + else: + return False, gettext('zip.errors.invalid_structure.obligated_extension_not_found') def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders=False): @@ -157,8 +145,8 @@ def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders= dirs = list_zip_directories(zip_file_path) for dir in struc: if dir not in dirs: - print(f"Error: Directory '{dir}' not defined.") - return False + # print(f"Error: Directory '{dir}' not defined.") TODO + return False, gettext('zip.errors.invalid_structure.directory_not_defined') with zipfile.ZipFile(zip_file_path, 'r') as zip_file: # zip_contents = set(zip_file.namelist()) @@ -168,15 +156,13 @@ def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders= obligated_extensions = info.get('obligated_extensions', set()) blocked_extensions = info.get('blocked_extensions', set()) - result = check_zip_content(zip_file_path, directory, obligated_extensions, blocked_extensions) + result, message = check_zip_content(zip_file_path, directory, obligated_extensions, blocked_extensions) if not result: - return False + return result, message # Check for any directories not present in the folder structure dictionary - # print(struc) - # print(dirs) if restrict_extra_folders: for actual_directory in dirs: if actual_directory not in struc: - print(f"Error: Directory '{actual_directory}' not defined in the folder structure dictionary.") - return False - return True + # print(f"Error: Directory '{actual_directory}' not defined in the folder structure dictionary.") TODO + return False, gettext('zip.errors.invalid_structure.directory_not_found_in_template') + return True, gettext('zip.success') From ce077ce2f373c2ae927a7a13ee61c97dcef35647 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 7 Mar 2024 22:41:53 +0100 Subject: [PATCH 225/397] chore: cleanup and fixed linting warnings --- backend/api/views/check_folder_structure.py | 79 ++++++++++++++------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 4faa40d9..ce116a13 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -1,11 +1,10 @@ import zipfile import os from api.models.checks import StructureCheck -from api.models.project import Project from api.models.extension import FileExtension from django.utils.translation import gettext -data_directory = "../../../data" # ../data\structures\zip_struct1.zip +data_directory = "../data" # ../data\structures\zip_struct1.zip def parseZipFile(project, dir_path): @@ -24,7 +23,8 @@ def parseZipFile(project, dir_path): def checkZipFile(project, dir_path, restrict_extra_folders=False): - project_structure_checks = StructureCheck.objects.filter(project=project.id) + project_structure_checks = StructureCheck.objects.filter( + project=project.id) structuur = {} for struct in project_structure_checks: structuur[struct.name] = { @@ -35,7 +35,8 @@ def checkZipFile(project, dir_path, restrict_extra_folders=False): structuur[struct.name]["obligated_extensions"].add(ext.extension) for ext in struct.blocked_extensions.all(): structuur[struct.name]["blocked_extensions"].add(ext.extension) - return check_zip_structure(structuur, dir_path, restrict_extra_folders=restrict_extra_folders) + return check_zip_structure( + structuur, dir_path, restrict_extra_folders=restrict_extra_folders) def get_parent_directories(dir_path): @@ -88,53 +89,75 @@ def get_zip_structure(root_path): _, file_extension = os.path.splitext(file) file_extension = file_extension[1:] if not file_extension == "": - directory_structure[map]["obligated_extensions"].add(file_extension) + directory_structure[map]["obligated_extensions"].add( + file_extension) else: _, file_extension = os.path.splitext(file_name) file_extension = file_extension[1:] - directory_structure["."]["obligated_extensions"].add(file_extension) + directory_structure["."]["obligated_extensions"].add( + file_extension) return directory_structure -def check_zip_content(root_path, dir_path, obligated_extensions, blocked_extensions): +def check_zip_content( + root_path, + dir_path, + obligated_extensions, + blocked_extensions): """ - Check the content of a directory without traversing subdirectories. - :param dir_path: The path of the zip we need to check. - :param obligated_extensions: The file extensions that are obligated to be present. - :param blocked_extensions: The file extensions that are forbidden to be present. - :return: - True: All checks pass. - False: At least 1 blocked extension is found or 1 obligated extension is not found. + Check the content of a directory without traversing subdirectories. + parameters: + dir_path: The path of the zip we need to check. + obligated_extensions: The file extensions that are obligated to be present. + blocked_extensions: The file extensions that are forbidden to be present. + :return: + True: All checks pass. + False: At least 1 blocked extension is found + or 1 obligated extension is not found. """ dir_path = dir_path.replace('\\', '/') with zipfile.ZipFile(root_path, 'r') as zip_file: zip_contents = set(zip_file.namelist()) found_obligated = set() # To track found obligated extensions if dir_path == ".": - files_in_subdirectory = [file for file in zip_contents if "/" not in file] + files_in_subdirectory = [ + file for file in zip_contents if "/" not in file + ] else: - files_in_subdirectory = [file[len(dir_path)+1:] for file in zip_contents if (file.startswith(dir_path) and '/' not in file[len(dir_path)+1:] and file[len(dir_path)+1:] != "")] + files_in_subdirectory = [ + file[len(dir_path)+1:] for file in zip_contents + if (file.startswith(dir_path) and + '/' not in file[len(dir_path)+1:] and + file[len(dir_path)+1:] != "")] for file in files_in_subdirectory: _, file_extension = os.path.splitext(file) - file_extension = file_extension[1:] # file_extension[1:] to remove the . + # file_extension[1:] to remove the . + file_extension = file_extension[1:] if file_extension in blocked_extensions: # print(f"Error: {file_extension} found in '{dir_path}' is not allowed.") TODO - return False, gettext('zip.errors.invalid_structure.blocked_extension_found') + return False, gettext( + 'zip.errors.invalid_structure.blocked_extension_found') elif file_extension in obligated_extensions: found_obligated.add(file_extension) if set(obligated_extensions) <= found_obligated: return True, gettext('zip.success') else: - return False, gettext('zip.errors.invalid_structure.obligated_extension_not_found') + return False, gettext( + 'zip.errors.invalid_structure.obligated_extension_not_found') -def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders=False): +def check_zip_structure( + folder_structure, + zip_file_path, + restrict_extra_folders=False): """ Check the structure of a zip file. - :param zip_file_path: Path to the zip file. - :param folder_structure: Dictionary representing the expected folder structure. + + parameters: + zip_file_path: Path to the zip file. + folder_structure: Dictionary representing the expected folder structure. :return: True: Zip file structure matches the expected structure. False: Zip file structure does not match the expected structure. @@ -146,7 +169,8 @@ def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders= for dir in struc: if dir not in dirs: # print(f"Error: Directory '{dir}' not defined.") TODO - return False, gettext('zip.errors.invalid_structure.directory_not_defined') + return False, gettext( + 'zip.errors.invalid_structure.directory_not_defined') with zipfile.ZipFile(zip_file_path, 'r') as zip_file: # zip_contents = set(zip_file.namelist()) @@ -156,7 +180,11 @@ def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders= obligated_extensions = info.get('obligated_extensions', set()) blocked_extensions = info.get('blocked_extensions', set()) - result, message = check_zip_content(zip_file_path, directory, obligated_extensions, blocked_extensions) + result, message = check_zip_content( + zip_file_path, + directory, + obligated_extensions, + blocked_extensions) if not result: return result, message # Check for any directories not present in the folder structure dictionary @@ -164,5 +192,6 @@ def check_zip_structure(folder_structure, zip_file_path, restrict_extra_folders= for actual_directory in dirs: if actual_directory not in struc: # print(f"Error: Directory '{actual_directory}' not defined in the folder structure dictionary.") TODO - return False, gettext('zip.errors.invalid_structure.directory_not_found_in_template') + return False, gettext( + 'zip.errors.invalid_structure.directory_not_found_in_template') return True, gettext('zip.success') From 83763ce3afa8e5fb8937b6fcc1b0f8dda36deac2 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 8 Mar 2024 07:35:13 +0100 Subject: [PATCH 226/397] chore: cleanup the data directory and started tests --- backend/api/tests/test_file_structure.py | 23 ++++++++++++++++++ backend/api/views/check_folder_structure.py | 9 ++++--- backend/api/views/project_view.py | 2 +- backend/ypovoli/settings.py | 2 ++ .../structures/mixed_template.zip | Bin .../structures/only_files_template.zip | Bin .../structures/only_folders_template.zip | Bin data/{ => production}/structures/remake.zip | Bin data/{ => production}/structures/root.zip | Bin .../structures/zip_struct1.zip | Bin data/{ => production}/tests/mixed.zip | Bin data/{ => production}/tests/only_files.zip | Bin data/{ => production}/tests/only_folders.zip | Bin .../tests/test_zip1struct1.zip | Bin .../tests/test_zip2struct1.zip | Bin .../tests/test_zip3struct1.zip | Bin .../tests/test_zip4struct1.zip | Bin data/testing/structures/mixed_template.zip | Bin 0 -> 822 bytes .../structures/only_files_template.zip | Bin 0 -> 474 bytes .../structures/only_folders_template.zip | Bin 0 -> 638 bytes data/testing/structures/remake.zip | Bin 0 -> 3234 bytes data/testing/structures/root.zip | Bin 0 -> 226 bytes data/testing/structures/zip_struct1.zip | Bin 0 -> 2136 bytes data/testing/tests/mixed.zip | Bin 0 -> 1660 bytes data/testing/tests/only_files.zip | Bin 0 -> 618 bytes data/testing/tests/only_folders.zip | Bin 0 -> 990 bytes data/testing/tests/test_zip1struct1.zip | Bin 0 -> 2136 bytes data/testing/tests/test_zip2struct1.zip | Bin 0 -> 1988 bytes data/testing/tests/test_zip3struct1.zip | Bin 0 -> 2258 bytes data/testing/tests/test_zip4struct1.zip | Bin 0 -> 1822 bytes 30 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 backend/api/tests/test_file_structure.py rename data/{ => production}/structures/mixed_template.zip (100%) rename data/{ => production}/structures/only_files_template.zip (100%) rename data/{ => production}/structures/only_folders_template.zip (100%) rename data/{ => production}/structures/remake.zip (100%) rename data/{ => production}/structures/root.zip (100%) rename data/{ => production}/structures/zip_struct1.zip (100%) rename data/{ => production}/tests/mixed.zip (100%) rename data/{ => production}/tests/only_files.zip (100%) rename data/{ => production}/tests/only_folders.zip (100%) rename data/{ => production}/tests/test_zip1struct1.zip (100%) rename data/{ => production}/tests/test_zip2struct1.zip (100%) rename data/{ => production}/tests/test_zip3struct1.zip (100%) rename data/{ => production}/tests/test_zip4struct1.zip (100%) create mode 100644 data/testing/structures/mixed_template.zip create mode 100644 data/testing/structures/only_files_template.zip create mode 100644 data/testing/structures/only_folders_template.zip create mode 100644 data/testing/structures/remake.zip create mode 100644 data/testing/structures/root.zip create mode 100644 data/testing/structures/zip_struct1.zip create mode 100644 data/testing/tests/mixed.zip create mode 100644 data/testing/tests/only_files.zip create mode 100644 data/testing/tests/only_folders.zip create mode 100644 data/testing/tests/test_zip1struct1.zip create mode 100644 data/testing/tests/test_zip2struct1.zip create mode 100644 data/testing/tests/test_zip3struct1.zip create mode 100644 data/testing/tests/test_zip4struct1.zip diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py new file mode 100644 index 00000000..a4279c7a --- /dev/null +++ b/backend/api/tests/test_file_structure.py @@ -0,0 +1,23 @@ +import os +import tempfile +from django.test import TestCase +from django.core.files.base import ContentFile +from api.views.check_folder_structure import check_zip_content, parseZipFile +from api.models.checks import StructureCheck +from api.models.project import Project +from django.conf import settings + + +class FileTestsTests(TestCase): + def setUp(self): + # Set up a temporary directory for MEDIA_ROOT during tests + self.old_media_root = settings.MEDIA_ROOT + settings.MEDIA_ROOT = os.path.normpath(os.path.join(settings.MEDIA_ROOT, '../testing')) + + def tearDown(self): + # Restore the original MEDIA_ROOT after tests + settings.MEDIA_ROOT = self.old_media_root + + def test_your_function(self): + # Test your function that interacts with the media directory + self.assertEqual(True, True) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index ce116a13..3fec83bd 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -3,11 +3,11 @@ from api.models.checks import StructureCheck from api.models.extension import FileExtension from django.utils.translation import gettext +from django.conf import settings -data_directory = "../data" # ../data\structures\zip_struct1.zip - -def parseZipFile(project, dir_path): +def parseZipFile(project, dir_path): # TODO block paths that start with .. + dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) struct = get_zip_structure(dir_path) for key, value in struct.items(): check = StructureCheck.objects.create( @@ -22,7 +22,8 @@ def parseZipFile(project, dir_path): project.structure_checks.add(check) -def checkZipFile(project, dir_path, restrict_extra_folders=False): +def checkZipFile(project, dir_path, restrict_extra_folders=False): # TODO block paths that start with .. + dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) project_structure_checks = StructureCheck.objects.filter( project=project.id) structuur = {} diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 99d2eca6..413b5a70 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from rest_framework.exceptions import NotFound from django.utils.translation import gettext_lazy as _ -from api.views.check_folder_structure import get_zip_structure, check_zip_structure, data_directory, parseZipFile +from api.views.check_folder_structure import get_zip_structure, check_zip_structure, parseZipFile from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 32355200..2fd97375 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -12,9 +12,11 @@ from datetime import timedelta from pathlib import Path +import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "../data/production")) # Quick-start development settings - unsuitable for production diff --git a/data/structures/mixed_template.zip b/data/production/structures/mixed_template.zip similarity index 100% rename from data/structures/mixed_template.zip rename to data/production/structures/mixed_template.zip diff --git a/data/structures/only_files_template.zip b/data/production/structures/only_files_template.zip similarity index 100% rename from data/structures/only_files_template.zip rename to data/production/structures/only_files_template.zip diff --git a/data/structures/only_folders_template.zip b/data/production/structures/only_folders_template.zip similarity index 100% rename from data/structures/only_folders_template.zip rename to data/production/structures/only_folders_template.zip diff --git a/data/structures/remake.zip b/data/production/structures/remake.zip similarity index 100% rename from data/structures/remake.zip rename to data/production/structures/remake.zip diff --git a/data/structures/root.zip b/data/production/structures/root.zip similarity index 100% rename from data/structures/root.zip rename to data/production/structures/root.zip diff --git a/data/structures/zip_struct1.zip b/data/production/structures/zip_struct1.zip similarity index 100% rename from data/structures/zip_struct1.zip rename to data/production/structures/zip_struct1.zip diff --git a/data/tests/mixed.zip b/data/production/tests/mixed.zip similarity index 100% rename from data/tests/mixed.zip rename to data/production/tests/mixed.zip diff --git a/data/tests/only_files.zip b/data/production/tests/only_files.zip similarity index 100% rename from data/tests/only_files.zip rename to data/production/tests/only_files.zip diff --git a/data/tests/only_folders.zip b/data/production/tests/only_folders.zip similarity index 100% rename from data/tests/only_folders.zip rename to data/production/tests/only_folders.zip diff --git a/data/tests/test_zip1struct1.zip b/data/production/tests/test_zip1struct1.zip similarity index 100% rename from data/tests/test_zip1struct1.zip rename to data/production/tests/test_zip1struct1.zip diff --git a/data/tests/test_zip2struct1.zip b/data/production/tests/test_zip2struct1.zip similarity index 100% rename from data/tests/test_zip2struct1.zip rename to data/production/tests/test_zip2struct1.zip diff --git a/data/tests/test_zip3struct1.zip b/data/production/tests/test_zip3struct1.zip similarity index 100% rename from data/tests/test_zip3struct1.zip rename to data/production/tests/test_zip3struct1.zip diff --git a/data/tests/test_zip4struct1.zip b/data/production/tests/test_zip4struct1.zip similarity index 100% rename from data/tests/test_zip4struct1.zip rename to data/production/tests/test_zip4struct1.zip diff --git a/data/testing/structures/mixed_template.zip b/data/testing/structures/mixed_template.zip new file mode 100644 index 0000000000000000000000000000000000000000..456c258b7a948ec1e68ae92e48baa5437412adde GIT binary patch literal 822 zcmWIWW@h1H0D-^BX%S!sl;B{HVMxo*Nl7g-)DI2eWMI|}d6D`$_(f`I1vdjD%L`@( z1~3tT(*$886UtJHigOav^$Jqb;D&(=#A(=LoX#q9(d ztPAxTGtea<^KiNb-7IOWW|b$Fq!#I=XF|LPG8BX{3}s}JW5yNc641~WV0h~YVj^OI z6%qp^m?q2sGmU{^Nuv{zX(+Kl*kH`KfEf&OWw>PZU#n{ z7t9O{U?Ko+jxL%xoIrCja}(23^$POR!A7(JwSzEDBecMSO(WoE hbkk5>c>&2ZQ0|N^X`Y})LL8nLpm^1YecoxR!lv3JKiw)%eF`_An9-tL2X;~K%wohRLW z{xJTzL0TlGN&2)q=!KN6B`F&Q=~{d5@l{fPzcr#~P{X6^q}CYK3E@AxMzvLgS{N}J zqyu)m!w&ox(FtYVVUi;dV?=Bz4Yv%4WcaM_$K%E?LGg`EbyHDYaj3qeEa~|NUN{Jj zc;BBv9n0AFeT8t_K}cyo?)h*O!ul_u{b3sYjmr8t$wQtP2XR=&anxg2cHlC@;;_s8 z!V|$V0s_S^`#G-*m;Qm`g-z90R81FD+@AF|z6$rE9!&Hn^>EMxPJ6oh>>2m~%qFBlVkfI60upxRS~a1zC^G~;7F z1KNaDBGhJX)lGZ;=6K`Jk>8v6sNeccvGu1=UL~0_A#Y!88!uGI8aWrS?eiDUqpHWehToC_HZ=ZPG&e0Rh90xa#yHnTQz@9?O; zR%YeYEHT_@_1Kmt(06C5nab0{eS+oeD0=$Z3d1A;f_4c@C(AjK5zxs$4uX;@fYT<5 z#Hj-GHH%eWs z^EnEKp$?QtA)q)`YdM25%kVB-R-hCT32@pplKz$RsEAu=+Wo_!WI6#&n@p0XTDX#1 bh{K&$pvj~%S=pS${F#IAJ9zp9nuPoZU*!%e literal 0 HcmV?d00001 diff --git a/data/testing/structures/root.zip b/data/testing/structures/root.zip new file mode 100644 index 0000000000000000000000000000000000000000..4e703157f5a0929e00ca8b7dd224e0f3e31150ce GIT binary patch literal 226 zcmWIWW@Zs#0D+R2vO?J3$a1~ hY7?3*2(8%cL1@)Rbs)%ARyL3{6A+dG=`aw70RSa#9%=vp literal 0 HcmV?d00001 diff --git a/data/testing/structures/zip_struct1.zip b/data/testing/structures/zip_struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..bd156cd2a3301c7dd2b28cc7520b8b7447a1ddb4 GIT binary patch literal 2136 zcma)-y-ve05XaLGSYfEdz(Dwz7!ulUAQmK8I?#~~g~V+m5*pbl(C!R80(}D}76cLx zz{<=6u(BZ-v1`b=t}l(F8}k84(18XDOW05}_|S^lSkZ<{i5JAgi37ak zDxS@#uate$%A#U5T#+bd$?=l}D!*$0!^FvXkpMn8=fvr$gjbC)r-f0N@R3c1&_ZFo zo5~OW)~cd?H(frg=}{OF=A=v+lTVGXhG{MdBD>uxm1`Ehs+_=&J$V*gTp*fskf>!I z4*0EE0W2~O5vweZfL!q#fn@v0MFyN{wI$Bx;i(8N1OG2N&`qlj^lAxhc6#vvXj**$ z9?l0&i~xA~pX3JP31a|=$r_-k7aXD(1<>@CgjL8RKJX7tlg0rclXXat%|pG6h_4$7 z08Lf`K$;I!j0K1$YXRb8J`s!tfVz0%-%%bR-WLWG#sd(O^+<@#!*&G`ZpMg!XtE+8 XM)^c(Obd%5VToUNRm-~PDVFsMDkRQV literal 0 HcmV?d00001 diff --git a/data/testing/tests/mixed.zip b/data/testing/tests/mixed.zip new file mode 100644 index 0000000000000000000000000000000000000000..509911007ed853f9249aa3ede21c717e5d25b976 GIT binary patch literal 1660 zcmb8wPfo%>6bA5z1*5nTFvbXB>5dYL9zeIcAV!ICCDMWz3ojTb(&}VVmy9#fj%LY`SHyZlEq*1@T*aW9`J`WCX zJ-uu}COdZk!b{64loVkWOdf}wzB~4N0wd6h{aWLAEY2DJy0MB-5OKWvY~TkY_hEb( zh9QcUHB7D>pG{Gr*o?edG^J~Fj+R-DX6+lTZfEM=kAr@guZM2@^!40TF%BB;m(h>cDSZ#g zGVwjoGj`VaY^YkO8LJ=@>t!g$61Npitr@y0ZP4AaaB0#XMpvnv?E$%UJW`Oy?+E`m Ievkq91EnNEO#lD@ literal 0 HcmV?d00001 diff --git a/data/testing/tests/only_files.zip b/data/testing/tests/only_files.zip new file mode 100644 index 0000000000000000000000000000000000000000..55a7402c58fd5025e0827be9af1f98886c1802d0 GIT binary patch literal 618 zcmWIWW@h1H0D;9xX%S!sl;C5KVaQ0$$;mIzFUm>L%StQ?4dG;9UKscy6@*JGxEUB( zUNAE-fQbOOIcw0&;RKqKnVXoNs#lPg4mM&lP&)|YG-3{#5gb4xit|$o^hzp9z$Q!w z>IPw)CQL&!fg5N-S!z*nPGY)VK}s6fkZzy>AdF!MBa<96t}u~+2hoy75EDIwSRo;V z$4t!dK{nF^XeLr<0gb~NQV1tvh841L-9Y0IAqO-KYp5Yi!wffM)6N1-LkUHofmlP3 Ul?|wpfdvSi7#SFZfS7>+00f?N$p8QV literal 0 HcmV?d00001 diff --git a/data/testing/tests/only_folders.zip b/data/testing/tests/only_folders.zip new file mode 100644 index 0000000000000000000000000000000000000000..012a030475b2e794d5b13dfec96d37dce82581db GIT binary patch literal 990 zcmb8t%?g4*5C`xvHLNb-sYCb%B6;j)bcrqo1<@%YJq1P|(6J}znR=S=(mOPxtGnr% zrtGp8e=|FO{kf`jfu7?oTqg&%88Cz}I_!hf$XP8rpiUQYFvfMg^+3^L6(HICTsRj5 zQxKSByQwuphrGs?CAD(ZW<8QitI~}c^vEm^9a@#vy&-s`pP1{UcXP9w zkwvT2jncBzssslt%Ran4I`TaAG|wMeIZaWU2wI*3W{a+L{=CbgYk}xKq;6gznMQq$ za>`5q5J)@# zD>Dzk%7$RXt|8~TzBI0q>vF%lobUf{w<^_|B~Im_dph~^_uAsIJBR{8J1nI`Kdrk# zNQs{g=;(y@yo+#5sMBZdjIi68Cala7*zhd&lDdm0t>Z~4iId1n2~*PV_NQy^6iLrO zj=)_*GtU}r6Q^Ag$|A4l1Os0IJZON8`G6$oLIZ_I*idHhp%t~Uq79c4FNlc~2T*cP zyR}t3n^9jW`=*seht+UJqSz(JPZFs7t^o`iC-+4H_~M)sr>_!THNu=0#$m!&HW@(+ z`p2Uy+f+XMTdRup-E{e~rpIAKn3FPPO+Gck8a8uD5ZUcksa&&gsd558_T*huxIi@R zokT73aL6Cc3P8v>L`Yd40lDH20?G8qhYUEAv?b2w;i(8N1OE#h=q9NHy;?$>ofbX- zP0|P8(R|><2!NM=B{vvP7z02|)&Nbt;1I|f`4$DG!6intV4oq9_nRe z__~n*&}1b5r1?O_Sb%7<79c+66TxTzs1HwAjXYv_Ul>pr4?s-TBOx{q+ZAMRGe!hN alNA9m$|p)=T38ebOZ<0RwXA!dVp+et6wGG; literal 0 HcmV?d00001 diff --git a/data/testing/tests/test_zip2struct1.zip b/data/testing/tests/test_zip2struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..fbce9306ed2d477e01496bd8c176376a7840c84d GIT binary patch literal 1988 zcmb7_u};G<5QbA)7!gAy1_nZ5Vn}Ga!G;739q7mgA#qEjgj%)>v^y`sOE4gT!~?MM z6i7_0Fk;t`bB(VJ7bTbFzPp_7zjQm*THRAt-Rqr9UVa{1()R{gLf9!6Y#57Xn52xv zV!%entRG#bW5R+9-Yp1?vuVQWUjiSV=Mim&c+dtOBuGxPNDyv>-|bH4+$j@YfC7cP zfnuI4+9poBB8+EIKS&0#0l2pSjrjnY^q_#!Eo>P*c(aoFSkhL=NR;Fx$P+x}8XnDA zY@~g)(xT03g(^_ZQV>%LnP2_~7^hSY9326mbhp|@*`HQc$DHdaZHIEy#9^8d9tdt6 z@`puO$2z77D!SXLEYv+*Xp$h!pFGRT4v4NkXw-^y`|`Gg3^K$aN{HGNao)Hq+d$1=DA{3}zSyMzk#S_Lh3TDAam2^W9|^MMl?18??`++sW>Yk;_f4MjcU z5JTobvrAG!*QO5m3#Vz>1IQ(O4f3UHE+Eaf*8l)rivWOZHW1bV0PRmzeWsu%Mj1|1~c=ijsO4v literal 0 HcmV?d00001 diff --git a/data/testing/tests/test_zip3struct1.zip b/data/testing/tests/test_zip3struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..315fd52e9e46cda79198cf42847465798587c850 GIT binary patch literal 2258 zcma)+y-ve05P;Jkm^)NrU?BWV3<+(SfC&i}20C_wkhpC`LL(;y+MSo+4VYLENIU>5 zGfOu%#D-wRt|8~RzBH~9cZk2cT<*U1QKecp_^Rx7JJUaZw+*&;2Z2ZENu1E3n>1|C zr^HPLbaYI6&V@fF)au9Wl+ZcN5|(BO@$f8kqK1thTE!0~B#Z(lA+dD)b|)KitH|*b zWO3XzB=e-u)^XY;p>g2!EN|dSfO`e7HXjgqT}YsC37b+4-j$#x7PM(o;&>skLJtqQ zjGvCFD}{Yk!lK1$+Wa8zCCiN>i2SAiG#w}UMF3dioD-`r6J8X;oD{}>#9B5QK?;TI z-I8AXTZxM1-LhG;CWn4NVk?QIHu+QtD_G41fuDw)KlZXMkB-?k%ehpp8~A*)uxy#r zd(mkJqON!XRb$&eyA*%G^y3gd|6*~-6}u2fI>ec`)cHW*Otw81!KL7TJ3z8JKLEX2 zLX*wf0)RSS0PtWwaB2_W<$sbJjK^&P5S>pTsb?G_*#&57O587E@elli(}ZmRr1Omc znQUv6k>acN0ie!50Hpaq$wolb`3Q&)`9!c20M+90Sr>~J?+XKRTLDDpD*-Xtwpl?6 rH)Ag#>ih-7AfG5~1~8q^1dPAq9R}leTUg`|*4S@SHH-;+)G&Sl8(!uQ literal 0 HcmV?d00001 diff --git a/data/testing/tests/test_zip4struct1.zip b/data/testing/tests/test_zip4struct1.zip new file mode 100644 index 0000000000000000000000000000000000000000..9acfea0241389ce71f27778ac5980ed9b7272931 GIT binary patch literal 1822 zcma)+F;9a)6o6?_Hxq{@4w^{R#7RioO*aRZ4l=qa!cj&tHBY`y$5EaYX0^<8&7Ct{0`0gnUZp zXLJ}`rwc;eg!z92epx1L{wJ{6)g;Ke9wa>-m6P`Hq#HunI2gLoG`s`AqXyWm2V_wU z`Hj2o6}nz(MIEeY&!Z%WCd8dYc*!k1o6)dP_Ejs34y)(MM0tE}m}OA;O#>JXSsjrA zh_f0IHz^WcG(t@ai!>8oNj8TT8qd35*!Y)L745t4al-g%Iws8Jtnit9YJ?qZ=87P* z{XwhUv2Z&CK`bYKHgyGvra36oh6pF(*6ag!0AjL7K^zfoaY)%E0Gez9;B!4NcMEvc zyZpv@(lvmXY=fo_Tga$N?g342N!q8D9Q+8UDHj1`vQdE?5gw{R;3fb~b^>r&4J^0{ zM3b#RJZPhnlY0es0jLj8{^Qk>!%HxrbQvHfn-#eiOTiWHJPw3 Mt`FO?w#98(KeNV#0RR91 literal 0 HcmV?d00001 From 998c51d3cd2cf2331e2770e8d6fcd9265d5d114c Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Fri, 8 Mar 2024 08:02:27 +0100 Subject: [PATCH 227/397] chore: worked at testing the file structure --- backend/api/tests/test_file_structure.py | 86 ++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index a4279c7a..5f83044c 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -1,15 +1,69 @@ import os -import tempfile -from django.test import TestCase -from django.core.files.base import ContentFile +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase from api.views.check_folder_structure import check_zip_content, parseZipFile from api.models.checks import StructureCheck +from api.models.extension import FileExtension +from api.models.course import Course from api.models.project import Project +from authentication.models import User from django.conf import settings -class FileTestsTests(TestCase): + +def create_course(id, name, academic_startyear): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + id=id, name=name, academic_startyear=academic_startyear + ) + + +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create(id=id, extension=extension) + + +def create_project(name, description, visible, archived, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + course=course, + ) + + +def create_structureCheck(name, project, obligated, blocked): + """ + Create a StructureCheck with the given arguments. + """ + structureCheck = StructureCheck.objects.create( + name=name, + project=project, + ) + for ch in obligated: + structureCheck.obligated_extensions.add(ch) + for ch in blocked: + structureCheck.blocked_extensions.add(ch) + + return structureCheck + + +class FileTestsTests(APITestCase): def setUp(self): + self.client.force_authenticate( + User.get_dummy_admin() + ) # Set up a temporary directory for MEDIA_ROOT during tests self.old_media_root = settings.MEDIA_ROOT settings.MEDIA_ROOT = os.path.normpath(os.path.join(settings.MEDIA_ROOT, '../testing')) @@ -19,5 +73,27 @@ def tearDown(self): settings.MEDIA_ROOT = self.old_media_root def test_your_function(self): - # Test your function that interacts with the media directory + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=100, + course=course, + ) + parseZipFile(project=project, dir_path="structures/zip_struct1.zip") + + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + # print("strucCheck:", content_json["structure_checks"]) + # TODO get the structure checks from this now its following + # strucCheck: http://testserver/projects/1/structure_checks/ + self.assertEqual(True, True) From 350ec00cf8157992da9990097f3b0781cd50bd79 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 13:52:37 +0100 Subject: [PATCH 228/397] feat: (wip) add assistants, course permissiosn --- backend/api/permissions/course_permissions.py | 34 ++++++++++ backend/api/permissions/role_permissions.py | 18 +++++- backend/api/views/course_view.py | 62 +++++++++---------- 3 files changed, 82 insertions(+), 32 deletions(-) create mode 100644 backend/api/permissions/course_permissions.py diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py new file mode 100644 index 00000000..afdfa71b --- /dev/null +++ b/backend/api/permissions/course_permissions.py @@ -0,0 +1,34 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.models.teacher import Teacher +from api.models.assistant import Assistant +from api.models.course import Course + + +class CoursePermission(BasePermission): + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general course endpoint.""" + user: User = request.user + + if request.method in SAFE_METHODS: + # Logged-in users can fetch course lists. + return request.user.is_authenticated + + # We only allow teachers to create new courses. + return user.teacher.exists() + + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + """Check if user has permission to view a detailed course endpoint""" + user: User = request.user + + if request.method in SAFE_METHODS: + # Logged-in users can fetch course details. + return request.user.is_authenticated + + # We only allow teachers and assistants to modify specified courses. + role: Teacher|Assistant = user.teacher or user.assistant + + return role is not None and \ + role.courses.filter(id=course.id).exists() \ No newline at end of file diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index dcd85686..2a023a9d 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,9 +1,25 @@ from rest_framework.permissions import IsAuthenticated from api.models.student import Student +from api.models.assistant import Assistant +from api.models.teacher import Teacher class IsStudent(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, with said user being a student""" return super().has_permission(request, view) and \ - Student.objects.filter(id=request.user.id).exists() \ No newline at end of file + Student.objects.filter(id=request.user.id).exists() + +class IsTeacher(IsAuthenticated): + def has_permission(self, request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and \ + Teacher.objects.filter(id=request.user.id).exists() + +class IsAssistant(IsAuthenticated): + def has_permission(self, request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and \ + Assistant.objects.filter(id=request.user.id).exists() \ No newline at end of file diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 05614ced..5d80662d 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,10 +1,12 @@ from django.utils.translation import gettext from rest_framework import viewsets, status +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.exceptions import NotFound from api.models.student import Student from api.models.course import Course +from api.permissions.course_permissions import CoursePermission from api.permissions.role_permissions import IsStudent from api.serializers.course_serializer import CourseSerializer from api.serializers.teacher_serializer import TeacherSerializer @@ -16,50 +18,48 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer + permission_classes = [IsAdminUser | CoursePermission] @action(detail=True, methods=["get"]) - def teachers(self, request, pk=None): + def teachers(self, request, **_): """Returns a list of teachers for the given course""" + # This automatically fetches the course from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + course = self.get_object() + teachers = course.teachers.all() - try: - queryset = Course.objects.get(id=pk) - teachers = queryset.teachers.all() + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={"request": request} + ) - # Serialize the teacher objects - serializer = TeacherSerializer( - teachers, many=True, context={"request": request} - ) - return Response(serializer.data) + return Response(serializer.data) - except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} - ) - - @action(detail=True, methods=["get"]) - def assistants(self, request, pk=None): + @action(detail=True, methods=["get", "post"]) + def assistants(self, request, **_): """Returns a list of assistants for the given course""" + # This automatically fetches the course from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + course = self.get_object() + assistants = course.assistants.all() - try: - queryset = Course.objects.get(id=pk) - assistants = queryset.assistants.all() - + if request.method == "GET": # Serialize the assistant objects serializer = AssistantSerializer( assistants, many=True, context={"request": request} ) + return Response(serializer.data) - except Course.DoesNotExist: - # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} - ) + # Add a new assistant to the course, assistant ID in request.get("assistant_id") + + + @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given course""" + course = self.get_object() try: queryset = Course.objects.get(id=pk) @@ -73,9 +73,7 @@ def students(self, request, pk=None): except Course.DoesNotExist: # Invalid course ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} - ) + raise NotFound(gettext("courses.errors.not_found")) @action(detail=True, methods=["get"]) def projects(self, request, pk=None): @@ -95,8 +93,10 @@ def projects(self, request, pk=None): # Invalid course ID raise NotFound(gettext("courses.errors.not_found")) - @action(detail=True, methods=['get'], permission_classes=[IsStudent]) + @action(detail=True, methods=["post"], permission_classes=[IsStudent]) def join(self, request, pk=None): + """Enrolls the authenticated student in the project""" + try: # Add the course to the student's enrollment list. Student.objects.get(id=request.user.id).courses.add( @@ -111,6 +111,6 @@ def join(self, request, pk=None): # Invalid course ID raise NotFound(gettext("courses.errors.not_found")) except Student.DoesNotExist: - # Invalid student user, this should not happen + # Invalid student user, this should not happen, # since the IsStudent permission class already checks this. raise NotFound(gettext("students.errors.not_found")) \ No newline at end of file From 009895eb25914a4e4675d79906445cf238d652c1 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 14:45:06 +0100 Subject: [PATCH 229/397] feat: add assistants, course permissions --- backend/api/permissions/course_permissions.py | 15 ++- backend/api/signals.py | 6 +- backend/api/views/course_view.py | 108 +++++++++--------- backend/authentication/models.py | 1 - 4 files changed, 71 insertions(+), 59 deletions(-) diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index afdfa71b..e2f7d987 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -8,6 +8,7 @@ class CoursePermission(BasePermission): + """Permission class used as default policy for course endpoints.""" def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general course endpoint.""" user: User = request.user @@ -31,4 +32,16 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) role: Teacher|Assistant = user.teacher or user.assistant return role is not None and \ - role.courses.filter(id=course.id).exists() \ No newline at end of file + role.courses.filter(id=course.id).exists() + +class CourseTeacherPermission(CoursePermission): + """Permission class for teacher-only course endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + user: User = request.user + + if request.method in SAFE_METHODS: + # Logged-in users can still fetch course details. + return request.user.is_authenticated + + return user.teacher.exists() and \ + user.teacher.courses.filter(id=course.id).exists() \ No newline at end of file diff --git a/backend/api/signals.py b/backend/api/signals.py index 85f94211..6395ea75 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -2,9 +2,9 @@ from api.models.student import Student -def user_creation(user: User, attributes: dict, **kwargs): +def user_creation(user: User, attributes: dict, **_): """Upon user creation, auto-populate additional properties""" - student_id = attributes.get("ugentStudentID") + student_id: str = attributes.get("ugentStudentID") - if student_id: + if student_id is not None: Student(user_ptr=user, student_id=student_id).save_base(raw=True) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 5d80662d..bf4320d2 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,12 +1,13 @@ from django.utils.translation import gettext -from rest_framework import viewsets, status +from rest_framework import viewsets +from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.exceptions import NotFound -from api.models.student import Student +from rest_framework.request import Request from api.models.course import Course -from api.permissions.course_permissions import CoursePermission +from api.models.assistant import Assistant +from api.permissions.course_permissions import CoursePermission, CourseTeacherPermission from api.permissions.role_permissions import IsStudent from api.serializers.course_serializer import CourseSerializer from api.serializers.teacher_serializer import TeacherSerializer @@ -35,82 +36,81 @@ def teachers(self, request, **_): return Response(serializer.data) - @action(detail=True, methods=["get", "post"]) - def assistants(self, request, **_): - """Returns a list of assistants for the given course""" + @action(detail=True, methods=["get", "post", "delete"], permission_classes=[IsAdminUser | CourseTeacherPermission]) + def assistants(self, request: Request, **_) -> Response: + """Action for managing assistants associated to a course""" # This automatically fetches the course from the URL. # It automatically gives back a 404 HTTP response in case of not found. course = self.get_object() - assistants = course.assistants.all() if request.method == "GET": - # Serialize the assistant objects + # Return assistants of a course. + assistants = course.assistants.all() + serializer = AssistantSerializer( assistants, many=True, context={"request": request} ) return Response(serializer.data) - # Add a new assistant to the course, assistant ID in request.get("assistant_id") + try: + assistant = Assistant.objects.get( + id=request.query_params.get("id") + ) + + if request.method == "POST": + # Add a new assistant to the course. + course.assistants.add(assistant) + return Response({ + "message": gettext("courses.success.assistants.add") + }) + elif request.method == "DELETE": + # Remove an assistant from the course. + course.assistants.remove(assistant) + return Response({ + "message": gettext("courses.success.assistants.remove") + }) + except Assistant.DoesNotExist: + # Not found + raise NotFound(gettext("assistants.error.404")) @action(detail=True, methods=["get"]) - def students(self, request, pk=None): + def students(self, request, **_): """Returns a list of students for the given course""" course = self.get_object() + students = course.students.all() - try: - queryset = Course.objects.get(id=pk) - students = queryset.students.all() - - # Serialize the student objects - serializer = StudentSerializer( - students, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": request} + ) - except Course.DoesNotExist: - # Invalid course ID - raise NotFound(gettext("courses.errors.not_found")) + return Response(serializer.data) @action(detail=True, methods=["get"]) - def projects(self, request, pk=None): + def projects(self, request, **_): """Returns a list of projects for the given course""" + course = self.get_object() + projects = course.projects.all() - try: - queryset = Course.objects.get(id=pk) - projects = queryset.projects.all() - - # Serialize the project objects - serializer = ProjectSerializer( - projects, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the project objects + serializer = ProjectSerializer( + projects, many=True, context={"request": request} + ) - except Course.DoesNotExist: - # Invalid course ID - raise NotFound(gettext("courses.errors.not_found")) + return Response(serializer.data) @action(detail=True, methods=["post"], permission_classes=[IsStudent]) - def join(self, request, pk=None): + def join(self, request, **_): """Enrolls the authenticated student in the project""" + # Add the course to the student's enrollment list. + self.get_object().students.add( + request.user.student + ) - try: - # Add the course to the student's enrollment list. - Student.objects.get(id=request.user.id).courses.add( - Course.objects.get(id=pk) - ) - - return Response({ - "message": gettext("courses.messages.successful_join") - }) - - except Course.DoesNotExist: - # Invalid course ID - raise NotFound(gettext("courses.errors.not_found")) - except Student.DoesNotExist: - # Invalid student user, this should not happen, - # since the IsStudent permission class already checks this. - raise NotFound(gettext("students.errors.not_found")) \ No newline at end of file + return Response({ + "message": gettext("courses.success.join") + }) \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index dcf28b4d..066d6fbb 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,5 +1,4 @@ from datetime import MINYEAR -from typing import Self, Type from django.db import models from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField, Model from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin From 155683d3ccc74dffc88f1a658e169b4a20a72104 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 14:46:29 +0100 Subject: [PATCH 230/397] chore: linting --- backend/api/permissions/course_permissions.py | 5 +++-- backend/api/permissions/role_permissions.py | 5 ++++- backend/api/views/course_view.py | 3 +-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index e2f7d987..46ee6f50 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -29,11 +29,12 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) return request.user.is_authenticated # We only allow teachers and assistants to modify specified courses. - role: Teacher|Assistant = user.teacher or user.assistant + role: Teacher | Assistant = user.teacher or user.assistant return role is not None and \ role.courses.filter(id=course.id).exists() + class CourseTeacherPermission(CoursePermission): """Permission class for teacher-only course endpoints.""" def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: @@ -44,4 +45,4 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) return request.user.is_authenticated return user.teacher.exists() and \ - user.teacher.courses.filter(id=course.id).exists() \ No newline at end of file + user.teacher.courses.filter(id=course.id).exists() diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 2a023a9d..94ace65c 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -3,6 +3,7 @@ from api.models.assistant import Assistant from api.models.teacher import Teacher + class IsStudent(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, @@ -10,6 +11,7 @@ def has_permission(self, request, view): return super().has_permission(request, view) and \ Student.objects.filter(id=request.user.id).exists() + class IsTeacher(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, @@ -17,9 +19,10 @@ def has_permission(self, request, view): return super().has_permission(request, view) and \ Teacher.objects.filter(id=request.user.id).exists() + class IsAssistant(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, with said user being a student""" return super().has_permission(request, view) and \ - Assistant.objects.filter(id=request.user.id).exists() \ No newline at end of file + Assistant.objects.filter(id=request.user.id).exists() diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index bf4320d2..c7123fa1 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -76,7 +76,6 @@ def assistants(self, request: Request, **_) -> Response: # Not found raise NotFound(gettext("assistants.error.404")) - @action(detail=True, methods=["get"]) def students(self, request, **_): """Returns a list of students for the given course""" @@ -113,4 +112,4 @@ def join(self, request, **_): return Response({ "message": gettext("courses.success.join") - }) \ No newline at end of file + }) From 171f12d546b65bbff402172a22a0d7c73eba059a Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 15:51:11 +0100 Subject: [PATCH 231/397] feat: course cloning --- backend/api/models/course.py | 10 ++ backend/api/permissions/course_permissions.py | 13 ++- backend/api/views/course_view.py | 108 ++++++++++++------ 3 files changed, 89 insertions(+), 42 deletions(-) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index f4ec41f2..57ff10ff 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -1,3 +1,4 @@ +from typing import Self from django.db import models @@ -30,6 +31,15 @@ def __str__(self) -> str: """The string representation of the course.""" return str(self.name) + def clone(self, year=None) -> Self: + # To-do: add more control over the cloning process. + return Course( + name=self.name, + description=self.description, + academic_startyear=year or self.academic_startyear + 1, + parent_course=self + ) + @property def academic_year(self) -> str: """The academic year of the course.""" diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 46ee6f50..cabf379f 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -18,7 +18,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: return request.user.is_authenticated # We only allow teachers to create new courses. - return user.teacher.exists() + return hasattr(user, "teacher") and user.teacher.exists() def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: """Check if user has permission to view a detailed course endpoint""" @@ -26,12 +26,13 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) if request.method in SAFE_METHODS: # Logged-in users can fetch course details. - return request.user.is_authenticated + return user.is_authenticated # We only allow teachers and assistants to modify specified courses. - role: Teacher | Assistant = user.teacher or user.assistant + role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant - return role is not None and \ + return role and \ role.courses.filter(id=course.id).exists() @@ -42,7 +43,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) if request.method in SAFE_METHODS: # Logged-in users can still fetch course details. - return request.user.is_authenticated + return user.is_authenticated - return user.teacher.exists() and \ + return hasattr(user, "teacher") and \ user.teacher.courses.filter(id=course.id).exists() diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index c7123fa1..4414ffa1 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext from rest_framework import viewsets from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.request import Request @@ -17,65 +17,80 @@ class CourseViewSet(viewsets.ModelViewSet): + """Actions for general course logic""" queryset = Course.objects.all() serializer_class = CourseSerializer permission_classes = [IsAdminUser | CoursePermission] - @action(detail=True, methods=["get"]) - def teachers(self, request, **_): - """Returns a list of teachers for the given course""" - # This automatically fetches the course from the URL. - # It automatically gives back a 404 HTTP response in case of not found. + @action(detail=True, permission_classes=[IsAdminUser | CourseTeacherPermission]) + def assistants(self, request: Request, **_): + """Returns a list of assistants for the given course""" course = self.get_object() - teachers = course.teachers.all() + assistants = course.assistants.all() - # Serialize the teacher objects - serializer = TeacherSerializer( - teachers, many=True, context={"request": request} + # Serialize assistants + serializer = AssistantSerializer( + assistants, many=True, context={"request": request} ) return Response(serializer.data) - @action(detail=True, methods=["get", "post", "delete"], permission_classes=[IsAdminUser | CourseTeacherPermission]) - def assistants(self, request: Request, **_) -> Response: - """Action for managing assistants associated to a course""" - # This automatically fetches the course from the URL. - # It automatically gives back a 404 HTTP response in case of not found. + @assistants.mapping.post + @assistants.mapping.put + def _add_assistant(self, request: Request, **_): + """Add an assistant to the course""" course = self.get_object() - if request.method == "GET": - # Return assistants of a course. - assistants = course.assistants.all() - - serializer = AssistantSerializer( - assistants, many=True, context={"request": request} + try: + # Add assistant to course + assistant = Assistant.objects.get( + id=request.data.get("id") ) - return Response(serializer.data) + course.assistants.add(assistant) + + return Response({ + "message": gettext("courses.success.assistants.add") + }) + except Assistant.DoesNotExist: + # Not found + raise NotFound(gettext("assistants.error.404")) + + @assistants.mapping.delete + def _remove_assistant(self, request: Request, **_): + """Remove an assistant from the course""" + course = self.get_object() try: + # Add assistant to course assistant = Assistant.objects.get( - id=request.query_params.get("id") + id=request.data.get("id") ) - if request.method == "POST": - # Add a new assistant to the course. - course.assistants.add(assistant) + course.assistants.remove(assistant) - return Response({ - "message": gettext("courses.success.assistants.add") - }) - elif request.method == "DELETE": - # Remove an assistant from the course. - course.assistants.remove(assistant) - - return Response({ - "message": gettext("courses.success.assistants.remove") - }) + return Response({ + "message": gettext("courses.success.assistants.delete") + }) except Assistant.DoesNotExist: # Not found raise NotFound(gettext("assistants.error.404")) + @action(detail=True, methods=["get"]) + def teachers(self, request, **_): + """Returns a list of teachers for the given course""" + # This automatically fetches the course from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + course = self.get_object() + teachers = course.teachers.all() + + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={"request": request} + ) + + return Response(serializer.data) + @action(detail=True, methods=["get"]) def students(self, request, **_): """Returns a list of students for the given course""" @@ -113,3 +128,24 @@ def join(self, request, **_): return Response({ "message": gettext("courses.success.join") }) + + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | CourseTeacherPermission]) + def clone(self, request: Request, **__): + """Copy the course to a new course with the same fields""" + course: Course = self.get_object() + + try: + course_serializer = CourseSerializer( + course.child_course, context={"request": request} + ) + except Course.DoesNotExist: + course_serializer = CourseSerializer( + course.clone( + year=request.data.get("academic_startyear") + ), + context={"request": request} + ) + + course_serializer.save() + + return Response(course_serializer.data) \ No newline at end of file From b2aba5eb6dfe6c170fc867b9746b2f26ccbd1ddc Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 15:53:20 +0100 Subject: [PATCH 232/397] chore: linting --- backend/api/views/course_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 4414ffa1..aa2d9219 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -148,4 +148,4 @@ def clone(self, request: Request, **__): course_serializer.save() - return Response(course_serializer.data) \ No newline at end of file + return Response(course_serializer.data) From fe9c1706f943f6b15b0a95fa2cf0d19e80f09133 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 20:48:22 +0100 Subject: [PATCH 233/397] chore: finished course logic with sensible permissions --- backend/api/models/course.py | 13 ++- backend/api/permissions/course_permissions.py | 60 +++++++--- backend/api/permissions/role_permissions.py | 24 ++-- backend/api/views/course_view.py | 107 +++++++++++------- 4 files changed, 134 insertions(+), 70 deletions(-) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index 57ff10ff..6c657c2c 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -31,15 +31,20 @@ def __str__(self) -> str: """The string representation of the course.""" return str(self.name) - def clone(self, year=None) -> Self: - # To-do: add more control over the cloning process. - return Course( + def clone(self, clone_assistants=True) -> Self: + """Clone the course to the next academic start year""" + course = Course( name=self.name, description=self.description, - academic_startyear=year or self.academic_startyear + 1, + academic_startyear=self.academic_startyear + 1, parent_course=self ) + if clone_assistants: + course.assistants.add(self.assistants) + + return course + @property def academic_year(self) -> str: """The academic year of the course.""" diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index cabf379f..fd0c0f9c 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -2,8 +2,7 @@ from rest_framework.request import Request from rest_framework.viewsets import ViewSet from authentication.models import User -from api.models.teacher import Teacher -from api.models.assistant import Assistant +from api.permissions.role_permissions import is_student, is_assistant, is_teacher from api.models.course import Course @@ -13,37 +12,64 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general course endpoint.""" user: User = request.user + # Logged-in users can fetch course information. if request.method in SAFE_METHODS: - # Logged-in users can fetch course lists. return request.user.is_authenticated - # We only allow teachers to create new courses. - return hasattr(user, "teacher") and user.teacher.exists() + # Only teachers can create courses. + return is_teacher(user) def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: """Check if user has permission to view a detailed course endpoint""" user: User = request.user + # Logged-in users can fetch course details. if request.method in SAFE_METHODS: - # Logged-in users can fetch course details. return user.is_authenticated - # We only allow teachers and assistants to modify specified courses. - role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + # We only allow teachers and assistants to modify their own courses. + return is_teacher(user) and user.teacher.courses.contains(course) or \ + is_assistant(user) and user.assistant.courses.contains(course) - return role and \ - role.courses.filter(id=course.id).exists() - -class CourseTeacherPermission(CoursePermission): - """Permission class for teacher-only course endpoints.""" +class CourseAssistantPermission(CoursePermission): + """Permission class for assistant related endpoints.""" def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: user: User = request.user + # Logged-in users can fetch course assistants. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only teachers can modify assistants of their own courses. + return is_teacher(user) and user.teacher.courses.contains(course) + + +class CourseStudentPermission(CoursePermission): + """Permission class for student related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course students. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only students can add or remove themselves from a course. + if is_student(user) and request.data.get("id") == user.id: + return True + + # Teachers and assistants can add and remove any student. + return super().has_object_permission(request, view, course) + + +class CourseProjectPermission(CoursePermission): + """Permission class for project related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course projects. if request.method in SAFE_METHODS: - # Logged-in users can still fetch course details. return user.is_authenticated - return hasattr(user, "teacher") and \ - user.teacher.courses.filter(id=course.id).exists() + # Teachers and assistants can modify projects. + return super().has_object_permission(request, view, course) diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 94ace65c..b196747c 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,28 +1,36 @@ from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from authentication.models import User from api.models.student import Student from api.models.assistant import Assistant from api.models.teacher import Teacher +def is_student(user: User): + return Student.objects.filter(id=user.id).exists() + +def is_assistant(user: User): + return Assistant.objects.filter(id=user.id).exists() + +def is_teacher(user: User): + return Teacher.objects.filter(id=user.id).exists() + class IsStudent(IsAuthenticated): - def has_permission(self, request, view): + def has_permission(self, request: Request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Student.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_student(request.user) class IsTeacher(IsAuthenticated): - def has_permission(self, request, view): + def has_permission(self, request: Request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Teacher.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_teacher(request.user) class IsAssistant(IsAuthenticated): def has_permission(self, request, view): """Returns true if the request contains a user, with said user being a student""" - return super().has_permission(request, view) and \ - Assistant.objects.filter(id=request.user.id).exists() + return super().has_permission(request, view) and is_assistant(request.user) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index aa2d9219..13dfbae4 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,14 +1,15 @@ from django.utils.translation import gettext from rest_framework import viewsets from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course from api.models.assistant import Assistant -from api.permissions.course_permissions import CoursePermission, CourseTeacherPermission -from api.permissions.role_permissions import IsStudent +from api.models.student import Student +from api.permissions.course_permissions import CoursePermission, CourseAssistantPermission, CourseStudentPermission +from api.permissions.role_permissions import IsTeacher from api.serializers.course_serializer import CourseSerializer from api.serializers.teacher_serializer import TeacherSerializer from api.serializers.assistant_serializer import AssistantSerializer @@ -22,7 +23,7 @@ class CourseViewSet(viewsets.ModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAdminUser | CoursePermission] - @action(detail=True, permission_classes=[IsAdminUser | CourseTeacherPermission]) + @action(detail=True, permission_classes=[IsAdminUser | CourseAssistantPermission]) def assistants(self, request: Request, **_): """Returns a list of assistants for the given course""" course = self.get_object() @@ -76,22 +77,8 @@ def _remove_assistant(self, request: Request, **_): # Not found raise NotFound(gettext("assistants.error.404")) - @action(detail=True, methods=["get"]) - def teachers(self, request, **_): - """Returns a list of teachers for the given course""" - # This automatically fetches the course from the URL. - # It automatically gives back a 404 HTTP response in case of not found. - course = self.get_object() - teachers = course.teachers.all() - # Serialize the teacher objects - serializer = TeacherSerializer( - teachers, many=True, context={"request": request} - ) - - return Response(serializer.data) - - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) def students(self, request, **_): """Returns a list of students for the given course""" course = self.get_object() @@ -104,6 +91,58 @@ def students(self, request, **_): return Response(serializer.data) + @students.mapping.post + @students.mapping.put + def _add_student(self, request: Request, **_): + """Add a student to the course""" + course = self.get_object() + + try: + # Add student to course + student = Student.objects.get( + id=request.data.get("id") + ) + + course.students.add(student) + + return Response({ + "message": gettext("courses.success.students.add") + }) + except Student.DoesNotExist: + raise NotFound(gettext("students.error.404")) + + @students.mapping.delete + def _remove_student(self, request: Request, **_): + """Remove a student from the course""" + course = self.get_object() + + try: + # Add student to course + student = Student.objects.get( + id=request.data.get("id") + ) + + course.students.remove(student) + + return Response({ + "message": gettext("courses.success.students.remove") + }) + except Student.DoesNotExist: + raise NotFound(gettext("students.error.404")) + + @action(detail=True, methods=["get"]) + def teachers(self, request, **_): + """Returns a list of teachers for the given course""" + course = self.get_object() + teachers = course.teachers.all() + + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={"request": request} + ) + + return Response(serializer.data) + @action(detail=True, methods=["get"]) def projects(self, request, **_): """Returns a list of projects for the given course""" @@ -117,35 +156,21 @@ def projects(self, request, **_): return Response(serializer.data) - @action(detail=True, methods=["post"], permission_classes=[IsStudent]) - def join(self, request, **_): - """Enrolls the authenticated student in the project""" - # Add the course to the student's enrollment list. - self.get_object().students.add( - request.user.student - ) - - return Response({ - "message": gettext("courses.success.join") - }) - - @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | CourseTeacherPermission]) + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) def clone(self, request: Request, **__): """Copy the course to a new course with the same fields""" course: Course = self.get_object() try: - course_serializer = CourseSerializer( - course.child_course, context={"request": request} - ) + course = course.child_course except Course.DoesNotExist: - course_serializer = CourseSerializer( - course.clone( - year=request.data.get("academic_startyear") - ), - context={"request": request} + course = course.clone( + clone_assistants=request.data.get("clone_assistants") ) - course_serializer.save() + course.save() + + # Return serialized cloned course + course_serializer = CourseSerializer(course, context={"request": request}) return Response(course_serializer.data) From bdb9d9e272a328037207493947b5a5f71f8e4438 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 20:52:00 +0100 Subject: [PATCH 234/397] chore: linting --- backend/api/permissions/course_permissions.py | 4 ++++ backend/api/permissions/role_permissions.py | 3 +++ backend/api/views/course_view.py | 1 - 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index fd0c0f9c..3c2543b5 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -8,6 +8,7 @@ class CoursePermission(BasePermission): """Permission class used as default policy for course endpoints.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general course endpoint.""" user: User = request.user @@ -34,6 +35,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) class CourseAssistantPermission(CoursePermission): """Permission class for assistant related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: user: User = request.user @@ -47,6 +49,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) class CourseStudentPermission(CoursePermission): """Permission class for student related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): user: User = request.user @@ -64,6 +67,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) class CourseProjectPermission(CoursePermission): """Permission class for project related endpoints.""" + def has_object_permission(self, request: Request, view: ViewSet, course: Course): user: User = request.user diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index b196747c..1e9c275c 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -9,12 +9,15 @@ def is_student(user: User): return Student.objects.filter(id=user.id).exists() + def is_assistant(user: User): return Assistant.objects.filter(id=user.id).exists() + def is_teacher(user: User): return Teacher.objects.filter(id=user.id).exists() + class IsStudent(IsAuthenticated): def has_permission(self, request: Request, view): """Returns true if the request contains a user, diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 13dfbae4..416ab1f5 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -77,7 +77,6 @@ def _remove_assistant(self, request: Request, **_): # Not found raise NotFound(gettext("assistants.error.404")) - @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) def students(self, request, **_): """Returns a list of students for the given course""" From 4082b4a57d40f0c7ce611e799864ecc3a6259587 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Fri, 8 Mar 2024 21:51:47 +0100 Subject: [PATCH 235/397] chore: serializers for validation --- backend/api/models/course.py | 5 + .../api/serializers/assistant_serializer.py | 6 + backend/api/serializers/course_serializer.py | 49 +++++++- backend/api/serializers/student_serializer.py | 6 + backend/api/views/course_view.py | 107 +++++++++--------- 5 files changed, 120 insertions(+), 53 deletions(-) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index 6c657c2c..02f346e8 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Self from django.db import models @@ -31,6 +32,10 @@ def __str__(self) -> str: """The string representation of the course.""" return str(self.name) + def is_past(self) -> bool: + """Returns whether the course is from a past academic year""" + return datetime(self.academic_startyear + 1, 10, 1) < datetime.now() + def clone(self, clone_assistants=True) -> Self: """Clone the course to the next academic start year""" course = Course( diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 16e26206..639b184b 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -24,3 +24,9 @@ class Meta: "create_time", "courses", ] + + +class AssistantIDSerializer(serializers.Serializer): + assistant_id = serializers.PrimaryKeyRelatedField( + queryset=Assistant.objects.all() + ) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 4d6edede..de7d0b70 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -1,5 +1,8 @@ +from django.utils.translation import gettext from rest_framework import serializers -from ..models.course import Course +from rest_framework.exceptions import ValidationError +from api.serializers.student_serializer import StudentIDSerializer +from api.models.course import Course class CourseSerializer(serializers.ModelSerializer): @@ -40,3 +43,47 @@ class Meta: "students", "projects", ] + + +class CourseIDSerializer(serializers.Serializer): + student_id = serializers.PrimaryKeyRelatedField( + queryset=Course.objects.all() + ) + + +class StudentJoinSerializer(StudentIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Check if the student isn't already enrolled. + if course.students.contains(data["student_id"]): + raise ValidationError(gettext("courses.error.students.already_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.students.past_course")) + + return data + + +class StudentLeaveSerializer(StudentIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Check if the student isn't already enrolled. + if not course.students.contains(data["student_id"]): + raise ValidationError(gettext("courses.error.students.not_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.students.past_course")) + + return data diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 9cd1f245..2c46593f 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -20,3 +20,9 @@ class StudentSerializer(serializers.ModelSerializer): class Meta: model = Student fields = '__all__' + + +class StudentIDSerializer(serializers.Serializer): + student_id = serializers.PrimaryKeyRelatedField( + queryset=Student.objects.all() + ) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 416ab1f5..be9bd152 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,18 +1,19 @@ from django.utils.translation import gettext from rest_framework import viewsets -from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course -from api.models.assistant import Assistant -from api.models.student import Student -from api.permissions.course_permissions import CoursePermission, CourseAssistantPermission, CourseStudentPermission +from api.permissions.course_permissions import ( + CoursePermission, + CourseAssistantPermission, + CourseStudentPermission +) from api.permissions.role_permissions import IsTeacher -from api.serializers.course_serializer import CourseSerializer +from api.serializers.course_serializer import CourseSerializer, StudentJoinSerializer, StudentLeaveSerializer from api.serializers.teacher_serializer import TeacherSerializer -from api.serializers.assistant_serializer import AssistantSerializer +from api.serializers.assistant_serializer import AssistantSerializer, AssistantIDSerializer from api.serializers.student_serializer import StudentSerializer from api.serializers.project_serializer import ProjectSerializer @@ -42,40 +43,38 @@ def _add_assistant(self, request: Request, **_): """Add an assistant to the course""" course = self.get_object() - try: - # Add assistant to course - assistant = Assistant.objects.get( - id=request.data.get("id") - ) + # Add assistant to course + serializer = AssistantIDSerializer( + data=request.data + ) - course.assistants.add(assistant) + if serializer.is_valid(raise_exception=True): + course.assistants.add( + serializer.validated_data["assistant_id"] + ) - return Response({ - "message": gettext("courses.success.assistants.add") - }) - except Assistant.DoesNotExist: - # Not found - raise NotFound(gettext("assistants.error.404")) + return Response({ + "message": gettext("courses.success.assistants.add") + }) @assistants.mapping.delete def _remove_assistant(self, request: Request, **_): """Remove an assistant from the course""" course = self.get_object() - try: - # Add assistant to course - assistant = Assistant.objects.get( - id=request.data.get("id") - ) + # Remove assistant from course + serializer = AssistantIDSerializer( + data=request.data + ) - course.assistants.remove(assistant) + if serializer.is_valid(raise_exception=True): + course.assistants.remove( + serializer.validated_data["assistant_id"] + ) - return Response({ - "message": gettext("courses.success.assistants.delete") - }) - except Assistant.DoesNotExist: - # Not found - raise NotFound(gettext("assistants.error.404")) + return Response({ + "message": gettext("courses.success.assistants.add") + }) @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) def students(self, request, **_): @@ -94,40 +93,42 @@ def students(self, request, **_): @students.mapping.put def _add_student(self, request: Request, **_): """Add a student to the course""" + # Get the course course = self.get_object() - try: - # Add student to course - student = Student.objects.get( - id=request.data.get("id") - ) + # Add student to course + serializer = StudentJoinSerializer(data=request.data, context={ + "course": course + }) - course.students.add(student) + if serializer.is_valid(raise_exception=True): + course.students.add( + serializer.validated_data["student_id"] + ) - return Response({ - "message": gettext("courses.success.students.add") - }) - except Student.DoesNotExist: - raise NotFound(gettext("students.error.404")) + return Response({ + "message": gettext("courses.success.students.add") + }) @students.mapping.delete def _remove_student(self, request: Request, **_): """Remove a student from the course""" + # Get the course course = self.get_object() - try: - # Add student to course - student = Student.objects.get( - id=request.data.get("id") - ) + # Add student to course + serializer = StudentLeaveSerializer(data=request.data, context={ + "course": course + }) - course.students.remove(student) + if serializer.is_valid(raise_exception=True): + course.students.remove( + serializer.validated_data["student_id"] + ) - return Response({ - "message": gettext("courses.success.students.remove") - }) - except Student.DoesNotExist: - raise NotFound(gettext("students.error.404")) + return Response({ + "message": gettext("courses.success.students.add") + }) @action(detail=True, methods=["get"]) def teachers(self, request, **_): @@ -161,8 +162,10 @@ def clone(self, request: Request, **__): course: Course = self.get_object() try: + # We should return the already cloned course, if present course = course.child_course except Course.DoesNotExist: + # Else, we clone the course course = course.clone( clone_assistants=request.data.get("clone_assistants") ) From af385e67e1875f49535188179b60298922577658 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 12:52:59 +0100 Subject: [PATCH 236/397] fix: course permissions --- backend/api/permissions/course_permissions.py | 2 +- backend/api/views/course_view.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 3c2543b5..7f88d7a9 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -58,7 +58,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) return user.is_authenticated # Only students can add or remove themselves from a course. - if is_student(user) and request.data.get("id") == user.id: + if is_student(user) and request.data.get("student_id") == user.id: return True # Teachers and assistants can add and remove any student. diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index be9bd152..84a50ae6 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext from rest_framework import viewsets from rest_framework.permissions import IsAdminUser -from rest_framework.decorators import action +from rest_framework.decorators import action, permission_classes from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course @@ -164,6 +164,7 @@ def clone(self, request: Request, **__): try: # We should return the already cloned course, if present course = course.child_course + except Course.DoesNotExist: # Else, we clone the course course = course.clone( From 3c5969a432119ebe402cf7c982a7a2379bde1559 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 12:54:34 +0100 Subject: [PATCH 237/397] chore: linting --- backend/api/views/course_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 84a50ae6..cea2b180 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -1,7 +1,7 @@ from django.utils.translation import gettext from rest_framework import viewsets from rest_framework.permissions import IsAdminUser -from rest_framework.decorators import action, permission_classes +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course From bd769fbfef95d98706bb902f1574be582526bcf6 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 13:05:39 +0100 Subject: [PATCH 238/397] fix: permission classes --- backend/api/permissions/course_permissions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 7f88d7a9..5bce23e7 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -49,6 +49,8 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) class CourseStudentPermission(CoursePermission): """Permission class for student related endpoints.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + return request.user and request.user.is_authenticated def has_object_permission(self, request: Request, view: ViewSet, course: Course): user: User = request.user @@ -67,6 +69,8 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) class CourseProjectPermission(CoursePermission): """Permission class for project related endpoints.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + return request.user and request.user.is_authenticated def has_object_permission(self, request: Request, view: ViewSet, course: Course): user: User = request.user From 3da961a05d749299e50d866abccd9ec10f88519c Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 9 Mar 2024 21:34:57 +0100 Subject: [PATCH 239/397] chore: add some tests for filestructures --- backend/api/models/project.py | 3 +- backend/api/tests/test_checks.py | 3 +- backend/api/tests/test_file_structure.py | 59 +++++++++++++++++++-- backend/api/tests/test_group.py | 11 ++-- backend/api/tests/test_project.py | 13 ++--- backend/api/tests/test_submission.py | 11 ++-- backend/api/views/check_folder_structure.py | 6 ++- backend/ypovoli/settings.py | 2 + 8 files changed, 84 insertions(+), 24 deletions(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 88e587fc..839907a1 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,4 +1,3 @@ -from datetime import datetime from django.db import models from django.utils import timezone from api.models.course import Course @@ -21,7 +20,7 @@ class Project(models.Model): start_date = models.DateTimeField( # The default value is the current date and time - default=datetime.now, + default=timezone.now, blank=True, ) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py index b66251b8..d1c66580 100644 --- a/backend/api/tests/test_checks.py +++ b/backend/api/tests/test_checks.py @@ -7,6 +7,7 @@ from api.models.extension import FileExtension from api.models.project import Project from api.models.course import Course +from django.conf import settings def create_fileExtension(id, extension): @@ -300,4 +301,4 @@ def test_extra_checks_exists(self): # Assert the details of the retrieved Checks match the created Checks retrieved_checks = content_json[0] self.assertEqual(int(retrieved_checks["id"]), checks.id) - self.assertEqual(retrieved_checks["run_script"], "http://testserver" + checks.run_script.url) + self.assertEqual(retrieved_checks["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index 5f83044c..84b261af 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -92,8 +92,59 @@ def test_your_function(self): content_json = json.loads(response.content.decode("utf-8")) - # print("strucCheck:", content_json["structure_checks"]) - # TODO get the structure checks from this now its following - # strucCheck: http://testserver/projects/1/structure_checks/ + response = self.client.get( + content_json["structure_checks"], follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 7) + + expected_project_url = settings.TESTING_BASE_LINK + reverse( + "project-detail", args=[str(project.id)] + ) - self.assertEqual(True, True) + content = content_json[0] + self.assertEqual(content["name"], ".") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 0) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[1] + self.assertEqual(content["name"], "folder_struct1") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[2] + self.assertEqual(content["name"], "folder_struct1/submap1") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 2) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[3] + self.assertEqual(content["name"], "folder_struct1/submap1/templates") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[4] + self.assertEqual(content["name"], "folder_struct1/submap2") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[5] + self.assertEqual(content["name"], "folder_struct1/submap2/src") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 3) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[6] + self.assertEqual(content["name"], "folder_struct1/submap3") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 2) + self.assertEqual(len(content["blocked_extensions"]), 0) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index f10e87d4..e90e80de 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -8,6 +8,7 @@ from api.models.student import Student from api.models.group import Group from api.models.course import Course +from django.conf import settings def create_course(name, academic_startyear, description=None, parent_course=None): @@ -83,7 +84,7 @@ def test_group_exists(self): self.assertEqual(len(content_json), 1) retrieved_group = content_json[0] - expected_project_url = "http://testserver" + reverse( + expected_project_url = settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project.id)] ) @@ -122,10 +123,10 @@ def test_multiple_groups(self): self.assertEqual(len(content_json), 2) retrieved_group1, retrieved_group2 = content_json - expected_project_url1 = "http://testserver" + reverse( + expected_project_url1 = settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project1.id)] ) - expected_project_url2 = "http://testserver" + reverse( + expected_project_url2 = settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project2.id)] ) @@ -159,7 +160,7 @@ def test_group_detail_view(self): content_json = json.loads(response.content.decode("utf-8")) - expected_project_url = "http://testserver" + reverse( + expected_project_url = settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project.id)] ) @@ -203,7 +204,7 @@ def test_group_project(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - expected_course_url = "http://testserver" + reverse( + expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] ) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 126e4257..a5885712 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -7,6 +7,7 @@ from api.models.course import Course from api.models.checks import StructureCheck, ExtraCheck from api.models.extension import FileExtension +from django.conf import settings def create_course(id, name, academic_startyear): @@ -212,7 +213,7 @@ def test_project_exists(self): retrieved_project = content_json[0] - expected_course_url = "http://testserver" + reverse( + expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] ) @@ -256,7 +257,7 @@ def test_multiple_project(self): retrieved_project = content_json[0] - expected_course_url = "http://testserver" + reverse( + expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] ) @@ -268,7 +269,7 @@ def test_multiple_project(self): retrieved_project = content_json[1] - expected_course_url = "http://testserver" + reverse( + expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] ) @@ -361,7 +362,7 @@ def test_project_structure_checks(self): retrieved_project = content_json[0] - expected_course_url = "http://testserver" + reverse( + expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] ) @@ -449,7 +450,7 @@ def test_project_extra_checks(self): content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) - self.assertEqual(content_json["project"], "http://testserver" + reverse( + self.assertEqual(content_json["project"], settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project.id)] )) - self.assertEqual(content_json["run_script"], "http://testserver" + checks.run_script.url) + self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index ac0b52ab..ec426a03 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -9,6 +9,7 @@ from api.models.group import Group from api.models.course import Course from api.models.checks import ExtraCheck +from django.conf import settings def create_course(name, academic_startyear, description=None, parent_course=None): @@ -98,7 +99,7 @@ def test_submission_exists(self): # Assert the details of the retrieved submission # match the created submission retrieved_submission = content_json[0] - expected_group_url = "http://testserver" + reverse( + expected_group_url = settings.TESTING_BASE_LINK + reverse( "group-detail", args=[str(group.id)] ) self.assertEqual(int(retrieved_submission["id"]), submission.id) @@ -139,7 +140,7 @@ def test_multiple_submission_exists(self): # Assert the details of the retrieved submission # match the created submission retrieved_submission = content_json[0] - expected_group_url = "http://testserver" + reverse( + expected_group_url = settings.TESTING_BASE_LINK + reverse( "group-detail", args=[str(group.id)] ) self.assertEqual(int(retrieved_submission["id"]), submission1.id) @@ -150,7 +151,7 @@ def test_multiple_submission_exists(self): self.assertEqual(retrieved_submission["group"], expected_group_url) retrieved_submission = content_json[1] - expected_group_url = "http://testserver" + reverse( + expected_group_url = settings.TESTING_BASE_LINK + reverse( "group-detail", args=[str(group.id)] ) self.assertEqual(int(retrieved_submission["id"]), submission2.id) @@ -188,7 +189,7 @@ def test_submission_detail_view(self): # Assert the details of the retrieved submission # match the created submission retrieved_submission = content_json - expected_group_url = "http://testserver" + reverse( + expected_group_url = settings.TESTING_BASE_LINK + reverse( "group-detail", args=[str(group.id)] ) self.assertEqual(int(retrieved_submission["id"]), submission.id) @@ -241,7 +242,7 @@ def test_submission_group(self): # Parse the JSON content from the response content_json = json.loads(response.content.decode("utf-8")) - expected_project_url = "http://testserver" + reverse( + expected_project_url = settings.TESTING_BASE_LINK + reverse( "project-detail", args=[str(project.id)] ) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index 3fec83bd..c85d0f63 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -9,7 +9,11 @@ def parseZipFile(project, dir_path): # TODO block paths that start with .. dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) struct = get_zip_structure(dir_path) - for key, value in struct.items(): + + sorted_keys = sorted(struct.keys()) + + for key in sorted_keys: + value = struct[key] check = StructureCheck.objects.create( name=key, project=project diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 2fd97375..f06ed720 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -18,6 +18,8 @@ BASE_DIR = Path(__file__).resolve().parent.parent MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "../data/production")) +TESTING_BASE_LINK = "http://testserver" + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ From df77e107ec69be11646cf912b15f7b81c6fc4402 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 9 Mar 2024 22:03:38 +0100 Subject: [PATCH 240/397] chore: finished file_structure tests --- backend/api/tests/test_file_structure.py | 76 ++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index 84b261af..7132d6a2 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -3,7 +3,7 @@ from django.utils import timezone from django.urls import reverse from rest_framework.test import APITestCase -from api.views.check_folder_structure import check_zip_content, parseZipFile +from api.views.check_folder_structure import checkZipFile, parseZipFile from api.models.checks import StructureCheck from api.models.extension import FileExtension from api.models.course import Course @@ -12,7 +12,6 @@ from django.conf import settings - def create_course(id, name, academic_startyear): """ Create a Course with the given arguments. @@ -22,11 +21,11 @@ def create_course(id, name, academic_startyear): ) -def create_fileExtension(id, extension): +def create_fileExtension(extension): """ Create a FileExtension with the given arguments. """ - return FileExtension.objects.create(id=id, extension=extension) + return FileExtension.objects.create(extension=extension) def create_project(name, description, visible, archived, days, course): @@ -72,7 +71,7 @@ def tearDown(self): # Restore the original MEDIA_ROOT after tests settings.MEDIA_ROOT = self.old_media_root - def test_your_function(self): + def test_your_parsing(self): course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test", @@ -148,3 +147,70 @@ def test_your_function(self): self.assertEqual(content["project"], expected_project_url) self.assertEqual(len(content["obligated_extensions"]), 2) self.assertEqual(len(content["blocked_extensions"]), 0) + + def test_your_checking(self): + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=100, + course=course, + ) + + fileExtensionHS = create_fileExtension(extension="hs") + fileExtensionPDF = create_fileExtension(extension="pdf") + fileExtensionDOCX = create_fileExtension(extension="docx") + fileExtensionLATEX = create_fileExtension(extension="latex") + fileExtensionMD = create_fileExtension(extension="md") + fileExtensionPY = create_fileExtension(extension="py") + fileExtensionHPP = create_fileExtension(extension="hpp") + fileExtensionCPP = create_fileExtension(extension="cpp") + fileExtensionTS = create_fileExtension(extension="ts") + fileExtensionTSX = create_fileExtension(extension="tsx") + + create_structureCheck( + name=".", + project=project, + obligated=[], + blocked=[]) + + create_structureCheck( + name="folder_struct1", + project=project, + obligated=[fileExtensionHS], + blocked=[]) + + create_structureCheck( + name="folder_struct1/submap1", + project=project, + obligated=[fileExtensionPDF, fileExtensionDOCX], + blocked=[]) + + create_structureCheck( + name="folder_struct1/submap1/templates", + project=project, + obligated=[fileExtensionLATEX], + blocked=[]) + + create_structureCheck( + name="folder_struct1/submap2", + project=project, + obligated=[fileExtensionMD], + blocked=[]) + + create_structureCheck( + name="folder_struct1/submap2/src", + project=project, + obligated=[fileExtensionPY, fileExtensionHPP, fileExtensionCPP], + blocked=[]) + + create_structureCheck( + name="folder_struct1/submap3", + project=project, + obligated=[fileExtensionTS, fileExtensionTSX], + blocked=[]) + + succes = (True, 'zip.success') + self.assertEqual(checkZipFile(project=project, dir_path="structures/zip_struct1.zip"), succes) From cc0d8e2af78c5b73173408c7ce5f4e58d546c675 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 9 Mar 2024 22:09:10 +0100 Subject: [PATCH 241/397] chore: fix doable lintning warnings --- backend/api/views/check_folder_structure.py | 31 +++++++++------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index c85d0f63..e6747f3a 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -87,8 +87,7 @@ def get_zip_structure(root_path): with zipfile.ZipFile(root_path, 'r') as zip_file: file_names = zip_file.namelist() for file_name in file_names: - # print(file_name) - parts = file_name.rsplit('/', 1) # You can also use '\\' if needed + parts = file_name.rsplit('/', 1) if len(parts) == 2: map, file = parts _, file_extension = os.path.splitext(file) @@ -169,7 +168,7 @@ def check_zip_structure( """ base, _ = os.path.splitext(zip_file_path) struc = [f for f in folder_structure.keys() if not f == "."] - # print(struc) + dirs = list_zip_directories(zip_file_path) for dir in struc: if dir not in dirs: @@ -177,21 +176,17 @@ def check_zip_structure( return False, gettext( 'zip.errors.invalid_structure.directory_not_defined') - with zipfile.ZipFile(zip_file_path, 'r') as zip_file: - # zip_contents = set(zip_file.namelist()) - # print(f"all contents in the zip are {zip_contents}") - for directory, info in folder_structure.items(): - # base_name, _ = os.path.splitext(zip_file_path) - obligated_extensions = info.get('obligated_extensions', set()) - blocked_extensions = info.get('blocked_extensions', set()) - - result, message = check_zip_content( - zip_file_path, - directory, - obligated_extensions, - blocked_extensions) - if not result: - return result, message + for directory, info in folder_structure.items(): + obligated_extensions = info.get('obligated_extensions', set()) + blocked_extensions = info.get('blocked_extensions', set()) + + result, message = check_zip_content( + zip_file_path, + directory, + obligated_extensions, + blocked_extensions) + if not result: + return result, message # Check for any directories not present in the folder structure dictionary if restrict_extra_folders: for actual_directory in dirs: From b1b20f2d4f3cfa0957b542f087ec762210d31af6 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 9 Mar 2024 22:19:34 +0100 Subject: [PATCH 242/397] chore: fixed some more linting errors --- backend/api/views/check_folder_structure.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index e6747f3a..bdf487c6 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -26,7 +26,8 @@ def parseZipFile(project, dir_path): # TODO block paths that start with .. project.structure_checks.add(check) -def checkZipFile(project, dir_path, restrict_extra_folders=False): # TODO block paths that start with .. +# TODO block paths that start with .. +def checkZipFile(project, dir_path, restrict_extra_folders=False): dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) project_structure_checks = StructureCheck.objects.filter( project=project.id) @@ -140,7 +141,9 @@ def check_zip_content( file_extension = file_extension[1:] if file_extension in blocked_extensions: - # print(f"Error: {file_extension} found in '{dir_path}' is not allowed.") TODO + # print( + # f"Error: {file_extension} found in + # '{dir_path}' is not allowed.") TODO return False, gettext( 'zip.errors.invalid_structure.blocked_extension_found') elif file_extension in obligated_extensions: @@ -191,7 +194,8 @@ def check_zip_structure( if restrict_extra_folders: for actual_directory in dirs: if actual_directory not in struc: - # print(f"Error: Directory '{actual_directory}' not defined in the folder structure dictionary.") TODO + # print(f"Error: Directory '{actual_directory}' + # not defined in the folder structure dictionary.") TODO return False, gettext( 'zip.errors.invalid_structure.directory_not_found_in_template') return True, gettext('zip.success') From a5984bafff5c854cb2ff71bf475567c5becd1d10 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sat, 9 Mar 2024 22:34:43 +0100 Subject: [PATCH 243/397] chore: finaly fixed linter warnings --- backend/.flake8 | 2 +- backend/api/views/check_folder_structure.py | 19 ++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/backend/.flake8 b/backend/.flake8 index ee3e436d..9c2d9f85 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -3,7 +3,7 @@ # Ignore unused imports ignore = F401 -max-line-length = 119 +max-line-length = 120 max-complexity = 10 diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index bdf487c6..bf65f5f9 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -29,8 +29,7 @@ def parseZipFile(project, dir_path): # TODO block paths that start with .. # TODO block paths that start with .. def checkZipFile(project, dir_path, restrict_extra_folders=False): dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) - project_structure_checks = StructureCheck.objects.filter( - project=project.id) + project_structure_checks = StructureCheck.objects.filter(project=project.id) structuur = {} for struct in project_structure_checks: structuur[struct.name] = { @@ -94,13 +93,11 @@ def get_zip_structure(root_path): _, file_extension = os.path.splitext(file) file_extension = file_extension[1:] if not file_extension == "": - directory_structure[map]["obligated_extensions"].add( - file_extension) + directory_structure[map]["obligated_extensions"].add(file_extension) else: _, file_extension = os.path.splitext(file_name) file_extension = file_extension[1:] - directory_structure["."]["obligated_extensions"].add( - file_extension) + directory_structure["."]["obligated_extensions"].add(file_extension) return directory_structure @@ -125,15 +122,11 @@ def check_zip_content( zip_contents = set(zip_file.namelist()) found_obligated = set() # To track found obligated extensions if dir_path == ".": - files_in_subdirectory = [ - file for file in zip_contents if "/" not in file - ] + files_in_subdirectory = [file for file in zip_contents if "/" not in file] else: files_in_subdirectory = [ - file[len(dir_path)+1:] for file in zip_contents - if (file.startswith(dir_path) and - '/' not in file[len(dir_path)+1:] and - file[len(dir_path)+1:] != "")] + file[len(dir_path) + 1:] for file in zip_contents + if file.startswith(dir_path) and '/' not in file[len(dir_path) + 1:] and bool(file[len(dir_path) + 1:])] for file in files_in_subdirectory: _, file_extension = os.path.splitext(file) From 0d4c01541e442a928d11d2588b57185c92a69e06 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:19:16 +0100 Subject: [PATCH 244/397] chore: linting --- backend/api/tests/test_file_structure.py | 6 +++--- backend/api/views/check_folder_structure.py | 4 ++-- backend/api/views/project_view.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index 7132d6a2..dc1a9706 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -3,7 +3,7 @@ from django.utils import timezone from django.urls import reverse from rest_framework.test import APITestCase -from api.views.check_folder_structure import checkZipFile, parseZipFile +from api.views.check_folder_structure import check_zip_file, parse_zip_file from api.models.checks import StructureCheck from api.models.extension import FileExtension from api.models.course import Course @@ -81,7 +81,7 @@ def test_your_parsing(self): days=100, course=course, ) - parseZipFile(project=project, dir_path="structures/zip_struct1.zip") + parse_zip_file(project=project, dir_path="structures/zip_struct1.zip") response = self.client.get( reverse("project-detail", args=[str(project.id)]), follow=True @@ -213,4 +213,4 @@ def test_your_checking(self): blocked=[]) succes = (True, 'zip.success') - self.assertEqual(checkZipFile(project=project, dir_path="structures/zip_struct1.zip"), succes) + self.assertEqual(check_zip_file(project=project, dir_path="structures/zip_struct1.zip"), succes) diff --git a/backend/api/views/check_folder_structure.py b/backend/api/views/check_folder_structure.py index bf65f5f9..b1bf70a0 100644 --- a/backend/api/views/check_folder_structure.py +++ b/backend/api/views/check_folder_structure.py @@ -6,7 +6,7 @@ from django.conf import settings -def parseZipFile(project, dir_path): # TODO block paths that start with .. +def parse_zip_file(project, dir_path): # TODO block paths that start with .. dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) struct = get_zip_structure(dir_path) @@ -27,7 +27,7 @@ def parseZipFile(project, dir_path): # TODO block paths that start with .. # TODO block paths that start with .. -def checkZipFile(project, dir_path, restrict_extra_folders=False): +def check_zip_file(project, dir_path, restrict_extra_folders=False): dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) project_structure_checks = StructureCheck.objects.filter(project=project.id) structuur = {} diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 413b5a70..0b041875 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -3,7 +3,6 @@ from rest_framework.response import Response from rest_framework.exceptions import NotFound from django.utils.translation import gettext_lazy as _ -from api.views.check_folder_structure import get_zip_structure, check_zip_structure, parseZipFile from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer From 16e2e10156de1d8943a0b106125324c5605f2605 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:21:37 +0100 Subject: [PATCH 245/397] chore: linting --- backend/api/helpers/__init__.py | 0 .../check_folder_structure.py | 0 backend/api/tests/test_file_structure.py | 50 +++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 backend/api/helpers/__init__.py rename backend/api/{views => helpers}/check_folder_structure.py (100%) diff --git a/backend/api/helpers/__init__.py b/backend/api/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/views/check_folder_structure.py b/backend/api/helpers/check_folder_structure.py similarity index 100% rename from backend/api/views/check_folder_structure.py rename to backend/api/helpers/check_folder_structure.py diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index dc1a9706..d16b2ea2 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -3,7 +3,7 @@ from django.utils import timezone from django.urls import reverse from rest_framework.test import APITestCase -from api.views.check_folder_structure import check_zip_file, parse_zip_file +from api.helpers.check_folder_structure import check_zip_file, parse_zip_file from api.models.checks import StructureCheck from api.models.extension import FileExtension from api.models.course import Course @@ -21,7 +21,7 @@ def create_course(id, name, academic_startyear): ) -def create_fileExtension(extension): +def create_file_extension(extension): """ Create a FileExtension with the given arguments. """ @@ -42,20 +42,20 @@ def create_project(name, description, visible, archived, days, course): ) -def create_structureCheck(name, project, obligated, blocked): +def create_structure_check(name, project, obligated, blocked): """ Create a StructureCheck with the given arguments. """ - structureCheck = StructureCheck.objects.create( + structure_check = StructureCheck.objects.create( name=name, project=project, ) for ch in obligated: - structureCheck.obligated_extensions.add(ch) + structure_check.obligated_extensions.add(ch) for ch in blocked: - structureCheck.blocked_extensions.add(ch) + structure_check.blocked_extensions.add(ch) - return structureCheck + return structure_check class FileTestsTests(APITestCase): @@ -159,54 +159,54 @@ def test_your_checking(self): course=course, ) - fileExtensionHS = create_fileExtension(extension="hs") - fileExtensionPDF = create_fileExtension(extension="pdf") - fileExtensionDOCX = create_fileExtension(extension="docx") - fileExtensionLATEX = create_fileExtension(extension="latex") - fileExtensionMD = create_fileExtension(extension="md") - fileExtensionPY = create_fileExtension(extension="py") - fileExtensionHPP = create_fileExtension(extension="hpp") - fileExtensionCPP = create_fileExtension(extension="cpp") - fileExtensionTS = create_fileExtension(extension="ts") - fileExtensionTSX = create_fileExtension(extension="tsx") - - create_structureCheck( + fileExtensionHS = create_file_extension(extension="hs") + fileExtensionPDF = create_file_extension(extension="pdf") + fileExtensionDOCX = create_file_extension(extension="docx") + fileExtensionLATEX = create_file_extension(extension="latex") + fileExtensionMD = create_file_extension(extension="md") + fileExtensionPY = create_file_extension(extension="py") + fileExtensionHPP = create_file_extension(extension="hpp") + fileExtensionCPP = create_file_extension(extension="cpp") + fileExtensionTS = create_file_extension(extension="ts") + fileExtensionTSX = create_file_extension(extension="tsx") + + create_structure_check( name=".", project=project, obligated=[], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1", project=project, obligated=[fileExtensionHS], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1/submap1", project=project, obligated=[fileExtensionPDF, fileExtensionDOCX], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1/submap1/templates", project=project, obligated=[fileExtensionLATEX], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1/submap2", project=project, obligated=[fileExtensionMD], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1/submap2/src", project=project, obligated=[fileExtensionPY, fileExtensionHPP, fileExtensionCPP], blocked=[]) - create_structureCheck( + create_structure_check( name="folder_struct1/submap3", project=project, obligated=[fileExtensionTS, fileExtensionTSX], From 4c239be2229c510dcaea307a683239ffedb7e9f7 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 14:34:54 +0100 Subject: [PATCH 246/397] chore: define group permissions --- backend/api/permissions/group_permissions.py | 36 ++++++++++++++++++++ backend/api/views/group_view.py | 35 +++++++++---------- 2 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 backend/api/permissions/group_permissions.py diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py new file mode 100644 index 00000000..ff37dcaa --- /dev/null +++ b/backend/api/permissions/group_permissions.py @@ -0,0 +1,36 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.models.teacher import Teacher +from api.models.assistant import Assistant +from api.models.student import Student + + +class GroupPermission(BasePermission): + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general group endpoint.""" + user: User = request.user + + # The general group endpoint is not accessible for any role. + + # We only allow teachers and assistants to create new groups. + return user.teacher.exists() or user.assistant.exists() + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + """Check if user has permission to view a detailed group endpoint""" + user: User = request.user + course = group.course + role: Teacher | Assistant | Student = user.teacher or user.assistant or user.student + + if request.method in SAFE_METHODS: + # Users linked to the course linked to the group can fetch group details. + return role is not None and \ + role.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to modify specified groups. + role = user.teacher or user.assistant + + return role is not None and \ + role.courses.filter(id=course.id).exists() diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 0402f198..a4aede83 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,31 +1,28 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response -from ..models.group import Group -from ..serializers.group_serializer import GroupSerializer -from ..serializers.student_serializer import StudentSerializer +from api.models.group import Group +from api.permissions.group_permissions import GroupPermission +from api.serializers.group_serializer import GroupSerializer +from api.serializers.student_serializer import StudentSerializer class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer + permission_classes = [IsAdminUser | GroupPermission] @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + students = group.students.all() - try: - queryset = Group.objects.get(id=pk) - students = queryset.students.all() - - # Serialize the student objects - serializer = StudentSerializer( - students, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Group.DoesNotExist: - # Invalid group ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Group not found"} - ) + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": request} + ) + return Response(serializer.data) From 9b69892d5cb10fc704f7b784dab30202dfba5aae Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 14:51:35 +0100 Subject: [PATCH 247/397] fix: no access to general group endpoint --- backend/api/permissions/group_permissions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index ff37dcaa..7f4e89f2 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -14,6 +14,8 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint is not accessible for any role. + if request.method in SAFE_METHODS: + return False # We only allow teachers and assistants to create new groups. return user.teacher.exists() or user.assistant.exists() @@ -30,7 +32,7 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: role.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - role = user.teacher or user.assistant + teacher_assistant_role: Teacher | Assistant = user.teacher or user.assistant - return role is not None and \ - role.courses.filter(id=course.id).exists() + return teacher_assistant_role is not None and \ + teacher_assistant_role.courses.filter(id=course.id).exists() From 1e2ab1fad8e19f315ea0d7536b94ada48759d58f Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 16:56:27 +0100 Subject: [PATCH 248/397] chore: join group for student + /group not accessible --- backend/api/fixtures/groups.yaml | 12 ++++++++ backend/api/fixtures/projects.yaml | 12 ++++++++ backend/api/models/group.py | 4 +++ backend/api/models/student.py | 4 +++ backend/api/permissions/group_permissions.py | 30 +++++++++++++++----- backend/api/views/group_view.py | 26 +++++++++++++++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml index 35b2d571..40657f3f 100644 --- a/backend/api/fixtures/groups.yaml +++ b/backend/api/fixtures/groups.yaml @@ -6,3 +6,15 @@ students: - '1' - '2' +- model: api.group + pk: 3 + fields: + project: 123456 + score: 8 + students: [] +- model: api.group + pk: 2 + fields: + project: 1 + score: 8 + students: [] diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml index 2b7eca2b..6e16ad73 100644 --- a/backend/api/fixtures/projects.yaml +++ b/backend/api/fixtures/projects.yaml @@ -10,3 +10,15 @@ group_size: 3 max_score: 20 course: 2 +- model: api.project + pk: 1 + fields: + name: sel3 + description: make a project + visible: true + archived: false + start_date: 2024-02-26 00:00:00+00:00 + deadline: 2024-02-27 00:00:00+00:00 + group_size: 3 + max_score: 20 + course: 1 diff --git a/backend/api/models/group.py b/backend/api/models/group.py index b963aa89..a03ab0a8 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -28,3 +28,7 @@ class Group(models.Model): # Score of the group score = models.FloatField(blank=True, null=True) + + def is_full(self) -> bool: + """Check if the group is full.""" + return self.students.count() >= self.project.group_size diff --git a/backend/api/models/student.py b/backend/api/models/student.py index c619d924..fea5cf73 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -19,3 +19,7 @@ class Student(User): related_name="students", blank=True, ) + + def is_enrolled_in_group(self, project): + """Check if the student is enrolled in a group for the given project.""" + return self.groups.filter(project=project).exists() diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 7f4e89f2..6548d3a3 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -13,26 +13,42 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general group endpoint.""" user: User = request.user - # The general group endpoint is not accessible for any role. - if request.method in SAFE_METHODS: + # The general group endpoint that lists all groups is not accessible for any role. + if view.action == "list": return False + elif request.method in SAFE_METHODS: + return True # We only allow teachers and assistants to create new groups. - return user.teacher.exists() or user.assistant.exists() + return hasattr(user, "teacher") and user.teacher.exists() or hasattr(user, "assistant") and user.assistant.exists() def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user - course = group.course - role: Teacher | Assistant | Student = user.teacher or user.assistant or user.student + course = group.project.course + teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: + role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student + # Users linked to the course linked to the group can fetch group details. return role is not None and \ role.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - teacher_assistant_role: Teacher | Assistant = user.teacher or user.assistant - return teacher_assistant_role is not None and \ teacher_assistant_role.courses.filter(id=course.id).exists() + + +class GroupStudentPermission(BasePermission): + """Permission class for student-only group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + user: User = request.user + course = group.project.course + student: Student = user.student + + # We only allow students to join groups. + return student is not None and \ + student.courses.filter(id=course.id).exists() diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index a4aede83..1d5b1655 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,9 +1,13 @@ +from django.utils.translation import gettext +from rest_framework.exceptions import ValidationError from rest_framework import viewsets from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group from api.permissions.group_permissions import GroupPermission +from api.permissions.group_permissions import GroupStudentPermission +from api.permissions.role_permissions import IsStudent from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer @@ -26,3 +30,25 @@ def students(self, request, pk=None): students, many=True, context={"request": request} ) return Response(serializer.data) + + @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) + def join(self, request, pk=None): + """Enrolls the authenticated student in the group""" + group = self.get_object() + student = request.user.student + + # Make sure the group is not full + if group.is_full(): + raise ValidationError(gettext("group.errors.full")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise ValidationError(gettext("group.errors.already_in_group")) + + # Add the student to the group + group.students.add(student) + + return Response({ + "message": gettext("group.success.joined"), + }) + From 25a45ef6a12442550467a16b6c1306a34c68b3e4 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Fri, 8 Mar 2024 20:36:22 +0100 Subject: [PATCH 249/397] chore: init testing permission logic --- backend/api/permissions/group_permissions.py | 19 ++- backend/api/tests/test_group.py | 160 ++++++++++++++++++- backend/api/views/group_view.py | 69 +++++++- 3 files changed, 237 insertions(+), 11 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 6548d3a3..bfd74fb8 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -20,14 +20,15 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: return True # We only allow teachers and assistants to create new groups. - return hasattr(user, "teacher") and user.teacher.exists() or hasattr(user, "assistant") and user.assistant.exists() + return hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student @@ -52,3 +53,17 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: # We only allow students to join groups. return student is not None and \ student.courses.filter(id=course.id).exists() + + +class GroupInstructorPermission(BasePermission): + """Permission class for teacher/assistant-only group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + user: User = request.user + course = group.project.course + role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ + hasattr(user, "assistant") and user.assistant + + # We only allow teachers and assistants to modify specified groups. + return role is not None and \ + role.courses.filter(id=course.id).exists() diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index e90e80de..81321714 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -1,5 +1,6 @@ import json from datetime import timedelta +from django.utils.translation import gettext from django.urls import reverse from django.utils import timezone from rest_framework.test import APITestCase @@ -9,6 +10,7 @@ from api.models.group import Group from api.models.course import Course from django.conf import settings +from api.models.teacher import Teacher def create_course(name, academic_startyear, description=None, parent_course=None): @@ -23,11 +25,11 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -def create_project(name, description, days, course): +def create_project(name, description, days, course, group_size=2): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course + name=name, description=description, deadline=deadline, course=course, group_size=group_size ) @@ -267,3 +269,157 @@ def test_group_students(self): self.assertEqual(content["first_name"], student2.first_name) self.assertEqual(content["last_name"], student2.last_name) self.assertEqual(content["email"], student2.email) + + +class GroupModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_assign_student_to_group(self): + """Able to assign a student to a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=student.id).exists()) + + def test_remove_student_from_group(self): + """Able to remove a student from a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=student.id).exists()) + + +class GroupModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_join_group(self): + """Able to join a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + # Try to join a group that is part of a course the student is not enrolled in + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + # Make sure that you can not join a group if you are not enrolled in the course + self.assertEqual(response.status_code, 403) + + # Add the student to the course + course.students.add(self.user) + + # Join the group now that the student is enrolled in the course + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Try to join a second group + group2 = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-join", args=[str(group2.id)]), + follow=True, + ) + + # Make sure you can only be in one group at a time + self.assertEqual(response.status_code, 400) + # self.assertEqual(content_json, gettext("group.errors.already_in_group")) + + def test_join_full_group(self): + """Able to join a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, group_size=1 + ) + group = create_group(project=project, score=10) + student = create_student( + id=5, first_name="Bernard", last_name="Doe", email="Bernard.Doe@gmail.com" + ) + group.students.add(student) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-join", args=[str(group.id)]), + follow=True, + ) + + self.assertEqual(response.status_code, 400) + # self.assertEqual(response.data['detail'], gettext("group.errors.full")) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 1d5b1655..46f9e00a 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -5,9 +5,10 @@ from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group +from api.models.student import Student from api.permissions.group_permissions import GroupPermission from api.permissions.group_permissions import GroupStudentPermission -from api.permissions.role_permissions import IsStudent +from api.permissions.group_permissions import GroupInstructorPermission from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer @@ -18,7 +19,7 @@ class GroupViewSet(viewsets.ModelViewSet): permission_classes = [IsAdminUser | GroupPermission] @action(detail=True, methods=["get"]) - def students(self, request, pk=None): + def students(self, request, **_): """Returns a list of students for the given group""" # This automatically fetches the group from the URL. # It automatically gives back a 404 HTTP response in case of not found. @@ -30,9 +31,64 @@ def students(self, request, pk=None): students, many=True, context={"request": request} ) return Response(serializer.data) - + + @students.mapping.post + @students.mapping.put + @action(detail=True, permission_classes=[GroupInstructorPermission]) + def _assign_student(self, request, **_): + """Assigns a student to the group""" + group = self.get_object() + student_id = request.data.get("student_id") + + try: + student = Student.objects.get(id=student_id) + + # Make sure the group is not full + if group.is_full(): + raise ValidationError(gettext("group.errors.full")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise ValidationError(gettext("group.errors.already_in_group")) + + # Add the student to the group + group.students.add(student) + + return Response({ + "message": gettext("group.success.student.add"), + }) + + except Student.DoesNotExist: + # Student not found + raise ValidationError(gettext("students.errors.404")) + + @students.mapping.delete + @action(detail=True, permission_classes=[GroupInstructorPermission]) + def _remove_student(self, request, **_): + """Removes a student from the group""" + group = self.get_object() + student_id = request.data.get("student_id") + + try: + student = Student.objects.get(id=student_id) + + # Check if student is part of the group + if not group.students.filter(id=student_id).exists(): + raise ValidationError(gettext("group.errors.student_not_in_group")) + + # Remove the student from the group + group.students.remove(student) + + return Response({ + "message": gettext("group.success.student.remove"), + }) + + except Student.DoesNotExist: + # Student not found + raise ValidationError(gettext("students.errors.404")) + @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) - def join(self, request, pk=None): + def join(self, request, **_): """Enrolls the authenticated student in the group""" group = self.get_object() student = request.user.student @@ -40,15 +96,14 @@ def join(self, request, pk=None): # Make sure the group is not full if group.is_full(): raise ValidationError(gettext("group.errors.full")) - + # Make sure the student is not already in a group if student.is_enrolled_in_group(group.project): raise ValidationError(gettext("group.errors.already_in_group")) - + # Add the student to the group group.students.add(student) return Response({ "message": gettext("group.success.joined"), }) - From e6049d0b8473d937bbe291ea246359bd429c6042 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 12:27:12 +0100 Subject: [PATCH 250/397] chore: refactor validation strat + testing --- backend/api/permissions/group_permissions.py | 60 +++++------ backend/api/serializers/group_serializer.py | 50 ++++++++- backend/api/tests/test_group.py | 108 +++++++++++++++++-- backend/api/views/group_view.py | 87 +++++---------- 4 files changed, 197 insertions(+), 108 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index bfd74fb8..fb445a93 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -2,9 +2,7 @@ from rest_framework.request import Request from rest_framework.viewsets import ViewSet from authentication.models import User -from api.models.teacher import Teacher -from api.models.assistant import Assistant -from api.models.student import Student +from api.permissions.role_permissions import is_student, is_assistant, is_teacher class GroupPermission(BasePermission): @@ -14,56 +12,46 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if view.action == "list": + if request.method in SAFE_METHODS: return False - elif request.method in SAFE_METHODS: - return True # We only allow teachers and assistants to create new groups. - return hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant + return is_teacher(user) or is_assistant(user) def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course - teacher_assistant_role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant if request.method in SAFE_METHODS: - role: Teacher | Assistant | Student = teacher_assistant_role or hasattr(user, "student") and user.student - - # Users linked to the course linked to the group can fetch group details. - return role is not None and \ - role.courses.filter(id=course.id).exists() + # Users that are linked to the course can view the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() # We only allow teachers and assistants to modify specified groups. - return teacher_assistant_role is not None and \ - teacher_assistant_role.courses.filter(id=course.id).exists() + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() class GroupStudentPermission(BasePermission): - """Permission class for student-only group endpoints""" + """Permission class for student related group endpoints""" def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: user: User = request.user course = group.project.course - student: Student = user.student - - # We only allow students to join groups. - return student is not None and \ - student.courses.filter(id=course.id).exists() - -class GroupInstructorPermission(BasePermission): - """Permission class for teacher/assistant-only group endpoints""" - - def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: - user: User = request.user - course = group.project.course - role: Teacher | Assistant = hasattr(user, "teacher") and user.teacher or \ - hasattr(user, "assistant") and user.assistant - - # We only allow teachers and assistants to modify specified groups. - return role is not None and \ - role.courses.filter(id=course.id).exists() + if request.method in SAFE_METHODS: + # Users related to the course can view the students of the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # Students can only add and remove themselves from a group. + if is_student(user) and request.data.get("student_id") == user.id: + # Make sure the student is actually part of the course. + return user.student.courses.filter(id=course.id).exists() + + # Teachers and assistants can add and remove any student from a group + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index d3b0ecfa..2e8f33ed 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -1,5 +1,9 @@ +from django.utils.translation import gettext from rest_framework import serializers -from ..models.group import Group +from rest_framework.exceptions import ValidationError +from api.models.group import Group +from api.models.student import Student +from api.serializers.student_serializer import StudentIDSerializer class GroupSerializer(serializers.ModelSerializer): @@ -15,3 +19,47 @@ class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ["id", "project", "students", "score"] + + +class StudentJoinGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure the group is not already full + if group.is_full(): + raise serializers.ValidationError(gettext("group.errors.full")) + + # Make sure the student is part of the course + if not group.project.course.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_in_course")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise serializers.ValidationError(gettext("group.errors.already_in_group")) + + return data + + +class StudentLeaveGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure the student was in the group + if not group.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_present")) + + return data diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 81321714..c67ee942 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -296,8 +296,9 @@ def test_assign_student_to_group(self): id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" ) - # Add this teacher to the course + # Add this teacher and student to the course course.teachers.add(self.user) + course.students.add(student) group = create_group(project=project, score=10) @@ -367,7 +368,8 @@ def test_join_group(self): # Try to join a group that is part of a course the student is not enrolled in response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) @@ -379,7 +381,8 @@ def test_join_group(self): # Join the group now that the student is enrolled in the course response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) @@ -392,16 +395,16 @@ def test_join_group(self): group2 = create_group(project=project, score=10) response = self.client.post( - reverse("group-join", args=[str(group2.id)]), + reverse("group-students", args=[str(group2.id)]), + {"student_id": self.user.id}, follow=True, ) # Make sure you can only be in one group at a time self.assertEqual(response.status_code, 400) - # self.assertEqual(content_json, gettext("group.errors.already_in_group")) def test_join_full_group(self): - """Able to join a group as a student.""" + """Not able to join a full group as a student.""" course = create_course(name="sel2", academic_startyear=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course, group_size=1 @@ -417,9 +420,98 @@ def test_join_full_group(self): # Join the group response = self.client.post( - reverse("group-join", args=[str(group.id)]), + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, follow=True, ) self.assertEqual(response.status_code, 400) - # self.assertEqual(response.data['detail'], gettext("group.errors.full")) + + def test_leave_group(self): + """Able to leave a group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Leave the group + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=self.user.id).exists()) + + def try_to_assign_other_student_to_group(self): + """Not able to assign another student to a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to assign another student to a group + self.assertEqual(response.status_code, 403) + + def try_to_delete_other_student_from_group(self): + """Not able to remove another student from a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + group.students.add(self.user) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to remove another student from a group + self.assertEqual(response.status_code, 403) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 46f9e00a..0d5c2f47 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,16 +1,14 @@ from django.utils.translation import gettext -from rest_framework.exceptions import ValidationError from rest_framework import viewsets from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response from api.models.group import Group -from api.models.student import Student from api.permissions.group_permissions import GroupPermission from api.permissions.group_permissions import GroupStudentPermission -from api.permissions.group_permissions import GroupInstructorPermission from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer +from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer class GroupViewSet(viewsets.ModelViewSet): @@ -18,7 +16,7 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] - @action(detail=True, methods=["get"]) + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | GroupStudentPermission]) def students(self, request, **_): """Returns a list of students for the given group""" # This automatically fetches the group from the URL. @@ -34,76 +32,39 @@ def students(self, request, **_): @students.mapping.post @students.mapping.put - @action(detail=True, permission_classes=[GroupInstructorPermission]) - def _assign_student(self, request, **_): - """Assigns a student to the group""" + def _add_student(self, request, **_): + """Add a student to the group""" group = self.get_object() - student_id = request.data.get("student_id") - try: - student = Student.objects.get(id=student_id) - - # Make sure the group is not full - if group.is_full(): - raise ValidationError(gettext("group.errors.full")) - - # Make sure the student is not already in a group - if student.is_enrolled_in_group(group.project): - raise ValidationError(gettext("group.errors.already_in_group")) - - # Add the student to the group - group.students.add(student) + serializer = StudentJoinGroupSerializer( + data=request.data, context={"group": group} + ) - return Response({ - "message": gettext("group.success.student.add"), - }) + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.add( + serializer.validated_data["student_id"] + ) - except Student.DoesNotExist: - # Student not found - raise ValidationError(gettext("students.errors.404")) + return Response({ + "message": gettext("group.success.student.add"), + }) @students.mapping.delete - @action(detail=True, permission_classes=[GroupInstructorPermission]) def _remove_student(self, request, **_): """Removes a student from the group""" group = self.get_object() - student_id = request.data.get("student_id") - - try: - student = Student.objects.get(id=student_id) - - # Check if student is part of the group - if not group.students.filter(id=student_id).exists(): - raise ValidationError(gettext("group.errors.student_not_in_group")) - # Remove the student from the group - group.students.remove(student) - - return Response({ - "message": gettext("group.success.student.remove"), - }) - - except Student.DoesNotExist: - # Student not found - raise ValidationError(gettext("students.errors.404")) - - @action(detail=True, methods=["post"], permission_classes=[GroupStudentPermission]) - def join(self, request, **_): - """Enrolls the authenticated student in the group""" - group = self.get_object() - student = request.user.student - - # Make sure the group is not full - if group.is_full(): - raise ValidationError(gettext("group.errors.full")) - - # Make sure the student is not already in a group - if student.is_enrolled_in_group(group.project): - raise ValidationError(gettext("group.errors.already_in_group")) + serializer = StudentLeaveGroupSerializer( + data=request.data, context={"group": group} + ) - # Add the student to the group - group.students.add(student) + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.remove( + serializer.validated_data["student_id"] + ) return Response({ - "message": gettext("group.success.joined"), + "message": gettext("group.success.student.remove"), }) From 9b797c893c2f4a5d80222abab7595278e6cb5f89 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 12:36:55 +0100 Subject: [PATCH 251/397] chore: test score updates only for teachers --- backend/api/tests/test_group.py | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c67ee942..c75bc427 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -343,6 +343,32 @@ def test_remove_student_from_group(self): # Make sure the student is not in the group anymore self.assertFalse(group.students.filter(id=student.id).exists()) + def test_update_score_of_group(self): + """Able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the score of the group is updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + class GroupModelTestsAsStudent(APITestCase): def setUp(self) -> None: @@ -462,7 +488,7 @@ def test_leave_group(self): # Make sure the student is not in the group anymore self.assertFalse(group.students.filter(id=self.user.id).exists()) - def try_to_assign_other_student_to_group(self): + def test_try_to_assign_other_student_to_group(self): """Not able to assign another student to a group.""" course = create_course(name="sel2", academic_startyear=2023) @@ -488,7 +514,7 @@ def try_to_assign_other_student_to_group(self): # Make sure that you are not able to assign another student to a group self.assertEqual(response.status_code, 403) - def try_to_delete_other_student_from_group(self): + def test_try_to_delete_other_student_from_group(self): """Not able to remove another student from a group.""" course = create_course(name="sel2", academic_startyear=2023) @@ -515,3 +541,29 @@ def try_to_delete_other_student_from_group(self): # Make sure that you are not able to remove another student from a group self.assertEqual(response.status_code, 403) + + def test_try_to_update_score_of_group(self): + """Not able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + # Add this student to the course + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(self.user) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + # Make sure that you are not able to update the score of a group + self.assertEqual(response.status_code, 403) + + group.refresh_from_db() + self.assertEqual(group.score, 10) From 5c8728df824744651947bb23a527a8a2f7c88322 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:28:05 +0100 Subject: [PATCH 252/397] chore: make groups as teacher for project --- backend/api/permissions/group_permissions.py | 4 +- .../api/permissions/project_permissions.py | 55 +++++++++++ backend/api/tests/test_project.py | 89 ++++++++++++++++++ backend/api/views/project_view.py | 91 ++++++++++--------- 4 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 backend/api/permissions/project_permissions.py diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index fb445a93..67edde69 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -12,8 +12,10 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if request.method in SAFE_METHODS: + if view.action == "list": return False + elif request.method in SAFE_METHODS: + return True # We only allow teachers and assistants to create new groups. return is_teacher(user) or is_assistant(user) diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py new file mode 100644 index 00000000..654c2ac8 --- /dev/null +++ b/backend/api/permissions/project_permissions.py @@ -0,0 +1,55 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher + + +class ProjectPermission(BasePermission): + """Permission class for project related endpoints""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general project endpoint.""" + user: User = request.user + + # The general project endpoint that lists all projects is not accessible for any role. + if view.action == "list": + return False + elif request.method in SAFE_METHODS: + return True + + # We only allow teachers and assistants to create new projects. + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + """Check if user has permission to view a detailed project endpoint""" + user: User = request.user + course = project.course + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the project. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to modify specified projects. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + +class ProjectGroupPermission(BasePermission): + """Permission class for project related group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + user: User = request.user + course = project.course + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the group. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ + is_student(user) and user.student.courses.filter(id=course.id).exists() + + # We only allow teachers and assistants to create new groups. + return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index a5885712..b7e1058c 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,6 +5,8 @@ from authentication.models import User from api.models.project import Project from api.models.course import Course +from api.models.teacher import Teacher +from api.models.student import Student from api.models.checks import StructureCheck, ExtraCheck from api.models.extension import FileExtension from django.conf import settings @@ -454,3 +456,90 @@ def test_project_extra_checks(self): "project-detail", args=[str(project.id)] )) self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) + + +class ProjectModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_create_groups(self): + """Able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you can not make groups for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Assert that the groups were created + self.assertEqual(project.groups.count(), 3) + + +class ProjectModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_try_to_create_groups(self): + """Not able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + course.students.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you can not make groups as a student + self.assertEqual(response.status_code, 403) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 0b041875..f748be17 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,8 +1,10 @@ +from django.utils.translation import gettext +from rest_framework.permissions import IsAdminUser from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.exceptions import NotFound -from django.utils.translation import gettext_lazy as _ +from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission +from api.models.group import Group from ..models.project import Project from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer @@ -12,57 +14,60 @@ class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer + permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project - @action(detail=True, methods=["get"]) - def groups(self, request, pk=None): + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + def groups(self, request, **_): """Returns a list of groups for the given project""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + project = self.get_object() + groups = project.groups.all() - try: - queryset = Project.objects.get(id=pk) - groups = queryset.groups.all() - - # Serialize the group objects - serializer = GroupSerializer( - groups, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + return Response(serializer.data) - @action(detail=True, methods=["get"]) - def structure_checks(self, request, pk=None): - """Returns the structure checks for the given project""" + @groups.mapping.post + def _create_groups(self, request, **_): + """Create a number of groups for the project""" + project = self.get_object() - try: - queryset = Project.objects.get(id=pk) - checks = queryset.structure_checks.all() + # Get the number of groups to create + num_groups = int(request.data.get("number_groups", 0)) - # Serialize the check objects - serializer = StructureCheckSerializer( - checks, many=True, context={"request": request} + # Create the groups + for _ in range(max(0, num_groups)): + Group.objects.create( + project=project ) - return Response(serializer.data) - - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + return Response({ + "message": gettext("project.success.groups.created"), + }) @action(detail=True, methods=["get"]) - def extra_checks(self, request, pk=None): - """Returns the extra checks for the given project""" + def structure_checks(self, request, **_): + """Returns the structure checks for the given project""" + project = self.get_object() + checks = project.structure_checks.all() - try: - queryset = Project.objects.get(id=pk) - checks = queryset.extra_checks.all() + # Serialize the check objects + serializer = StructureCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) - # Serialize the check objects - serializer = ExtraCheckSerializer( - checks, many=True, context={"request": request} - ) - return Response(serializer.data) + @action(detail=True, methods=["get"]) + def extra_checks(self, request, **_): + """Returns the extra checks for the given project""" + project = self.get_object() + checks = project.extra_checks.all() - except Project.DoesNotExist: - # Invalid project ID - raise NotFound(_('project.status.not_found')) + # Serialize the check objects + serializer = ExtraCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) From 7b7aa158c6a804f99b873196cf969fade039a77a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:37:07 +0100 Subject: [PATCH 253/397] chore: validate number of groups --- backend/api/serializers/project_serializer.py | 4 ++++ backend/api/views/project_view.py | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 5f4ee39f..a906177c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -39,3 +39,7 @@ class Meta: "course", "groups" ] + + +class TeacherCreateGroupSerializer(serializers.Serializer): + number_groups = serializers.IntegerField(min_value=1) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index f748be17..232221da 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -9,6 +9,8 @@ from ..serializers.project_serializer import ProjectSerializer from ..serializers.group_serializer import GroupSerializer from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer +from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer +from api.serializers.group_serializer import GroupSerializer class ProjectViewSet(viewsets.ModelViewSet): @@ -36,14 +38,21 @@ def _create_groups(self, request, **_): """Create a number of groups for the project""" project = self.get_object() - # Get the number of groups to create - num_groups = int(request.data.get("number_groups", 0)) + serializer = TeacherCreateGroupSerializer( + data=request.data, context={"project": project} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + # Get the number of groups to create + num_groups = serializer.validated_data["number_groups"] + + # Create the groups + for _ in range(num_groups): + Group.objects.create( + project=project + ) - # Create the groups - for _ in range(max(0, num_groups)): - Group.objects.create( - project=project - ) return Response({ "message": gettext("project.success.groups.created"), }) From 3a4cd9d2dce65224d631b32e3c607a3d86dc3086 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 16:56:27 +0100 Subject: [PATCH 254/397] chore: simplify --- backend/api/permissions/group_permissions.py | 18 ++++++++---------- backend/api/permissions/project_permissions.py | 18 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 67edde69..b8ee92a8 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -24,16 +24,15 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: """Check if user has permission to view a detailed group endpoint""" user: User = request.user course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to modify specified groups. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assitant class GroupStudentPermission(BasePermission): @@ -42,12 +41,12 @@ class GroupStudentPermission(BasePermission): def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: user: User = request.user course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users related to the course can view the students of the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # Students can only add and remove themselves from a group. if is_student(user) and request.data.get("student_id") == user.id: @@ -55,5 +54,4 @@ def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: return user.student.courses.filter(id=course.id).exists() # Teachers and assistants can add and remove any student from a group - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assitant diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index 654c2ac8..99552375 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -25,16 +25,15 @@ def has_object_permission(self, request: Request, view: ViewSet, project) -> boo """Check if user has permission to view a detailed project endpoint""" user: User = request.user course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the project. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to modify specified projects. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assistant class ProjectGroupPermission(BasePermission): @@ -43,13 +42,12 @@ class ProjectGroupPermission(BasePermission): def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: user: User = request.user course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() if request.method in SAFE_METHODS: # Users that are linked to the course can view the group. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() or \ - is_student(user) and user.student.courses.filter(id=course.id).exists() + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) # We only allow teachers and assistants to create new groups. - return is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + return teacher_or_assistant From 4b0ab18644cf287e0442ecae813d4256d62b1adc Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:36:27 +0100 Subject: [PATCH 255/397] chore: check score is below max_score project --- backend/api/serializers/group_serializer.py | 13 +++++++++++-- backend/api/tests/test_group.py | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 2e8f33ed..92847e7f 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -20,6 +20,15 @@ class Meta: model = Group fields = ["id", "project", "students", "score"] + def validate(self, data): + # Make sure the score of the group is lower or equal to the maximum score + group: Group = self.instance + + if "score" in data and data["score"] > group.project.max_score: + raise ValidationError(gettext("group.errors.score_exceeds_max")) + + return data + class StudentJoinGroupSerializer(StudentIDSerializer): @@ -34,7 +43,7 @@ def validate(self, data): # Make sure the group is not already full if group.is_full(): - raise serializers.ValidationError(gettext("group.errors.full")) + raise ValidationError(gettext("group.errors.full")) # Make sure the student is part of the course if not group.project.course.students.filter(id=student.id).exists(): @@ -42,7 +51,7 @@ def validate(self, data): # Make sure the student is not already in a group if student.is_enrolled_in_group(group.project): - raise serializers.ValidationError(gettext("group.errors.already_in_group")) + raise ValidationError(gettext("group.errors.already_in_group")) return data diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index c75bc427..3d9409d0 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -25,11 +25,11 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -def create_project(name, description, days, course, group_size=2): +def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, group_size=group_size + name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score ) @@ -348,7 +348,7 @@ def test_update_score_of_group(self): course = create_course(name="sel2", academic_startyear=2023) project = create_project( - name="Project 1", description="Description 1", days=7, course=course + name="Project 1", description="Description 1", days=7, course=course, max_score=20 ) # Add this teacher to the course @@ -369,6 +369,19 @@ def test_update_score_of_group(self): group.refresh_from_db() self.assertEqual(group.score, 20) + # Try to update the score of a group to a score higher than the maximum score + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 30}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + # Make sure the score of the group is not updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + class GroupModelTestsAsStudent(APITestCase): def setUp(self) -> None: From b1da49b0bd768b83254b4ed88258410237e1a4a5 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:37:56 +0100 Subject: [PATCH 256/397] fix: linting --- backend/api/tests/test_group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 3d9409d0..581cca89 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -29,7 +29,8 @@ def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score + name=name, description=description, deadline=deadline, course=course, + group_size=group_size, max_score=max_score ) From 1e464dc287a1c43502712a547b674f4fe266354e Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 22:39:31 +0100 Subject: [PATCH 257/397] fix: linting --- backend/api/tests/test_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 581cca89..2aa29126 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -29,7 +29,7 @@ def create_project(name, description, days, course, group_size=2, max_score=20): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course, + name=name, description=description, deadline=deadline, course=course, group_size=group_size, max_score=max_score ) From fd78dbb0ca721280f7dda9867f7f642750c8d83d Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sat, 9 Mar 2024 23:06:39 +0100 Subject: [PATCH 258/397] chore: no list action for groups and projects --- backend/api/permissions/group_permissions.py | 4 +- .../api/permissions/project_permissions.py | 4 +- backend/api/tests/test_project.py | 86 ++++--------------- backend/api/views/group_view.py | 10 ++- backend/api/views/project_view.py | 8 +- 5 files changed, 36 insertions(+), 76 deletions(-) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index b8ee92a8..64eea5b1 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -12,9 +12,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general group endpoint that lists all groups is not accessible for any role. - if view.action == "list": - return False - elif request.method in SAFE_METHODS: + if request.method in SAFE_METHODS: return True # We only allow teachers and assistants to create new groups. diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index 99552375..aca5aee1 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -13,9 +13,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: user: User = request.user # The general project endpoint that lists all projects is not accessible for any role. - if view.action == "list": - return False - elif request.method in SAFE_METHODS: + if request.method in SAFE_METHODS: return True # We only allow teachers and assistants to create new projects. diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b7e1058c..b698a5e4 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -181,14 +181,6 @@ def test_deadline_passed_with_past_Project(self): ) self.assertIs(past_project.deadline_passed(), True) - def test_no_projects(self): - """Able to retrieve no projects before creating any.""" - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(content_json, []) - def test_project_exists(self): """ Able to retrieve a single project after creating it. @@ -204,60 +196,17 @@ def test_project_exists(self): course=course, ) - response = self.client.get(reverse("project-list"), follow=True) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - - content_json = json.loads(response.content.decode("utf-8")) - - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] - - expected_course_url = settings.TESTING_BASE_LINK + reverse( - "course-detail", args=[str(course.id)] - ) - - self.assertEqual(retrieved_project["name"], project.name) - self.assertEqual(retrieved_project["description"], project.description) - self.assertEqual(retrieved_project["visible"], project.visible) - self.assertEqual(retrieved_project["archived"], project.archived) - self.assertEqual(retrieved_project["course"], expected_course_url) - - def test_multiple_project(self): - """ - Able to retrieve multiple projects after creating it. - """ - course = create_course(id=3, name="test course", academic_startyear=2024) - project = create_project( - name="test project", - description="test description", - visible=True, - archived=False, - days=7, - course=course, - ) - - project2 = create_project( - name="test project2", - description="test description2", - visible=True, - archived=False, - days=7, - course=course, + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True ) - response = self.client.get(reverse("project-list"), follow=True) - self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 2) - - retrieved_project = content_json[0] + retrieved_project = content_json expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] @@ -296,16 +245,17 @@ def test_project_course(self): course=course, ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json self.assertEqual(retrieved_project["name"], project.name) self.assertEqual(retrieved_project["description"], project.description) @@ -353,16 +303,17 @@ def test_project_structure_checks(self): blocked_extensions=[fileExtension2, fileExtension3], ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json expected_course_url = settings.TESTING_BASE_LINK + reverse( "course-detail", args=[str(course.id)] @@ -429,16 +380,17 @@ def test_project_extra_checks(self): run_script="testscript.sh", ) - response = self.client.get(reverse("project-list"), follow=True) + response = self.client.get( + reverse("project-detail", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_project = content_json[0] + retrieved_project = content_json response = self.client.get(retrieved_project["extra_checks"], follow=True) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 0d5c2f47..7c86b3db 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -1,5 +1,6 @@ from django.utils.translation import gettext -from rest_framework import viewsets +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin +from rest_framework.viewsets import GenericViewSet from rest_framework.permissions import IsAdminUser from rest_framework.decorators import action from rest_framework.response import Response @@ -11,7 +12,12 @@ from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer -class GroupViewSet(viewsets.ModelViewSet): +class GroupViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + GenericViewSet): + queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 232221da..b7a36db4 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,4 +1,5 @@ from django.utils.translation import gettext +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin from rest_framework.permissions import IsAdminUser from rest_framework import viewsets from rest_framework.decorators import action @@ -13,7 +14,12 @@ from api.serializers.group_serializer import GroupSerializer -class ProjectViewSet(viewsets.ModelViewSet): +class ProjectViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + viewsets.GenericViewSet): + queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project From 6b21dcda7b907cb9d683f255c7170a5a7df28a3d Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:44:34 +0100 Subject: [PATCH 259/397] chore: rebase --- backend/api/tests/test_group.py | 85 ------------------------------- backend/api/tests/test_project.py | 12 ----- 2 files changed, 97 deletions(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 2aa29126..16ffa2df 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -1,6 +1,5 @@ import json from datetime import timedelta -from django.utils.translation import gettext from django.urls import reverse from django.utils import timezone from rest_framework.test import APITestCase @@ -57,90 +56,6 @@ def setUp(self) -> None: User.get_dummy_admin() ) - def test_no_groups(self): - """Able to retrieve no groups before creating any.""" - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(content_json, []) - - def test_group_exists(self): - """Able to retrieve a single group after creating it.""" - course = create_course(name="sel2", academic_startyear=2023) - - project = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - - student = create_student( - id=1, first_name="John", last_name="Doe", email="john.doe@example.com" - ) - - group = create_group(project=project, score=10) - group.students.add(student) - - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 1) - - retrieved_group = content_json[0] - expected_project_url = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project.id)] - ) - - self.assertEqual(retrieved_group["project"], expected_project_url) - self.assertEqual(int(retrieved_group["id"]), group.id) - self.assertEqual(retrieved_group["score"], group.score) - - def test_multiple_groups(self): - """Able to retrieve multiple groups after creating them.""" - course = create_course(name="sel2", academic_startyear=2023) - - project1 = create_project( - name="Project 1", description="Description 1", days=7, course=course - ) - project2 = create_project( - name="Project 2", description="Description 2", days=7, course=course - ) - - student1 = create_student( - id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com" - ) - student2 = create_student( - id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com" - ) - - group1 = create_group(project=project1, score=10) - group1.students.add(student1) - - group2 = create_group(project=project2, score=10) - group2.students.add(student1, student2) - - response = self.client.get(reverse("group-list"), follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.accepted_media_type, "application/json") - content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(len(content_json), 2) - - retrieved_group1, retrieved_group2 = content_json - expected_project_url1 = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project1.id)] - ) - expected_project_url2 = settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project2.id)] - ) - - self.assertEqual(retrieved_group1["project"], expected_project_url1) - self.assertEqual(int(retrieved_group1["id"]), group1.id) - self.assertEqual(retrieved_group1["score"], group1.score) - - self.assertEqual(retrieved_group2["project"], expected_project_url2) - self.assertEqual(int(retrieved_group2["id"]), group2.id) - self.assertEqual(retrieved_group2["score"], group2.score) - def test_group_detail_view(self): """Able to retrieve details of a single group.""" course = create_course(name="sel2", academic_startyear=2023) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b698a5e4..27b8fb79 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -218,18 +218,6 @@ def test_project_exists(self): self.assertEqual(retrieved_project["archived"], project.archived) self.assertEqual(retrieved_project["course"], expected_course_url) - retrieved_project = content_json[1] - - expected_course_url = settings.TESTING_BASE_LINK + reverse( - "course-detail", args=[str(course.id)] - ) - - self.assertEqual(retrieved_project["name"], project2.name) - self.assertEqual(retrieved_project["description"], project2.description) - self.assertEqual(retrieved_project["visible"], project2.visible) - self.assertEqual(retrieved_project["archived"], project2.archived) - self.assertEqual(retrieved_project["course"], expected_course_url) - def test_project_course(self): """ Able to retrieve a course of a project after creating it. From b4510355876f22209d980f5c41927dd08fdc8a5b Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sat, 9 Mar 2024 23:45:18 +0100 Subject: [PATCH 260/397] chore: linting --- backend/api/views/project_view.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index b7a36db4..761496f1 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -7,8 +7,6 @@ from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group from ..models.project import Project -from ..serializers.project_serializer import ProjectSerializer -from ..serializers.group_serializer import GroupSerializer from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer from api.serializers.group_serializer import GroupSerializer From c14424e24e7a6b2a59bfd67a6c1d7625a9b58c17 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 10:22:40 +0100 Subject: [PATCH 261/397] chore: course serializer --- backend/api/serializers/project_serializer.py | 9 +++++++ backend/api/tests/test_project.py | 27 ++++++++++++++++++- backend/api/views/course_view.py | 20 ++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index a906177c..083724b3 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,3 +1,5 @@ +from rest_framework.exceptions import ValidationError +from django.utils.translation import gettext from rest_framework import serializers from ..models.project import Project @@ -40,6 +42,13 @@ class Meta: "groups" ] + def validate(self, data): + print("*** Project validation goes here ***") + return data + class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) + + def validate(self, data): + return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 27b8fb79..ed7a1280 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -101,6 +101,31 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) + def test_deadline_of_Project_in_past_on_creation(self): + """ + unable to create a project as a teacher/admin if the deadline lies within the past. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + past_deadline = timezone.now() - timezone.timedelta(days=1) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": timezone.now(), + "deadline": past_deadline, + "course": course, + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), + data=project_data, + follow=True + ) + + print(f"Response: {response}") + def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -351,7 +376,7 @@ def test_project_structure_checks(self): def test_project_extra_checks(self): """ - Able to retrieve a extra check of a project after creating it. + Able to retrieve an extra check of a project after creating it. """ course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index cea2b180..bcdd8bac 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -156,6 +156,26 @@ def projects(self, request, **_): return Response(serializer.data) + @projects.mapping.post + @projects.mapping.put + def _add_project(self, request, **_): + """Add a project to the course""" + course = self.get_object() + + serializer = ProjectSerializer( + data=request.data, context={} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + course.projects.add( + serializer.validated_data["project_id"] + ) + + return Response({ + "message": gettext("course.success.project.add"), + }) + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) def clone(self, request: Request, **__): """Copy the course to a new course with the same fields""" From 2c10e88c7057e8601fb7daaa0c39d33a53474518 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 10:24:33 +0100 Subject: [PATCH 262/397] Revert "chore: course serializer" This reverts commit c14424e24e7a6b2a59bfd67a6c1d7625a9b58c17. --- backend/api/serializers/project_serializer.py | 9 ------- backend/api/tests/test_project.py | 27 +------------------ backend/api/views/course_view.py | 20 -------------- 3 files changed, 1 insertion(+), 55 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 083724b3..a906177c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,5 +1,3 @@ -from rest_framework.exceptions import ValidationError -from django.utils.translation import gettext from rest_framework import serializers from ..models.project import Project @@ -42,13 +40,6 @@ class Meta: "groups" ] - def validate(self, data): - print("*** Project validation goes here ***") - return data - class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) - - def validate(self, data): - return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index ed7a1280..27b8fb79 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -101,31 +101,6 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) - def test_deadline_of_Project_in_past_on_creation(self): - """ - unable to create a project as a teacher/admin if the deadline lies within the past. - """ - course = create_course(id=3, name="test course", academic_startyear=2024) - past_deadline = timezone.now() - timezone.timedelta(days=1) - - project_data = { - "name": "Test Project", - "description": "Test project description", - "visible": True, - "archived": False, - "start_date": timezone.now(), - "deadline": past_deadline, - "course": course, - } - - response = self.client.post( - reverse("course-projects", args=[course.id]), - data=project_data, - follow=True - ) - - print(f"Response: {response}") - def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -376,7 +351,7 @@ def test_project_structure_checks(self): def test_project_extra_checks(self): """ - Able to retrieve an extra check of a project after creating it. + Able to retrieve a extra check of a project after creating it. """ course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index bcdd8bac..cea2b180 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -156,26 +156,6 @@ def projects(self, request, **_): return Response(serializer.data) - @projects.mapping.post - @projects.mapping.put - def _add_project(self, request, **_): - """Add a project to the course""" - course = self.get_object() - - serializer = ProjectSerializer( - data=request.data, context={} - ) - - # Validate the serializer - if serializer.is_valid(raise_exception=True): - course.projects.add( - serializer.validated_data["project_id"] - ) - - return Response({ - "message": gettext("course.success.project.add"), - }) - @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) def clone(self, request: Request, **__): """Copy the course to a new course with the same fields""" From d68323184e721429dc6b0c2c85e36559e739a268 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 10:22:40 +0100 Subject: [PATCH 263/397] fix: merge conflicts --- backend/api/serializers/project_serializer.py | 13 +++++++++ backend/api/tests/test_project.py | 27 ++++++++++++++++++- backend/api/views/course_view.py | 20 ++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index f9c458d5..0810cc2f 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,3 +1,5 @@ +from rest_framework.exceptions import ValidationError +from django.utils.translation import gettext from rest_framework import serializers from ..models.project import Project @@ -37,3 +39,14 @@ class Meta: "course", "groups" ] + + def validate(self, data): + print("*** Project validation goes here ***") + return data + + +class TeacherCreateGroupSerializer(serializers.Serializer): + number_groups = serializers.IntegerField(min_value=1) + + def validate(self, data): + return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 03dfe7cd..5526dcc2 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -98,6 +98,31 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) + def test_deadline_of_Project_in_past_on_creation(self): + """ + unable to create a project as a teacher/admin if the deadline lies within the past. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + past_deadline = timezone.now() - timezone.timedelta(days=1) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": timezone.now(), + "deadline": past_deadline, + "course": course, + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), + data=project_data, + follow=True + ) + + print(f"Response: {response}") + def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -409,7 +434,7 @@ def test_project_structure_checks(self): def test_project_extra_checks(self): """ - Able to retrieve a extra check of a project after creating it. + Able to retrieve an extra check of a project after creating it. """ course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index cea2b180..bcdd8bac 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -156,6 +156,26 @@ def projects(self, request, **_): return Response(serializer.data) + @projects.mapping.post + @projects.mapping.put + def _add_project(self, request, **_): + """Add a project to the course""" + course = self.get_object() + + serializer = ProjectSerializer( + data=request.data, context={} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + course.projects.add( + serializer.validated_data["project_id"] + ) + + return Response({ + "message": gettext("course.success.project.add"), + }) + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) def clone(self, request: Request, **__): """Copy the course to a new course with the same fields""" From d260cd5a12c12982358dd274f2f599136afba083 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 10 Mar 2024 11:05:37 +0100 Subject: [PATCH 264/397] fix: creating project --- backend/api/serializers/project_serializer.py | 3 ++- backend/api/views/course_view.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 0810cc2f..5a6d747c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -2,11 +2,12 @@ from django.utils.translation import gettext from rest_framework import serializers from ..models.project import Project +from api.models.course import Course class ProjectSerializer(serializers.ModelSerializer): course = serializers.HyperlinkedRelatedField( - many=False, read_only=True, view_name="course-detail" + many=False, view_name="course-detail", queryset=Course.objects.all() ) structure_checks = serializers.HyperlinkedRelatedField( diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index bcdd8bac..e43911d1 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -163,13 +163,13 @@ def _add_project(self, request, **_): course = self.get_object() serializer = ProjectSerializer( - data=request.data, context={} + data=request.data, context={"request": request} ) # Validate the serializer if serializer.is_valid(raise_exception=True): course.projects.add( - serializer.validated_data["project_id"] + serializer.save() ) return Response({ From 65844802a29ffe67bf77eb6487a74f762e41d219 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sun, 10 Mar 2024 12:25:39 +0100 Subject: [PATCH 265/397] chore: added course context to project serializer --- backend/api/serializers/project_serializer.py | 8 ++++++-- backend/api/tests/test_project.py | 8 ++++---- backend/api/views/course_view.py | 5 ++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 75b2fb89..d8605458 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -7,7 +7,9 @@ class ProjectSerializer(serializers.ModelSerializer): course = serializers.HyperlinkedRelatedField( - many=False, view_name="course-detail", queryset=Course.objects.all() + many=False, + view_name="course-detail", + read_only=True ) structure_checks = serializers.HyperlinkedIdentityField( @@ -44,7 +46,9 @@ class Meta: ] def validate(self, data): - print("*** Project validation goes here ***") + if "course" in self.context: + data["course_id"] = self.context["course"].id + return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index ed7a1280..2ca832ca 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -101,7 +101,7 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) - def test_deadline_of_Project_in_past_on_creation(self): + def test_deadline_of_project_in_past_on_creation(self): """ unable to create a project as a teacher/admin if the deadline lies within the past. """ @@ -114,8 +114,7 @@ def test_deadline_of_Project_in_past_on_creation(self): "visible": True, "archived": False, "start_date": timezone.now(), - "deadline": past_deadline, - "course": course, + "deadline": past_deadline } response = self.client.post( @@ -124,7 +123,8 @@ def test_deadline_of_Project_in_past_on_creation(self): follow=True ) - print(f"Response: {response}") + # Should not work since deadline is in the past + self.assertEqual(response.status_code, 400) def test_deadline_approaching_in_with_past_Project(self): """ diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index e43911d1..8e7b9ac4 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -163,7 +163,10 @@ def _add_project(self, request, **_): course = self.get_object() serializer = ProjectSerializer( - data=request.data, context={"request": request} + data=request.data, context={ + "request": request, + "course": course + } ) # Validate the serializer From cbb5cb542d0d4c6f448ed88e46b19b06cdf53b71 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 16:30:29 +0100 Subject: [PATCH 266/397] chore: deadline project not in past --- backend/api/serializers/project_serializer.py | 7 +++++++ backend/api/tests/test_project.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index d8605458..f1766558 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,5 +1,6 @@ from rest_framework.exceptions import ValidationError from django.utils.translation import gettext +from django.utils import timezone from rest_framework import serializers from ..models.project import Project from api.models.course import Course @@ -48,6 +49,12 @@ class Meta: def validate(self, data): if "course" in self.context: data["course_id"] = self.context["course"].id + else: + raise ValidationError(gettext("project.errors.context")) + + # Check if deadline of the course is in the future + if data["deadline"] < timezone.now(): + raise ValidationError(gettext("project.errors.deadline_in_past")) return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 2ca832ca..7620252e 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -106,7 +106,7 @@ def test_deadline_of_project_in_past_on_creation(self): unable to create a project as a teacher/admin if the deadline lies within the past. """ course = create_course(id=3, name="test course", academic_startyear=2024) - past_deadline = timezone.now() - timezone.timedelta(days=1) + past_deadline = timezone.now() timezone.timedelta(days=1) project_data = { "name": "Test Project", @@ -125,6 +125,7 @@ def test_deadline_of_project_in_past_on_creation(self): # Should not work since deadline is in the past self.assertEqual(response.status_code, 400) + def test_deadline_approaching_in_with_past_Project(self): """ From 4e5ef09a2df796c201c71930cf01c80091a3c172 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 16:35:39 +0100 Subject: [PATCH 267/397] chore: deadline project before start_date --- backend/api/serializers/project_serializer.py | 10 +++-- backend/api/tests/test_project.py | 40 +++++++++++++++---- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index f1766558..f74c78de 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -52,9 +52,13 @@ def validate(self, data): else: raise ValidationError(gettext("project.errors.context")) - # Check if deadline of the course is in the future - if data["deadline"] < timezone.now(): - raise ValidationError(gettext("project.errors.deadline_in_past")) + # Check if start data of the project is not in the past + if data["start_date"] < timezone.now(): + raise ValidationError(gettext("project.errors.start_date_in_past")) + + # Check if deadline of the project is before the start date + if data["deadline"] < data["start_date"]: + raise ValidationError(gettext("project.errors.deadline_before_start_date")) return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 7620252e..78593f5c 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -101,20 +101,20 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) - def test_deadline_of_project_in_past_on_creation(self): + def test_start_date_Project_not_in_past(self): """ - unable to create a project as a teacher/admin if the deadline lies within the past. + unable to create a project as a teacher/admin if the start date lies within the past. """ course = create_course(id=3, name="test course", academic_startyear=2024) - past_deadline = timezone.now() timezone.timedelta(days=1) + start_date = timezone.now() - timezone.timedelta(days=1) project_data = { "name": "Test Project", "description": "Test project description", "visible": True, "archived": False, - "start_date": timezone.now(), - "deadline": past_deadline + "start_date": start_date, + "deadline": timezone.now() + timezone.timedelta(days=1) } response = self.client.post( @@ -123,9 +123,35 @@ def test_deadline_of_project_in_past_on_creation(self): follow=True ) - # Should not work since deadline is in the past + # Should not work since the start date lies in the past self.assertEqual(response.status_code, 400) - + + def test_deadline_Project_before_start_date(self): + """ + unable to create a project as a teacher/admin if the deadline lies before the start date. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + deadline = timezone.now() + timezone.timedelta(days=1) + start_date = timezone.now() + timezone.timedelta(days=2) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": start_date, + "deadline": deadline + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), + data=project_data, + follow=True + ) + + # Should not work since deadline is before the start date + self.assertEqual(response.status_code, 400) + def test_deadline_approaching_in_with_past_Project(self): """ From 0400af458b9775d8577d8982c6b7fab6eaafa84f Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 10 Mar 2024 16:59:14 +0100 Subject: [PATCH 268/397] chore: add check project deadline is not passed and add submisions field to groups --- backend/api/models/project.py | 1 + backend/api/serializers/group_serializer.py | 7 +++- backend/api/serializers/project_serializer.py | 17 +++++++++ backend/api/views/group_view.py | 37 +++++++++++++++++++ backend/api/views/project_view.py | 20 +++++++++- 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 839907a1..c74b0eea 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -3,6 +3,7 @@ from api.models.course import Course +# TODO max submission size class Project(models.Model): """Model that represents a project.""" diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 92847e7f..35a61402 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -16,9 +16,14 @@ class GroupSerializer(serializers.ModelSerializer): read_only=True, ) + submissions = serializers.HyperlinkedIdentityField( + view_name="group-submissions", + read_only=True, + ) + class Meta: model = Group - fields = ["id", "project", "students", "score"] + fields = ["id", "project", "students", "score", "submissions"] def validate(self, data): # Make sure the score of the group is lower or equal to the maximum score diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index a906177c..fad1f23c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,5 +1,7 @@ +from django.utils.translation import gettext from rest_framework import serializers from ..models.project import Project +from rest_framework.exceptions import ValidationError class ProjectSerializer(serializers.ModelSerializer): @@ -43,3 +45,18 @@ class Meta: class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) + + +class SubmissionAddSerializer(serializers.Serializer): + def validate(self, data): + # The validator needs the project context. + if "project" not in self.context: + raise ValidationError(gettext("project.error.context")) + + project: Project = self.context["project"] + + # Check if the project's deadline is not passed. + if project.deadline_passed(): + raise ValidationError(gettext("project.error.submission.past_project")) + + return data diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 7c86b3db..50bdcecb 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -10,6 +10,8 @@ from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer +from api.serializers.submission_serializer import SubmissionSerializer +from rest_framework.request import Request class GroupViewSet(CreateModelMixin, @@ -36,6 +38,20 @@ def students(self, request, **_): ) return Response(serializer.data) + @action(detail=True, permission_classes=[IsAdminUser]) + def submissions(self, request, **_): + """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + submissions = group.submissions.all() + + # Serialize the student objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + return Response(serializer.data) + @students.mapping.post @students.mapping.put def _add_student(self, request, **_): @@ -74,3 +90,24 @@ def _remove_student(self, request, **_): return Response({ "message": gettext("group.success.student.remove"), }) + + @submissions.mapping.post + @submissions.mapping.put + def _add_submission(self, request: Request, **_): + """Add an submission to the group""" + + group = self.get_object() + + # Add submission to course + serializer = SubmissionSerializer( + data=request.data + ) + + if serializer.is_valid(raise_exception=True): + group.submissions.create( + serializer.validated_data + ) + + return Response({ + "message": gettext("group.success.submissions.add") + }) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 761496f1..4365b78c 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -10,6 +10,7 @@ from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer from api.serializers.group_serializer import GroupSerializer +from api.serializers.submission_serializer import SubmissionSerializer class ProjectViewSet(CreateModelMixin, @@ -22,7 +23,7 @@ class ProjectViewSet(CreateModelMixin, serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project - @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) def groups(self, request, **_): """Returns a list of groups for the given project""" # This automatically fetches the group from the URL. @@ -37,6 +38,23 @@ def groups(self, request, **_): return Response(serializer.data) + """ + @action(detail=True, permission_classes=[IsAdminUser]) + def submissions(self, request, **_): + # Returns a list of subbmisions for the given project + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + project = self.get_object() + submissions = project.submissions.all() + + # Serialize the group objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + + return Response(serializer.data) + """ + @groups.mapping.post def _create_groups(self, request, **_): """Create a number of groups for the project""" From ac5e55a185bb0a36966a270001e275b1462bf03a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 10 Mar 2024 17:23:23 +0100 Subject: [PATCH 269/397] feat(notifications): send mail function #7 --- .../locale/en/LC_MESSAGES/django.po | 3 + .../locale/nl/LC_MESSAGES/django.po | 3 + backend/notifications/logic.py | 68 +++++++++++++++++++ backend/notifications/models.py | 5 ++ backend/notifications/serializers.py | 7 +- backend/notifications/signals.py | 9 ++- backend/notifications/urls.py | 6 +- backend/notifications/views.py | 26 +++++++ backend/requirements.txt | 3 +- backend/ypovoli/settings.py | 15 ++-- 10 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 backend/notifications/logic.py diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.po b/backend/notifications/locale/en/LC_MESSAGES/django.po index 5db896e0..465520e9 100644 --- a/backend/notifications/locale/en/LC_MESSAGES/django.po +++ b/backend/notifications/locale/en/LC_MESSAGES/django.po @@ -17,6 +17,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Dear %(name)s\nYou have a new notification.\n%(title)s\n%(description)s\n\n- Ypovoli" # Score Added msgid "Title: Score added" msgstr "Score Added" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po index 4d9cb70f..5a854108 100644 --- a/backend/notifications/locale/nl/LC_MESSAGES/django.po +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -17,6 +17,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Beste %(name)s\nU heeft een nieuwe notificatie.\n%(title)s\n%(description)s\n\n- Ypovoli" # Score Added msgid "Title: Score added" msgstr "Score toegevoegd" diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py new file mode 100644 index 00000000..99104eb9 --- /dev/null +++ b/backend/notifications/logic.py @@ -0,0 +1,68 @@ +import threading +from smtplib import SMTPException +from typing import Dict, List + +from django.core import mail +from django.utils.translation import gettext as _ +from notifications.models import Notification +from ypovoli.settings import EMAIL_CUSTOM + + +# Returns a dictionary with the title and description of the notification +def get_message_dict(notification: Notification) -> Dict[str, str]: + return { + "title": _(notification.template_id.title_key), + "description": _(notification.template_id.description_key) + % notification.arguments, + } + + +# Try to send one email and set the result +def _send_mail(mail: mail.EmailMessage, result: List[bool]): + try: + mail.send(fail_silently=False) + result[0] = True + except SMTPException: + result[0] = False + + +# Send all unsent emails +def send_mails(): + notifications = Notification.objects.filter(is_sent=False) + + # No notifications to send + if notifications.count() == 0: + return + + # Connection with the mail server + connection = mail.get_connection() + + for notification in notifications: + message = get_message_dict(notification) + content = _("Email %(name)s %(title)s %(description)s") % { + "name": notification.user.username, + "title": message["title"], + "description": message["description"], + } + + # Construct the email + email = mail.EmailMessage( + subject=EMAIL_CUSTOM["subject"], + body=content, + from_email=EMAIL_CUSTOM["from"], + to=[notification.user.email], + connection=connection, + ) + + # Send the email with a timeout + result: List[bool] = [False] + thread = threading.Thread(target=_send_mail, args=(email, result)) + thread.start() + thread.join(timeout=EMAIL_CUSTOM["timeout"]) + + # If the email was not sent, continue + if thread.is_alive() or not result[0]: + continue + + # Mark the notification as sent + notification.sent() diff --git a/backend/notifications/models.py b/backend/notifications/models.py index d2827892..c9c2fbde 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -22,3 +22,8 @@ class Notification(models.Model): is_sent = models.BooleanField( default=False ) # Whether the notification has been sent (email) + + # Mark the notification as read + def sent(self): + self.is_sent = True + self.save() diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 4a24cd59..d4c488ba 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -2,7 +2,7 @@ from typing import Dict, List from authentication.models import User -from django.utils.translation import gettext as _ +from notifications.logic import get_message_dict from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -57,10 +57,7 @@ def validate(self, data: Dict[str, str]) -> Dict[str, str]: # Get the message from the template and arguments def get_message(self, obj: Notification) -> Dict[str, str]: - return { - "title": _(obj.template_id.title_key), - "description": _(obj.template_id.description_key) % obj.arguments, - } + return get_message_dict(obj) class Meta: model = Notification diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 2ded382f..6d7e993f 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -6,14 +6,19 @@ from authentication.models import User from django.dispatch import Signal, receiver from django.urls import reverse +from notifications.logic import send_mails from notifications.serializers import NotificationSerializer notification_create = Signal() +# TODO: Remove send_mails call @receiver(notification_create) def notification_creation( - type: NotificationType, user: User, arguments: Dict[str, str], **kwargs + type: NotificationType, + user: User, + arguments: Dict[str, str], + **kwargs, # Required by django ) -> bool: serializer = NotificationSerializer( data={ @@ -28,6 +33,8 @@ def notification_creation( serializer.save() + send_mails() + return True diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index e80acd66..a9dacf25 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,6 +1,8 @@ from django.urls import path -from notifications.views import NotificationView +from notifications.views import NotificationView, TestingView +# TODO: Remove test urlpatterns = [ - path("/", NotificationView.as_view(), name="notification-detail"), + path("tmp//", NotificationView.as_view(), name="notification-detail"), + path("test/", TestingView.as_view(), name="notification-test"), ] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 5f4dd772..c83d2da8 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -36,3 +36,29 @@ def post(self, request: Request, user_id: str) -> Response: notifications.update(is_read=True) return Response(status=HTTP_200_OK) + + +# TODO: Remove this view +class TestingView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request): + from notifications.signals import NotificationType, notification_create + + print("Hi") + + notification_create.send( + sender="", + type=NotificationType.SCORE_ADDED, + user=request.user, + arguments={"score": "10"}, + ) + + return Response(status=HTTP_200_OK) + + def post(self, request: Request): + from notifications.logic import send_mails + + send_mails() + + return Response(status=HTTP_200_OK) diff --git a/backend/requirements.txt b/backend/requirements.txt index f0eec8cc..610d422a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,4 +6,5 @@ drf-yasg==1.21.7 requests==2.31.0 cas-client==1.0.0 psycopg2-binary==2.9.9 -djangorestframework-simplejwt==5.3.1 \ No newline at end of file +djangorestframework-simplejwt==5.3.1 +celery[redis]==5.3.6 \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 32355200..1e496534 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -65,11 +65,9 @@ ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", - "rest_framework.authentication.SessionAuthentication" + "rest_framework.authentication.SessionAuthentication", ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ] + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], } SIMPLE_JWT = { @@ -130,3 +128,12 @@ }, }, ] + +EMAIL_HOST = "smtprelay.UGent.be" +EMAIL_PORT = 25 + +EMAIL_CUSTOM = { + "from": "ypovoli@ugent.be", + "subject": "[Ypovoli] New Notification", + "timeout": 2, +} From f79616ba276b60598fc1b21a2e4ce33b56e2af12 Mon Sep 17 00:00:00 2001 From: francis Date: Sun, 10 Mar 2024 18:02:36 +0100 Subject: [PATCH 270/397] chore: project submission status --- backend/api/serializers/project_serializer.py | 8 ++- backend/api/tests/test_project.py | 53 ++++++++++++++++++- backend/api/views/project_view.py | 25 ++++++++- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index f74c78de..ee6a00fe 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -3,6 +3,7 @@ from django.utils import timezone from rest_framework import serializers from ..models.project import Project +from api.models.group import Group from api.models.course import Course @@ -52,7 +53,7 @@ def validate(self, data): else: raise ValidationError(gettext("project.errors.context")) - # Check if start data of the project is not in the past + # Check if start date of the project is not in the past if data["start_date"] < timezone.now(): raise ValidationError(gettext("project.errors.start_date_in_past")) @@ -68,3 +69,8 @@ class TeacherCreateGroupSerializer(serializers.Serializer): def validate(self, data): return data + +class SubmissionStatusSerializer(serializers.Serializer): + groups_total = serializers.IntegerField(read_only=True) + # groups_submitted = serializers.IntegerField(read_only=True) # TODO : Wait for submissions to be implemented + # submissions_passed = serializers.IntegerField(read_only=True) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 78593f5c..cf99e726 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,6 +5,7 @@ from authentication.models import User from api.models.project import Project from api.models.course import Course +from api.models.group import Group from api.models.teacher import Teacher from api.models.student import Student from api.models.checks import StructureCheck, ExtraCheck @@ -41,6 +42,13 @@ def create_project(name, description, visible, archived, days, course): course=course, ) +def create_group(project): + """Create a Group with the given arguments.""" + + return Group.objects.create( + project=project + ) + def create_structure_check(id, name, project, obligated_extensions, blocked_extensions): """ @@ -482,7 +490,7 @@ def test_create_groups(self): follow=True, ) - # Make sure you can not make groups for a project that is not yours + # Make sure you cannot make groups for a project that is not yours self.assertEqual(response.status_code, 403) # Add the teacher to the course @@ -499,6 +507,47 @@ def test_create_groups(self): # Assert that the groups were created self.assertEqual(project.groups.count(), 3) + def test_submission_status(self): + """Retrieve the submission status for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-groups", args=[str(project.id)]), + follow=True + ) + + # Make sure you cannot retrieve the submission status for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + group1 = create_group(project=project) + group2 = create_group(project=project) + group3 = create_group(project=project) + + response = self.client.get( + reverse("project-submission-status", args=[str(project.id)]), + follow=True + ) + + # TODO: Complete this test once submissions is implemented + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, { + "groups_total": 3, + # "groups_submitted": 0, + # "submissions_passed": 0 + }) + class ProjectModelTestsAsStudent(APITestCase): def setUp(self) -> None: @@ -533,5 +582,5 @@ def test_try_to_create_groups(self): follow=True, ) - # Make sure you can not make groups as a student + # Make sure you cannot make groups as a student self.assertEqual(response.status_code, 403) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 761496f1..cd5d3d42 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -8,7 +8,7 @@ from api.models.group import Group from ..models.project import Project from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer -from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer +from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer, SubmissionStatusSerializer from api.serializers.group_serializer import GroupSerializer @@ -84,3 +84,26 @@ def extra_checks(self, request, **_): checks, many=True, context={"request": request} ) return Response(serializer.data) + + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + def submission_status(self, request, **_): + """Returns the current submission status for the given project + This includes: + - The amount of groups that have uploaded a submission + - The amount of submissions that passed the basic tests + - The total amount of groups + """ + project = self.get_object() + groups_total = project.groups.count() + groups_submitted = None + submissions_passed = None + + # TODO: Once submissions is implemented, pass these arguments to the serializer as well + + serializer = SubmissionStatusSerializer({ + "groups_total": groups_total, + # "groups_submitted": groups_submitted, + # "submissions_passed": submissions_passed, + }) + + return Response(serializer.data) From dcd486375996014fe5683623720c2ddf2e60116f Mon Sep 17 00:00:00 2001 From: Lander Maes Date: Sun, 10 Mar 2024 20:08:17 +0100 Subject: [PATCH 271/397] Admin create action #88 --- .../api/permissions/teacher_permissions.py | 0 backend/api/views/admin_view.py | 22 ++++++++++++++-- backend/api/views/teacher_view.py | 26 +++++++------------ backend/authentication/models.py | 5 +++- backend/authentication/serializers.py | 6 +++++ 5 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 backend/api/permissions/teacher_permissions.py diff --git a/backend/api/permissions/teacher_permissions.py b/backend/api/permissions/teacher_permissions.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 63fdab43..34ee3b99 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,26 @@ +from django.utils.translation import gettext from rest_framework import viewsets -from authentication.serializers import UserSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +from authentication.serializers import UserSerializer, UserIDSerializer from authentication.models import User -class AdminViewSet(viewsets.ModelViewSet): +class AdminViewSet(ReadOnlyModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer + permission_classes = [IsAdminUser] + + def create(self, request): + # Add an Admin + serializer = UserIDSerializer(data=request.data) + + if serializer.is_valid(raise_exception=True): + user = User.objects.get(serializer.validated_data["user_id"]) + user.make_admin() + + return Response({ + "message": gettext("admins.success.add") + }) \ No newline at end of file diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 49038133..89cf3a3a 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,31 +1,25 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet from ..models.teacher import Teacher from ..serializers.teacher_serializer import TeacherSerializer from ..serializers.course_serializer import CourseSerializer -class TeacherViewSet(viewsets.ModelViewSet): +class TeacherViewSet(ReadOnlyModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer + permission_classes = [] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" + teacher = self.get_object() + courses = teacher.courses.all() - try: - queryset = Teacher.objects.get(id=pk) - courses = queryset.courses.all() - - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Teacher.DoesNotExist: - # Invalid teacher ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} - ) + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) + return Response(serializer.data) \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 066d6fbb..058eb38c 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -33,6 +33,10 @@ class User(AbstractBaseUser): """Model settings""" USERNAME_FIELD = "username" EMAIL_FIELD = "email" + + def make_admin(self): + self.is_staff = True + self.save() @staticmethod def get_dummy_admin(): @@ -45,7 +49,6 @@ def get_dummy_admin(): is_staff=True ) - class Faculty(models.Model): """This model represents a faculty.""" diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 7c4f1d92..0db21efb 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -12,6 +12,7 @@ ModelSerializer, Serializer, ValidationError, + PrimaryKeyRelatedField ) from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, RefreshToken @@ -107,3 +108,8 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) + +class UserIDSerializer(Serializer): + user_id = PrimaryKeyRelatedField( + queryset=User.objects.all() + ) From b8d77105e3b781ded681e411d0905a7c9b2d45d5 Mon Sep 17 00:00:00 2001 From: Lander Date: Sun, 10 Mar 2024 20:12:42 +0100 Subject: [PATCH 272/397] Revert "Admin create action #88" This reverts commit dcd486375996014fe5683623720c2ddf2e60116f. --- .../api/permissions/teacher_permissions.py | 0 backend/api/views/admin_view.py | 22 ++-------------- backend/api/views/teacher_view.py | 26 ++++++++++++------- backend/authentication/models.py | 5 +--- backend/authentication/serializers.py | 6 ----- 5 files changed, 19 insertions(+), 40 deletions(-) delete mode 100644 backend/api/permissions/teacher_permissions.py diff --git a/backend/api/permissions/teacher_permissions.py b/backend/api/permissions/teacher_permissions.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 34ee3b99..63fdab43 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,26 +1,8 @@ -from django.utils.translation import gettext from rest_framework import viewsets -from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework.permissions import IsAdminUser -from rest_framework.response import Response - -from authentication.serializers import UserSerializer, UserIDSerializer +from authentication.serializers import UserSerializer from authentication.models import User -class AdminViewSet(ReadOnlyModelViewSet): +class AdminViewSet(viewsets.ModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer - permission_classes = [IsAdminUser] - - def create(self, request): - # Add an Admin - serializer = UserIDSerializer(data=request.data) - - if serializer.is_valid(raise_exception=True): - user = User.objects.get(serializer.validated_data["user_id"]) - user.make_admin() - - return Response({ - "message": gettext("admins.success.add") - }) \ No newline at end of file diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 89cf3a3a..49038133 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,25 +1,31 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.viewsets import ReadOnlyModelViewSet from ..models.teacher import Teacher from ..serializers.teacher_serializer import TeacherSerializer from ..serializers.course_serializer import CourseSerializer -class TeacherViewSet(ReadOnlyModelViewSet): +class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer - permission_classes = [] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" - teacher = self.get_object() - courses = teacher.courses.all() - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) \ No newline at end of file + try: + queryset = Teacher.objects.get(id=pk) + courses = queryset.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) + return Response(serializer.data) + + except Teacher.DoesNotExist: + # Invalid teacher ID + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} + ) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 058eb38c..066d6fbb 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -33,10 +33,6 @@ class User(AbstractBaseUser): """Model settings""" USERNAME_FIELD = "username" EMAIL_FIELD = "email" - - def make_admin(self): - self.is_staff = True - self.save() @staticmethod def get_dummy_admin(): @@ -49,6 +45,7 @@ def get_dummy_admin(): is_staff=True ) + class Faculty(models.Model): """This model represents a faculty.""" diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 0db21efb..7c4f1d92 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -12,7 +12,6 @@ ModelSerializer, Serializer, ValidationError, - PrimaryKeyRelatedField ) from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, RefreshToken @@ -108,8 +107,3 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) - -class UserIDSerializer(Serializer): - user_id = PrimaryKeyRelatedField( - queryset=User.objects.all() - ) From 643956f40680b4920da9b2d7bbe5d87be5167bd5 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 10 Mar 2024 20:43:13 +0100 Subject: [PATCH 273/397] chore: try to upload files from submission --- backend/api/serializers/submission_serializer.py | 14 ++++++++++++++ backend/api/views/group_view.py | 6 ++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 744cd4ac..7517042a 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -45,3 +45,17 @@ class Meta: "structure_checks_passed", "extra_checks_results" ] + + def create(self, validated_data): + # Extract files from the request + request = self.context.get('request') + files_data = request.FILES.getlist('files') # Assuming 'files' is the key for the files in the request + + # Create the Submission instance without the files + submission = Submission.objects.create(**validated_data) + + # Create SubmissionFile instances for each file + for file in files_data: + SubmissionFile.objects.create(submission=submission, file=file) + + return submission diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 50bdcecb..967563fd 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -100,13 +100,11 @@ def _add_submission(self, request: Request, **_): # Add submission to course serializer = SubmissionSerializer( - data=request.data + data=request.data, context={"request": request} ) if serializer.is_valid(raise_exception=True): - group.submissions.create( - serializer.validated_data - ) + serializer.save(group=group) return Response({ "message": gettext("group.success.submissions.add") From 91a709c50be251e1be8e32819dd5617b3ce93d01 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Sun, 10 Mar 2024 20:55:08 +0100 Subject: [PATCH 274/397] fix: comment --- backend/api/serializers/submission_serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 7517042a..85231e45 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -49,7 +49,7 @@ class Meta: def create(self, validated_data): # Extract files from the request request = self.context.get('request') - files_data = request.FILES.getlist('files') # Assuming 'files' is the key for the files in the request + files_data = request.FILES.getlist('files') # Create the Submission instance without the files submission = Submission.objects.create(**validated_data) From 8d98643ff2f1093f27271b3a76b377c2cc9b293d Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 10 Mar 2024 21:53:18 +0100 Subject: [PATCH 275/397] chore: check file structure when upload project --- backend/api/helpers/check_folder_structure.py | 5 +++++ backend/api/serializers/project_serializer.py | 1 + backend/api/serializers/submission_serializer.py | 10 +++++++++- data/production/structures/empty.zip | Bin 0 -> 22 bytes 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 data/production/structures/empty.zip diff --git a/backend/api/helpers/check_folder_structure.py b/backend/api/helpers/check_folder_structure.py index b1bf70a0..9dde5c0a 100644 --- a/backend/api/helpers/check_folder_structure.py +++ b/backend/api/helpers/check_folder_structure.py @@ -28,6 +28,9 @@ def parse_zip_file(project, dir_path): # TODO block paths that start with .. # TODO block paths that start with .. def check_zip_file(project, dir_path, restrict_extra_folders=False): + # print(f"Checking project: {project}") + # print(f"Checking file: {dir_path}") + dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) project_structure_checks = StructureCheck.objects.filter(project=project.id) structuur = {} @@ -152,6 +155,8 @@ def check_zip_structure( folder_structure, zip_file_path, restrict_extra_folders=False): + # print(f"Checking folder_structure: {folder_structure}") + # print(f"Checking zip_file_path: {zip_file_path}") """ Check the structure of a zip file. diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index fad1f23c..ab3c036b 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -2,6 +2,7 @@ from rest_framework import serializers from ..models.project import Project from rest_framework.exceptions import ValidationError +from ..models.submission import Submission, SubmissionFile class ProjectSerializer(serializers.ModelSerializer): diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 85231e45..eb7bc919 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -1,5 +1,6 @@ from rest_framework import serializers from ..models.submission import Submission, SubmissionFile, ExtraChecksResult +from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file class SubmissionFileSerializer(serializers.ModelSerializer): @@ -54,8 +55,15 @@ def create(self, validated_data): # Create the Submission instance without the files submission = Submission.objects.create(**validated_data) - # Create SubmissionFile instances for each file + pas: bool = True + # Create SubmissionFile instances for each file and check if none fail structure checks for file in files_data: SubmissionFile.objects.create(submission=submission, file=file) + status, _ = check_zip_file(submission.group.project, file.name) + if not status: + pas = False + # Set structure_checks_passed to True + submission.structure_checks_passed = pas + submission.save() return submission diff --git a/data/production/structures/empty.zip b/data/production/structures/empty.zip new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 From d7e9d0ee370cb666fa178d6365d0dc30b6f232ab Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 10 Mar 2024 22:23:32 +0100 Subject: [PATCH 276/397] feat(deployment): Added development environment / container --- .env | 9 +++++ .gitignore | 1 + README.md | 6 +++ backend/Dockerfile | 17 +++++++++ data/nginx/nginx.conf | 64 ++++++++++++++++++++++++++++++++ data/nginx/ssl/certificate.crt | 19 ++++++++++ data/nginx/ssl/nginx.conf | 0 data/nginx/ssl/private.key | 28 ++++++++++++++ development.sh | 3 ++ development.yml | 67 ++++++++++++++++++++++++++++++++++ frontend/Dockerfile | 12 ++++++ frontend/nginx.conf | 9 +++++ 12 files changed, 235 insertions(+) create mode 100644 .env create mode 100644 backend/Dockerfile create mode 100644 data/nginx/nginx.conf create mode 100644 data/nginx/ssl/certificate.crt create mode 100755 data/nginx/ssl/nginx.conf create mode 100644 data/nginx/ssl/private.key create mode 100755 development.sh create mode 100644 development.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/.env b/.env new file mode 100644 index 00000000..118bb629 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +PUID=1000 +PGID=1000 +TZ="Europe/Brussels" + +DATADIR="./data" + +BACKEND_DIR="./backend" + +FRONTEND_DIR="./frontend" diff --git a/.gitignore b/.gitignore index f494b1b6..7c90a8ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .tool-versions +data/postgres/* \ No newline at end of file diff --git a/README.md b/README.md index 0c3a257d..6a987026 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ This application was developed within the framework of the course "Software Engi ## Development +Run `development.sh`. +It starts the development environment and attaches itself to the output of the backend. +The backend will auto reload when changing a file. + +If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. + ### Backend Instructions for the setup of the Django backend are to be found in `backend/README.md`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..bce03b5a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /code/ +RUN pip install -r requirements.txt + +COPY . /code/ + +RUN ./setup.sh + +CMD ["python", "manage.py", "runsslserver", "192.168.90.2:8080"] \ No newline at end of file diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf new file mode 100644 index 00000000..af8d4362 --- /dev/null +++ b/data/nginx/nginx.conf @@ -0,0 +1,64 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:8080; + } + + upstream frontend { + server frontend:3000; + } + + server { + listen 80; + listen [::]:80; + + location / { + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + } + + server { + + listen 8080; + listen [::]:8080; + + location / { + proxy_pass https://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } + + server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + + ssl_certificate certificate.crt; + ssl_certificate_key private.key; + + return 301 http://$host$request_uri; + } + + server { + listen 8080 ssl default_server; + listen [::]:8080 ssl default_server; + + ssl_certificate certificate.crt; + ssl_certificate_key private.key; + + location / { + proxy_pass https://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } +} diff --git a/data/nginx/ssl/certificate.crt b/data/nginx/ssl/certificate.crt new file mode 100644 index 00000000..2e061c41 --- /dev/null +++ b/data/nginx/ssl/certificate.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDETCCAfkCFBhBz10TLA7SeDaEZKMIVotHAu8FMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkJFMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjQwMzEwMjA1ODQ0WhcNMjUwMzEwMjA1 +ODQ0WjBFMQswCQYDVQQGEwJCRTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAxtUMnT5ahfM7VjK+ls+sr9+rvdjuuWtfJ7r49hAzucRFf8DC +Ew63bO/RpLFGW6vp4uBsL6y8zonQWjBvHnf9YUDBV+ZR+Pl/R+Zz6uB/yLHqBuT8 +InXFU4taptV5jPYx5/F6VpubE/W46gzXK66akgAoKUxlHZXdoxMbMBqjpUVz85Hx +BLM6N+LRcttl/6ittCPPjgkR7KF08AZu+jnm7NzJ16eF0VmgMAhKqozwSkegcQSb +vzDucmIDsLl/ZvFkP8L2tDNh1GNRcByPQ7pAR+VsIBkZOgAtkYyTMQP/mpHS7HKW +uSiT6o+2A8J7Y58nkPHpk1L7dVdWaeLB0ZmnAwIDAQABMA0GCSqGSIb3DQEBCwUA +A4IBAQCR220B4S08uxtOFll0q93QVpvyw5sOnumjeUOd1GRp2n/mgAzjA6/ANwgu +xXzdS/jCk6Bqv8gWqKDaFCvTC4437Ql5PRw8mho72K9cx/4T4TbTi7UffxU0BB7f +2XYSS0ORdAaSs3MzwnBBHTPM94EfbDPvfhWmDUwJT2QM1CAsHJ7KqMnYbWdR5j7h +XIy4M7NiE76QWSsjliN+s9f1CWFKtH4vZ+6nlu2uX473Hg6PX9AZD8MBC4juvRsS +wWBiF+x2498yMqzc+bYkEDqox1u9BfMfi8dbBu+QUBs4dpN9cdInEYcN5yTJBVHF +st2ChvK+w/ZptJ95fbDFiDhdL2mt +-----END CERTIFICATE----- diff --git a/data/nginx/ssl/nginx.conf b/data/nginx/ssl/nginx.conf new file mode 100755 index 00000000..e69de29b diff --git a/data/nginx/ssl/private.key b/data/nginx/ssl/private.key new file mode 100644 index 00000000..df1ca317 --- /dev/null +++ b/data/nginx/ssl/private.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDG1QydPlqF8ztW +Mr6Wz6yv36u92O65a18nuvj2EDO5xEV/wMITDrds79GksUZbq+ni4GwvrLzOidBa +MG8ed/1hQMFX5lH4+X9H5nPq4H/IseoG5PwidcVTi1qm1XmM9jHn8XpWm5sT9bjq +DNcrrpqSACgpTGUdld2jExswGqOlRXPzkfEEszo34tFy22X/qK20I8+OCRHsoXTw +Bm76Oebs3MnXp4XRWaAwCEqqjPBKR6BxBJu/MO5yYgOwuX9m8WQ/wva0M2HUY1Fw +HI9DukBH5WwgGRk6AC2RjJMxA/+akdLscpa5KJPqj7YDwntjnyeQ8emTUvt1V1Zp +4sHRmacDAgMBAAECggEAQRKL4NB93tXmZwUPhBruiNa6hdT/+BYQW9fgz+MokpUO +K8vhmEwaMuBf67cK8EiYsKRDM+0kE7Jdyo6MZ1vcxJ3lSQe7bzD0e4sMB+Q2XfAA +SAZcEEkb7gYvAmfeMoiGd8L7h2nAvK0QOiU+rHCl7L95ZV63vxGDqnG/1aP6R8Wa +BiK2ncHymUtsJc05lSrkbTo+rOiuR0AMPkBcw+egKb0Topyu9UNL9FwCiZvUg10o +82OtJqIXDJ4CMIU/70apJ59AR7h+1ekAgBZwJ0Pf00OFOxJUORIoKQg08u8JjoNo +duzsbf095b6lcNGB00sWqTnpqbkgEqjU2YU/N64eOQKBgQDlGg6b+C1ob6JL3QN0 +qra4fRIAzDWaT4UU1ePWovK65kG1t3lvh75e1bSRW3v3Uwy33DB+atI90VA/zsAb +aEFlWufnkGSl2Mr+0ommpPkl7ML01be4IjivXQzMV9JBhizpUNZ/TELCspcwhzjx +owtfDtcQwVks/dtjwt+euSXtGQKBgQDeLTPO+h+QLnS/H7+jJjAX4vIVhXIbSXZm +ROq6/6X8wCpIdf4x2/y38VkjS5ayeNMNq6joRzte1Q9eURCwekj8kbIdh04cKSSg +Wc3waWDMvMNw52drzmadfe2mW64X42Md0mzXH6F7RVH4ZwxCu2d8JAWaXBpJHoL6 +URlRBkIcewKBgACi3+OC/u1JUhQP2xCZ4MQGZORnrMZu7hmutmFENpRaS1hr2AR9 +RgQRZ9z3ehKnwmNIU0ImncraJ/TlaBcrZPMZG4fDGOR1A6tNfmBeGOsIC0qOxWHX +hnzGL2Dp5YWVD87eEJpt5cmQoWbbGUdigoeTDPnY75x2YAOY6PIR5Y8RAoGAJNVX +nnvHGc8p2bm4uqKNHJiqS7kY5r8yGthYFfJmIVX2bJbrMnbnGdOwVHKmpCX1z3Fj +Ckcs55bo+lj0LF3Jld3NqqmQ4IhNoyvgQXgm7SpqOGCUu8G3L2r+KDNQ1HMFLp+B +HdUHn3kpksX6uWF6UZFjQGj+jpq5WihxywX/ldsCgYAsVhw8tvkN5Dysu5VWt/XN +drj5/V9MRsvJ4cJ2fg3EA8iccTaoaDmrlpwZ+deSBQzKlxnzGFTsSUMNySjK3wAq +0tOvKN8FhJcSttbYUaTVyexMfpNihm/NIIXqsTs7jUtE0qlgrfoOqip6/FAgfsxE +KAeZx1IBFLr9C5UcDKFSFw== +-----END PRIVATE KEY----- diff --git a/development.sh b/development.sh new file mode 100755 index 00000000..bb64f9fd --- /dev/null +++ b/development.sh @@ -0,0 +1,3 @@ +docker-compose -f development.yml up -d + +docker-compose -f development.yml logs --follow --tail 50 backend \ No newline at end of file diff --git a/development.yml b/development.yml new file mode 100644 index 00000000..d72ee963 --- /dev/null +++ b/development.yml @@ -0,0 +1,67 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + ports: + - 80:80 + - 443:443 + - 8080:8080 + volumes: + - $DATADIR/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - $DATADIR/nginx/ssl:/etc/nginx/ + depends_on: + - backend + - frontend + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile + expose: + - 8000 + volumes: + - $BACKEND_DIR:/code + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile + expose: + - 3000 + depends_on: + - backend \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..5254aa37 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:16 as build-stage +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY ./ . +RUN npm run build + +FROM nginx as production-stage +EXPOSE 3000 +RUN mkdir /app +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build-stage /app/dist /app \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..0ae8c83e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 3000; + + location / { + root /app; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file From 41abbbcc2c2144c5375a1e740fe859dc4d3b6aa3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 10 Mar 2024 22:40:59 +0100 Subject: [PATCH 277/397] chore: http -> https --- README.md | 1 + data/nginx/nginx.conf | 32 ++++++++++---------------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6a987026..a7503111 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This application was developed within the framework of the course "Software Engi Run `development.sh`. It starts the development environment and attaches itself to the output of the backend. The backend will auto reload when changing a file. +Acces the server by going to `https://localhost:8080` for the backend and `https://localhost:443` for the frontend. If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf index af8d4362..a3a6b6d0 100644 --- a/data/nginx/nginx.conf +++ b/data/nginx/nginx.conf @@ -12,25 +12,23 @@ http { } server { - listen 80; - listen [::]:80; + listen 80; + listen [::]:80; location / { - proxy_pass http://frontend; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; + return 301 https://$host$request_uri; } - } server { + listen 443 ssl; + listen [::]:443 ssl; - listen 8080; - listen [::]:8080; + ssl_certificate certificate.crt; + ssl_certificate_key private.key; location / { - proxy_pass https://backend; + proxy_pass https://frontend; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; @@ -38,18 +36,8 @@ http { } server { - listen 443 ssl default_server; - listen [::]:443 ssl default_server; - - ssl_certificate certificate.crt; - ssl_certificate_key private.key; - - return 301 http://$host$request_uri; - } - - server { - listen 8080 ssl default_server; - listen [::]:8080 ssl default_server; + listen 8080 ssl; + listen [::]:8080 ssl; ssl_certificate certificate.crt; ssl_certificate_key private.key; From b765db6487c677c4fe520a63a278803ac836ea64 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 10 Mar 2024 22:56:02 +0100 Subject: [PATCH 278/397] chore: generate ssl keys --- .gitignore | 3 ++- data/nginx/ssl/certificate.crt | 19 ------------------- data/nginx/ssl/nginx.conf | 0 data/nginx/ssl/private.key | 28 ---------------------------- development.sh | 14 ++++++++++++++ 5 files changed, 16 insertions(+), 48 deletions(-) delete mode 100644 data/nginx/ssl/certificate.crt delete mode 100755 data/nginx/ssl/nginx.conf delete mode 100644 data/nginx/ssl/private.key diff --git a/.gitignore b/.gitignore index 7c90a8ac..3984195e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .tool-versions -data/postgres/* \ No newline at end of file +data/postgres/* +data/nginx/ssl/* \ No newline at end of file diff --git a/data/nginx/ssl/certificate.crt b/data/nginx/ssl/certificate.crt deleted file mode 100644 index 2e061c41..00000000 --- a/data/nginx/ssl/certificate.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDETCCAfkCFBhBz10TLA7SeDaEZKMIVotHAu8FMA0GCSqGSIb3DQEBCwUAMEUx -CzAJBgNVBAYTAkJFMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl -cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjQwMzEwMjA1ODQ0WhcNMjUwMzEwMjA1 -ODQ0WjBFMQswCQYDVQQGEwJCRTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE -CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC -AQ8AMIIBCgKCAQEAxtUMnT5ahfM7VjK+ls+sr9+rvdjuuWtfJ7r49hAzucRFf8DC -Ew63bO/RpLFGW6vp4uBsL6y8zonQWjBvHnf9YUDBV+ZR+Pl/R+Zz6uB/yLHqBuT8 -InXFU4taptV5jPYx5/F6VpubE/W46gzXK66akgAoKUxlHZXdoxMbMBqjpUVz85Hx -BLM6N+LRcttl/6ittCPPjgkR7KF08AZu+jnm7NzJ16eF0VmgMAhKqozwSkegcQSb -vzDucmIDsLl/ZvFkP8L2tDNh1GNRcByPQ7pAR+VsIBkZOgAtkYyTMQP/mpHS7HKW -uSiT6o+2A8J7Y58nkPHpk1L7dVdWaeLB0ZmnAwIDAQABMA0GCSqGSIb3DQEBCwUA -A4IBAQCR220B4S08uxtOFll0q93QVpvyw5sOnumjeUOd1GRp2n/mgAzjA6/ANwgu -xXzdS/jCk6Bqv8gWqKDaFCvTC4437Ql5PRw8mho72K9cx/4T4TbTi7UffxU0BB7f -2XYSS0ORdAaSs3MzwnBBHTPM94EfbDPvfhWmDUwJT2QM1CAsHJ7KqMnYbWdR5j7h -XIy4M7NiE76QWSsjliN+s9f1CWFKtH4vZ+6nlu2uX473Hg6PX9AZD8MBC4juvRsS -wWBiF+x2498yMqzc+bYkEDqox1u9BfMfi8dbBu+QUBs4dpN9cdInEYcN5yTJBVHF -st2ChvK+w/ZptJ95fbDFiDhdL2mt ------END CERTIFICATE----- diff --git a/data/nginx/ssl/nginx.conf b/data/nginx/ssl/nginx.conf deleted file mode 100755 index e69de29b..00000000 diff --git a/data/nginx/ssl/private.key b/data/nginx/ssl/private.key deleted file mode 100644 index df1ca317..00000000 --- a/data/nginx/ssl/private.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDG1QydPlqF8ztW -Mr6Wz6yv36u92O65a18nuvj2EDO5xEV/wMITDrds79GksUZbq+ni4GwvrLzOidBa -MG8ed/1hQMFX5lH4+X9H5nPq4H/IseoG5PwidcVTi1qm1XmM9jHn8XpWm5sT9bjq -DNcrrpqSACgpTGUdld2jExswGqOlRXPzkfEEszo34tFy22X/qK20I8+OCRHsoXTw -Bm76Oebs3MnXp4XRWaAwCEqqjPBKR6BxBJu/MO5yYgOwuX9m8WQ/wva0M2HUY1Fw -HI9DukBH5WwgGRk6AC2RjJMxA/+akdLscpa5KJPqj7YDwntjnyeQ8emTUvt1V1Zp -4sHRmacDAgMBAAECggEAQRKL4NB93tXmZwUPhBruiNa6hdT/+BYQW9fgz+MokpUO -K8vhmEwaMuBf67cK8EiYsKRDM+0kE7Jdyo6MZ1vcxJ3lSQe7bzD0e4sMB+Q2XfAA -SAZcEEkb7gYvAmfeMoiGd8L7h2nAvK0QOiU+rHCl7L95ZV63vxGDqnG/1aP6R8Wa -BiK2ncHymUtsJc05lSrkbTo+rOiuR0AMPkBcw+egKb0Topyu9UNL9FwCiZvUg10o -82OtJqIXDJ4CMIU/70apJ59AR7h+1ekAgBZwJ0Pf00OFOxJUORIoKQg08u8JjoNo -duzsbf095b6lcNGB00sWqTnpqbkgEqjU2YU/N64eOQKBgQDlGg6b+C1ob6JL3QN0 -qra4fRIAzDWaT4UU1ePWovK65kG1t3lvh75e1bSRW3v3Uwy33DB+atI90VA/zsAb -aEFlWufnkGSl2Mr+0ommpPkl7ML01be4IjivXQzMV9JBhizpUNZ/TELCspcwhzjx -owtfDtcQwVks/dtjwt+euSXtGQKBgQDeLTPO+h+QLnS/H7+jJjAX4vIVhXIbSXZm -ROq6/6X8wCpIdf4x2/y38VkjS5ayeNMNq6joRzte1Q9eURCwekj8kbIdh04cKSSg -Wc3waWDMvMNw52drzmadfe2mW64X42Md0mzXH6F7RVH4ZwxCu2d8JAWaXBpJHoL6 -URlRBkIcewKBgACi3+OC/u1JUhQP2xCZ4MQGZORnrMZu7hmutmFENpRaS1hr2AR9 -RgQRZ9z3ehKnwmNIU0ImncraJ/TlaBcrZPMZG4fDGOR1A6tNfmBeGOsIC0qOxWHX -hnzGL2Dp5YWVD87eEJpt5cmQoWbbGUdigoeTDPnY75x2YAOY6PIR5Y8RAoGAJNVX -nnvHGc8p2bm4uqKNHJiqS7kY5r8yGthYFfJmIVX2bJbrMnbnGdOwVHKmpCX1z3Fj -Ckcs55bo+lj0LF3Jld3NqqmQ4IhNoyvgQXgm7SpqOGCUu8G3L2r+KDNQ1HMFLp+B -HdUHn3kpksX6uWF6UZFjQGj+jpq5WihxywX/ldsCgYAsVhw8tvkN5Dysu5VWt/XN -drj5/V9MRsvJ4cJ2fg3EA8iccTaoaDmrlpwZ+deSBQzKlxnzGFTsSUMNySjK3wAq -0tOvKN8FhJcSttbYUaTVyexMfpNihm/NIIXqsTs7jUtE0qlgrfoOqip6/FAgfsxE -KAeZx1IBFLr9C5UcDKFSFw== ------END PRIVATE KEY----- diff --git a/development.sh b/development.sh index bb64f9fd..ed637eb4 100755 --- a/development.sh +++ b/development.sh @@ -1,3 +1,17 @@ +echo "Checking for existing SSL certificates..." + +if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then +echo "Generating SSL certificates..." + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout data/nginx/ssl/private.key \ + -out data/nginx/ssl/certificate.crt \ + -subj "/C=BE/ST=/L=/O=/OU=/CN=" +else + echo "SSL certificates already exist, skipping generation." +fi + +echo "Starting services..." docker-compose -f development.yml up -d +echo "Following logs..." docker-compose -f development.yml logs --follow --tail 50 backend \ No newline at end of file From 176183ce66d89d16b5d07438e0845c8464aa8586 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Sun, 10 Mar 2024 23:07:05 +0100 Subject: [PATCH 279/397] chore: keep ssl directory --- data/nginx/ssl/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 data/nginx/ssl/.gitkeep diff --git a/data/nginx/ssl/.gitkeep b/data/nginx/ssl/.gitkeep new file mode 100644 index 00000000..e69de29b From dbfcbbd36441b228debbb522a2efda63c141f38c Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 10 Mar 2024 23:08:26 +0100 Subject: [PATCH 280/397] chore: add increasing submsion number --- backend/api/models/submission.py | 2 +- backend/api/serializers/project_serializer.py | 3 ++- .../api/serializers/submission_serializer.py | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 531f07a0..d72983ac 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -18,7 +18,7 @@ class Submission(models.Model): ) # Multiple submissions can be made by a group - submission_number = models.PositiveIntegerField(blank=False, null=False) + submission_number = models.PositiveIntegerField(blank=True, null=True) # Automatically set the submission time to the current time submission_time = models.DateTimeField(auto_now_add=True) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index ab3c036b..b6cc7241 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -48,7 +48,7 @@ class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) -class SubmissionAddSerializer(serializers.Serializer): +class SubmissionAddSerializer(serializers.ModelSerializer): def validate(self, data): # The validator needs the project context. if "project" not in self.context: @@ -56,6 +56,7 @@ def validate(self, data): project: Project = self.context["project"] + data["submission_number"] = 156 # Check if the project's deadline is not passed. if project.deadline_passed(): raise ValidationError(gettext("project.error.submission.past_project")) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index eb7bc919..c541521a 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -1,6 +1,7 @@ from rest_framework import serializers from ..models.submission import Submission, SubmissionFile, ExtraChecksResult -from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file +from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file +from django.db.models import Max class SubmissionFileSerializer(serializers.ModelSerializer): @@ -52,6 +53,20 @@ def create(self, validated_data): request = self.context.get('request') files_data = request.FILES.getlist('files') + # Get the group for the submission + group = validated_data['group'] + + # Get the project associated with the group + project = group.project + + # Get the maximum submission number for the group's project + max_submission_number = Submission.objects.filter( + group__project=project + ).aggregate(Max('submission_number'))['submission_number__max'] or 0 + + # Set the new submission number to the maximum value plus 1 + validated_data['submission_number'] = max_submission_number + 1 + # Create the Submission instance without the files submission = Submission.objects.create(**validated_data) From e81c363b758f35ebd4d75f9e7eb0a7a9cc16be7d Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Sun, 10 Mar 2024 23:18:37 +0100 Subject: [PATCH 281/397] chore: make submision number not required but automaticaly generated --- backend/api/models/submission.py | 1 - backend/api/serializers/submission_serializer.py | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index d72983ac..174f5265 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -49,7 +49,6 @@ class SubmissionFile(models.Model): null=False, ) - # TODO - Set the right place to save the file file = models.FileField(blank=False, null=False) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index c541521a..e0975562 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -47,6 +47,12 @@ class Meta: "structure_checks_passed", "extra_checks_results" ] + extra_kwargs = { + "submission_number": { + "required": False, + "default": 0, # Provide a default value or use the one you prefer + } + } def create(self, validated_data): # Extract files from the request From eb0022837eb995171a0bf000e0e00f020226ea00 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 00:13:13 +0100 Subject: [PATCH 282/397] chore: Use celery & Redis --- .env | 4 ++++ .gitignore | 6 ++++-- backend/Dockerfile | 4 ---- backend/notifications/logic.py | 19 ++++++++++++++++++- backend/notifications/signals.py | 4 ++-- backend/requirements.txt | 3 ++- backend/ypovoli/__init__.py | 5 +++++ backend/ypovoli/celery.py | 22 ++++++++++++++++++++++ backend/ypovoli/settings.py | 21 +++++++++++++++++++++ data/nginx/nginx.conf | 8 ++++---- development.sh | 19 +++++++++++++++---- development.yml | 32 ++++++++++++++++++++++++++++++-- frontend/Dockerfile | 2 +- 13 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 backend/ypovoli/celery.py diff --git a/.env b/.env index 118bb629..0d3f97be 100644 --- a/.env +++ b/.env @@ -7,3 +7,7 @@ DATADIR="./data" BACKEND_DIR="./backend" FRONTEND_DIR="./frontend" + +REDIS_IP="192.168.90.10" +REDIS_PORT=6379 +REDIS_PASSWORD="oqOsNX1PXGOX5soJtKkw" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3984195e..5dafea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .tool-versions -data/postgres/* -data/nginx/ssl/* \ No newline at end of file +data/* + +!data/nginx/nginx.conf +!data/nginx/ssl/.gitkeep diff --git a/backend/Dockerfile b/backend/Dockerfile index bce03b5a..0365d5f9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,7 +11,3 @@ COPY requirements.txt /code/ RUN pip install -r requirements.txt COPY . /code/ - -RUN ./setup.sh - -CMD ["python", "manage.py", "runsslserver", "192.168.90.2:8080"] \ No newline at end of file diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index 99104eb9..f7140134 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -2,7 +2,9 @@ from smtplib import SMTPException from typing import Dict, List +from celery import shared_task from django.core import mail +from django.core.cache import cache from django.utils.translation import gettext as _ from notifications.models import Notification from ypovoli.settings import EMAIL_CUSTOM @@ -17,6 +19,17 @@ def get_message_dict(notification: Notification) -> Dict[str, str]: } +# Call the function after 60 seconds and no more than once in that period +def schedule_send_mails(): + print("Hiii") + if not cache.get("notifications_send_mails"): + print("Not in cache yet") + cache.set("notifications_send_mails", True) + _send_mails.apply_async(countdown=60) + else: + print("Already in cache") + + # Try to send one email and set the result def _send_mail(mail: mail.EmailMessage, result: List[bool]): try: @@ -27,7 +40,11 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]): # Send all unsent emails -def send_mails(): +@shared_task +def _send_mails(): + print("Sending") + cache.set("notifications_send_mails", False) + notifications = Notification.objects.filter(is_sent=False) # No notifications to send diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 6d7e993f..7a817e5a 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -6,7 +6,7 @@ from authentication.models import User from django.dispatch import Signal, receiver from django.urls import reverse -from notifications.logic import send_mails +from notifications.logic import schedule_send_mails from notifications.serializers import NotificationSerializer notification_create = Signal() @@ -33,7 +33,7 @@ def notification_creation( serializer.save() - send_mails() + schedule_send_mails() return True diff --git a/backend/requirements.txt b/backend/requirements.txt index 610d422a..d1dab654 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,4 +7,5 @@ requests==2.31.0 cas-client==1.0.0 psycopg2-binary==2.9.9 djangorestframework-simplejwt==5.3.1 -celery[redis]==5.3.6 \ No newline at end of file +celery[redis]==5.3.6 +django-redis==5.4.0 \ No newline at end of file diff --git a/backend/ypovoli/__init__.py b/backend/ypovoli/__init__.py index e69de29b..5568b6d7 100644 --- a/backend/ypovoli/__init__.py +++ b/backend/ypovoli/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/backend/ypovoli/celery.py b/backend/ypovoli/celery.py new file mode 100644 index 00000000..4195e638 --- /dev/null +++ b/backend/ypovoli/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + +app = Celery("ypovoli") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f"Request: {self.request!r}") diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 1e496534..22b29a82 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +import os from datetime import timedelta from pathlib import Path @@ -137,3 +138,23 @@ "subject": "[Ypovoli] New Notification", "timeout": 2, } + +REDIS_CUSTOM = { + "host": os.environ.get("REDIS_IP", "localhost"), + "port": os.environ.get("REDIS_PORT", 6379), + "password": os.environ.get("REDIS_PASSWORD", ""), + "db_django": 0, + "db_celery": 1, +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_django']}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +CELERY_BROKER_URL = f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf index a3a6b6d0..c46ebf37 100644 --- a/data/nginx/nginx.conf +++ b/data/nginx/nginx.conf @@ -24,8 +24,8 @@ http { listen 443 ssl; listen [::]:443 ssl; - ssl_certificate certificate.crt; - ssl_certificate_key private.key; + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; location / { proxy_pass https://frontend; @@ -39,8 +39,8 @@ http { listen 8080 ssl; listen [::]:8080 ssl; - ssl_certificate certificate.crt; - ssl_certificate_key private.key; + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; location / { proxy_pass https://backend; diff --git a/development.sh b/development.sh index ed637eb4..24697323 100755 --- a/development.sh +++ b/development.sh @@ -1,11 +1,12 @@ echo "Checking for existing SSL certificates..." if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then -echo "Generating SSL certificates..." + echo "Generating SSL certificates..." sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout data/nginx/ssl/private.key \ -out data/nginx/ssl/certificate.crt \ - -subj "/C=BE/ST=/L=/O=/OU=/CN=" + -subj "/C=BE/ST=/L=/O=/OU=/CN=" > /dev/null + echo "SSL certificates generated." else echo "SSL certificates already exist, skipping generation." fi @@ -13,5 +14,15 @@ fi echo "Starting services..." docker-compose -f development.yml up -d -echo "Following logs..." -docker-compose -f development.yml logs --follow --tail 50 backend \ No newline at end of file +echo "-------------------------------------" +echo "Following backend logs..." +echo "Press CTRL + C to stop all containers" +echo "-------------------------------------" + +docker-compose -f development.yml logs --follow --tail 50 backend + +echo "Cleaning up..." + +docker-compose -f development.yml down + +echo "Done." diff --git a/development.yml b/development.yml index d72ee963..58a532e3 100644 --- a/development.yml +++ b/development.yml @@ -39,7 +39,7 @@ services: - 8080:8080 volumes: - $DATADIR/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - $DATADIR/nginx/ssl:/etc/nginx/ + - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro depends_on: - backend - frontend @@ -50,11 +50,25 @@ services: build: context: $BACKEND_DIR dockerfile: Dockerfile + command: bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" expose: - 8000 volumes: - $BACKEND_DIR:/code + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile + command: celery -A ypovoli worker -l INFO + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + frontend: <<: *common-keys-selab container_name: frontend @@ -64,4 +78,18 @@ services: expose: - 3000 depends_on: - - backend \ No newline at end of file + - backend + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --requirepass $REDIS_PASSWORD --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - $DATADIR/redis:/data + \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5254aa37..d3a568de 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,7 +5,7 @@ RUN npm install COPY ./ . RUN npm run build -FROM nginx as production-stage +FROM nginx as development-stage EXPOSE 3000 RUN mkdir /app COPY nginx.conf /etc/nginx/conf.d/default.conf From cd4efb82c95797efe312d284bac2833200c9bff8 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 00:46:35 +0100 Subject: [PATCH 283/397] chore: cleanup + dev environment --- backend/notifications/logic.py | 5 ----- backend/notifications/signals.py | 1 - backend/notifications/urls.py | 4 +--- backend/notifications/views.py | 27 --------------------------- backend/ypovoli/celery.py | 5 ----- data/nginx/nginx.conf | 4 ++-- development.yml | 5 ++++- frontend/Dockerfile | 28 +++++++++++++++++++--------- frontend/package.json | 1 + 9 files changed, 27 insertions(+), 53 deletions(-) diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index f7140134..731b4281 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -21,13 +21,9 @@ def get_message_dict(notification: Notification) -> Dict[str, str]: # Call the function after 60 seconds and no more than once in that period def schedule_send_mails(): - print("Hiii") if not cache.get("notifications_send_mails"): - print("Not in cache yet") cache.set("notifications_send_mails", True) _send_mails.apply_async(countdown=60) - else: - print("Already in cache") # Try to send one email and set the result @@ -42,7 +38,6 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]): # Send all unsent emails @shared_task def _send_mails(): - print("Sending") cache.set("notifications_send_mails", False) notifications = Notification.objects.filter(is_sent=False) diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 7a817e5a..24d7ea4e 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -12,7 +12,6 @@ notification_create = Signal() -# TODO: Remove send_mails call @receiver(notification_create) def notification_creation( type: NotificationType, diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index a9dacf25..23b79d7e 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,8 +1,6 @@ from django.urls import path -from notifications.views import NotificationView, TestingView +from notifications.views import NotificationView -# TODO: Remove test urlpatterns = [ path("tmp//", NotificationView.as_view(), name="notification-detail"), - path("test/", TestingView.as_view(), name="notification-test"), ] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index c83d2da8..b5d1f4f6 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -11,7 +11,6 @@ from rest_framework.views import APIView -# TODO: Give admin access to everything class NotificationPermission(BasePermission): # The user can only access their own notifications # An admin can access all notifications @@ -36,29 +35,3 @@ def post(self, request: Request, user_id: str) -> Response: notifications.update(is_read=True) return Response(status=HTTP_200_OK) - - -# TODO: Remove this view -class TestingView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request): - from notifications.signals import NotificationType, notification_create - - print("Hi") - - notification_create.send( - sender="", - type=NotificationType.SCORE_ADDED, - user=request.user, - arguments={"score": "10"}, - ) - - return Response(status=HTTP_200_OK) - - def post(self, request: Request): - from notifications.logic import send_mails - - send_mails() - - return Response(status=HTTP_200_OK) diff --git a/backend/ypovoli/celery.py b/backend/ypovoli/celery.py index 4195e638..5f2cac39 100644 --- a/backend/ypovoli/celery.py +++ b/backend/ypovoli/celery.py @@ -15,8 +15,3 @@ # Load task modules from all registered Django apps. app.autodiscover_tasks() - - -@app.task(bind=True, ignore_result=True) -def debug_task(self): - print(f"Request: {self.request!r}") diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf index c46ebf37..f6a71bee 100644 --- a/data/nginx/nginx.conf +++ b/data/nginx/nginx.conf @@ -8,7 +8,7 @@ http { } upstream frontend { - server frontend:3000; + server frontend:5173; } server { @@ -28,7 +28,7 @@ http { ssl_certificate_key ssl/private.key; location / { - proxy_pass https://frontend; + proxy_pass http://frontend; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; diff --git a/development.yml b/development.yml index 58a532e3..2530320b 100644 --- a/development.yml +++ b/development.yml @@ -75,8 +75,11 @@ services: build: context: $FRONTEND_DIR dockerfile: Dockerfile + command: bash -c "npm install && npm run host" expose: - - 3000 + - 5173 + volumes: + - $FRONTEND_DIR:/app depends_on: - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d3a568de..ec6c3ba7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,22 @@ -FROM node:16 as build-stage +# FROM node:16 as build-stage +# WORKDIR /app +# COPY package*.json ./ +# RUN npm install +# COPY ./ . +# RUN npm run build + +# FROM nginx as development-stage +# EXPOSE 3000 +# RUN mkdir /app +# COPY nginx.conf /etc/nginx/conf.d/default.conf +# COPY --from=build-stage /app/dist /app + +FROM node:16 + WORKDIR /app + COPY package*.json ./ + RUN npm install -COPY ./ . -RUN npm run build - -FROM nginx as development-stage -EXPOSE 3000 -RUN mkdir /app -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build-stage /app/dist /app \ No newline at end of file + +COPY . /app/ diff --git a/frontend/package.json b/frontend/package.json index 57fc78e0..60994926 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "host": "vite --host", "build": "vue-tsc && vite build", "preview": "vite preview" }, From ba1a28c26df71cb853bf3a6bf109c70a2e7c5315 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 01:08:23 +0100 Subject: [PATCH 284/397] chore: linting --- backend/ypovoli/settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 22b29a82..38931569 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -150,11 +150,15 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_django']}", + "LOCATION": f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" + f"{REDIS_CUSTOM['db_django']}", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", }, } } -CELERY_BROKER_URL = f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" +CELERY_BROKER_URL = ( + f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" + f"{REDIS_CUSTOM['db_celery']}" +) From b1bda33aeabbce32520b8efc16f508e16eb670ce Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 01:13:59 +0100 Subject: [PATCH 285/397] chore: Remove unused nginx conf --- frontend/nginx.conf | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 frontend/nginx.conf diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index 0ae8c83e..00000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,9 +0,0 @@ -server { - listen 3000; - - location / { - root /app; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } -} \ No newline at end of file From 8a74d5bfb9a78eb72179b9a3249ff04891a36882 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 11 Mar 2024 10:55:31 +0100 Subject: [PATCH 286/397] fix: fix the submisionAdd validator to be used --- backend/api/models/project.py | 13 +++++++++++++ backend/api/serializers/project_serializer.py | 18 ++++++++++++++---- .../api/serializers/submission_serializer.py | 2 +- backend/api/views/group_view.py | 7 ++++--- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index c74b0eea..0cecf456 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,4 +1,5 @@ from django.db import models +from datetime import timedelta from django.utils import timezone from api.models.course import Course @@ -62,6 +63,14 @@ def deadline_passed(self): now = timezone.now() return now > self.deadline + def is_archived(self): + """Returns True if a project is archived.""" + return self.archived + + def is_visible(self): + """Returns True if a project is visible.""" + return self.visible + def toggle_visible(self): """Toggles the visibility of the project.""" self.visible = not self.visible @@ -71,3 +80,7 @@ def toggle_archived(self): """Toggles the archived status of the project.""" self.archived = not self.archived self.save() + + def increase_deadline(self, days): + self.deadline = self.deadline + timedelta(days=days) + self.save() diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index b6cc7241..f6708e5d 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,8 +1,10 @@ from django.utils.translation import gettext from rest_framework import serializers -from ..models.project import Project +from api.models.project import Project +from api.models.group import Group from rest_framework.exceptions import ValidationError from ..models.submission import Submission, SubmissionFile +from api.serializers.submission_serializer import SubmissionSerializer class ProjectSerializer(serializers.ModelSerializer): @@ -48,17 +50,25 @@ class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) -class SubmissionAddSerializer(serializers.ModelSerializer): +class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): + """ # The validator needs the project context. if "project" not in self.context: raise ValidationError(gettext("project.error.context")) + """ - project: Project = self.context["project"] + group: Group = self.context["group"] + project: Project = group.project - data["submission_number"] = 156 # Check if the project's deadline is not passed. if project.deadline_passed(): raise ValidationError(gettext("project.error.submission.past_project")) + if not project.is_visible(): + raise ValidationError(gettext("project.error.submission.non_visible_project")) + + if project.is_archived(): + raise ValidationError(gettext("project.error.submission.archived_project")) + return data diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index e0975562..d75cd853 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -50,7 +50,7 @@ class Meta: extra_kwargs = { "submission_number": { "required": False, - "default": 0, # Provide a default value or use the one you prefer + "default": 0, } } diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 967563fd..632bb7ff 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -10,6 +10,7 @@ from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer +from api.serializers.project_serializer import SubmissionAddSerializer from api.serializers.submission_serializer import SubmissionSerializer from rest_framework.request import Request @@ -96,11 +97,11 @@ def _remove_student(self, request, **_): def _add_submission(self, request: Request, **_): """Add an submission to the group""" - group = self.get_object() + group: Group = self.get_object() # Add submission to course - serializer = SubmissionSerializer( - data=request.data, context={"request": request} + serializer = SubmissionAddSerializer( + data=request.data, context={"group": group, "request": request} ) if serializer.is_valid(raise_exception=True): From 696b7d3cfd76dd2b66079665d52fbf34e11e2d08 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 11:45:23 +0100 Subject: [PATCH 287/397] chore: linter --- backend/ypovoli/settings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 38931569..2d09c8f3 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,8 +10,8 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ -import os from datetime import timedelta +from os import environ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -140,9 +140,9 @@ } REDIS_CUSTOM = { - "host": os.environ.get("REDIS_IP", "localhost"), - "port": os.environ.get("REDIS_PORT", 6379), - "password": os.environ.get("REDIS_PASSWORD", ""), + "host": environ.get("REDIS_IP", "localhost"), + "port": environ.get("REDIS_PORT", 6379), + "password": environ.get("REDIS_PASSWORD", ""), "db_django": 0, "db_celery": 1, } From c2024e3dfcf7467cfdfdd897b71186a3d0ccf3f5 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 16:12:47 +0100 Subject: [PATCH 288/397] chore: change single user to queryset --- backend/notifications/signals.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 24d7ea4e..f8203f39 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -1,9 +1,10 @@ from __future__ import annotations from enum import Enum -from typing import Dict +from typing import Dict, List, Union from authentication.models import User +from django.db.models.query import QuerySet from django.dispatch import Signal, receiver from django.urls import reverse from notifications.logic import schedule_send_mails @@ -15,17 +16,22 @@ @receiver(notification_create) def notification_creation( type: NotificationType, - user: User, + queryset: QuerySet[User], arguments: Dict[str, str], **kwargs, # Required by django ) -> bool: - serializer = NotificationSerializer( - data={ - "template_id": type.value, - "user": reverse("user-detail", kwargs={"pk": user.id}), - "arguments": arguments, - } - ) + data: List[Dict[str, Union[str, int, Dict[str, str]]]] = [] + + for user in queryset: + data.append( + { + "template_id": type.value, + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } + ) + + serializer = NotificationSerializer(data=data, many=True) if not serializer.is_valid(): return False From 84d5d1e3d7cd6fb4a280aa2a86a87191a378443b Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 16:35:17 +0100 Subject: [PATCH 289/397] chore: max sent retries --- backend/notifications/logic.py | 33 +++++++++++++++++++++++++++++---- backend/ypovoli/settings.py | 1 + 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index 731b4281..b5df6d18 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -1,6 +1,8 @@ import threading +from collections import defaultdict +from os import error from smtplib import SMTPException -from typing import Dict, List +from typing import DefaultDict, Dict, List from celery import shared_task from django.core import mail @@ -38,9 +40,12 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]): # Send all unsent emails @shared_task def _send_mails(): - cache.set("notifications_send_mails", False) - + # All notifications that need to be sent notifications = Notification.objects.filter(is_sent=False) + # Dictionary with the number of errors for each email + errors: DefaultDict[str, int] = cache.get( + "notifications_send_mails_errors", defaultdict(int) + ) # No notifications to send if notifications.count() == 0: @@ -72,9 +77,29 @@ def _send_mails(): thread.start() thread.join(timeout=EMAIL_CUSTOM["timeout"]) - # If the email was not sent, continue + # Email failed to send if thread.is_alive() or not result[0]: + # Increase the number of errors for the email + errors[notification.user.email] += 1 + # Mark notification as sent if the maximum number of errors is reached + if errors[notification.user.email] >= EMAIL_CUSTOM["max_errors"]: + errors.pop(notification.user.email) + notification.sent() + continue + # Email sent successfully + if notification.user.email in errors: + errors.pop(notification.user.email) + # Mark the notification as sent notification.sent() + + # Save the number of errors for each email + cache.set("notifications_send_mails_errors", errors) + + # Restart the process if there are any notifications left that were not sent + unsent_notifications = Notification.objects.filter(is_sent=False) + cache.set("notifications_send_mails", False) + if unsent_notifications.count() > 0: + schedule_send_mails() diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 2d09c8f3..414d9409 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -137,6 +137,7 @@ "from": "ypovoli@ugent.be", "subject": "[Ypovoli] New Notification", "timeout": 2, + "max_errors": 3, } REDIS_CUSTOM = { From 3f249e044c979b1335eb59587497b9ef6159ed21 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 16:36:30 +0100 Subject: [PATCH 290/397] chore: dockerfile cleanup --- frontend/Dockerfile | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ec6c3ba7..5792f9fc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,16 +1,3 @@ -# FROM node:16 as build-stage -# WORKDIR /app -# COPY package*.json ./ -# RUN npm install -# COPY ./ . -# RUN npm run build - -# FROM nginx as development-stage -# EXPOSE 3000 -# RUN mkdir /app -# COPY nginx.conf /etc/nginx/conf.d/default.conf -# COPY --from=build-stage /app/dist /app - FROM node:16 WORKDIR /app From ce0765dc4e5a9836f7c9a3e4e2218811e94ddcc3 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 17:11:01 +0100 Subject: [PATCH 291/397] chore: move notification urls / views to api --- .../permissions/notification_permissions.py | 10 +++++ backend/api/views/user_view.py | 32 +++++++++++++++- backend/notifications/urls.py | 7 +--- backend/notifications/views.py | 37 ------------------- 4 files changed, 41 insertions(+), 45 deletions(-) create mode 100644 backend/api/permissions/notification_permissions.py diff --git a/backend/api/permissions/notification_permissions.py b/backend/api/permissions/notification_permissions.py new file mode 100644 index 00000000..b9443fb6 --- /dev/null +++ b/backend/api/permissions/notification_permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class NotificationPermission(BasePermission): + # The user can only access their own notifications + # An admin can access all notifications + def has_permission(self, request: Request, view: ViewSet) -> bool: + return view.kwargs.get("pk") == request.user.id or request.user.is_staff diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py index 870243da..7868eacf 100644 --- a/backend/api/views/user_view.py +++ b/backend/api/views/user_view.py @@ -1,9 +1,37 @@ -from rest_framework.viewsets import GenericViewSet -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from api.permissions.notification_permissions import NotificationPermission from authentication.models import User from authentication.serializers import UserSerializer +from notifications.models import Notification +from notifications.serializers import NotificationSerializer +from rest_framework.decorators import action +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.viewsets import GenericViewSet class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer + + @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) + def notifications(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + serializer = NotificationSerializer( + notifications, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action( + detail=True, + methods=["post"], + permission_classes=[NotificationPermission], + url_path="notifications/read", + ) + def read(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + notifications.update(is_read=True) + + return Response(status=HTTP_200_OK) diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index 23b79d7e..637600f5 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,6 +1 @@ -from django.urls import path -from notifications.views import NotificationView - -urlpatterns = [ - path("tmp//", NotificationView.as_view(), name="notification-detail"), -] +urlpatterns = [] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index b5d1f4f6..e69de29b 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,37 +0,0 @@ -from __future__ import annotations - -from typing import List - -from notifications.models import Notification -from notifications.serializers import NotificationSerializer -from rest_framework.permissions import BasePermission, IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK -from rest_framework.views import APIView - - -class NotificationPermission(BasePermission): - # The user can only access their own notifications - # An admin can access all notifications - def has_permission(self, request: Request, view: NotificationView) -> bool: - return view.kwargs.get("user_id") == request.user.id or request.user.is_staff - - -class NotificationView(APIView): - permission_classes: List[BasePermission] = [IsAuthenticated, NotificationPermission] - - def get(self, request: Request, user_id: str) -> Response: - notifications = Notification.objects.filter(user=user_id) - serializer = NotificationSerializer( - notifications, many=True, context={"request": request} - ) - - return Response(serializer.data) - - # Mark all notifications as read for the user - def post(self, request: Request, user_id: str) -> Response: - notifications = Notification.objects.filter(user=user_id) - notifications.update(is_read=True) - - return Response(status=HTTP_200_OK) From 8e3fc3e39c0bbff35e8f4edf00d7c3e1a1872292 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Mon, 11 Mar 2024 18:14:30 +0100 Subject: [PATCH 292/397] chore: some cleanup --- backend/api/helpers/check_folder_structure.py | 3 --- backend/api/serializers/project_serializer.py | 2 +- backend/api/views/admin_view.py | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/api/helpers/check_folder_structure.py b/backend/api/helpers/check_folder_structure.py index 9dde5c0a..bfac3e4b 100644 --- a/backend/api/helpers/check_folder_structure.py +++ b/backend/api/helpers/check_folder_structure.py @@ -28,9 +28,6 @@ def parse_zip_file(project, dir_path): # TODO block paths that start with .. # TODO block paths that start with .. def check_zip_file(project, dir_path, restrict_extra_folders=False): - # print(f"Checking project: {project}") - # print(f"Checking file: {dir_path}") - dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) project_structure_checks = StructureCheck.objects.filter(project=project.id) structuur = {} diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index f6708e5d..dd564a33 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -3,7 +3,7 @@ from api.models.project import Project from api.models.group import Group from rest_framework.exceptions import ValidationError -from ..models.submission import Submission, SubmissionFile +from api.models.submission import Submission, SubmissionFile from api.serializers.submission_serializer import SubmissionSerializer diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 63fdab43..ad0862d0 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -3,6 +3,6 @@ from authentication.models import User -class AdminViewSet(viewsets.ModelViewSet): +class AdminViewSet(viewsets.ReadOnlyModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer From 6d260dc70b9291b13dc6a74d08bc81a80e557a38 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 18:50:55 +0100 Subject: [PATCH 293/397] chore: /api --- backend/ypovoli/settings.py | 6 +++--- backend/ypovoli/urls.py | 43 ++++++++++++++++++++++++------------- data/nginx/nginx.conf | 25 ++++++++++++--------- development.yml | 2 +- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 7f1a3107..1ba6ba6c 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,10 +10,10 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +import os from datetime import timedelta from os import environ from pathlib import Path -import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -89,8 +89,8 @@ # Application endpoints CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/auth/cas/echo" -API_ENDPOINT = "https://localhost:8080" +CAS_RESPONSE = "https://localhost:8080/api/auth/cas/echo" +API_ENDPOINT = "https://localhost:8080/api" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index f3093cdc..fdcdc726 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -15,7 +15,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import include, path +from django.urls import include, path, re_path from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions @@ -33,18 +33,31 @@ urlpatterns = [ - # Base API endpoints. - path("", include("api.urls")), - # Authentication endpoints. - path("auth/", include("authentication.urls")), - path("notifications/", include("notifications.urls"), name="notifications"), - # Swagger documentation. - path( - "swagger/", - schema_view.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - path( - "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" - ), + re_path( + "api/", + include( + [ + # Base API endpoints. + path("", include("api.urls")), + # Authentication endpoints. + path("auth/", include("authentication.urls")), + path( + "notifications/", + include("notifications.urls"), + name="notifications", + ), + # Swagger documentation. + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + ] + ), + ) ] diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf index f6a71bee..7475f718 100644 --- a/data/nginx/nginx.conf +++ b/data/nginx/nginx.conf @@ -21,32 +21,37 @@ http { } server { - listen 443 ssl; - listen [::]:443 ssl; + listen 8080 ssl; + listen [::]:8080 ssl; ssl_certificate ssl/certificate.crt; ssl_certificate_key ssl/private.key; location / { - proxy_pass http://frontend; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; + return 301 https://$host$request_uri; } } server { - listen 8080 ssl; - listen [::]:8080 ssl; + listen 443 ssl; + listen [::]:443 ssl; ssl_certificate ssl/certificate.crt; ssl_certificate_key ssl/private.key; + location /api/ { + proxy_pass https://backend$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location / { - proxy_pass https://backend; + proxy_pass http://frontend; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_redirect off; } } -} +} \ No newline at end of file diff --git a/development.yml b/development.yml index 2530320b..1164fa38 100644 --- a/development.yml +++ b/development.yml @@ -50,7 +50,7 @@ services: build: context: $BACKEND_DIR dockerfile: Dockerfile - command: bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" expose: - 8000 volumes: From 6184da3043d7ef9b386dc9bb67420e3a855dc384 Mon Sep 17 00:00:00 2001 From: Tybo Verslype <97916632+tyboro2002@users.noreply.github.com> Date: Mon, 11 Mar 2024 20:43:10 +0100 Subject: [PATCH 294/397] fix: Update project_serializer.py --- backend/api/serializers/project_serializer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index dd564a33..e05397e2 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -52,12 +52,6 @@ class TeacherCreateGroupSerializer(serializers.Serializer): class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): - """ - # The validator needs the project context. - if "project" not in self.context: - raise ValidationError(gettext("project.error.context")) - """ - group: Group = self.context["group"] project: Project = group.project From eace686e59a27a0df5ad14230a6009243357f97b Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 21:19:11 +0100 Subject: [PATCH 295/397] feat(deployement): production environment --- .dev.env | 20 +++++ .env | 13 --- .gitignore | 1 + .prod.env | 30 +++++++ backend/{Dockerfile => Dockerfile.dev} | 0 backend/Dockerfile.prod | 15 ++++ backend/gunicorn_config.py | 4 + backend/requirements.txt | 3 +- backend/ypovoli/settings.py | 33 ++++--- backend/ypovoli/urls.py | 43 +++++---- data/nginx/nginx.conf | 52 ----------- development.sh | 5 ++ development.yml | 12 +-- frontend/{Dockerfile => Dockerfile.dev} | 0 frontend/Dockerfile.prod | 12 +++ frontend/nginx.conf | 9 ++ production.yml | 110 ++++++++++++++++++++++++ 17 files changed, 258 insertions(+), 104 deletions(-) create mode 100644 .dev.env delete mode 100644 .env create mode 100644 .prod.env rename backend/{Dockerfile => Dockerfile.dev} (100%) create mode 100644 backend/Dockerfile.prod create mode 100644 backend/gunicorn_config.py delete mode 100644 data/nginx/nginx.conf rename frontend/{Dockerfile => Dockerfile.dev} (100%) create mode 100644 frontend/Dockerfile.prod create mode 100644 frontend/nginx.conf create mode 100644 production.yml diff --git a/.dev.env b/.dev.env new file mode 100644 index 00000000..890efd41 --- /dev/null +++ b/.dev.env @@ -0,0 +1,20 @@ +PUID=1000 +PGID=1000 +TZ=Europe/Brussels + +DATADIR=./data + +BACKEND_DIR=./backend + +FRONTEND_DIR=./frontend + +REDIS_IP=192.168.90.10 +REDIS_PORT=6379 + +DJANGO_SECRET_KEY= +DJANGO_DEBUG=True +DJANGO_DB_ENGINE=django.db.backends.sqlite3 +DJANGO_DB_NAME=${BACKEND_DIR}/db.sqlite3 +DJANGO_REDIS_HOST=${REDIS_IP} +DJANGO_REDIS_PORT=${REDIS_PORT} +DJANGO_REDIS_PASSWORD=${REDIS_PASSWORD} \ No newline at end of file diff --git a/.env b/.env deleted file mode 100644 index 0d3f97be..00000000 --- a/.env +++ /dev/null @@ -1,13 +0,0 @@ -PUID=1000 -PGID=1000 -TZ="Europe/Brussels" - -DATADIR="./data" - -BACKEND_DIR="./backend" - -FRONTEND_DIR="./frontend" - -REDIS_IP="192.168.90.10" -REDIS_PORT=6379 -REDIS_PASSWORD="oqOsNX1PXGOX5soJtKkw" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5dafea5e..6a9bedb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .tool-versions +.env data/* !data/nginx/nginx.conf diff --git a/.prod.env b/.prod.env new file mode 100644 index 00000000..7cf0fc6e --- /dev/null +++ b/.prod.env @@ -0,0 +1,30 @@ +PUID=1000 +PGID=1000 +TZ=Europe/Brussels + +DATADIR=./data + +BACKEND_DIR=./backend + +FRONTEND_DIR=./frontend + +POSTGRES_IP=192.168.90.9 +POSTGRES_PORT=5432 +POSTGRES_DB=selab +POSTGRES_USER=selab_user +POSTGRES_PASSWORD= + +REDIS_IP=192.168.90.10 +REDIS_PORT=6379 + +DJANGO_SECRET_KEY= +DJANGO_DEBUG=false +DJANGO_DB_ENGINE=django.db.backends.postgresql +DJANGO_DB_NAME=${POSTGRES_DB} +DJANGO_DB_USER=${POSTGRES_USER} +DJANGO_DB_PASSWORD=${POSTGRES_PASSWORD} +DJANGO_DB_HOST=${POSTGRES_IP} +DJANGO_DB_PORT=${POSTGRES_PORT} +DJANGO_REDIS_HOST=${REDIS_IP} +DJANGO_REDIS_PORT=${REDIS_PORT} +DJANGO_REDIS_PASSWORD=${REDIS_PASSWORD} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile.dev similarity index 100% rename from backend/Dockerfile rename to backend/Dockerfile.dev diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 00000000..1377e32d --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,15 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN ./setup.sh diff --git a/backend/gunicorn_config.py b/backend/gunicorn_config.py new file mode 100644 index 00000000..6ec33776 --- /dev/null +++ b/backend/gunicorn_config.py @@ -0,0 +1,4 @@ +workers = 4 +bind = "0.0.0.0:8080" +chdir = "/code/" +module = "ypovoli.wsgi:application" diff --git a/backend/requirements.txt b/backend/requirements.txt index d1dab654..addd2c6c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,5 @@ cas-client==1.0.0 psycopg2-binary==2.9.9 djangorestframework-simplejwt==5.3.1 celery[redis]==5.3.6 -django-redis==5.4.0 \ No newline at end of file +django-redis==5.4.0 +gunicorn==21.2.0 \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 7f1a3107..7adda2b5 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,10 +10,10 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +import os from datetime import timedelta from os import environ from pathlib import Path -import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,10 +26,10 @@ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-_upw+)mo--8_0slsl&8ot0*h8p50z_rlid6nwobd*%%gm$_!1x" +SECRET_KEY = environ.get("DJANGO_SECRET_KEY", "lnZZ2xHc6HjU5D85GDE3Nnu4CJsBnm") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = environ.get("DJANGO_DEBUG", False) ALLOWED_HOSTS = [] @@ -89,17 +89,20 @@ # Application endpoints CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/auth/cas/echo" -API_ENDPOINT = "https://localhost:8080" +CAS_RESPONSE = "https://localhost:8080/api/auth/cas/echo" +API_ENDPOINT = "https://localhost:8080/api" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": environ.get("DJANGO_DB_ENGINE", "django.db.backends.sqlite3"), + "NAME": environ.get("DJANGO_DB_NAME", BASE_DIR / "db.sqlite3"), + "USER": environ.get("DJANGO_DB_USER", ""), + "PASSWORD": environ.get("DJANGO_DB_PASSWORD", ""), + "HOST": environ.get("DJANGO_DB_HOST", ""), + "PORT": environ.get("DJANGO_DB_PORT", ""), }, - "production": {"ENGINE": "django.db.backends.postgresql"}, } # Default primary key field type @@ -145,9 +148,8 @@ } REDIS_CUSTOM = { - "host": environ.get("REDIS_IP", "localhost"), - "port": environ.get("REDIS_PORT", 6379), - "password": environ.get("REDIS_PASSWORD", ""), + "host": environ.get("DJANGO_REDIS_HOST", "localhost"), + "port": environ.get("DJANGO_REDIS_PORT", 6379), "db_django": 0, "db_celery": 1, } @@ -155,15 +157,12 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" - f"{REDIS_CUSTOM['db_django']}", + "LOCATION": f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_django']}", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", }, } } -CELERY_BROKER_URL = ( - f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" - f"{REDIS_CUSTOM['db_celery']}" -) +CELERY_BROKER_URL = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" +CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index f3093cdc..fdcdc726 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -15,7 +15,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import include, path +from django.urls import include, path, re_path from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions @@ -33,18 +33,31 @@ urlpatterns = [ - # Base API endpoints. - path("", include("api.urls")), - # Authentication endpoints. - path("auth/", include("authentication.urls")), - path("notifications/", include("notifications.urls"), name="notifications"), - # Swagger documentation. - path( - "swagger/", - schema_view.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - path( - "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" - ), + re_path( + "api/", + include( + [ + # Base API endpoints. + path("", include("api.urls")), + # Authentication endpoints. + path("auth/", include("authentication.urls")), + path( + "notifications/", + include("notifications.urls"), + name="notifications", + ), + # Swagger documentation. + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + ] + ), + ) ] diff --git a/data/nginx/nginx.conf b/data/nginx/nginx.conf deleted file mode 100644 index f6a71bee..00000000 --- a/data/nginx/nginx.conf +++ /dev/null @@ -1,52 +0,0 @@ -events { - worker_connections 1024; -} - -http { - upstream backend { - server backend:8080; - } - - upstream frontend { - server frontend:5173; - } - - server { - listen 80; - listen [::]:80; - - location / { - return 301 https://$host$request_uri; - } - } - - server { - listen 443 ssl; - listen [::]:443 ssl; - - ssl_certificate ssl/certificate.crt; - ssl_certificate_key ssl/private.key; - - location / { - proxy_pass http://frontend; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; - } - } - - server { - listen 8080 ssl; - listen [::]:8080 ssl; - - ssl_certificate ssl/certificate.crt; - ssl_certificate_key ssl/private.key; - - location / { - proxy_pass https://backend; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_redirect off; - } - } -} diff --git a/development.sh b/development.sh index 24697323..210027f6 100755 --- a/development.sh +++ b/development.sh @@ -1,3 +1,8 @@ +if ! [ -f .env ]; then + echo "Error: .env file does not exist." + exit 1 +fi + echo "Checking for existing SSL certificates..." if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then diff --git a/development.yml b/development.yml index 2530320b..6aeb479d 100644 --- a/development.yml +++ b/development.yml @@ -38,7 +38,7 @@ services: - 443:443 - 8080:8080 volumes: - - $DATADIR/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - $DATADIR/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro depends_on: - backend @@ -49,10 +49,10 @@ services: container_name: backend build: context: $BACKEND_DIR - dockerfile: Dockerfile + dockerfile: Dockerfile.dev command: bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" expose: - - 8000 + - 8080 volumes: - $BACKEND_DIR:/code @@ -61,8 +61,8 @@ services: container_name: celery build: context: $BACKEND_DIR - dockerfile: Dockerfile - command: celery -A ypovoli worker -l INFO + dockerfile: Dockerfile.dev + command: celery -A ypovoli worker -l DEBUG volumes: - $BACKEND_DIR:/code depends_on: @@ -74,7 +74,7 @@ services: container_name: frontend build: context: $FRONTEND_DIR - dockerfile: Dockerfile + dockerfile: Dockerfile.dev command: bash -c "npm install && npm run host" expose: - 5173 diff --git a/frontend/Dockerfile b/frontend/Dockerfile.dev similarity index 100% rename from frontend/Dockerfile rename to frontend/Dockerfile.dev diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 00000000..5254aa37 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,12 @@ +FROM node:16 as build-stage +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY ./ . +RUN npm run build + +FROM nginx as production-stage +EXPOSE 3000 +RUN mkdir /app +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build-stage /app/dist /app \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..0ae8c83e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 3000; + + location / { + root /app; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/production.yml b/production.yml new file mode 100644 index 00000000..4c416e8b --- /dev/null +++ b/production.yml @@ -0,0 +1,110 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + ports: + - 80:80 + - 443:443 + - 8080:8080 + volumes: + - $DATADIR/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + + postgres: + <<: *common-keys-selab + image: postgres:15.2 + container_name: postgres + networks: + selab_network: + ipv4_address: $POSTGRES_IP + environment: + POSTGRES_DB: $POSTGRES_DB + POSTGRES_USER: $POSTGRES_USER + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + expose: + - $POSTGRES_PORT + volumes: + - $DATADIR/postgres:/var/lib/postgresql/data + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.prod + command: bash -c "./setup.sh && gunicorn --config gunicorn_config.py ypovoli.wsgi:application" + expose: + - 8080 + depends_on: + - postgres + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - $DATADIR/redis:/data + + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.prod + command: celery -A ypovoli worker -l ERROR + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile.prod + expose: + - 3000 + depends_on: + - backend \ No newline at end of file From 6d77b6435928e0a57e5b5ea74e86b353fb8028c9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 21:26:53 +0100 Subject: [PATCH 296/397] chore: re_path -> path --- backend/ypovoli/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index fdcdc726..0d786c39 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -15,7 +15,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.urls import include, path, re_path +from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions @@ -33,7 +33,7 @@ urlpatterns = [ - re_path( + path( "api/", include( [ From 0d7e100db7a5f11961a10946deb6c29c9095367f Mon Sep 17 00:00:00 2001 From: Topvennie Date: Mon, 11 Mar 2024 21:28:25 +0100 Subject: [PATCH 297/397] chore: Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7503111..35ab8ec3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This application was developed within the framework of the course "Software Engi Run `development.sh`. It starts the development environment and attaches itself to the output of the backend. The backend will auto reload when changing a file. -Acces the server by going to `https://localhost:8080` for the backend and `https://localhost:443` for the frontend. +Acces the server by going to `https://localhost/api` for the backend and `https://localhost` for the frontend. If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. From 90147f38df581b38ecc4de8c75f0a60b0a873ec2 Mon Sep 17 00:00:00 2001 From: francis Date: Mon, 11 Mar 2024 22:49:54 +0100 Subject: [PATCH 298/397] merge --- .env | 13 +++ .gitignore | 4 + README.md | 7 ++ backend/Dockerfile | 13 +++ backend/api/helpers/check_folder_structure.py | 2 + backend/api/models/project.py | 14 +++ backend/api/models/submission.py | 3 +- .../permissions/notification_permissions.py | 10 ++ backend/api/serializers/group_serializer.py | 7 +- backend/api/serializers/project_serializer.py | 25 ++++- .../api/serializers/submission_serializer.py | 43 +++++++ backend/api/views/admin_view.py | 2 +- backend/api/views/group_view.py | 36 ++++++ backend/api/views/project_view.py | 20 +++- backend/api/views/user_view.py | 32 +++++- .../locale/en/LC_MESSAGES/django.po | 3 + .../locale/nl/LC_MESSAGES/django.po | 3 + backend/notifications/logic.py | 105 ++++++++++++++++++ backend/notifications/models.py | 5 + backend/notifications/serializers.py | 7 +- backend/notifications/signals.py | 30 +++-- backend/notifications/urls.py | 7 +- backend/notifications/views.py | 38 ------- backend/requirements.txt | 4 +- backend/ypovoli/__init__.py | 5 + backend/ypovoli/celery.py | 17 +++ backend/ypovoli/settings.py | 47 ++++++-- backend/ypovoli/urls.py | 39 ++++--- 28 files changed, 454 insertions(+), 87 deletions(-) create mode 100644 .env create mode 100644 backend/Dockerfile create mode 100644 backend/api/permissions/notification_permissions.py create mode 100644 backend/notifications/logic.py create mode 100644 backend/ypovoli/celery.py diff --git a/.env b/.env new file mode 100644 index 00000000..0d3f97be --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +PUID=1000 +PGID=1000 +TZ="Europe/Brussels" + +DATADIR="./data" + +BACKEND_DIR="./backend" + +FRONTEND_DIR="./frontend" + +REDIS_IP="192.168.90.10" +REDIS_PORT=6379 +REDIS_PASSWORD="oqOsNX1PXGOX5soJtKkw" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f494b1b6..5dafea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .tool-versions +data/* + +!data/nginx/nginx.conf +!data/nginx/ssl/.gitkeep diff --git a/README.md b/README.md index 0c3a257d..35ab8ec3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ This application was developed within the framework of the course "Software Engi ## Development +Run `development.sh`. +It starts the development environment and attaches itself to the output of the backend. +The backend will auto reload when changing a file. +Acces the server by going to `https://localhost/api` for the backend and `https://localhost` for the frontend. + +If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. + ### Backend Instructions for the setup of the Django backend are to be found in `backend/README.md`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..0365d5f9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /code/ +RUN pip install -r requirements.txt + +COPY . /code/ diff --git a/backend/api/helpers/check_folder_structure.py b/backend/api/helpers/check_folder_structure.py index b1bf70a0..bfac3e4b 100644 --- a/backend/api/helpers/check_folder_structure.py +++ b/backend/api/helpers/check_folder_structure.py @@ -152,6 +152,8 @@ def check_zip_structure( folder_structure, zip_file_path, restrict_extra_folders=False): + # print(f"Checking folder_structure: {folder_structure}") + # print(f"Checking zip_file_path: {zip_file_path}") """ Check the structure of a zip file. diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 839907a1..0cecf456 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,8 +1,10 @@ from django.db import models +from datetime import timedelta from django.utils import timezone from api.models.course import Course +# TODO max submission size class Project(models.Model): """Model that represents a project.""" @@ -61,6 +63,14 @@ def deadline_passed(self): now = timezone.now() return now > self.deadline + def is_archived(self): + """Returns True if a project is archived.""" + return self.archived + + def is_visible(self): + """Returns True if a project is visible.""" + return self.visible + def toggle_visible(self): """Toggles the visibility of the project.""" self.visible = not self.visible @@ -70,3 +80,7 @@ def toggle_archived(self): """Toggles the archived status of the project.""" self.archived = not self.archived self.save() + + def increase_deadline(self, days): + self.deadline = self.deadline + timedelta(days=days) + self.save() diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 531f07a0..174f5265 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -18,7 +18,7 @@ class Submission(models.Model): ) # Multiple submissions can be made by a group - submission_number = models.PositiveIntegerField(blank=False, null=False) + submission_number = models.PositiveIntegerField(blank=True, null=True) # Automatically set the submission time to the current time submission_time = models.DateTimeField(auto_now_add=True) @@ -49,7 +49,6 @@ class SubmissionFile(models.Model): null=False, ) - # TODO - Set the right place to save the file file = models.FileField(blank=False, null=False) diff --git a/backend/api/permissions/notification_permissions.py b/backend/api/permissions/notification_permissions.py new file mode 100644 index 00000000..b9443fb6 --- /dev/null +++ b/backend/api/permissions/notification_permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class NotificationPermission(BasePermission): + # The user can only access their own notifications + # An admin can access all notifications + def has_permission(self, request: Request, view: ViewSet) -> bool: + return view.kwargs.get("pk") == request.user.id or request.user.is_staff diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 92847e7f..35a61402 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -16,9 +16,14 @@ class GroupSerializer(serializers.ModelSerializer): read_only=True, ) + submissions = serializers.HyperlinkedIdentityField( + view_name="group-submissions", + read_only=True, + ) + class Meta: model = Group - fields = ["id", "project", "students", "score"] + fields = ["id", "project", "students", "score", "submissions"] def validate(self, data): # Make sure the score of the group is lower or equal to the maximum score diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index a906177c..e05397e2 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -1,5 +1,10 @@ +from django.utils.translation import gettext from rest_framework import serializers -from ..models.project import Project +from api.models.project import Project +from api.models.group import Group +from rest_framework.exceptions import ValidationError +from api.models.submission import Submission, SubmissionFile +from api.serializers.submission_serializer import SubmissionSerializer class ProjectSerializer(serializers.ModelSerializer): @@ -43,3 +48,21 @@ class Meta: class TeacherCreateGroupSerializer(serializers.Serializer): number_groups = serializers.IntegerField(min_value=1) + + +class SubmissionAddSerializer(SubmissionSerializer): + def validate(self, data): + group: Group = self.context["group"] + project: Project = group.project + + # Check if the project's deadline is not passed. + if project.deadline_passed(): + raise ValidationError(gettext("project.error.submission.past_project")) + + if not project.is_visible(): + raise ValidationError(gettext("project.error.submission.non_visible_project")) + + if project.is_archived(): + raise ValidationError(gettext("project.error.submission.archived_project")) + + return data diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index 744cd4ac..d75cd853 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -1,5 +1,7 @@ from rest_framework import serializers from ..models.submission import Submission, SubmissionFile, ExtraChecksResult +from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file +from django.db.models import Max class SubmissionFileSerializer(serializers.ModelSerializer): @@ -45,3 +47,44 @@ class Meta: "structure_checks_passed", "extra_checks_results" ] + extra_kwargs = { + "submission_number": { + "required": False, + "default": 0, + } + } + + def create(self, validated_data): + # Extract files from the request + request = self.context.get('request') + files_data = request.FILES.getlist('files') + + # Get the group for the submission + group = validated_data['group'] + + # Get the project associated with the group + project = group.project + + # Get the maximum submission number for the group's project + max_submission_number = Submission.objects.filter( + group__project=project + ).aggregate(Max('submission_number'))['submission_number__max'] or 0 + + # Set the new submission number to the maximum value plus 1 + validated_data['submission_number'] = max_submission_number + 1 + + # Create the Submission instance without the files + submission = Submission.objects.create(**validated_data) + + pas: bool = True + # Create SubmissionFile instances for each file and check if none fail structure checks + for file in files_data: + SubmissionFile.objects.create(submission=submission, file=file) + status, _ = check_zip_file(submission.group.project, file.name) + if not status: + pas = False + + # Set structure_checks_passed to True + submission.structure_checks_passed = pas + submission.save() + return submission diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 63fdab43..ad0862d0 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -3,6 +3,6 @@ from authentication.models import User -class AdminViewSet(viewsets.ModelViewSet): +class AdminViewSet(viewsets.ReadOnlyModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 7c86b3db..632bb7ff 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -10,6 +10,9 @@ from api.serializers.group_serializer import GroupSerializer from api.serializers.student_serializer import StudentSerializer from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer +from api.serializers.project_serializer import SubmissionAddSerializer +from api.serializers.submission_serializer import SubmissionSerializer +from rest_framework.request import Request class GroupViewSet(CreateModelMixin, @@ -36,6 +39,20 @@ def students(self, request, **_): ) return Response(serializer.data) + @action(detail=True, permission_classes=[IsAdminUser]) + def submissions(self, request, **_): + """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + submissions = group.submissions.all() + + # Serialize the student objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + return Response(serializer.data) + @students.mapping.post @students.mapping.put def _add_student(self, request, **_): @@ -74,3 +91,22 @@ def _remove_student(self, request, **_): return Response({ "message": gettext("group.success.student.remove"), }) + + @submissions.mapping.post + @submissions.mapping.put + def _add_submission(self, request: Request, **_): + """Add an submission to the group""" + + group: Group = self.get_object() + + # Add submission to course + serializer = SubmissionAddSerializer( + data=request.data, context={"group": group, "request": request} + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(group=group) + + return Response({ + "message": gettext("group.success.submissions.add") + }) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 761496f1..4365b78c 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -10,6 +10,7 @@ from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer from api.serializers.group_serializer import GroupSerializer +from api.serializers.submission_serializer import SubmissionSerializer class ProjectViewSet(CreateModelMixin, @@ -22,7 +23,7 @@ class ProjectViewSet(CreateModelMixin, serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project - @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) def groups(self, request, **_): """Returns a list of groups for the given project""" # This automatically fetches the group from the URL. @@ -37,6 +38,23 @@ def groups(self, request, **_): return Response(serializer.data) + """ + @action(detail=True, permission_classes=[IsAdminUser]) + def submissions(self, request, **_): + # Returns a list of subbmisions for the given project + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + project = self.get_object() + submissions = project.submissions.all() + + # Serialize the group objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + + return Response(serializer.data) + """ + @groups.mapping.post def _create_groups(self, request, **_): """Create a number of groups for the project""" diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py index 870243da..7868eacf 100644 --- a/backend/api/views/user_view.py +++ b/backend/api/views/user_view.py @@ -1,9 +1,37 @@ -from rest_framework.viewsets import GenericViewSet -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from api.permissions.notification_permissions import NotificationPermission from authentication.models import User from authentication.serializers import UserSerializer +from notifications.models import Notification +from notifications.serializers import NotificationSerializer +from rest_framework.decorators import action +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.viewsets import GenericViewSet class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer + + @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) + def notifications(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + serializer = NotificationSerializer( + notifications, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action( + detail=True, + methods=["post"], + permission_classes=[NotificationPermission], + url_path="notifications/read", + ) + def read(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + notifications.update(is_read=True) + + return Response(status=HTTP_200_OK) diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.po b/backend/notifications/locale/en/LC_MESSAGES/django.po index 5db896e0..465520e9 100644 --- a/backend/notifications/locale/en/LC_MESSAGES/django.po +++ b/backend/notifications/locale/en/LC_MESSAGES/django.po @@ -17,6 +17,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Dear %(name)s\nYou have a new notification.\n%(title)s\n%(description)s\n\n- Ypovoli" # Score Added msgid "Title: Score added" msgstr "Score Added" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po index 4d9cb70f..5a854108 100644 --- a/backend/notifications/locale/nl/LC_MESSAGES/django.po +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -17,6 +17,9 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Beste %(name)s\nU heeft een nieuwe notificatie.\n%(title)s\n%(description)s\n\n- Ypovoli" # Score Added msgid "Title: Score added" msgstr "Score toegevoegd" diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py new file mode 100644 index 00000000..b5df6d18 --- /dev/null +++ b/backend/notifications/logic.py @@ -0,0 +1,105 @@ +import threading +from collections import defaultdict +from os import error +from smtplib import SMTPException +from typing import DefaultDict, Dict, List + +from celery import shared_task +from django.core import mail +from django.core.cache import cache +from django.utils.translation import gettext as _ +from notifications.models import Notification +from ypovoli.settings import EMAIL_CUSTOM + + +# Returns a dictionary with the title and description of the notification +def get_message_dict(notification: Notification) -> Dict[str, str]: + return { + "title": _(notification.template_id.title_key), + "description": _(notification.template_id.description_key) + % notification.arguments, + } + + +# Call the function after 60 seconds and no more than once in that period +def schedule_send_mails(): + if not cache.get("notifications_send_mails"): + cache.set("notifications_send_mails", True) + _send_mails.apply_async(countdown=60) + + +# Try to send one email and set the result +def _send_mail(mail: mail.EmailMessage, result: List[bool]): + try: + mail.send(fail_silently=False) + result[0] = True + except SMTPException: + result[0] = False + + +# Send all unsent emails +@shared_task +def _send_mails(): + # All notifications that need to be sent + notifications = Notification.objects.filter(is_sent=False) + # Dictionary with the number of errors for each email + errors: DefaultDict[str, int] = cache.get( + "notifications_send_mails_errors", defaultdict(int) + ) + + # No notifications to send + if notifications.count() == 0: + return + + # Connection with the mail server + connection = mail.get_connection() + + for notification in notifications: + message = get_message_dict(notification) + content = _("Email %(name)s %(title)s %(description)s") % { + "name": notification.user.username, + "title": message["title"], + "description": message["description"], + } + + # Construct the email + email = mail.EmailMessage( + subject=EMAIL_CUSTOM["subject"], + body=content, + from_email=EMAIL_CUSTOM["from"], + to=[notification.user.email], + connection=connection, + ) + + # Send the email with a timeout + result: List[bool] = [False] + thread = threading.Thread(target=_send_mail, args=(email, result)) + thread.start() + thread.join(timeout=EMAIL_CUSTOM["timeout"]) + + # Email failed to send + if thread.is_alive() or not result[0]: + # Increase the number of errors for the email + errors[notification.user.email] += 1 + # Mark notification as sent if the maximum number of errors is reached + if errors[notification.user.email] >= EMAIL_CUSTOM["max_errors"]: + errors.pop(notification.user.email) + notification.sent() + + continue + + # Email sent successfully + if notification.user.email in errors: + errors.pop(notification.user.email) + + # Mark the notification as sent + notification.sent() + + # Save the number of errors for each email + cache.set("notifications_send_mails_errors", errors) + + # Restart the process if there are any notifications left that were not sent + unsent_notifications = Notification.objects.filter(is_sent=False) + cache.set("notifications_send_mails", False) + if unsent_notifications.count() > 0: + schedule_send_mails() diff --git a/backend/notifications/models.py b/backend/notifications/models.py index d2827892..c9c2fbde 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -22,3 +22,8 @@ class Notification(models.Model): is_sent = models.BooleanField( default=False ) # Whether the notification has been sent (email) + + # Mark the notification as read + def sent(self): + self.is_sent = True + self.save() diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index 4a24cd59..d4c488ba 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -2,7 +2,7 @@ from typing import Dict, List from authentication.models import User -from django.utils.translation import gettext as _ +from notifications.logic import get_message_dict from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -57,10 +57,7 @@ def validate(self, data: Dict[str, str]) -> Dict[str, str]: # Get the message from the template and arguments def get_message(self, obj: Notification) -> Dict[str, str]: - return { - "title": _(obj.template_id.title_key), - "description": _(obj.template_id.description_key) % obj.arguments, - } + return get_message_dict(obj) class Meta: model = Notification diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 2ded382f..f8203f39 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -1,11 +1,13 @@ from __future__ import annotations from enum import Enum -from typing import Dict +from typing import Dict, List, Union from authentication.models import User +from django.db.models.query import QuerySet from django.dispatch import Signal, receiver from django.urls import reverse +from notifications.logic import schedule_send_mails from notifications.serializers import NotificationSerializer notification_create = Signal() @@ -13,21 +15,31 @@ @receiver(notification_create) def notification_creation( - type: NotificationType, user: User, arguments: Dict[str, str], **kwargs + type: NotificationType, + queryset: QuerySet[User], + arguments: Dict[str, str], + **kwargs, # Required by django ) -> bool: - serializer = NotificationSerializer( - data={ - "template_id": type.value, - "user": reverse("user-detail", kwargs={"pk": user.id}), - "arguments": arguments, - } - ) + data: List[Dict[str, Union[str, int, Dict[str, str]]]] = [] + + for user in queryset: + data.append( + { + "template_id": type.value, + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } + ) + + serializer = NotificationSerializer(data=data, many=True) if not serializer.is_valid(): return False serializer.save() + schedule_send_mails() + return True diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py index e80acd66..637600f5 100644 --- a/backend/notifications/urls.py +++ b/backend/notifications/urls.py @@ -1,6 +1 @@ -from django.urls import path -from notifications.views import NotificationView - -urlpatterns = [ - path("/", NotificationView.as_view(), name="notification-detail"), -] +urlpatterns = [] diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 5f4dd772..e69de29b 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import List - -from notifications.models import Notification -from notifications.serializers import NotificationSerializer -from rest_framework.permissions import BasePermission, IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK -from rest_framework.views import APIView - - -# TODO: Give admin access to everything -class NotificationPermission(BasePermission): - # The user can only access their own notifications - # An admin can access all notifications - def has_permission(self, request: Request, view: NotificationView) -> bool: - return view.kwargs.get("user_id") == request.user.id or request.user.is_staff - - -class NotificationView(APIView): - permission_classes: List[BasePermission] = [IsAuthenticated, NotificationPermission] - - def get(self, request: Request, user_id: str) -> Response: - notifications = Notification.objects.filter(user=user_id) - serializer = NotificationSerializer( - notifications, many=True, context={"request": request} - ) - - return Response(serializer.data) - - # Mark all notifications as read for the user - def post(self, request: Request, user_id: str) -> Response: - notifications = Notification.objects.filter(user=user_id) - notifications.update(is_read=True) - - return Response(status=HTTP_200_OK) diff --git a/backend/requirements.txt b/backend/requirements.txt index f0eec8cc..d1dab654 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,4 +6,6 @@ drf-yasg==1.21.7 requests==2.31.0 cas-client==1.0.0 psycopg2-binary==2.9.9 -djangorestframework-simplejwt==5.3.1 \ No newline at end of file +djangorestframework-simplejwt==5.3.1 +celery[redis]==5.3.6 +django-redis==5.4.0 \ No newline at end of file diff --git a/backend/ypovoli/__init__.py b/backend/ypovoli/__init__.py index e69de29b..5568b6d7 100644 --- a/backend/ypovoli/__init__.py +++ b/backend/ypovoli/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/backend/ypovoli/celery.py b/backend/ypovoli/celery.py new file mode 100644 index 00000000..5f2cac39 --- /dev/null +++ b/backend/ypovoli/celery.py @@ -0,0 +1,17 @@ +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + +app = Celery("ypovoli") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index f06ed720..1ba6ba6c 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,9 +10,10 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +import os from datetime import timedelta +from os import environ from pathlib import Path -import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -69,11 +70,9 @@ ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", - "rest_framework.authentication.SessionAuthentication" + "rest_framework.authentication.SessionAuthentication", ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ] + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], } SIMPLE_JWT = { @@ -90,8 +89,8 @@ # Application endpoints CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/auth/cas/echo" -API_ENDPOINT = "https://localhost:8080" +CAS_RESPONSE = "https://localhost:8080/api/auth/cas/echo" +API_ENDPOINT = "https://localhost:8080/api" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases @@ -134,3 +133,37 @@ }, }, ] + +EMAIL_HOST = "smtprelay.UGent.be" +EMAIL_PORT = 25 + +EMAIL_CUSTOM = { + "from": "ypovoli@ugent.be", + "subject": "[Ypovoli] New Notification", + "timeout": 2, + "max_errors": 3, +} + +REDIS_CUSTOM = { + "host": environ.get("REDIS_IP", "localhost"), + "port": environ.get("REDIS_PORT", 6379), + "password": environ.get("REDIS_PASSWORD", ""), + "db_django": 0, + "db_celery": 1, +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" + f"{REDIS_CUSTOM['db_django']}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +CELERY_BROKER_URL = ( + f"redis://:{REDIS_CUSTOM['password']}@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/" + f"{REDIS_CUSTOM['db_celery']}" +) diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index f3093cdc..0d786c39 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -33,18 +33,31 @@ urlpatterns = [ - # Base API endpoints. - path("", include("api.urls")), - # Authentication endpoints. - path("auth/", include("authentication.urls")), - path("notifications/", include("notifications.urls"), name="notifications"), - # Swagger documentation. path( - "swagger/", - schema_view.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - path( - "swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json" - ), + "api/", + include( + [ + # Base API endpoints. + path("", include("api.urls")), + # Authentication endpoints. + path("auth/", include("authentication.urls")), + path( + "notifications/", + include("notifications.urls"), + name="notifications", + ), + # Swagger documentation. + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + ] + ), + ) ] From d9e8917f34dff52dd5644e82179f5480db247b64 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 00:05:21 +0100 Subject: [PATCH 299/397] chore: enhance submission status --- backend/api/serializers/project_serializer.py | 6 +- backend/api/tests/test_project.py | 113 ++++++++++++++++-- backend/api/views/project_view.py | 17 ++- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 42b3cb48..e20ec0ca 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -76,9 +76,9 @@ def validate(self, data): class SubmissionStatusSerializer(serializers.Serializer): - groups_total = serializers.IntegerField(read_only=True) - # groups_submitted = serializers.IntegerField(read_only=True) # TODO : Wait for submissions to be implemented - # submissions_passed = serializers.IntegerField(read_only=True) + non_empty_groups = serializers.IntegerField(read_only=True) + groups_submitted = serializers.IntegerField(read_only=True) + submissions_passed = serializers.IntegerField(read_only=True) class SubmissionAddSerializer(SubmissionSerializer): diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index cf99e726..a68c5a27 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -6,6 +6,7 @@ from api.models.project import Project from api.models.course import Course from api.models.group import Group +from api.models.submission import Submission from api.models.teacher import Teacher from api.models.student import Student from api.models.checks import StructureCheck, ExtraCheck @@ -42,11 +43,34 @@ def create_project(name, description, visible, archived, days, course): course=course, ) + def create_group(project): """Create a Group with the given arguments.""" return Group.objects.create( - project=project + project=project, + ) + + +def create_submission(submission_number, group, structure_checks_passed): + """Create a Submission with the given arguments.""" + + return Submission.objects.create( + submission_number=submission_number, + group=group, + structure_checks_passed=structure_checks_passed, + ) + + +def create_student(id, first_name, last_name, email): + """Create a Student with the given arguments.""" + username = f"{first_name}_{last_name}" + return Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, ) @@ -507,8 +531,9 @@ def test_create_groups(self): # Assert that the groups were created self.assertEqual(project.groups.count(), 3) - def test_submission_status(self): - """Retrieve the submission status for a project.""" + + def test_submission_status_non_empty_groups(self): + """Submission status returns the correct amount of non empty groups participating in the corresponding project.""" course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test", @@ -530,22 +555,96 @@ def test_submission_status(self): # Add the teacher to the course course.teachers.add(self.user) + # Create example students + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Create example groups group1 = create_group(project=project) group2 = create_group(project=project) group3 = create_group(project=project) + # Add the students to some of the groups + group1.students.add(student1) + group3.students.add(student2) + response = self.client.get( reverse("project-submission-status", args=[str(project.id)]), follow=True ) - # TODO: Complete this test once submissions is implemented + self.assertEqual(response.status_code, 200) + + # Only two of the three created groups contain at least one student + self.assertEqual(response.data, { + "non_empty_groups": 2, + "groups_submitted": 0, + "submissions_passed": 0 + }) + + + def test_submission_status_groups_submitted_and_passed_checks(self): + """Retrieve the submission status for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-groups", args=[str(project.id)]), + follow=True + ) + + # Make sure you cannot retrieve the submission status for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + # Create example students + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + student3 = create_student( + id=3, first_name="Joe", last_name="Doe", email="Joe.doe@example.com" + ) + + # Create example groups + group1 = create_group(project=project) + group2 = create_group(project=project) + group3 = create_group(project=project) + + # Add students to the groups + group1.students.add(student1) + group2.students.add(student2) + group3.students.add(student3) + + # Create submissions for certain groups + create_submission(submission_number=1, group=group1, structure_checks_passed=True) + create_submission(submission_number=2, group=group3, structure_checks_passed=False) + + response = self.client.get( + reverse("project-submission-status", args=[str(project.id)]), + follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, { - "groups_total": 3, - # "groups_submitted": 0, - # "submissions_passed": 0 + "non_empty_groups": 3, + "groups_submitted": 2, + "submissions_passed": 1 }) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 7a50361d..08eb61d3 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group +from api.models.submission import Submission from ..models.project import Project from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer, SubmissionStatusSerializer @@ -107,21 +108,19 @@ def extra_checks(self, request, **_): def submission_status(self, request, **_): """Returns the current submission status for the given project This includes: + - The total amount of groups that contain at least one student - The amount of groups that have uploaded a submission - The amount of submissions that passed the basic tests - - The total amount of groups """ project = self.get_object() - groups_total = project.groups.count() - groups_submitted = None - submissions_passed = None - - # TODO: Once submissions is implemented, pass these arguments to the serializer as well + non_empty_groups = project.groups.filter(students__isnull=False).count() + groups_submitted = Submission.objects.filter(group__project=project).count() + submissions_passed = Submission.objects.filter(group__project=project, structure_checks_passed=True).count() serializer = SubmissionStatusSerializer({ - "groups_total": groups_total, - # "groups_submitted": groups_submitted, - # "submissions_passed": submissions_passed, + "non_empty_groups": non_empty_groups, + "groups_submitted": groups_submitted, + "submissions_passed": submissions_passed, }) return Response(serializer.data) From 8b9ea34277f0a0a63a173e9e8dccffe19d635568 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Tue, 12 Mar 2024 08:44:13 +0100 Subject: [PATCH 300/397] chore: startup structure_checks add --- backend/api/serializers/project_serializer.py | 26 +++++++++++++++---- backend/api/views/project_view.py | 20 ++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index dd564a33..3b2d6deb 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -5,6 +5,7 @@ from rest_framework.exceptions import ValidationError from api.models.submission import Submission, SubmissionFile from api.serializers.submission_serializer import SubmissionSerializer +from api.serializers.checks_serializer import StructureCheckSerializer class ProjectSerializer(serializers.ModelSerializer): @@ -52,11 +53,6 @@ class TeacherCreateGroupSerializer(serializers.Serializer): class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): - """ - # The validator needs the project context. - if "project" not in self.context: - raise ValidationError(gettext("project.error.context")) - """ group: Group = self.context["group"] project: Project = group.project @@ -72,3 +68,23 @@ def validate(self, data): raise ValidationError(gettext("project.error.submission.archived_project")) return data + + +class StructureCheckAddSerializer(StructureCheckSerializer): + def validate(self, data): + + project: Project = self.context["project"] + + """ + # Check if the project's deadline is not passed. + if project.deadline_passed(): + raise ValidationError(gettext("project.error.submission.past_project")) + + if not project.is_visible(): + raise ValidationError(gettext("project.error.submission.non_visible_project")) + + if project.is_archived(): + raise ValidationError(gettext("project.error.submission.archived_project")) + """ + + return data diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 4365b78c..e2116612 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -9,8 +9,10 @@ from ..models.project import Project from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer +from api.serializers.project_serializer import StructureCheckAddSerializer from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer +from rest_framework.request import Request class ProjectViewSet(CreateModelMixin, @@ -91,6 +93,24 @@ def structure_checks(self, request, **_): ) return Response(serializer.data) + @structure_checks.mapping.post + def _add_structure_check(self, request: Request, **_): + """Add an structure_check to the project""" + + project: Project = self.get_object() + + # Add submission to course + serializer = StructureCheckAddSerializer( + data=request.data, context={"project": project, "request": request} + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response({ + "message": gettext("project.success.structure_check.add") + }) + @action(detail=True, methods=["get"]) def extra_checks(self, request, **_): """Returns the extra checks for the given project""" From 6bcb75c12e5a54af4ea066c5fdae15eb7f422817 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 12 Mar 2024 11:09:22 +0100 Subject: [PATCH 301/397] chore: score visible if set in project --- ...sible_alter_project_start_date_and_more.py | 29 ++++++++++++ backend/api/models/project.py | 3 ++ backend/api/serializers/group_serializer.py | 9 ++++ backend/api/serializers/project_serializer.py | 1 + backend/api/tests/test_group.py | 47 ++++++++++++++++++- backend/api/tests/test_submission.py | 2 +- 6 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py diff --git a/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py b/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py new file mode 100644 index 00000000..dd13125d --- /dev/null +++ b/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.2 on 2024-03-12 09:49 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_alter_project_max_score'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='score_visible', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='project', + name='start_date', + field=models.DateTimeField(blank=True, default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='submission', + name='submission_number', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 0cecf456..5cd45ac4 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -35,6 +35,9 @@ class Project(models.Model): default=20 ) + # Score already visible to students + score_visible = models.BooleanField(default=False) + # Size of the groups than can be formed group_size = models.PositiveSmallIntegerField( blank=False, diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 35a61402..80822583 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -25,6 +25,15 @@ class Meta: model = Group fields = ["id", "project", "students", "score", "submissions"] + def to_representation(self, instance): + data = super().to_representation(instance) + + # Check if the score should be visible, if not exclude it from the representation + if not instance.project.score_visible: + data.pop('score', None) + + return data + def validate(self, data): # Make sure the score of the group is lower or equal to the maximum score group: Group = self.instance diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index e05397e2..66393edf 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -38,6 +38,7 @@ class Meta: "start_date", "deadline", "max_score", + "score_visible", "group_size", "structure_checks", "extra_checks", diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 16ffa2df..74abeee0 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -24,12 +24,12 @@ def create_course(name, academic_startyear, description=None, parent_course=None ) -def create_project(name, description, days, course, group_size=2, max_score=20): +def create_project(name, description, days, course, group_size=2, max_score=20, score_visible=True): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( name=name, description=description, deadline=deadline, course=course, - group_size=group_size, max_score=max_score + group_size=group_size, max_score=max_score, score_visible=score_visible ) @@ -86,6 +86,49 @@ def test_group_detail_view(self): self.assertEqual(content_json["project"], expected_project_url) self.assertEqual(content_json["score"], group.score) + def test_group_score_visibility(self): + """Only able to retrieve the score of a group if it is visible.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, score_visible=False + ) + group = create_group(project=project, score=10) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure that score is not included + self.assertNotIn("score", content_json) + + # Update that the score is visible + response = self.client.patch( + reverse("project-detail", args=[str(project.id)]), + {"score_visible": True}, + ) + + # Refresh the project from the database + project.refresh_from_db() + + # Make sure the project is updated + self.assertEqual(project.score_visible, True) + self.assertEqual(response.status_code, 200) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure the score is included now + self.assertIn("score", content_json) + def test_group_project(self): """Able to retrieve details of a single group.""" course = create_course(name="sel2", academic_startyear=2023) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index ec426a03..a9aa6c9f 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -28,7 +28,7 @@ def create_project(name, description, days, course): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) return Project.objects.create( - name=name, description=description, deadline=deadline, course=course + name=name, description=description, deadline=deadline, course=course, score_visible=True ) From 5fe5626ecbae5977102300b40919ede20062e2f5 Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 12 Mar 2024 11:37:12 +0100 Subject: [PATCH 302/397] chore: score not visible for students of other groups --- backend/api/serializers/group_serializer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 80822583..ddbaa158 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -1,6 +1,7 @@ from django.utils.translation import gettext from rest_framework import serializers from rest_framework.exceptions import ValidationError +from api.permissions.role_permissions import is_student from api.models.group import Group from api.models.student import Student from api.serializers.student_serializer import StudentIDSerializer @@ -28,9 +29,13 @@ class Meta: def to_representation(self, instance): data = super().to_representation(instance) - # Check if the score should be visible, if not exclude it from the representation - if not instance.project.score_visible: - data.pop('score', None) + # If you are not a student, you can always see the score + if is_student(self.context["request"].user): + # Student can not see the score if they are not part of the group, or it is not visible yet + if not instance.students.filter(id=self.context["request"].user.student.id).exists() or\ + not instance.project.score_visible: + + data.pop("score") return data From 50f2ebdc2502a09a79f5de3aa165090ea091d36a Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Tue, 12 Mar 2024 11:45:00 +0100 Subject: [PATCH 303/397] chore: test with student permissions --- backend/api/tests/test_group.py | 98 ++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index 74abeee0..63017054 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -86,49 +86,6 @@ def test_group_detail_view(self): self.assertEqual(content_json["project"], expected_project_url) self.assertEqual(content_json["score"], group.score) - def test_group_score_visibility(self): - """Only able to retrieve the score of a group if it is visible.""" - course = create_course(name="sel2", academic_startyear=2023) - - project = create_project( - name="Project 1", description="Description 1", days=7, course=course, score_visible=False - ) - group = create_group(project=project, score=10) - - response = self.client.get( - reverse("group-detail", args=[str(group.id)]), follow=True - ) - - self.assertEqual(response.status_code, 200) - - content_json = json.loads(response.content.decode("utf-8")) - - # Make sure that score is not included - self.assertNotIn("score", content_json) - - # Update that the score is visible - response = self.client.patch( - reverse("project-detail", args=[str(project.id)]), - {"score_visible": True}, - ) - - # Refresh the project from the database - project.refresh_from_db() - - # Make sure the project is updated - self.assertEqual(project.score_visible, True) - self.assertEqual(response.status_code, 200) - - response = self.client.get( - reverse("group-detail", args=[str(group.id)]), follow=True - ) - - self.assertEqual(response.status_code, 200) - content_json = json.loads(response.content.decode("utf-8")) - - # Make sure the score is included now - self.assertIn("score", content_json) - def test_group_project(self): """Able to retrieve details of a single group.""" course = create_course(name="sel2", academic_startyear=2023) @@ -539,3 +496,58 @@ def test_try_to_update_score_of_group(self): group.refresh_from_db() self.assertEqual(group.score, 10) + + def test_group_score_visibility(self): + """Only able to retrieve the score of a group if it is visible, and the student is part of the group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, score_visible=True + ) + group = create_group(project=project, score=10) + course.students.add(self.user) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure that score is not included, because the student is not part of the group + self.assertNotIn("score", content_json) + + # Add the student to the group + group.students.add(self.user) + + # Set the visibility of the score to False, to make sure the score is not included if it is not visible + project.score_visible = False + project.save() + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure that score is not included, because the teacher has set the visibility of the score to False + self.assertNotIn("score", content_json) + + # Update that the score is visible + project.score_visible = True + project.save() + + self.assertEqual(response.status_code, 200) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure the score is included now + self.assertIn("score", content_json) From 15914854e689df3b1038d341b09592827f1c6a4e Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 16:51:39 +0100 Subject: [PATCH 304/397] chore: update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6a9bedb8..38e61349 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ .env data/* -!data/nginx/nginx.conf +!data/nginx/nginx.*.conf !data/nginx/ssl/.gitkeep From f3f54c025a7463303ba94c5c7491fa696f8d72b9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 17:05:39 +0100 Subject: [PATCH 305/397] chore: added nginx conf --- .gitignore | 6 +++- data/nginx/nginx.dev.conf | 57 ++++++++++++++++++++++++++++++++++++++ data/nginx/nginx.prod.conf | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 data/nginx/nginx.dev.conf create mode 100644 data/nginx/nginx.prod.conf diff --git a/.gitignore b/.gitignore index 38e61349..e0ac2593 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ .tool-versions .env data/* +data/nginx/ssl/* +data/postres* +data/redis/* -!data/nginx/nginx.*.conf !data/nginx/ssl/.gitkeep +!data/production/* +!data/testing/* \ No newline at end of file diff --git a/data/nginx/nginx.dev.conf b/data/nginx/nginx.dev.conf new file mode 100644 index 00000000..7475f718 --- /dev/null +++ b/data/nginx/nginx.dev.conf @@ -0,0 +1,57 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:8080; + } + + upstream frontend { + server frontend:5173; + } + + server { + listen 80; + listen [::]:80; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 8080 ssl; + listen [::]:8080 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location /api/ { + proxy_pass https://backend$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + + location / { + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } +} \ No newline at end of file diff --git a/data/nginx/nginx.prod.conf b/data/nginx/nginx.prod.conf new file mode 100644 index 00000000..9d97bc96 --- /dev/null +++ b/data/nginx/nginx.prod.conf @@ -0,0 +1,57 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:8080; + } + + upstream frontend { + server frontend:3000; + } + + server { + listen 80; + listen [::]:80; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 8080 ssl; + listen [::]:8080 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location /api/ { + proxy_pass http://backend$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + + location / { + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } +} \ No newline at end of file From 1f0520c18151b4959989211be1ac663976f56a15 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 17:21:31 +0100 Subject: [PATCH 306/397] chore: add allowed host --- backend/ypovoli/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 7adda2b5..9c18bb2b 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -30,7 +30,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = environ.get("DJANGO_DEBUG", False) -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["sel2-7.ugent.be"] # Application definition From 5a3022e3668648b2137d903c36b25d2ef11ba572 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 17:23:28 +0100 Subject: [PATCH 307/397] chore: debug env --- backend/ypovoli/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 9c18bb2b..c6abe3d2 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -29,7 +29,7 @@ SECRET_KEY = environ.get("DJANGO_SECRET_KEY", "lnZZ2xHc6HjU5D85GDE3Nnu4CJsBnm") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = environ.get("DJANGO_DEBUG", False) +DEBUG = environ.get("DJANGO_DEBUG", "False").lower() in ["true", "1", "t"] ALLOWED_HOSTS = ["sel2-7.ugent.be"] From 26dc59790572064f7f2174ad1f042d0d8b673911 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 17:34:47 +0100 Subject: [PATCH 308/397] chore: different cas urls --- .dev.env | 1 + .prod.env | 1 + backend/ypovoli/settings.py | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.dev.env b/.dev.env index 890efd41..52cc4f46 100644 --- a/.dev.env +++ b/.dev.env @@ -13,6 +13,7 @@ REDIS_PORT=6379 DJANGO_SECRET_KEY= DJANGO_DEBUG=True +DJANGO_BASE_URL="https://localhost:8080" DJANGO_DB_ENGINE=django.db.backends.sqlite3 DJANGO_DB_NAME=${BACKEND_DIR}/db.sqlite3 DJANGO_REDIS_HOST=${REDIS_IP} diff --git a/.prod.env b/.prod.env index 7cf0fc6e..f560ab3e 100644 --- a/.prod.env +++ b/.prod.env @@ -19,6 +19,7 @@ REDIS_PORT=6379 DJANGO_SECRET_KEY= DJANGO_DEBUG=false +DJANGO_BASE_URL="https://sel2-7.ugent.be" DJANGO_DB_ENGINE=django.db.backends.postgresql DJANGO_DB_NAME=${POSTGRES_DB} DJANGO_DB_USER=${POSTGRES_USER} diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index c6abe3d2..a57ea885 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -88,9 +88,10 @@ # Application endpoints +BASE_URL = environ.get("DJANGO_BASE_URL", "https://localhost:8080") CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/api/auth/cas/echo" -API_ENDPOINT = "https://localhost:8080/api" +CAS_RESPONSE = f"{BASE_URL}/api/auth/cas/echo" +API_ENDPOINT = f"{BASE_URL}/api" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases From 4dadcd51152e4b52f523ada6014b1fd14cfda3ce Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 18:15:56 +0100 Subject: [PATCH 309/397] chore: support authentication --- .prod.env | 2 +- data/nginx/nginx.prod.conf | 7 +++++++ production.yml | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.prod.env b/.prod.env index f560ab3e..dbf0dcbb 100644 --- a/.prod.env +++ b/.prod.env @@ -19,7 +19,7 @@ REDIS_PORT=6379 DJANGO_SECRET_KEY= DJANGO_DEBUG=false -DJANGO_BASE_URL="https://sel2-7.ugent.be" +DJANGO_BASE_URL="https://sel2-7.ugent.be/auth" DJANGO_DB_ENGINE=django.db.backends.postgresql DJANGO_DB_NAME=${POSTGRES_DB} DJANGO_DB_USER=${POSTGRES_USER} diff --git a/data/nginx/nginx.prod.conf b/data/nginx/nginx.prod.conf index 9d97bc96..2518e7d4 100644 --- a/data/nginx/nginx.prod.conf +++ b/data/nginx/nginx.prod.conf @@ -46,6 +46,13 @@ http { proxy_redirect off; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } location / { proxy_pass http://frontend; diff --git a/production.yml b/production.yml index 4c416e8b..d0ab4212 100644 --- a/production.yml +++ b/production.yml @@ -36,7 +36,6 @@ services: ports: - 80:80 - 443:443 - - 8080:8080 volumes: - $DATADIR/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro From 8ff97c898c3e3c5502076ebdda8231c35c5c9583 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Tue, 12 Mar 2024 18:35:13 +0100 Subject: [PATCH 310/397] feat: give CAS attributes in debug mode --- backend/authentication/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 7c4f1d92..3005d86f 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -48,6 +48,7 @@ def validate(self, data): # Return access tokens for the now logged-in user. return { + "attributes": attributes, "access": str(AccessToken.for_user(user)), "refresh": str(RefreshToken.for_user(user)), } From 8dd295fa1b43c494010758f4c3773df380a3204d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 19:00:32 +0100 Subject: [PATCH 311/397] chore: cleanup --- .dev.env | 1 - backend/Dockerfile.dev | 3 +++ backend/notifications/logic.py | 1 - backend/ypovoli/settings.py | 2 +- data/nginx/nginx.prod.conf | 12 ------------ development.sh | 8 ++++++-- development.yml | 4 ++-- 7 files changed, 12 insertions(+), 19 deletions(-) diff --git a/.dev.env b/.dev.env index 52cc4f46..53902a7f 100644 --- a/.dev.env +++ b/.dev.env @@ -15,7 +15,6 @@ DJANGO_SECRET_KEY= DJANGO_DEBUG=True DJANGO_BASE_URL="https://localhost:8080" DJANGO_DB_ENGINE=django.db.backends.sqlite3 -DJANGO_DB_NAME=${BACKEND_DIR}/db.sqlite3 DJANGO_REDIS_HOST=${REDIS_IP} DJANGO_REDIS_PORT=${REDIS_PORT} DJANGO_REDIS_PASSWORD=${REDIS_PASSWORD} \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 0365d5f9..75a7bf99 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -11,3 +11,6 @@ COPY requirements.txt /code/ RUN pip install -r requirements.txt COPY . /code/ + +RUN ./setup.sh + diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index b5df6d18..d8999ad8 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -1,6 +1,5 @@ import threading from collections import defaultdict -from os import error from smtplib import SMTPException from typing import DefaultDict, Dict, List diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index a57ea885..19ddd545 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -30,7 +30,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = environ.get("DJANGO_DEBUG", "False").lower() in ["true", "1", "t"] -ALLOWED_HOSTS = ["sel2-7.ugent.be"] +ALLOWED_HOSTS = ["localhost", "sel2-7.ugent.be"] # Application definition diff --git a/data/nginx/nginx.prod.conf b/data/nginx/nginx.prod.conf index 2518e7d4..042f9859 100644 --- a/data/nginx/nginx.prod.conf +++ b/data/nginx/nginx.prod.conf @@ -20,18 +20,6 @@ http { } } - server { - listen 8080 ssl; - listen [::]:8080 ssl; - - ssl_certificate ssl/certificate.crt; - ssl_certificate_key ssl/private.key; - - location / { - return 301 https://$host$request_uri; - } - } - server { listen 443 ssl; listen [::]:443 ssl; diff --git a/development.sh b/development.sh index 210027f6..8b7780b4 100755 --- a/development.sh +++ b/development.sh @@ -1,6 +1,10 @@ +echo "Checking environment file..." + if ! [ -f .env ]; then - echo "Error: .env file does not exist." - exit 1 + cp .dev.env .env + read -s -p "Enter a random string for the django secret (just smash keyboard): " new_secret + sed -i "s/^DJANGO_SECRET_KEY=.*/DJANGO_SECRET_KEY=$new_secret/" .env + echo "Created environment file" fi echo "Checking for existing SSL certificates..." diff --git a/development.yml b/development.yml index 2723a878..7c093801 100644 --- a/development.yml +++ b/development.yml @@ -49,7 +49,7 @@ services: container_name: backend build: context: $BACKEND_DIR - dockerfile: Dockerfile + dockerfile: Dockerfile.dev command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" expose: - 8080 @@ -92,7 +92,7 @@ services: ipv4_address: $REDIS_IP expose: - $REDIS_PORT - entrypoint: redis-server --appendonly yes --requirepass $REDIS_PASSWORD --maxmemory 512mb --maxmemory-policy allkeys-lru + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - $DATADIR/redis:/data \ No newline at end of file From 7cf69eae8b67bb243c188b030b2a16e021e28392 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 12 Mar 2024 19:31:08 +0100 Subject: [PATCH 312/397] fix: swagger integration --- .gitignore | 2 ++ backend/requirements.txt | 3 ++- backend/setup.sh | 3 +++ backend/ypovoli/settings.py | 5 ++++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e0ac2593..7cc4571b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ data/nginx/ssl/* data/postres* data/redis/* +backend/staticfiles/* + !data/nginx/ssl/.gitkeep !data/production/* !data/testing/* \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index addd2c6c..7f09f5fe 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,4 +9,5 @@ psycopg2-binary==2.9.9 djangorestframework-simplejwt==5.3.1 celery[redis]==5.3.6 django-redis==5.4.0 -gunicorn==21.2.0 \ No newline at end of file +gunicorn==21.2.0 +whitenoise==5.3.0 diff --git a/backend/setup.sh b/backend/setup.sh index 4fa590be..1f6fa4c8 100755 --- a/backend/setup.sh +++ b/backend/setup.sh @@ -10,4 +10,7 @@ python manage.py loaddata */fixtures/* > /dev/null echo "Compiling translations..." django-admin compilemessages > /dev/null +echo "Generating Swagger documentation..." +echo "yes" | python manage.py collectstatic > /dev/null + echo "Done" \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 19ddd545..aa3665fc 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -62,6 +62,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", ] REST_FRAMEWORK = { @@ -120,7 +121,9 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ -STATIC_URL = "static/" +STATIC_URL = "api/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") +STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" TEMPLATES = [ { From bfa13d422b759d9f9c112f8096bc1ee5a337bbf6 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 7 Mar 2024 14:57:25 +0100 Subject: [PATCH 313/397] chore: rebase --- backend/ypovoli/handlers.py | 14 ++++++++ .../ypovoli/locale/nl/LC_MESSAGES/django.po | 35 +++++++++++++++++++ backend/ypovoli/settings.py | 2 ++ 3 files changed, 51 insertions(+) create mode 100644 backend/ypovoli/handlers.py create mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py new file mode 100644 index 00000000..1a027b7a --- /dev/null +++ b/backend/ypovoli/handlers.py @@ -0,0 +1,14 @@ +from rest_framework.views import exception_handler +from django.utils.translation import gettext_lazy as _ + + +def translate_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response.status_code == 401: + response.data['detail'] = _('Given token not valid for any token type') + + if response.status_code == 404: + response.data['detail'] = _('Not found.') + + return response diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..6e497baf --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,35 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-07 14:34+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ypovoli/handlers.py:9 +msgid "Given token not valid for any token type" +msgstr "Gegeven token is niet geldig voor eender welk token type" + +#: ypovoli/handlers.py:12 +msgid "Not found." +msgstr "Niet gevonden." + +#: ypovoli/settings.py:113 +msgid "English" +msgstr "Engels" + +#: ypovoli/settings.py:113 +msgid "Dutch" +msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index aa3665fc..e958f70a 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +from django.utils.translation import gettext_lazy as _ import os from datetime import timedelta from os import environ @@ -74,6 +75,7 @@ "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + 'EXCEPTION_HANDLERE': 'ypovoli' } SIMPLE_JWT = { From d66ecc517c4f09cec6e071cc4a77d8f90b253a11 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:35:45 +0100 Subject: [PATCH 314/397] chore: (Dutch) folder structure helper + course serializer + group serializer + project serializer translation #100 --- backend/ypovoli/handlers.py | 14 -------- .../ypovoli/locale/nl/LC_MESSAGES/django.po | 35 ------------------- backend/ypovoli/settings.py | 2 +- 3 files changed, 1 insertion(+), 50 deletions(-) delete mode 100644 backend/ypovoli/handlers.py delete mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py deleted file mode 100644 index 1a027b7a..00000000 --- a/backend/ypovoli/handlers.py +++ /dev/null @@ -1,14 +0,0 @@ -from rest_framework.views import exception_handler -from django.utils.translation import gettext_lazy as _ - - -def translate_exception_handler(exc, context): - response = exception_handler(exc, context) - - if response.status_code == 401: - response.data['detail'] = _('Given token not valid for any token type') - - if response.status_code == 404: - response.data['detail'] = _('Not found.') - - return response diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po deleted file mode 100644 index 6e497baf..00000000 --- a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po +++ /dev/null @@ -1,35 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-07 14:34+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#: ypovoli/handlers.py:9 -msgid "Given token not valid for any token type" -msgstr "Gegeven token is niet geldig voor eender welk token type" - -#: ypovoli/handlers.py:12 -msgid "Not found." -msgstr "Niet gevonden." - -#: ypovoli/settings.py:113 -msgid "English" -msgstr "Engels" - -#: ypovoli/settings.py:113 -msgid "Dutch" -msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index e958f70a..21e59fd6 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -75,7 +75,6 @@ "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], - 'EXCEPTION_HANDLERE': 'ypovoli' } SIMPLE_JWT = { @@ -118,6 +117,7 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True +LANGUAGES = [("en", _("languages.en")), ("nl", _("languages.nl"))] USE_L10N = False USE_TZ = True From 545a8589082bdee3a5dffb4189a3efafe994f0bd Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:38:56 +0100 Subject: [PATCH 315/397] chore: (Dutch) folder structure helper + course serializer + group serializer + project serializer translation (now for real) #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 118 ++++++++++++++++++++ backend/api/locale/nl/LC_MESSAGES/django.po | 118 ++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 backend/api/locale/en/LC_MESSAGES/django.po create mode 100644 backend/api/locale/nl/LC_MESSAGES/django.po diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..e5fea38b --- /dev/null +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 16:59+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: api/helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "" + +#: api/helpers/check_folder_structure.py:145 +#: api/helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "" + +#: api/helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "" + +#: api/helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "" + +#: api/helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "" + +#: api/serializers/course_serializer.py:58 +#: api/serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "" + +#: api/serializers/course_serializer.py:64 +msgid "courses.error.students.already_present" +msgstr "" + +#: api/serializers/course_serializer.py:68 +#: api/serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "" + +#: api/serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "" + +#: api/serializers/group_serializer.py:33 +msgid "group.errors.score_exceeds_max" +msgstr "" + +#: api/serializers/group_serializer.py:43 +#: api/serializers/group_serializer.py:69 +msgid "group.error.context" +msgstr "" + +#: api/serializers/group_serializer.py:51 +msgid "group.errors.full" +msgstr "" + +#: api/serializers/group_serializer.py:55 +msgid "group.errors.not_in_course" +msgstr "" + +#: api/serializers/group_serializer.py:59 +msgid "group.errors.already_in_group" +msgstr "" + +#: api/serializers/group_serializer.py:77 +msgid "group.errors.not_present" +msgstr "" + +#: api/serializers/project_serializer.py:60 +msgid "project.error.submission.past_project" +msgstr "" + +#: api/serializers/project_serializer.py:63 +msgid "project.error.submission.non_visible_project" +msgstr "" + +#: api/serializers/project_serializer.py:66 +msgid "project.error.submission.archived_project" +msgstr "" + +#: api/views/course_view.py:57 api/views/course_view.py:76 +msgid "courses.success.assistants.add" +msgstr "" + +#: api/views/course_view.py:110 api/views/course_view.py:130 +msgid "courses.success.students.add" +msgstr "" + +#: api/views/group_view.py:73 +msgid "group.success.student.add" +msgstr "" + +#: api/views/group_view.py:92 +msgid "group.success.student.remove" +msgstr "" + +#: api/views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "" + +#: api/views/project_view.py:79 +msgid "project.success.groups.created" +msgstr "" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..57154573 --- /dev/null +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 16:56+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: api/helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "Bestanden met een verboden extensie zijn gevonden in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:145 +#: api/helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." + +#: api/helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "Er is geen enkel bestand met een bepaalde extensie die verplicht is in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." + +#: api/serializers/course_serializer.py:58 +#: api/serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "De opleiding is niet meegeleverd als context bij het aanmaken van StudentJoinSerializer." + +#: api/serializers/course_serializer.py:64 +msgid "courses.error.students.already_present" +msgstr "De student die men probeert toe te voegen aan een opleiding, is daar al aan toegevoegd." + +#: api/serializers/course_serializer.py:68 +#: api/serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." + +#: api/serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "De student bevindt zich niet in de opleiding." + +#: api/serializers/group_serializer.py:33 +msgid "group.errors.score_exceeds_max" +msgstr "De score van de groep is groter dan de maximum score." + +#: api/serializers/group_serializer.py:43 +#: api/serializers/group_serializer.py:69 +msgid "group.error.context" +msgstr "De groep is niet meegegeven als context waar dat nodig is." + +#: api/serializers/group_serializer.py:51 +msgid "group.errors.full" +msgstr "De groep is al vol." + +#: api/serializers/group_serializer.py:55 +msgid "group.errors.not_in_course" +msgstr "De student bevindt zich niet in de opleiding waartoe het project hoort." + +#: api/serializers/group_serializer.py:59 +msgid "group.errors.already_in_group" +msgstr "De student bevindt zich al in deze groep." + +#: api/serializers/group_serializer.py:77 +msgid "group.errors.not_present" +msgstr "De student bevindt zich niet in de groep." + +#: api/serializers/project_serializer.py:60 +msgid "project.error.submission.past_project" +msgstr "De uiterste inleverdatum is gepasseerd voor het project." + +#: api/serializers/project_serializer.py:63 +msgid "project.error.submission.non_visible_project" +msgstr "Het project is niet zichtbaar." + +#: api/serializers/project_serializer.py:66 +msgid "project.error.submission.archived_project" +msgstr "Het project is gearchiveerd." + +#: api/views/course_view.py:57 api/views/course_view.py:76 +msgid "courses.success.assistants.add" +msgstr "" + +#: api/views/course_view.py:110 api/views/course_view.py:130 +msgid "courses.success.students.add" +msgstr "" + +#: api/views/group_view.py:73 +msgid "group.success.student.add" +msgstr "" + +#: api/views/group_view.py:92 +msgid "group.success.student.remove" +msgstr "" + +#: api/views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "" + +#: api/views/project_view.py:79 +msgid "project.success.groups.created" +msgstr "" From ead419c9fd04c525f3162df8e4940b0ce327a4ae Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:43:06 +0100 Subject: [PATCH 316/397] test: make authentication test names more professional --- .../tests/test_authentication_serializer.py | 10 +++++----- .../authentication/tests/test_authentication_views.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index 736ed2f0..c931970e 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -13,10 +13,10 @@ WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" ID = "1234" -USERNAME = "ddickwd" +USERNAME = "dackers" EMAIL = "dummy@dummy.be" FIRST_NAME = "Dummy" -LAST_NAME = "McDickwad" +LAST_NAME = "Ackers" class UserSerializerModelTests(TestCase): @@ -152,7 +152,7 @@ def test_new_user_activates_user_created_signal(self): be sent when trying to validate the token.""" mock = Mock() - user_created.connect(mock, dispatch_uid="STDsAllAround") + user_created.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) @@ -169,7 +169,7 @@ def test_old_user_does_not_activate_user_created_signal(self): be sent when trying to validate the token.""" mock = Mock() - user_created.connect(mock, dispatch_uid="STDsAllAround") + user_created.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) @@ -186,7 +186,7 @@ def test_login_signal(self): the token, then the user_login signal should be sent. """ mock = Mock() - user_login.connect(mock, dispatch_uid="STDsAllAround") + user_login.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 8e7a4155..cb5734c8 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -11,10 +11,10 @@ def setUp(self): """Create a user and generate a token for that user""" self.user = User.objects.create(**{ "id": "1234", - "username": "ddickwd", + "username": "dackers", "email": "dummy@dummy.com", "first_name": "dummy", - "last_name": "McDickwad", + "last_name": "Ackers", }) self.token = f'Bearer {AccessToken().for_user(self.user)}' @@ -54,10 +54,10 @@ class TestLogoutView(APITestCase): def setUp(self): user_data = { "id": "1234", - "username": "ddickwd", + "username": "dackers", "email": "dummy@dummy.com", "first_name": "dummy", - "last_name": "McDickwad", + "last_name": "Ackers", } self.user = User.objects.create(**user_data) From 543903f175bed0c053ec015895923a46dd9b2d7e Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:43:44 +0100 Subject: [PATCH 317/397] chore: add available language translations #100 --- .../ypovoli/locale/en/LC_MESSAGES/django.po | 26 +++++++++++++++++++ .../ypovoli/locale/nl/LC_MESSAGES/django.po | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 backend/ypovoli/locale/en/LC_MESSAGES/django.po create mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/locale/en/LC_MESSAGES/django.po b/backend/ypovoli/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..b14e202b --- /dev/null +++ b/backend/ypovoli/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "English" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Dutch" diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..513be77b --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "Engels" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Nederlands" From 46746141c014f773e17e06e07bbb00f4cb291f26 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 21:55:29 +0100 Subject: [PATCH 318/397] chore: course_view dutch translation + adaption to translation keys #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 73 +++++++------- backend/api/locale/nl/LC_MESSAGES/django.po | 98 +++++++++++-------- backend/api/serializers/project_serializer.py | 6 +- backend/api/views/course_view.py | 4 +- backend/api/views/group_view.py | 4 +- 5 files changed, 102 insertions(+), 83 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index e5fea38b..8b2226d6 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 16:59+0100\n" +"POT-Creation-Date: 2024-03-12 21:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,102 +17,107 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: api/helpers/check_folder_structure.py:141 + +#: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" msgstr "" -#: api/helpers/check_folder_structure.py:145 -#: api/helpers/check_folder_structure.py:196 +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" msgstr "" -#: api/helpers/check_folder_structure.py:148 +#: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" -#: api/helpers/check_folder_structure.py:175 +#: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "" -#: api/helpers/check_folder_structure.py:195 +#: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "" -#: api/serializers/course_serializer.py:58 -#: api/serializers/course_serializer.py:77 +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" msgstr "" -#: api/serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" msgstr "" -#: api/serializers/course_serializer.py:68 -#: api/serializers/course_serializer.py:87 +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" msgstr "" -#: api/serializers/course_serializer.py:83 +#: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "" -#: api/serializers/group_serializer.py:33 +#: serializers/group_serializer.py:33 msgid "group.errors.score_exceeds_max" msgstr "" -#: api/serializers/group_serializer.py:43 -#: api/serializers/group_serializer.py:69 +#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 msgid "group.error.context" msgstr "" -#: api/serializers/group_serializer.py:51 +#: serializers/group_serializer.py:51 msgid "group.errors.full" msgstr "" -#: api/serializers/group_serializer.py:55 +#: serializers/group_serializer.py:55 msgid "group.errors.not_in_course" msgstr "" -#: api/serializers/group_serializer.py:59 +#: serializers/group_serializer.py:59 msgid "group.errors.already_in_group" msgstr "" -#: api/serializers/group_serializer.py:77 +#: serializers/group_serializer.py:77 msgid "group.errors.not_present" msgstr "" -#: api/serializers/project_serializer.py:60 -msgid "project.error.submission.past_project" +#: serializers/project_serializer.py:60 +msgid "project.error.submissions.past_project" msgstr "" -#: api/serializers/project_serializer.py:63 -msgid "project.error.submission.non_visible_project" +#: serializers/project_serializer.py:63 +msgid "project.error.submissions.non_visible_project" msgstr "" -#: api/serializers/project_serializer.py:66 -msgid "project.error.submission.archived_project" +#: serializers/project_serializer.py:66 +msgid "project.error.submissions.archived_project" msgstr "" -#: api/views/course_view.py:57 api/views/course_view.py:76 +#: views/course_view.py:57 msgid "courses.success.assistants.add" msgstr "" -#: api/views/course_view.py:110 api/views/course_view.py:130 +#: views/course_view.py:76 +msgid "courses.success.assistants.remove" +msgstr "" + +#: views/course_view.py:110 msgid "courses.success.students.add" msgstr "" -#: api/views/group_view.py:73 -msgid "group.success.student.add" +#: views/course_view.py:130 +msgid "courses.success.students.remove" +msgstr "" + +#: views/group_view.py:73 +msgid "group.success.students.add" msgstr "" -#: api/views/group_view.py:92 -msgid "group.success.student.remove" +#: views/group_view.py:92 +msgid "group.success.students.remove" msgstr "" -#: api/views/group_view.py:111 +#: views/group_view.py:111 msgid "group.success.submissions.add" msgstr "" -#: api/views/project_view.py:79 +#: views/project_view.py:79 msgid "project.success.groups.created" msgstr "" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 57154573..bcd1945c 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 16:56+0100\n" +"POT-Creation-Date: 2024-03-12 21:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,102 +17,116 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: api/helpers/check_folder_structure.py:141 + +#: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" -msgstr "Bestanden met een verboden extensie zijn gevonden in het ingediende zip-bestand." +msgstr "" +"Bestanden met een verboden extensie zijn gevonden in het ingediende zip-" +"bestand." -#: api/helpers/check_folder_structure.py:145 -#: api/helpers/check_folder_structure.py:196 +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." -#: api/helpers/check_folder_structure.py:148 +#: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" -msgstr "Er is geen enkel bestand met een bepaalde extensie die verplicht is in het ingediende zip-bestand." +msgstr "" +"Er is geen enkel bestand met een bepaalde extensie die verplicht is in het " +"ingediende zip-bestand." -#: api/helpers/check_folder_structure.py:175 +#: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." -#: api/helpers/check_folder_structure.py:195 +#: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." -#: api/serializers/course_serializer.py:58 -#: api/serializers/course_serializer.py:77 +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "De opleiding is niet meegeleverd als context bij het aanmaken van StudentJoinSerializer." +msgstr "" +"De opleiding is niet meegeleverd als context bij het aanmaken van " +"StudentJoinSerializer." -#: api/serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "De student die men probeert toe te voegen aan een opleiding, is daar al aan toegevoegd." +msgstr "" +"De student die men probeert toe te voegen aan een opleiding, is daar al aan " +"toegevoegd." -#: api/serializers/course_serializer.py:68 -#: api/serializers/course_serializer.py:87 +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." -#: api/serializers/course_serializer.py:83 +#: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: api/serializers/group_serializer.py:33 +#: serializers/group_serializer.py:33 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: api/serializers/group_serializer.py:43 -#: api/serializers/group_serializer.py:69 +#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: api/serializers/group_serializer.py:51 +#: serializers/group_serializer.py:51 msgid "group.errors.full" msgstr "De groep is al vol." -#: api/serializers/group_serializer.py:55 +#: serializers/group_serializer.py:55 msgid "group.errors.not_in_course" -msgstr "De student bevindt zich niet in de opleiding waartoe het project hoort." +msgstr "" +"De student bevindt zich niet in de opleiding waartoe het project hoort." -#: api/serializers/group_serializer.py:59 +#: serializers/group_serializer.py:59 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in deze groep." -#: api/serializers/group_serializer.py:77 +#: serializers/group_serializer.py:77 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: api/serializers/project_serializer.py:60 -msgid "project.error.submission.past_project" +#: serializers/project_serializer.py:60 +msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum is gepasseerd voor het project." -#: api/serializers/project_serializer.py:63 -msgid "project.error.submission.non_visible_project" +#: serializers/project_serializer.py:63 +msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: api/serializers/project_serializer.py:66 -msgid "project.error.submission.archived_project" +#: serializers/project_serializer.py:66 +msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: api/views/course_view.py:57 api/views/course_view.py:76 +#: views/course_view.py:57 msgid "courses.success.assistants.add" -msgstr "" +msgstr "De assistent is succesvol toegevoegd aan de opleiding." + +#: views/course_view.py:76 +msgid "courses.success.assistants.remove" +msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: api/views/course_view.py:110 api/views/course_view.py:130 +#: views/course_view.py:110 msgid "courses.success.students.add" -msgstr "" +msgstr "De student is succesvol toegevoegd aan de opleiding." -#: api/views/group_view.py:73 -msgid "group.success.student.add" -msgstr "" +#: views/course_view.py:130 +msgid "courses.success.students.remove" +msgstr "De student is succesvol verwijderd uit de opleiding." + +#: views/group_view.py:73 +msgid "group.success.students.add" +msgstr "De student i" -#: api/views/group_view.py:92 -msgid "group.success.student.remove" +#: views/group_view.py:92 +msgid "group.success.students.remove" msgstr "" -#: api/views/group_view.py:111 +#: views/group_view.py:111 msgid "group.success.submissions.add" msgstr "" -#: api/views/project_view.py:79 +#: views/project_view.py:79 msgid "project.success.groups.created" msgstr "" diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 66393edf..e96ddc66 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -58,12 +58,12 @@ def validate(self, data): # Check if the project's deadline is not passed. if project.deadline_passed(): - raise ValidationError(gettext("project.error.submission.past_project")) + raise ValidationError(gettext("project.error.submissions.past_project")) if not project.is_visible(): - raise ValidationError(gettext("project.error.submission.non_visible_project")) + raise ValidationError(gettext("project.error.submissions.non_visible_project")) if project.is_archived(): - raise ValidationError(gettext("project.error.submission.archived_project")) + raise ValidationError(gettext("project.error.submissions.archived_project")) return data diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index cea2b180..d10fc26e 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -73,7 +73,7 @@ def _remove_assistant(self, request: Request, **_): ) return Response({ - "message": gettext("courses.success.assistants.add") + "message": gettext("courses.success.assistants.remove") }) @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) @@ -127,7 +127,7 @@ def _remove_student(self, request: Request, **_): ) return Response({ - "message": gettext("courses.success.students.add") + "message": gettext("courses.success.students.remove") }) @action(detail=True, methods=["get"]) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 632bb7ff..ef82db0d 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -70,7 +70,7 @@ def _add_student(self, request, **_): ) return Response({ - "message": gettext("group.success.student.add"), + "message": gettext("group.success.students.add"), }) @students.mapping.delete @@ -89,7 +89,7 @@ def _remove_student(self, request, **_): ) return Response({ - "message": gettext("group.success.student.remove"), + "message": gettext("group.success.students.remove"), }) @submissions.mapping.post From d5b865e9ade95432173ad943ad98942eadaed4bc Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 21:57:46 +0100 Subject: [PATCH 319/397] chore: group_view and project_view Dutch translation #100 --- backend/api/locale/nl/LC_MESSAGES/django.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index bcd1945c..28757ed3 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -117,16 +117,16 @@ msgstr "De student is succesvol verwijderd uit de opleiding." #: views/group_view.py:73 msgid "group.success.students.add" -msgstr "De student i" +msgstr "De student is succesvol toegevoegd aan de groep." #: views/group_view.py:92 msgid "group.success.students.remove" -msgstr "" +msgstr "De student is succesvol verwijderd uit de groep." #: views/group_view.py:111 msgid "group.success.submissions.add" -msgstr "" +msgstr "De indiening is succesvol toegevoegd aan de groep." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "" +msgstr "De groepp is succesvol toegevoegd aan het project." From 6b9408b91c7553aa9f2e7aad32db13a754f84c6a Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:06:10 +0100 Subject: [PATCH 320/397] chore: locked groups --- ...roups_alter_project_start_date_and_more.py | 28 +++++++++ backend/api/models/project.py | 12 ++++ backend/api/serializers/group_serializer.py | 8 +++ backend/api/tests/test_project.py | 61 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py diff --git a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py new file mode 100644 index 00000000..9f9232d5 --- /dev/null +++ b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-03-12 20:25 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0005_alter_project_max_score"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="locked_groups", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="start_date", + field=models.DateTimeField(blank=True, default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="submission", + name="submission_number", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 0cecf456..ddb90bea 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -20,6 +20,9 @@ class Project(models.Model): # Project archived archived = models.BooleanField(default=False) + # Locked groups + locked_groups = models.BooleanField(default=False) + start_date = models.DateTimeField( # The default value is the current date and time default=timezone.now, @@ -81,6 +84,15 @@ def toggle_archived(self): self.archived = not self.archived self.save() + def is_groups_locked(self): + """Returns True if a participating groups are locked.""" + return self.locked_groups + + def toggle_groups_locked(self): + """Toggles the locke state of the groups related to the project.""" + self.locked_groups = not self.locked_groups + self.save() + def increase_deadline(self, days): self.deadline = self.deadline + timedelta(days=days) self.save() diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 35a61402..f718638e 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -46,6 +46,10 @@ def validate(self, data): group: Group = self.context["group"] student: Student = data["student_id"] + # Make sure a student can't join if groups are locked + if group.project.is_groups_locked(): + raise ValidationError(gettext("group.errors.locked")) + # Make sure the group is not already full if group.is_full(): raise ValidationError(gettext("group.errors.full")) @@ -76,4 +80,8 @@ def validate(self, data): if not group.students.filter(id=student.id).exists(): raise ValidationError(gettext("group.errors.not_present")) + # Make sure a student can't leave if groups are locked + if group.project.is_groups_locked(): + raise ValidationError(gettext("group.errors.locked")) + return data diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index a68c5a27..bd221262 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -133,6 +133,26 @@ def test_toggle_archived(self): past_project.toggle_archived() self.assertIs(past_project.archived, True) + def test_toggle_locked_groups(self): + """ + toggle the locked state of the project groups. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + course=course, + ) + self.assertIs(past_project.locked_groups, False) + past_project.toggle_groups_locked() + self.assertIs(past_project.locked_groups, True) + past_project.toggle_groups_locked() + self.assertIs(past_project.locked_groups, False) + + def test_start_date_Project_not_in_past(self): """ unable to create a project as a teacher/admin if the start date lies within the past. @@ -158,6 +178,7 @@ def test_start_date_Project_not_in_past(self): # Should not work since the start date lies in the past self.assertEqual(response.status_code, 400) + def test_deadline_Project_before_start_date(self): """ unable to create a project as a teacher/admin if the deadline lies before the start date. @@ -201,6 +222,7 @@ def test_deadline_approaching_in_with_past_Project(self): ) self.assertIs(past_project.deadline_approaching_in(), False) + def test_deadline_approaching_in_with_future_Project_within_time(self): """ deadline_approaching_in() returns True for Projects whose Deadline @@ -482,6 +504,45 @@ def test_project_extra_checks(self): self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) + def test_cant_join_locked_groups(self): + """Should not be able to add a student to a group if the groups are locked.""" + course = create_course(id=3, name="sel2", academic_startyear=2023) + + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + + # Lock the groups + project.locked_groups = True + project.save() + + group = create_group(project=project) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Should not work since the groups are locked + self.assertEqual(response.status_code, 400) + + # Make sure the student is not in the group now + self.assertFalse(group.students.filter(id=student.id).exists()) + + + class ProjectModelTestsAsTeacher(APITestCase): def setUp(self) -> None: self.user = Teacher.objects.create( From 9364a342f6861bea220da4ea44227a4041c90d09 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:28:54 +0100 Subject: [PATCH 321/397] chore: extra test for locked groups --- backend/api/tests/test_project.py | 35 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index bd221262..d0bb59a9 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -516,22 +516,33 @@ def test_cant_join_locked_groups(self): days=7, course=course, ) - student = create_student( + + # Create example students + student1 = create_student( id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" ) + student2 = create_student( + id=7, first_name="Jane", last_name="Doe", email="Jane.Doe@gmail.com" + ) + + # Add these student to the course + course.students.add(student1) + course.students.add(student2) + + # Create an exmample group + group = create_group(project=project) - # Add this student to the course - course.students.add(student) + # Already add one student to the group + group.students.add(student1) # Lock the groups project.locked_groups = True project.save() - group = create_group(project=project) - + # Try to add a student to the group response = self.client.post( reverse("group-students", args=[str(group.id)]), - {"student_id": student.id}, + {"student_id": student2.id}, follow=True, ) @@ -539,7 +550,17 @@ def test_cant_join_locked_groups(self): self.assertEqual(response.status_code, 400) # Make sure the student is not in the group now - self.assertFalse(group.students.filter(id=student.id).exists()) + self.assertFalse(group.students.filter(id=student2.id).exists()) + + # Try to remove a student from the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student1.id}, + follow=True, + ) + + # Make sure the student is still in the group now + self.assertTrue(group.students.filter(id=student1.id).exists()) From 0db0959b7d48354e065f387797800fea2b25f888 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:43:12 +0100 Subject: [PATCH 322/397] fix: linting errors --- backend/.flake8 | 2 +- backend/api/serializers/project_serializer.py | 1 + backend/api/tests/test_project.py | 101 +++++++----------- backend/ypovoli/settings.py | 2 +- 4 files changed, 43 insertions(+), 63 deletions(-) diff --git a/backend/.flake8 b/backend/.flake8 index 9c2d9f85..146023ca 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -3,7 +3,7 @@ # Ignore unused imports ignore = F401 -max-line-length = 120 +max-line-length = 122 max-complexity = 10 diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 2014907a..6046ddea 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -78,6 +78,7 @@ class SubmissionStatusSerializer(serializers.Serializer): groups_submitted = serializers.IntegerField(read_only=True) submissions_passed = serializers.IntegerField(read_only=True) + class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): group: Group = self.context["group"] diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index d0bb59a9..c8a50c82 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -90,9 +90,7 @@ def create_structure_check(id, name, project, obligated_extensions, blocked_exte class ProjectModelTests(APITestCase): def setUp(self) -> None: - self.client.force_authenticate( - User.get_dummy_admin() - ) + self.client.force_authenticate(User.get_dummy_admin()) def test_toggle_visible(self): """ @@ -152,7 +150,6 @@ def test_toggle_locked_groups(self): past_project.toggle_groups_locked() self.assertIs(past_project.locked_groups, False) - def test_start_date_Project_not_in_past(self): """ unable to create a project as a teacher/admin if the start date lies within the past. @@ -166,19 +163,16 @@ def test_start_date_Project_not_in_past(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": timezone.now() + timezone.timedelta(days=1) + "deadline": timezone.now() + timezone.timedelta(days=1), } response = self.client.post( - reverse("course-projects", args=[course.id]), - data=project_data, - follow=True + reverse("course-projects", args=[course.id]), data=project_data, follow=True ) # Should not work since the start date lies in the past self.assertEqual(response.status_code, 400) - def test_deadline_Project_before_start_date(self): """ unable to create a project as a teacher/admin if the deadline lies before the start date. @@ -193,19 +187,16 @@ def test_deadline_Project_before_start_date(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": deadline + "deadline": deadline, } response = self.client.post( - reverse("course-projects", args=[course.id]), - data=project_data, - follow=True + reverse("course-projects", args=[course.id]), data=project_data, follow=True ) # Should not work since deadline is before the start date self.assertEqual(response.status_code, 400) - def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -222,7 +213,6 @@ def test_deadline_approaching_in_with_past_Project(self): ) self.assertIs(past_project.deadline_approaching_in(), False) - def test_deadline_approaching_in_with_future_Project_within_time(self): """ deadline_approaching_in() returns True for Projects whose Deadline @@ -303,8 +293,7 @@ def test_project_exists(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -340,8 +329,7 @@ def test_project_course(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -398,8 +386,7 @@ def test_project_structure_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -475,8 +462,7 @@ def test_project_extra_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -498,11 +484,14 @@ def test_project_extra_checks(self): content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) - self.assertEqual(content_json["project"], settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project.id)] - )) - self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) - + self.assertEqual( + content_json["project"], + settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]), + ) + self.assertEqual( + content_json["run_script"], + settings.TESTING_BASE_LINK + checks.run_script.url, + ) def test_cant_join_locked_groups(self): """Should not be able to add a student to a group if the groups are locked.""" @@ -563,7 +552,6 @@ def test_cant_join_locked_groups(self): self.assertTrue(group.students.filter(id=student1.id).exists()) - class ProjectModelTestsAsTeacher(APITestCase): def setUp(self) -> None: self.user = Teacher.objects.create( @@ -571,12 +559,10 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Test@gmail.com" + email="Test@gmail.com", ) - self.client.force_authenticate( - self.user - ) + self.client.force_authenticate(self.user) def test_create_groups(self): """Able to create groups for a project.""" @@ -613,9 +599,8 @@ def test_create_groups(self): # Assert that the groups were created self.assertEqual(project.groups.count(), 3) - def test_submission_status_non_empty_groups(self): - """Submission status returns the correct amount of non empty groups participating in the corresponding project.""" + """Submission status returns the correct amount of non empty groups participating in the project.""" course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test", @@ -627,8 +612,7 @@ def test_submission_status_non_empty_groups(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), - follow=True + reverse("project-groups", args=[str(project.id)]), follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -647,7 +631,6 @@ def test_submission_status_non_empty_groups(self): # Create example groups group1 = create_group(project=project) - group2 = create_group(project=project) group3 = create_group(project=project) # Add the students to some of the groups @@ -655,19 +638,16 @@ def test_submission_status_non_empty_groups(self): group3.students.add(student2) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), - follow=True + reverse("project-submission-status", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) # Only two of the three created groups contain at least one student - self.assertEqual(response.data, { - "non_empty_groups": 2, - "groups_submitted": 0, - "submissions_passed": 0 - }) - + self.assertEqual( + response.data, + {"non_empty_groups": 2, "groups_submitted": 0, "submissions_passed": 0}, + ) def test_submission_status_groups_submitted_and_passed_checks(self): """Retrieve the submission status for a project.""" @@ -682,8 +662,7 @@ def test_submission_status_groups_submitted_and_passed_checks(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), - follow=True + reverse("project-groups", args=[str(project.id)]), follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -714,20 +693,22 @@ def test_submission_status_groups_submitted_and_passed_checks(self): group3.students.add(student3) # Create submissions for certain groups - create_submission(submission_number=1, group=group1, structure_checks_passed=True) - create_submission(submission_number=2, group=group3, structure_checks_passed=False) + create_submission( + submission_number=1, group=group1, structure_checks_passed=True + ) + create_submission( + submission_number=2, group=group3, structure_checks_passed=False + ) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), - follow=True + reverse("project-submission-status", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, { - "non_empty_groups": 3, - "groups_submitted": 2, - "submissions_passed": 1 - }) + self.assertEqual( + response.data, + {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 1}, + ) class ProjectModelTestsAsStudent(APITestCase): @@ -737,12 +718,10 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Bobke.Peeters@gmail.com" + email="Bobke.Peeters@gmail.com", ) - self.client.force_authenticate( - self.user - ) + self.client.force_authenticate(self.user) def test_try_to_create_groups(self): """Not able to create groups for a project.""" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index ae8319cc..aa3665fc 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -169,4 +169,4 @@ } CELERY_BROKER_URL = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" -CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" \ No newline at end of file +CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" From fbdec72758ee6fda0c8bd230b4cae9c38a1e881a Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:45:26 +0100 Subject: [PATCH 323/397] fix: conflicting migrations --- .../migrations/0005_alter_project_max_score.py | 18 ------------------ ...groups_alter_project_max_score_and_more.py} | 9 +++++++-- 2 files changed, 7 insertions(+), 20 deletions(-) delete mode 100644 backend/api/migrations/0005_alter_project_max_score.py rename backend/api/migrations/{0006_project_locked_groups_alter_project_start_date_and_more.py => 0005_project_locked_groups_alter_project_max_score_and_more.py} (70%) diff --git a/backend/api/migrations/0005_alter_project_max_score.py b/backend/api/migrations/0005_alter_project_max_score.py deleted file mode 100644 index faec3a5f..00000000 --- a/backend/api/migrations/0005_alter_project_max_score.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-06 14:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0004_submission_structure_checks_passed_extrachecksresult'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='max_score', - field=models.PositiveSmallIntegerField(default=20), - ), - ] diff --git a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py b/backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py similarity index 70% rename from backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py rename to backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py index 9f9232d5..1b75f36b 100644 --- a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py +++ b/backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-03-12 20:25 +# Generated by Django 5.0.2 on 2024-03-12 21:45 import django.utils.timezone from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("api", "0005_alter_project_max_score"), + ("api", "0004_submission_structure_checks_passed_extrachecksresult"), ] operations = [ @@ -15,6 +15,11 @@ class Migration(migrations.Migration): name="locked_groups", field=models.BooleanField(default=False), ), + migrations.AlterField( + model_name="project", + name="max_score", + field=models.PositiveSmallIntegerField(default=20), + ), migrations.AlterField( model_name="project", name="start_date", From 17d74a9171a774c90185de883a638ca45ddf9265 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:47:26 +0100 Subject: [PATCH 324/397] Revert "fix: linting errors" This reverts commit 0db0959b7d48354e065f387797800fea2b25f888. --- backend/.flake8 | 2 +- backend/api/serializers/project_serializer.py | 1 - backend/api/tests/test_project.py | 101 +++++++++++------- backend/ypovoli/settings.py | 2 +- 4 files changed, 63 insertions(+), 43 deletions(-) diff --git a/backend/.flake8 b/backend/.flake8 index 146023ca..9c2d9f85 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -3,7 +3,7 @@ # Ignore unused imports ignore = F401 -max-line-length = 122 +max-line-length = 120 max-complexity = 10 diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 6046ddea..2014907a 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -78,7 +78,6 @@ class SubmissionStatusSerializer(serializers.Serializer): groups_submitted = serializers.IntegerField(read_only=True) submissions_passed = serializers.IntegerField(read_only=True) - class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): group: Group = self.context["group"] diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index c8a50c82..d0bb59a9 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -90,7 +90,9 @@ def create_structure_check(id, name, project, obligated_extensions, blocked_exte class ProjectModelTests(APITestCase): def setUp(self) -> None: - self.client.force_authenticate(User.get_dummy_admin()) + self.client.force_authenticate( + User.get_dummy_admin() + ) def test_toggle_visible(self): """ @@ -150,6 +152,7 @@ def test_toggle_locked_groups(self): past_project.toggle_groups_locked() self.assertIs(past_project.locked_groups, False) + def test_start_date_Project_not_in_past(self): """ unable to create a project as a teacher/admin if the start date lies within the past. @@ -163,16 +166,19 @@ def test_start_date_Project_not_in_past(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": timezone.now() + timezone.timedelta(days=1), + "deadline": timezone.now() + timezone.timedelta(days=1) } response = self.client.post( - reverse("course-projects", args=[course.id]), data=project_data, follow=True + reverse("course-projects", args=[course.id]), + data=project_data, + follow=True ) # Should not work since the start date lies in the past self.assertEqual(response.status_code, 400) + def test_deadline_Project_before_start_date(self): """ unable to create a project as a teacher/admin if the deadline lies before the start date. @@ -187,16 +193,19 @@ def test_deadline_Project_before_start_date(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": deadline, + "deadline": deadline } response = self.client.post( - reverse("course-projects", args=[course.id]), data=project_data, follow=True + reverse("course-projects", args=[course.id]), + data=project_data, + follow=True ) # Should not work since deadline is before the start date self.assertEqual(response.status_code, 400) + def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -213,6 +222,7 @@ def test_deadline_approaching_in_with_past_Project(self): ) self.assertIs(past_project.deadline_approaching_in(), False) + def test_deadline_approaching_in_with_future_Project_within_time(self): """ deadline_approaching_in() returns True for Projects whose Deadline @@ -293,7 +303,8 @@ def test_project_exists(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), follow=True + reverse("project-detail", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) @@ -329,7 +340,8 @@ def test_project_course(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), follow=True + reverse("project-detail", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) @@ -386,7 +398,8 @@ def test_project_structure_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), follow=True + reverse("project-detail", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) @@ -462,7 +475,8 @@ def test_project_extra_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), follow=True + reverse("project-detail", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) @@ -484,14 +498,11 @@ def test_project_extra_checks(self): content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) - self.assertEqual( - content_json["project"], - settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]), - ) - self.assertEqual( - content_json["run_script"], - settings.TESTING_BASE_LINK + checks.run_script.url, - ) + self.assertEqual(content_json["project"], settings.TESTING_BASE_LINK + reverse( + "project-detail", args=[str(project.id)] + )) + self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) + def test_cant_join_locked_groups(self): """Should not be able to add a student to a group if the groups are locked.""" @@ -552,6 +563,7 @@ def test_cant_join_locked_groups(self): self.assertTrue(group.students.filter(id=student1.id).exists()) + class ProjectModelTestsAsTeacher(APITestCase): def setUp(self) -> None: self.user = Teacher.objects.create( @@ -559,10 +571,12 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Test@gmail.com", + email="Test@gmail.com" ) - self.client.force_authenticate(self.user) + self.client.force_authenticate( + self.user + ) def test_create_groups(self): """Able to create groups for a project.""" @@ -599,8 +613,9 @@ def test_create_groups(self): # Assert that the groups were created self.assertEqual(project.groups.count(), 3) + def test_submission_status_non_empty_groups(self): - """Submission status returns the correct amount of non empty groups participating in the project.""" + """Submission status returns the correct amount of non empty groups participating in the corresponding project.""" course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test", @@ -612,7 +627,8 @@ def test_submission_status_non_empty_groups(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), follow=True + reverse("project-groups", args=[str(project.id)]), + follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -631,6 +647,7 @@ def test_submission_status_non_empty_groups(self): # Create example groups group1 = create_group(project=project) + group2 = create_group(project=project) group3 = create_group(project=project) # Add the students to some of the groups @@ -638,16 +655,19 @@ def test_submission_status_non_empty_groups(self): group3.students.add(student2) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), follow=True + reverse("project-submission-status", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) # Only two of the three created groups contain at least one student - self.assertEqual( - response.data, - {"non_empty_groups": 2, "groups_submitted": 0, "submissions_passed": 0}, - ) + self.assertEqual(response.data, { + "non_empty_groups": 2, + "groups_submitted": 0, + "submissions_passed": 0 + }) + def test_submission_status_groups_submitted_and_passed_checks(self): """Retrieve the submission status for a project.""" @@ -662,7 +682,8 @@ def test_submission_status_groups_submitted_and_passed_checks(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), follow=True + reverse("project-groups", args=[str(project.id)]), + follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -693,22 +714,20 @@ def test_submission_status_groups_submitted_and_passed_checks(self): group3.students.add(student3) # Create submissions for certain groups - create_submission( - submission_number=1, group=group1, structure_checks_passed=True - ) - create_submission( - submission_number=2, group=group3, structure_checks_passed=False - ) + create_submission(submission_number=1, group=group1, structure_checks_passed=True) + create_submission(submission_number=2, group=group3, structure_checks_passed=False) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), follow=True + reverse("project-submission-status", args=[str(project.id)]), + follow=True ) self.assertEqual(response.status_code, 200) - self.assertEqual( - response.data, - {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 1}, - ) + self.assertEqual(response.data, { + "non_empty_groups": 3, + "groups_submitted": 2, + "submissions_passed": 1 + }) class ProjectModelTestsAsStudent(APITestCase): @@ -718,10 +737,12 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Bobke.Peeters@gmail.com", + email="Bobke.Peeters@gmail.com" ) - self.client.force_authenticate(self.user) + self.client.force_authenticate( + self.user + ) def test_try_to_create_groups(self): """Not able to create groups for a project.""" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index aa3665fc..ae8319cc 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -169,4 +169,4 @@ } CELERY_BROKER_URL = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" -CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" +CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" \ No newline at end of file From 65094ab3d49d5ab7cce6debfde3dc6fb56cc5062 Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 22:55:45 +0100 Subject: [PATCH 325/397] fix: should fix server tests problem --- .github/workflows/backend-linting.yaml | 2 +- .github/workflows/backend-tests.yaml | 2 +- backend/.flake8 | 2 +- .../0005_alter_project_max_score.py | 18 ++++ ...oups_alter_project_start_date_and_more.py} | 9 +- backend/api/serializers/project_serializer.py | 1 + backend/api/tests/test_project.py | 101 +++++++----------- backend/ypovoli/settings.py | 2 +- 8 files changed, 65 insertions(+), 72 deletions(-) create mode 100644 backend/api/migrations/0005_alter_project_max_score.py rename backend/api/migrations/{0005_project_locked_groups_alter_project_max_score_and_more.py => 0006_project_locked_groups_alter_project_start_date_and_more.py} (70%) diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml index d145985d..e3a7c8e5 100644 --- a/.github/workflows/backend-linting.yaml +++ b/.github/workflows/backend-linting.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index c8ec08da..6131fa13 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 diff --git a/backend/.flake8 b/backend/.flake8 index 9c2d9f85..91d939c2 100644 --- a/backend/.flake8 +++ b/backend/.flake8 @@ -3,7 +3,7 @@ # Ignore unused imports ignore = F401 -max-line-length = 120 +max-line-length = 125 max-complexity = 10 diff --git a/backend/api/migrations/0005_alter_project_max_score.py b/backend/api/migrations/0005_alter_project_max_score.py new file mode 100644 index 00000000..faec3a5f --- /dev/null +++ b/backend/api/migrations/0005_alter_project_max_score.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-03-06 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_submission_structure_checks_passed_extrachecksresult'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='max_score', + field=models.PositiveSmallIntegerField(default=20), + ), + ] diff --git a/backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py similarity index 70% rename from backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py rename to backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py index 1b75f36b..9f9232d5 100644 --- a/backend/api/migrations/0005_project_locked_groups_alter_project_max_score_and_more.py +++ b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-03-12 21:45 +# Generated by Django 5.0.2 on 2024-03-12 20:25 import django.utils.timezone from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("api", "0004_submission_structure_checks_passed_extrachecksresult"), + ("api", "0005_alter_project_max_score"), ] operations = [ @@ -15,11 +15,6 @@ class Migration(migrations.Migration): name="locked_groups", field=models.BooleanField(default=False), ), - migrations.AlterField( - model_name="project", - name="max_score", - field=models.PositiveSmallIntegerField(default=20), - ), migrations.AlterField( model_name="project", name="start_date", diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 2014907a..6046ddea 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -78,6 +78,7 @@ class SubmissionStatusSerializer(serializers.Serializer): groups_submitted = serializers.IntegerField(read_only=True) submissions_passed = serializers.IntegerField(read_only=True) + class SubmissionAddSerializer(SubmissionSerializer): def validate(self, data): group: Group = self.context["group"] diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index d0bb59a9..c8a50c82 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -90,9 +90,7 @@ def create_structure_check(id, name, project, obligated_extensions, blocked_exte class ProjectModelTests(APITestCase): def setUp(self) -> None: - self.client.force_authenticate( - User.get_dummy_admin() - ) + self.client.force_authenticate(User.get_dummy_admin()) def test_toggle_visible(self): """ @@ -152,7 +150,6 @@ def test_toggle_locked_groups(self): past_project.toggle_groups_locked() self.assertIs(past_project.locked_groups, False) - def test_start_date_Project_not_in_past(self): """ unable to create a project as a teacher/admin if the start date lies within the past. @@ -166,19 +163,16 @@ def test_start_date_Project_not_in_past(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": timezone.now() + timezone.timedelta(days=1) + "deadline": timezone.now() + timezone.timedelta(days=1), } response = self.client.post( - reverse("course-projects", args=[course.id]), - data=project_data, - follow=True + reverse("course-projects", args=[course.id]), data=project_data, follow=True ) # Should not work since the start date lies in the past self.assertEqual(response.status_code, 400) - def test_deadline_Project_before_start_date(self): """ unable to create a project as a teacher/admin if the deadline lies before the start date. @@ -193,19 +187,16 @@ def test_deadline_Project_before_start_date(self): "visible": True, "archived": False, "start_date": start_date, - "deadline": deadline + "deadline": deadline, } response = self.client.post( - reverse("course-projects", args=[course.id]), - data=project_data, - follow=True + reverse("course-projects", args=[course.id]), data=project_data, follow=True ) # Should not work since deadline is before the start date self.assertEqual(response.status_code, 400) - def test_deadline_approaching_in_with_past_Project(self): """ deadline_approaching_in() returns False for Projects whose Deadline @@ -222,7 +213,6 @@ def test_deadline_approaching_in_with_past_Project(self): ) self.assertIs(past_project.deadline_approaching_in(), False) - def test_deadline_approaching_in_with_future_Project_within_time(self): """ deadline_approaching_in() returns True for Projects whose Deadline @@ -303,8 +293,7 @@ def test_project_exists(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -340,8 +329,7 @@ def test_project_course(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -398,8 +386,7 @@ def test_project_structure_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -475,8 +462,7 @@ def test_project_extra_checks(self): ) response = self.client.get( - reverse("project-detail", args=[str(project.id)]), - follow=True + reverse("project-detail", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) @@ -498,11 +484,14 @@ def test_project_extra_checks(self): content_json = json.loads(response.content.decode("utf-8"))[0] self.assertEqual(int(content_json["id"]), checks.id) - self.assertEqual(content_json["project"], settings.TESTING_BASE_LINK + reverse( - "project-detail", args=[str(project.id)] - )) - self.assertEqual(content_json["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) - + self.assertEqual( + content_json["project"], + settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]), + ) + self.assertEqual( + content_json["run_script"], + settings.TESTING_BASE_LINK + checks.run_script.url, + ) def test_cant_join_locked_groups(self): """Should not be able to add a student to a group if the groups are locked.""" @@ -563,7 +552,6 @@ def test_cant_join_locked_groups(self): self.assertTrue(group.students.filter(id=student1.id).exists()) - class ProjectModelTestsAsTeacher(APITestCase): def setUp(self) -> None: self.user = Teacher.objects.create( @@ -571,12 +559,10 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Test@gmail.com" + email="Test@gmail.com", ) - self.client.force_authenticate( - self.user - ) + self.client.force_authenticate(self.user) def test_create_groups(self): """Able to create groups for a project.""" @@ -613,9 +599,8 @@ def test_create_groups(self): # Assert that the groups were created self.assertEqual(project.groups.count(), 3) - def test_submission_status_non_empty_groups(self): - """Submission status returns the correct amount of non empty groups participating in the corresponding project.""" + """Submission status returns the correct amount of non empty groups participating in the project.""" course = create_course(id=3, name="test course", academic_startyear=2024) project = create_project( name="test", @@ -627,8 +612,7 @@ def test_submission_status_non_empty_groups(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), - follow=True + reverse("project-groups", args=[str(project.id)]), follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -647,7 +631,6 @@ def test_submission_status_non_empty_groups(self): # Create example groups group1 = create_group(project=project) - group2 = create_group(project=project) group3 = create_group(project=project) # Add the students to some of the groups @@ -655,19 +638,16 @@ def test_submission_status_non_empty_groups(self): group3.students.add(student2) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), - follow=True + reverse("project-submission-status", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) # Only two of the three created groups contain at least one student - self.assertEqual(response.data, { - "non_empty_groups": 2, - "groups_submitted": 0, - "submissions_passed": 0 - }) - + self.assertEqual( + response.data, + {"non_empty_groups": 2, "groups_submitted": 0, "submissions_passed": 0}, + ) def test_submission_status_groups_submitted_and_passed_checks(self): """Retrieve the submission status for a project.""" @@ -682,8 +662,7 @@ def test_submission_status_groups_submitted_and_passed_checks(self): ) response = self.client.get( - reverse("project-groups", args=[str(project.id)]), - follow=True + reverse("project-groups", args=[str(project.id)]), follow=True ) # Make sure you cannot retrieve the submission status for a project that is not yours @@ -714,20 +693,22 @@ def test_submission_status_groups_submitted_and_passed_checks(self): group3.students.add(student3) # Create submissions for certain groups - create_submission(submission_number=1, group=group1, structure_checks_passed=True) - create_submission(submission_number=2, group=group3, structure_checks_passed=False) + create_submission( + submission_number=1, group=group1, structure_checks_passed=True + ) + create_submission( + submission_number=2, group=group3, structure_checks_passed=False + ) response = self.client.get( - reverse("project-submission-status", args=[str(project.id)]), - follow=True + reverse("project-submission-status", args=[str(project.id)]), follow=True ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, { - "non_empty_groups": 3, - "groups_submitted": 2, - "submissions_passed": 1 - }) + self.assertEqual( + response.data, + {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 1}, + ) class ProjectModelTestsAsStudent(APITestCase): @@ -737,12 +718,10 @@ def setUp(self) -> None: first_name="Bobke", last_name="Peeters", username="bpeeters", - email="Bobke.Peeters@gmail.com" + email="Bobke.Peeters@gmail.com", ) - self.client.force_authenticate( - self.user - ) + self.client.force_authenticate(self.user) def test_try_to_create_groups(self): """Not able to create groups for a project.""" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index ae8319cc..aa3665fc 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -169,4 +169,4 @@ } CELERY_BROKER_URL = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" -CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" \ No newline at end of file +CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" From add8e538441b788b60dd6c29de9e974475da933e Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 23:00:47 +0100 Subject: [PATCH 326/397] build: migrate when testing --- .github/workflows/backend-linting.yaml | 2 +- .github/workflows/backend-tests.yaml | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml index e3a7c8e5..d145985d 100644 --- a/.github/workflows/backend-linting.yaml +++ b/.github/workflows/backend-linting.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 6131fa13..eaaa253d 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 @@ -21,5 +21,9 @@ jobs: python -m pip install --upgrade pip pip install flake8 pip install -r ./backend/requirements.txt + - name: Make migrations + run: | + python ./backend/manage.py makemigrations + python ./backend/manage.py migrate - name: Execute tests run: cd backend; python manage.py test From 835c3d5c04265f1e3c5990c52fc68ff2dd9a5b8b Mon Sep 17 00:00:00 2001 From: francis Date: Tue, 12 Mar 2024 23:04:36 +0100 Subject: [PATCH 327/397] fix: merge makemigrations --- .github/workflows/backend-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index eaaa253d..12adead5 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -23,7 +23,7 @@ jobs: pip install -r ./backend/requirements.txt - name: Make migrations run: | - python ./backend/manage.py makemigrations + python ./backend/manage.py makemigrations --merge python ./backend/manage.py migrate - name: Execute tests run: cd backend; python manage.py test From bb9029ccdc2572a199b8e44c51a8f693ff5f285a Mon Sep 17 00:00:00 2001 From: francis Date: Wed, 13 Mar 2024 07:43:25 +0100 Subject: [PATCH 328/397] fix: conflicting migrations from development --- .github/workflows/backend-tests.yaml | 4 ---- ...ocked_groups_alter_project_start_date_and_more.py | 10 ---------- backend/api/migrations/0007_merge_20240313_0639.py | 12 ++++++++++++ 3 files changed, 12 insertions(+), 14 deletions(-) create mode 100644 backend/api/migrations/0007_merge_20240313_0639.py diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 12adead5..c8ec08da 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -21,9 +21,5 @@ jobs: python -m pip install --upgrade pip pip install flake8 pip install -r ./backend/requirements.txt - - name: Make migrations - run: | - python ./backend/manage.py makemigrations --merge - python ./backend/manage.py migrate - name: Execute tests run: cd backend; python manage.py test diff --git a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py index 9f9232d5..af36053b 100644 --- a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py +++ b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py @@ -15,14 +15,4 @@ class Migration(migrations.Migration): name="locked_groups", field=models.BooleanField(default=False), ), - migrations.AlterField( - model_name="project", - name="start_date", - field=models.DateTimeField(blank=True, default=django.utils.timezone.now), - ), - migrations.AlterField( - model_name="submission", - name="submission_number", - field=models.PositiveIntegerField(blank=True, null=True), - ), ] diff --git a/backend/api/migrations/0007_merge_20240313_0639.py b/backend/api/migrations/0007_merge_20240313_0639.py new file mode 100644 index 00000000..d95bfa44 --- /dev/null +++ b/backend/api/migrations/0007_merge_20240313_0639.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.2 on 2024-03-13 06:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0006_project_locked_groups_alter_project_start_date_and_more"), + ("api", "0006_project_score_visible_alter_project_start_date_and_more"), + ] + + operations = [] From 55eca3d3c3c3fc291557e84d08c377602849a1be Mon Sep 17 00:00:00 2001 From: Bram Meir Date: Wed, 13 Mar 2024 12:53:35 +0100 Subject: [PATCH 329/397] chore: faculty permissions --- backend/api/permissions/faculty_permissions.py | 17 +++++++++++++++++ backend/api/views/faculty_view.py | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 backend/api/permissions/faculty_permissions.py diff --git a/backend/api/permissions/faculty_permissions.py b/backend/api/permissions/faculty_permissions.py new file mode 100644 index 00000000..5c63a5c5 --- /dev/null +++ b/backend/api/permissions/faculty_permissions.py @@ -0,0 +1,17 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class FacultyPermission(BasePermission): + """Permission class for faculty related endpoints""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general faculty endpoint.""" + + # The general faculty endpoint that lists all faculties is accessible for any role. + if request.method in SAFE_METHODS: + return True + + # We only allow admins to create, update, and delete faculties. + return False diff --git a/backend/api/views/faculty_view.py b/backend/api/views/faculty_view.py index 446ac45b..fc8c71ca 100644 --- a/backend/api/views/faculty_view.py +++ b/backend/api/views/faculty_view.py @@ -1,8 +1,11 @@ from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser from authentication.models import Faculty +from api.permissions.faculty_permissions import FacultyPermission from ..serializers.faculty_serializer import facultySerializer class FacultyViewSet(viewsets.ModelViewSet): queryset = Faculty.objects.all() serializer_class = facultySerializer + permission_classes = [IsAdminUser | FacultyPermission] From 9fde752ecf473a5885f310ccc6c7db04390eb29b Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 12:57:04 +0100 Subject: [PATCH 330/397] chore: English translation zip checks + course_serializer + group_serializer #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 50 +++++++++++---------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 8b2226d6..183a0594 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 21:52+0100\n" +"POT-Creation-Date: 2024-03-13 12:56+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,73 +20,77 @@ msgstr "" #: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" -msgstr "" +msgstr "The submitted zip file contains a file with a non-allowed extension." #: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" -msgstr "" +msgstr "The submitted zip file succeeds in all checks." #: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" +"The submitted zip file doesn't have any file with a certain file extension " +"that's obligated." #: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" -msgstr "" +msgstr "An obligated directory was not found in the submitted zip file." #: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "" +"There was a directory found in the submitted zip file, which was not asked " +"for." #: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "" +msgstr "The course is not supplied in the context." #: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "" +msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "" +msgstr "The course is not from this year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" -msgstr "" +msgstr "The student is not present in the course." -#: serializers/group_serializer.py:33 +#: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" -msgstr "" +msgstr "The score exceeds the group's max score." -#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 msgid "group.error.context" -msgstr "" +msgstr "The group is not supplied in the context." -#: serializers/group_serializer.py:51 +#: serializers/group_serializer.py:65 msgid "group.errors.full" -msgstr "" +msgstr "This group is already full." -#: serializers/group_serializer.py:55 +#: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" -msgstr "" +msgstr "The student is not present in the related course." -#: serializers/group_serializer.py:59 +#: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" -msgstr "" +msgstr "The student is already in the group." -#: serializers/group_serializer.py:77 +#: serializers/group_serializer.py:91 msgid "group.errors.not_present" -msgstr "" +msgstr "The student is currently not in this group." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" msgstr "" -#: serializers/project_serializer.py:63 +#: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" msgstr "" -#: serializers/project_serializer.py:66 +#: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" msgstr "" From 174ddff7442f9c202b67cc79ad093a1a727d35b1 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 12:59:09 +0100 Subject: [PATCH 331/397] chore: English translation project_serializer #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 183a0594..fa5481f1 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -84,15 +84,15 @@ msgstr "The student is currently not in this group." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" -msgstr "" +msgstr "The deadline of the project has already passed." #: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" -msgstr "" +msgstr "The project is currently in a non-visible state." #: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" -msgstr "" +msgstr "The project is archived." #: views/course_view.py:57 msgid "courses.success.assistants.add" From 8b65cd74a2387e697081ef8f77cee424bfd37e07 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:02:59 +0100 Subject: [PATCH 332/397] chore: English translation course_view + group_view + project_view #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index fa5481f1..0f18f3e9 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -96,32 +96,32 @@ msgstr "The project is archived." #: views/course_view.py:57 msgid "courses.success.assistants.add" -msgstr "" +msgstr "The assistant was successfully added to the course." #: views/course_view.py:76 msgid "courses.success.assistants.remove" -msgstr "" +msgstr "The assistant was successfully removed from the course." #: views/course_view.py:110 msgid "courses.success.students.add" -msgstr "" +msgstr "The student was successfully added to the course." #: views/course_view.py:130 msgid "courses.success.students.remove" -msgstr "" +msgstr "The student was successfully removed from the course." #: views/group_view.py:73 msgid "group.success.students.add" -msgstr "" +msgstr "The student was successfully added to the group." #: views/group_view.py:92 msgid "group.success.students.remove" -msgstr "" +msgstr "The student was successfully removed from the group." #: views/group_view.py:111 msgid "group.success.submissions.add" -msgstr "" +msgstr "The submission was successfully added to the group." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "" +msgstr "A group was successfully created for the project." From c773823eae4c7bff6f7892eebf9405e11ccb1c03 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:03:16 +0100 Subject: [PATCH 333/397] chore: slight adjustments #100 --- backend/api/locale/nl/LC_MESSAGES/django.po | 20 ++++++++++---------- backend/api/models/checks.py | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 28757ed3..0f71f78a 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 21:52+0100\n" +"POT-Creation-Date: 2024-03-13 13:02+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -62,40 +62,40 @@ msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: serializers/group_serializer.py:33 +#: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:51 +#: serializers/group_serializer.py:65 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:55 +#: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:59 +#: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in deze groep." -#: serializers/group_serializer.py:77 +#: serializers/group_serializer.py:91 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum is gepasseerd voor het project." -#: serializers/project_serializer.py:63 +#: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/project_serializer.py:66 +#: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index 980acbe6..b27c3bdc 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -41,6 +41,7 @@ class StructureCheck(models.Model): # ID check should be generated automatically + class ExtraCheck(models.Model): """Model that represents an extra check for a project. These checks are not obligated to pass.""" From dfda4a42944a7b934ffffae03d573318ebf0c27f Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:04:26 +0100 Subject: [PATCH 334/397] chore: adjustment to courses.error.students.past_course #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 0f18f3e9..cc0fd7f8 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -52,7 +52,7 @@ msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "The course is not from this year, thus cannot be manipulated." +msgstr "The course is not from a past year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" From 7de83292b073a8ec424c2b4cd7d92d60e2624113 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:05:02 +0100 Subject: [PATCH 335/397] chore: adjustment to group.errors.full translation #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index cc0fd7f8..f608a8b3 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -68,7 +68,7 @@ msgstr "The group is not supplied in the context." #: serializers/group_serializer.py:65 msgid "group.errors.full" -msgstr "This group is already full." +msgstr "The group is already full." #: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" From 6f434a76c65169afb190e8bff8c6c074b2579146 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:11:22 +0100 Subject: [PATCH 336/397] chore: translation remove spelling mistakes #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 4 ++-- backend/api/locale/nl/LC_MESSAGES/django.po | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index f608a8b3..c224881b 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -52,7 +52,7 @@ msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "The course is not from a past year, thus cannot be manipulated." +msgstr "The course is from a past year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" @@ -80,7 +80,7 @@ msgstr "The student is already in the group." #: serializers/group_serializer.py:91 msgid "group.errors.not_present" -msgstr "The student is currently not in this group." +msgstr "The student is currently not in the group." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 0f71f78a..8f760d02 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -40,19 +40,15 @@ msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." #: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" -msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." +msgstr "Het ingediende zip-bestand bevat een map die niet gevraagd is." #: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "" -"De opleiding is niet meegeleverd als context bij het aanmaken van " -"StudentJoinSerializer." +msgstr "De opleiding is niet meegeleverd als context." #: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "" -"De student die men probeert toe te voegen aan een opleiding, is daar al aan " -"toegevoegd." +msgstr "De student bevindt zich al in de opleiding." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" @@ -81,7 +77,7 @@ msgstr "" #: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" -msgstr "De student bevindt zich al in deze groep." +msgstr "De student bevindt zich al in de groep." #: serializers/group_serializer.py:91 msgid "group.errors.not_present" @@ -89,7 +85,7 @@ msgstr "De student bevindt zich niet in de groep." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" -msgstr "De uiterste inleverdatum is gepasseerd voor het project." +msgstr "De uiterste inleverdatum voor het project is gepasseerd." #: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" @@ -129,4 +125,4 @@ msgstr "De indiening is succesvol toegevoegd aan de groep." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "De groepp is succesvol toegevoegd aan het project." +msgstr "De groep is succesvol toegevoegd aan het project." From 892aeeaf80f5a4d8188884b550ff5ae11e6b6501 Mon Sep 17 00:00:00 2001 From: francis Date: Wed, 13 Mar 2024 13:18:13 +0100 Subject: [PATCH 337/397] chore: automatically create groups when creating project --- backend/api/serializers/project_serializer.py | 3 +- backend/api/tests/test_project.py | 48 +++++++++++++++++++ backend/api/views/course_view.py | 13 +++-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 10811f0b..44c2468c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -4,7 +4,6 @@ from api.models.group import Group from rest_framework.exceptions import ValidationError from django.utils import timezone -from api.models.course import Course from api.models.submission import Submission, SubmissionFile from api.serializers.submission_serializer import SubmissionSerializer @@ -57,7 +56,7 @@ def validate(self, data): raise ValidationError(gettext("project.errors.context")) # Check if start date of the project is not in the past - if data["start_date"] < timezone.now(): + if data["start_date"] < timezone.now().replace(hour=0, minute=0, second=0): raise ValidationError(gettext("project.errors.start_date_in_past")) # Check if deadline of the project is before the start date diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index c8a50c82..31c38110 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -150,6 +150,54 @@ def test_toggle_locked_groups(self): past_project.toggle_groups_locked() self.assertIs(past_project.locked_groups, False) + def test_automatically_create_groups_when_creating_project(self): + """ + creating a project as a teacher should open the same amount of groups as students enrolled in the project. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + student1.courses.add(course) + student2.courses.add(course) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": timezone.now(), + "deadline": timezone.now() + timezone.timedelta(days=1), + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), data=project_data, follow=True + ) + + # Creating a group as a teacher should work + self.assertEqual(response.status_code, 200) + + project = Project.objects.get( + name="Test Project", + description="Test project description", + visible=True, + archived=False, + start_date=project_data["start_date"], + deadline=project_data["deadline"], + course=course, + ) + + groups_count = project.groups.count() + # The amount of students participating in the corresponding course + expected_groups_count = 2 + + # We expect the amount of groups to be the same as the amount of students in the course + self.assertEqual(groups_count, expected_groups_count) + def test_start_date_Project_not_in_past(self): """ unable to create a project as a teacher/admin if the start date lies within the past. diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 8e7b9ac4..6aac3096 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -5,6 +5,7 @@ from rest_framework.response import Response from rest_framework.request import Request from api.models.course import Course +from api.models.group import Group from api.permissions.course_permissions import ( CoursePermission, CourseAssistantPermission, @@ -169,11 +170,17 @@ def _add_project(self, request, **_): } ) + project = None + # Validate the serializer if serializer.is_valid(raise_exception=True): - course.projects.add( - serializer.save() - ) + project = serializer.save() + course.projects.add(project) + + # Create groups for the project + students_count = course.students.count() + for _ in range(students_count): + Group.objects.create(project=project) return Response({ "message": gettext("course.success.project.add"), From 1a0bcf1e5d340e0c5443ba80cff04e2c4e20d48c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 13 Mar 2024 14:54:36 +0100 Subject: [PATCH 338/397] chore: more environment variables --- .dev.env | 22 +++++++++++++--------- .prod.env | 26 +++++++++++++++----------- backend/ypovoli/settings.py | 14 +++++++++----- development.sh | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/.dev.env b/.dev.env index 53902a7f..3902da29 100644 --- a/.dev.env +++ b/.dev.env @@ -1,20 +1,24 @@ +# Default values PUID=1000 PGID=1000 TZ=Europe/Brussels -DATADIR=./data - +# File directories +DATADIR=./data # Directory to store container data BACKEND_DIR=./backend - FRONTEND_DIR=./frontend +# Redis REDIS_IP=192.168.90.10 REDIS_PORT=6379 -DJANGO_SECRET_KEY= -DJANGO_DEBUG=True -DJANGO_BASE_URL="https://localhost:8080" -DJANGO_DB_ENGINE=django.db.backends.sqlite3 -DJANGO_REDIS_HOST=${REDIS_IP} +# Django +DJANGO_SECRET_KEY= # Set to a random string +DJANGO_DEBUG=True # Django debug mode +DJANGO_DOMAIN_NAME=localhost # Domain name for the Django server +DJANGO_CAS_URL_PREFIX= # URL prefix for the CAS server. Should be empty for development +DJANGO_CAS_PORT=8080 # Port for the CAS server. Should be 8080 if DJANGO_DOMAIN_NAME is localhost +DJANGO_DB_ENGINE=django.db.backends.sqlite3 # Database engine +DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration DJANGO_REDIS_PORT=${REDIS_PORT} -DJANGO_REDIS_PASSWORD=${REDIS_PASSWORD} \ No newline at end of file +``` \ No newline at end of file diff --git a/.prod.env b/.prod.env index dbf0dcbb..ab98b3c9 100644 --- a/.prod.env +++ b/.prod.env @@ -1,31 +1,35 @@ +# Default values PUID=1000 PGID=1000 TZ=Europe/Brussels -DATADIR=./data - +# File directories +DATADIR=./data # Directory to store container data BACKEND_DIR=./backend - FRONTEND_DIR=./frontend +# Postgress DB POSTGRES_IP=192.168.90.9 POSTGRES_PORT=5432 POSTGRES_DB=selab POSTGRES_USER=selab_user -POSTGRES_PASSWORD= +POSTGRES_PASSWORD= # Set to desired password +# Redis REDIS_IP=192.168.90.10 REDIS_PORT=6379 -DJANGO_SECRET_KEY= -DJANGO_DEBUG=false -DJANGO_BASE_URL="https://sel2-7.ugent.be/auth" -DJANGO_DB_ENGINE=django.db.backends.postgresql +# Django +DJANGO_SECRET_KEY= # Set to a random string +DJANGO_DEBUG=False # Django debug mode +DJANGO_DOMAIN_NAME= # Domain name for the Django server +DJANGO_CAS_URL_PREFIX= # URL prefix for the CAS server +DJANGO_CAS_PORT= # Port for the CAS server +DJANGO_DB_ENGINE=django.db.backends.postgresql # Database engine configuration DJANGO_DB_NAME=${POSTGRES_DB} DJANGO_DB_USER=${POSTGRES_USER} DJANGO_DB_PASSWORD=${POSTGRES_PASSWORD} DJANGO_DB_HOST=${POSTGRES_IP} DJANGO_DB_PORT=${POSTGRES_PORT} -DJANGO_REDIS_HOST=${REDIS_IP} -DJANGO_REDIS_PORT=${REDIS_PORT} -DJANGO_REDIS_PASSWORD=${REDIS_PASSWORD} \ No newline at end of file +DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration +DJANGO_REDIS_PORT=${REDIS_PORT} \ No newline at end of file diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index aa3665fc..380e6163 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -30,7 +30,8 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = environ.get("DJANGO_DEBUG", "False").lower() in ["true", "1", "t"] -ALLOWED_HOSTS = ["localhost", "sel2-7.ugent.be"] +DOMAIN_NAME = environ.get("DJANGO_DOMAIN_NAME", "localhost") +ALLOWED_HOSTS = [DOMAIN_NAME] # Application definition @@ -47,7 +48,7 @@ "rest_framework", # Django rest framework "drf_yasg", # Yet Another Swagger generator "sslserver", # Used for local SSL support (needed by CAS) - # First party + # First party`` "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application "notifications", # Ypovoli notifications @@ -89,10 +90,13 @@ # Application endpoints -BASE_URL = environ.get("DJANGO_BASE_URL", "https://localhost:8080") +URL_PREFIX = environ.get("DJANGO_URL_PREFIX", "") +PORT = environ.get("DJANGO_PORT", "8080") CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = f"{BASE_URL}/api/auth/cas/echo" -API_ENDPOINT = f"{BASE_URL}/api" +CAS_RESPONSE = ( + f"https://{DOMAIN_NAME}:{PORT}{'/' + URL_PREFIX if URL_PREFIX else ''}/api/auth/cas/echo" +) +API_ENDPOINT = f"https://{DOMAIN_NAME}/api" # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases diff --git a/development.sh b/development.sh index 8b7780b4..fc495302 100755 --- a/development.sh +++ b/development.sh @@ -1,3 +1,15 @@ +#!/bin/bash + +build=false + +while getopts ":b" opt; do + case ${opt} in + b ) build=true ;; + \? ) echo "Usage: $0 [-b]" >&2 + exit 1 ;; + esac +done + echo "Checking environment file..." if ! [ -f .env ]; then @@ -20,6 +32,12 @@ else echo "SSL certificates already exist, skipping generation." fi +if [ "$build" = true ]; then + echo "Building Docker images..." + echo "This can take a while..." + docker-compose -f development.yml build --no-cache +fi + echo "Starting services..." docker-compose -f development.yml up -d From 4b4fb813e0f220b848863d98730e530a87972bd9 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Wed, 13 Mar 2024 15:03:16 +0100 Subject: [PATCH 339/397] chore: add endpoint to add structure checks --- backend/api/serializers/checks_serializer.py | 4 ++-- backend/api/serializers/project_serializer.py | 19 ++++++++++++++++++- backend/api/views/project_view.py | 9 ++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 4a876809..082a7da9 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -16,9 +16,9 @@ class StructureCheckSerializer(serializers.ModelSerializer): read_only=True ) - obligated_extensions = FileExtensionSerializer(many=True) + obligated_extensions = FileExtensionSerializer(many=True, required=False, default=[], read_only=True) - blocked_extensions = FileExtensionSerializer(many=True) + blocked_extensions = FileExtensionSerializer(many=True, required=False, default=[], read_only=True) class Meta: model = StructureCheck diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 3b2d6deb..d42688c4 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -4,8 +4,10 @@ from api.models.group import Group from rest_framework.exceptions import ValidationError from api.models.submission import Submission, SubmissionFile +from api.models.checks import FileExtension from api.serializers.submission_serializer import SubmissionSerializer from api.serializers.checks_serializer import StructureCheckSerializer +from rest_framework.request import Request class ProjectSerializer(serializers.ModelSerializer): @@ -72,9 +74,24 @@ def validate(self, data): class StructureCheckAddSerializer(StructureCheckSerializer): def validate(self, data): - project: Project = self.context["project"] + obl_ext = set() + for ext in self.context["obligated"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + obl_ext.add(extensie) + data["obligated_extensions"] = obl_ext + + block_ext = set() + for ext in self.context["blocked"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + block_ext.add(extensie) + data["blocked_extensions"] = block_ext + """ # Check if the project's deadline is not passed. if project.deadline_passed(): diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index e2116612..fb9f01ad 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -94,6 +94,7 @@ def structure_checks(self, request, **_): return Response(serializer.data) @structure_checks.mapping.post + @structure_checks.mapping.put def _add_structure_check(self, request: Request, **_): """Add an structure_check to the project""" @@ -101,7 +102,13 @@ def _add_structure_check(self, request: Request, **_): # Add submission to course serializer = StructureCheckAddSerializer( - data=request.data, context={"project": project, "request": request} + data=request.data, + context={ + "project": project, + "request": request, + "obligated": request.data.getlist('obligated_extensions'), + "blocked": request.data.getlist('blocked_extensions') + } ) if serializer.is_valid(raise_exception=True): From 219d1cc49c74380c7d2cc6bc83e561569c4b9f2d Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 13 Mar 2024 15:10:14 +0100 Subject: [PATCH 340/397] chore: variables rename --- backend/ypovoli/settings.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 380e6163..e0eabde9 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -90,12 +90,10 @@ # Application endpoints -URL_PREFIX = environ.get("DJANGO_URL_PREFIX", "") -PORT = environ.get("DJANGO_PORT", "8080") +URL_PREFIX = environ.get("DJANGO_CAS_URL_PREFIX", "") +PORT = environ.get("DJANGO_CAS_PORT", "8080") CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = ( - f"https://{DOMAIN_NAME}:{PORT}{'/' + URL_PREFIX if URL_PREFIX else ''}/api/auth/cas/echo" -) +CAS_RESPONSE = f"https://{DOMAIN_NAME}:{PORT}{'/' + URL_PREFIX if URL_PREFIX else ''}/api/auth/cas/echo" API_ENDPOINT = f"https://{DOMAIN_NAME}/api" # Database From a5ba61f8e6bb91d09cef20f7d6afe7153e60cb4f Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Wed, 13 Mar 2024 15:18:48 +0100 Subject: [PATCH 341/397] chore: finished structure_checks add endpoint --- backend/api/serializers/project_serializer.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index d42688c4..4db1fb3e 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -75,6 +75,8 @@ def validate(self, data): class StructureCheckAddSerializer(StructureCheckSerializer): def validate(self, data): project: Project = self.context["project"] + if project.structure_checks.filter(name=data["name"]).count(): + raise ValidationError(gettext("project.error.structure_checks.already_existing")) obl_ext = set() for ext in self.context["obligated"]: @@ -89,19 +91,9 @@ def validate(self, data): extensie, _ = FileExtension.objects.get_or_create( extension=ext ) + if extensie in obl_ext: + raise ValidationError(gettext("project.error.structure_checks.extension_blocked_and_obligated")) block_ext.add(extensie) data["blocked_extensions"] = block_ext - """ - # Check if the project's deadline is not passed. - if project.deadline_passed(): - raise ValidationError(gettext("project.error.submission.past_project")) - - if not project.is_visible(): - raise ValidationError(gettext("project.error.submission.non_visible_project")) - - if project.is_archived(): - raise ValidationError(gettext("project.error.submission.archived_project")) - """ - return data From ddc7159b477ce02d54b36cb77b2ff7afc0130332 Mon Sep 17 00:00:00 2001 From: francis Date: Wed, 13 Mar 2024 18:24:24 +0100 Subject: [PATCH 342/397] fix: spelling and function name --- backend/api/models/project.py | 4 ++-- backend/api/tests/test_project.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index a03eed89..d6d48a72 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -88,11 +88,11 @@ def toggle_archived(self): self.save() def is_groups_locked(self): - """Returns True if a participating groups are locked.""" + """Returns True if participating groups are locked.""" return self.locked_groups def toggle_groups_locked(self): - """Toggles the locke state of the groups related to the project.""" + """Toggles the locked state of the groups related to the project.""" self.locked_groups = not self.locked_groups self.save() diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 31c38110..b1b2f6e4 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -679,11 +679,11 @@ def test_submission_status_non_empty_groups(self): # Create example groups group1 = create_group(project=project) - group3 = create_group(project=project) + group2 = create_group(project=project) # Add the students to some of the groups group1.students.add(student1) - group3.students.add(student2) + group2.students.add(student2) response = self.client.get( reverse("project-submission-status", args=[str(project.id)]), follow=True From 0bcefb86dfe7d0e10a862fb26996350fc7475f84 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 7 Mar 2024 14:57:25 +0100 Subject: [PATCH 343/397] chore: rebase --- backend/ypovoli/handlers.py | 14 ++++++++ .../ypovoli/locale/nl/LC_MESSAGES/django.po | 35 +++++++++++++++++++ backend/ypovoli/settings.py | 2 ++ 3 files changed, 51 insertions(+) create mode 100644 backend/ypovoli/handlers.py create mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py new file mode 100644 index 00000000..1a027b7a --- /dev/null +++ b/backend/ypovoli/handlers.py @@ -0,0 +1,14 @@ +from rest_framework.views import exception_handler +from django.utils.translation import gettext_lazy as _ + + +def translate_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response.status_code == 401: + response.data['detail'] = _('Given token not valid for any token type') + + if response.status_code == 404: + response.data['detail'] = _('Not found.') + + return response diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..6e497baf --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,35 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-07 14:34+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: ypovoli/handlers.py:9 +msgid "Given token not valid for any token type" +msgstr "Gegeven token is niet geldig voor eender welk token type" + +#: ypovoli/handlers.py:12 +msgid "Not found." +msgstr "Niet gevonden." + +#: ypovoli/settings.py:113 +msgid "English" +msgstr "Engels" + +#: ypovoli/settings.py:113 +msgid "Dutch" +msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index aa3665fc..e958f70a 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ """ +from django.utils.translation import gettext_lazy as _ import os from datetime import timedelta from os import environ @@ -74,6 +75,7 @@ "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + 'EXCEPTION_HANDLERE': 'ypovoli' } SIMPLE_JWT = { From 096cfa1c3517cd7ab1ed195297f96995f2337d03 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:35:45 +0100 Subject: [PATCH 344/397] chore: (Dutch) folder structure helper + course serializer + group serializer + project serializer translation #100 --- backend/ypovoli/handlers.py | 14 -------- .../ypovoli/locale/nl/LC_MESSAGES/django.po | 35 ------------------- backend/ypovoli/settings.py | 2 +- 3 files changed, 1 insertion(+), 50 deletions(-) delete mode 100644 backend/ypovoli/handlers.py delete mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py deleted file mode 100644 index 1a027b7a..00000000 --- a/backend/ypovoli/handlers.py +++ /dev/null @@ -1,14 +0,0 @@ -from rest_framework.views import exception_handler -from django.utils.translation import gettext_lazy as _ - - -def translate_exception_handler(exc, context): - response = exception_handler(exc, context) - - if response.status_code == 401: - response.data['detail'] = _('Given token not valid for any token type') - - if response.status_code == 404: - response.data['detail'] = _('Not found.') - - return response diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po deleted file mode 100644 index 6e497baf..00000000 --- a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po +++ /dev/null @@ -1,35 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-07 14:34+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" - -#: ypovoli/handlers.py:9 -msgid "Given token not valid for any token type" -msgstr "Gegeven token is niet geldig voor eender welk token type" - -#: ypovoli/handlers.py:12 -msgid "Not found." -msgstr "Niet gevonden." - -#: ypovoli/settings.py:113 -msgid "English" -msgstr "Engels" - -#: ypovoli/settings.py:113 -msgid "Dutch" -msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index e958f70a..21e59fd6 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -75,7 +75,6 @@ "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], - 'EXCEPTION_HANDLERE': 'ypovoli' } SIMPLE_JWT = { @@ -118,6 +117,7 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True +LANGUAGES = [("en", _("languages.en")), ("nl", _("languages.nl"))] USE_L10N = False USE_TZ = True From 42fd59349f676b343693631bc971908cf1da61e6 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:38:56 +0100 Subject: [PATCH 345/397] chore: (Dutch) folder structure helper + course serializer + group serializer + project serializer translation (now for real) #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 118 ++++++++++++++++++++ backend/api/locale/nl/LC_MESSAGES/django.po | 118 ++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 backend/api/locale/en/LC_MESSAGES/django.po create mode 100644 backend/api/locale/nl/LC_MESSAGES/django.po diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..e5fea38b --- /dev/null +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 16:59+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: api/helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "" + +#: api/helpers/check_folder_structure.py:145 +#: api/helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "" + +#: api/helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "" + +#: api/helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "" + +#: api/helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "" + +#: api/serializers/course_serializer.py:58 +#: api/serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "" + +#: api/serializers/course_serializer.py:64 +msgid "courses.error.students.already_present" +msgstr "" + +#: api/serializers/course_serializer.py:68 +#: api/serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "" + +#: api/serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "" + +#: api/serializers/group_serializer.py:33 +msgid "group.errors.score_exceeds_max" +msgstr "" + +#: api/serializers/group_serializer.py:43 +#: api/serializers/group_serializer.py:69 +msgid "group.error.context" +msgstr "" + +#: api/serializers/group_serializer.py:51 +msgid "group.errors.full" +msgstr "" + +#: api/serializers/group_serializer.py:55 +msgid "group.errors.not_in_course" +msgstr "" + +#: api/serializers/group_serializer.py:59 +msgid "group.errors.already_in_group" +msgstr "" + +#: api/serializers/group_serializer.py:77 +msgid "group.errors.not_present" +msgstr "" + +#: api/serializers/project_serializer.py:60 +msgid "project.error.submission.past_project" +msgstr "" + +#: api/serializers/project_serializer.py:63 +msgid "project.error.submission.non_visible_project" +msgstr "" + +#: api/serializers/project_serializer.py:66 +msgid "project.error.submission.archived_project" +msgstr "" + +#: api/views/course_view.py:57 api/views/course_view.py:76 +msgid "courses.success.assistants.add" +msgstr "" + +#: api/views/course_view.py:110 api/views/course_view.py:130 +msgid "courses.success.students.add" +msgstr "" + +#: api/views/group_view.py:73 +msgid "group.success.student.add" +msgstr "" + +#: api/views/group_view.py:92 +msgid "group.success.student.remove" +msgstr "" + +#: api/views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "" + +#: api/views/project_view.py:79 +msgid "project.success.groups.created" +msgstr "" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..57154573 --- /dev/null +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 16:56+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: api/helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "Bestanden met een verboden extensie zijn gevonden in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:145 +#: api/helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." + +#: api/helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "Er is geen enkel bestand met een bepaalde extensie die verplicht is in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." + +#: api/helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." + +#: api/serializers/course_serializer.py:58 +#: api/serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "De opleiding is niet meegeleverd als context bij het aanmaken van StudentJoinSerializer." + +#: api/serializers/course_serializer.py:64 +msgid "courses.error.students.already_present" +msgstr "De student die men probeert toe te voegen aan een opleiding, is daar al aan toegevoegd." + +#: api/serializers/course_serializer.py:68 +#: api/serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." + +#: api/serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "De student bevindt zich niet in de opleiding." + +#: api/serializers/group_serializer.py:33 +msgid "group.errors.score_exceeds_max" +msgstr "De score van de groep is groter dan de maximum score." + +#: api/serializers/group_serializer.py:43 +#: api/serializers/group_serializer.py:69 +msgid "group.error.context" +msgstr "De groep is niet meegegeven als context waar dat nodig is." + +#: api/serializers/group_serializer.py:51 +msgid "group.errors.full" +msgstr "De groep is al vol." + +#: api/serializers/group_serializer.py:55 +msgid "group.errors.not_in_course" +msgstr "De student bevindt zich niet in de opleiding waartoe het project hoort." + +#: api/serializers/group_serializer.py:59 +msgid "group.errors.already_in_group" +msgstr "De student bevindt zich al in deze groep." + +#: api/serializers/group_serializer.py:77 +msgid "group.errors.not_present" +msgstr "De student bevindt zich niet in de groep." + +#: api/serializers/project_serializer.py:60 +msgid "project.error.submission.past_project" +msgstr "De uiterste inleverdatum is gepasseerd voor het project." + +#: api/serializers/project_serializer.py:63 +msgid "project.error.submission.non_visible_project" +msgstr "Het project is niet zichtbaar." + +#: api/serializers/project_serializer.py:66 +msgid "project.error.submission.archived_project" +msgstr "Het project is gearchiveerd." + +#: api/views/course_view.py:57 api/views/course_view.py:76 +msgid "courses.success.assistants.add" +msgstr "" + +#: api/views/course_view.py:110 api/views/course_view.py:130 +msgid "courses.success.students.add" +msgstr "" + +#: api/views/group_view.py:73 +msgid "group.success.student.add" +msgstr "" + +#: api/views/group_view.py:92 +msgid "group.success.student.remove" +msgstr "" + +#: api/views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "" + +#: api/views/project_view.py:79 +msgid "project.success.groups.created" +msgstr "" From d140174ddd578b0b7ddac9057fd7c068dc310325 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:43:06 +0100 Subject: [PATCH 346/397] test: make authentication test names more professional --- .../tests/test_authentication_serializer.py | 10 +++++----- .../authentication/tests/test_authentication_views.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py index 736ed2f0..c931970e 100644 --- a/backend/authentication/tests/test_authentication_serializer.py +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -13,10 +13,10 @@ WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" ID = "1234" -USERNAME = "ddickwd" +USERNAME = "dackers" EMAIL = "dummy@dummy.be" FIRST_NAME = "Dummy" -LAST_NAME = "McDickwad" +LAST_NAME = "Ackers" class UserSerializerModelTests(TestCase): @@ -152,7 +152,7 @@ def test_new_user_activates_user_created_signal(self): be sent when trying to validate the token.""" mock = Mock() - user_created.connect(mock, dispatch_uid="STDsAllAround") + user_created.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) @@ -169,7 +169,7 @@ def test_old_user_does_not_activate_user_created_signal(self): be sent when trying to validate the token.""" mock = Mock() - user_created.connect(mock, dispatch_uid="STDsAllAround") + user_created.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) @@ -186,7 +186,7 @@ def test_login_signal(self): the token, then the user_login signal should be sent. """ mock = Mock() - user_login.connect(mock, dispatch_uid="STDsAllAround") + user_login.connect(mock, dispatch_uid="duid") serializer = CASTokenObtainSerializer( data={"token": RefreshToken(), "ticket": TICKET} ) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py index 8e7a4155..cb5734c8 100644 --- a/backend/authentication/tests/test_authentication_views.py +++ b/backend/authentication/tests/test_authentication_views.py @@ -11,10 +11,10 @@ def setUp(self): """Create a user and generate a token for that user""" self.user = User.objects.create(**{ "id": "1234", - "username": "ddickwd", + "username": "dackers", "email": "dummy@dummy.com", "first_name": "dummy", - "last_name": "McDickwad", + "last_name": "Ackers", }) self.token = f'Bearer {AccessToken().for_user(self.user)}' @@ -54,10 +54,10 @@ class TestLogoutView(APITestCase): def setUp(self): user_data = { "id": "1234", - "username": "ddickwd", + "username": "dackers", "email": "dummy@dummy.com", "first_name": "dummy", - "last_name": "McDickwad", + "last_name": "Ackers", } self.user = User.objects.create(**user_data) From 33734add1e6f52189acadaf1db959bf3de1eb153 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 18:43:44 +0100 Subject: [PATCH 347/397] chore: add available language translations #100 --- .../ypovoli/locale/en/LC_MESSAGES/django.po | 26 +++++++++++++++++++ .../ypovoli/locale/nl/LC_MESSAGES/django.po | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 backend/ypovoli/locale/en/LC_MESSAGES/django.po create mode 100644 backend/ypovoli/locale/nl/LC_MESSAGES/django.po diff --git a/backend/ypovoli/locale/en/LC_MESSAGES/django.po b/backend/ypovoli/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..b14e202b --- /dev/null +++ b/backend/ypovoli/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "English" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Dutch" diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..513be77b --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "Engels" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Nederlands" From 6ac3a1ebb6fb977238a0869335eb163e995f3455 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 21:55:29 +0100 Subject: [PATCH 348/397] chore: course_view dutch translation + adaption to translation keys #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 73 +++++++------- backend/api/locale/nl/LC_MESSAGES/django.po | 98 +++++++++++-------- backend/api/serializers/project_serializer.py | 6 +- backend/api/views/course_view.py | 4 +- backend/api/views/group_view.py | 4 +- 5 files changed, 102 insertions(+), 83 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index e5fea38b..8b2226d6 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 16:59+0100\n" +"POT-Creation-Date: 2024-03-12 21:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,102 +17,107 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: api/helpers/check_folder_structure.py:141 + +#: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" msgstr "" -#: api/helpers/check_folder_structure.py:145 -#: api/helpers/check_folder_structure.py:196 +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" msgstr "" -#: api/helpers/check_folder_structure.py:148 +#: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" -#: api/helpers/check_folder_structure.py:175 +#: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "" -#: api/helpers/check_folder_structure.py:195 +#: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "" -#: api/serializers/course_serializer.py:58 -#: api/serializers/course_serializer.py:77 +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" msgstr "" -#: api/serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" msgstr "" -#: api/serializers/course_serializer.py:68 -#: api/serializers/course_serializer.py:87 +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" msgstr "" -#: api/serializers/course_serializer.py:83 +#: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "" -#: api/serializers/group_serializer.py:33 +#: serializers/group_serializer.py:33 msgid "group.errors.score_exceeds_max" msgstr "" -#: api/serializers/group_serializer.py:43 -#: api/serializers/group_serializer.py:69 +#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 msgid "group.error.context" msgstr "" -#: api/serializers/group_serializer.py:51 +#: serializers/group_serializer.py:51 msgid "group.errors.full" msgstr "" -#: api/serializers/group_serializer.py:55 +#: serializers/group_serializer.py:55 msgid "group.errors.not_in_course" msgstr "" -#: api/serializers/group_serializer.py:59 +#: serializers/group_serializer.py:59 msgid "group.errors.already_in_group" msgstr "" -#: api/serializers/group_serializer.py:77 +#: serializers/group_serializer.py:77 msgid "group.errors.not_present" msgstr "" -#: api/serializers/project_serializer.py:60 -msgid "project.error.submission.past_project" +#: serializers/project_serializer.py:60 +msgid "project.error.submissions.past_project" msgstr "" -#: api/serializers/project_serializer.py:63 -msgid "project.error.submission.non_visible_project" +#: serializers/project_serializer.py:63 +msgid "project.error.submissions.non_visible_project" msgstr "" -#: api/serializers/project_serializer.py:66 -msgid "project.error.submission.archived_project" +#: serializers/project_serializer.py:66 +msgid "project.error.submissions.archived_project" msgstr "" -#: api/views/course_view.py:57 api/views/course_view.py:76 +#: views/course_view.py:57 msgid "courses.success.assistants.add" msgstr "" -#: api/views/course_view.py:110 api/views/course_view.py:130 +#: views/course_view.py:76 +msgid "courses.success.assistants.remove" +msgstr "" + +#: views/course_view.py:110 msgid "courses.success.students.add" msgstr "" -#: api/views/group_view.py:73 -msgid "group.success.student.add" +#: views/course_view.py:130 +msgid "courses.success.students.remove" +msgstr "" + +#: views/group_view.py:73 +msgid "group.success.students.add" msgstr "" -#: api/views/group_view.py:92 -msgid "group.success.student.remove" +#: views/group_view.py:92 +msgid "group.success.students.remove" msgstr "" -#: api/views/group_view.py:111 +#: views/group_view.py:111 msgid "group.success.submissions.add" msgstr "" -#: api/views/project_view.py:79 +#: views/project_view.py:79 msgid "project.success.groups.created" msgstr "" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 57154573..bcd1945c 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 16:56+0100\n" +"POT-Creation-Date: 2024-03-12 21:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,102 +17,116 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: api/helpers/check_folder_structure.py:141 + +#: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" -msgstr "Bestanden met een verboden extensie zijn gevonden in het ingediende zip-bestand." +msgstr "" +"Bestanden met een verboden extensie zijn gevonden in het ingediende zip-" +"bestand." -#: api/helpers/check_folder_structure.py:145 -#: api/helpers/check_folder_structure.py:196 +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." -#: api/helpers/check_folder_structure.py:148 +#: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" -msgstr "Er is geen enkel bestand met een bepaalde extensie die verplicht is in het ingediende zip-bestand." +msgstr "" +"Er is geen enkel bestand met een bepaalde extensie die verplicht is in het " +"ingediende zip-bestand." -#: api/helpers/check_folder_structure.py:175 +#: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." -#: api/helpers/check_folder_structure.py:195 +#: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." -#: api/serializers/course_serializer.py:58 -#: api/serializers/course_serializer.py:77 +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "De opleiding is niet meegeleverd als context bij het aanmaken van StudentJoinSerializer." +msgstr "" +"De opleiding is niet meegeleverd als context bij het aanmaken van " +"StudentJoinSerializer." -#: api/serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "De student die men probeert toe te voegen aan een opleiding, is daar al aan toegevoegd." +msgstr "" +"De student die men probeert toe te voegen aan een opleiding, is daar al aan " +"toegevoegd." -#: api/serializers/course_serializer.py:68 -#: api/serializers/course_serializer.py:87 +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." -#: api/serializers/course_serializer.py:83 +#: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: api/serializers/group_serializer.py:33 +#: serializers/group_serializer.py:33 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: api/serializers/group_serializer.py:43 -#: api/serializers/group_serializer.py:69 +#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: api/serializers/group_serializer.py:51 +#: serializers/group_serializer.py:51 msgid "group.errors.full" msgstr "De groep is al vol." -#: api/serializers/group_serializer.py:55 +#: serializers/group_serializer.py:55 msgid "group.errors.not_in_course" -msgstr "De student bevindt zich niet in de opleiding waartoe het project hoort." +msgstr "" +"De student bevindt zich niet in de opleiding waartoe het project hoort." -#: api/serializers/group_serializer.py:59 +#: serializers/group_serializer.py:59 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in deze groep." -#: api/serializers/group_serializer.py:77 +#: serializers/group_serializer.py:77 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: api/serializers/project_serializer.py:60 -msgid "project.error.submission.past_project" +#: serializers/project_serializer.py:60 +msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum is gepasseerd voor het project." -#: api/serializers/project_serializer.py:63 -msgid "project.error.submission.non_visible_project" +#: serializers/project_serializer.py:63 +msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: api/serializers/project_serializer.py:66 -msgid "project.error.submission.archived_project" +#: serializers/project_serializer.py:66 +msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: api/views/course_view.py:57 api/views/course_view.py:76 +#: views/course_view.py:57 msgid "courses.success.assistants.add" -msgstr "" +msgstr "De assistent is succesvol toegevoegd aan de opleiding." + +#: views/course_view.py:76 +msgid "courses.success.assistants.remove" +msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: api/views/course_view.py:110 api/views/course_view.py:130 +#: views/course_view.py:110 msgid "courses.success.students.add" -msgstr "" +msgstr "De student is succesvol toegevoegd aan de opleiding." -#: api/views/group_view.py:73 -msgid "group.success.student.add" -msgstr "" +#: views/course_view.py:130 +msgid "courses.success.students.remove" +msgstr "De student is succesvol verwijderd uit de opleiding." + +#: views/group_view.py:73 +msgid "group.success.students.add" +msgstr "De student i" -#: api/views/group_view.py:92 -msgid "group.success.student.remove" +#: views/group_view.py:92 +msgid "group.success.students.remove" msgstr "" -#: api/views/group_view.py:111 +#: views/group_view.py:111 msgid "group.success.submissions.add" msgstr "" -#: api/views/project_view.py:79 +#: views/project_view.py:79 msgid "project.success.groups.created" msgstr "" diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 66393edf..e96ddc66 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -58,12 +58,12 @@ def validate(self, data): # Check if the project's deadline is not passed. if project.deadline_passed(): - raise ValidationError(gettext("project.error.submission.past_project")) + raise ValidationError(gettext("project.error.submissions.past_project")) if not project.is_visible(): - raise ValidationError(gettext("project.error.submission.non_visible_project")) + raise ValidationError(gettext("project.error.submissions.non_visible_project")) if project.is_archived(): - raise ValidationError(gettext("project.error.submission.archived_project")) + raise ValidationError(gettext("project.error.submissions.archived_project")) return data diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index cea2b180..d10fc26e 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -73,7 +73,7 @@ def _remove_assistant(self, request: Request, **_): ) return Response({ - "message": gettext("courses.success.assistants.add") + "message": gettext("courses.success.assistants.remove") }) @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) @@ -127,7 +127,7 @@ def _remove_student(self, request: Request, **_): ) return Response({ - "message": gettext("courses.success.students.add") + "message": gettext("courses.success.students.remove") }) @action(detail=True, methods=["get"]) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 632bb7ff..ef82db0d 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -70,7 +70,7 @@ def _add_student(self, request, **_): ) return Response({ - "message": gettext("group.success.student.add"), + "message": gettext("group.success.students.add"), }) @students.mapping.delete @@ -89,7 +89,7 @@ def _remove_student(self, request, **_): ) return Response({ - "message": gettext("group.success.student.remove"), + "message": gettext("group.success.students.remove"), }) @submissions.mapping.post From c5e43bd90ea072253616ba818f9e92cff21338f0 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Tue, 12 Mar 2024 21:57:46 +0100 Subject: [PATCH 349/397] chore: group_view and project_view Dutch translation #100 --- backend/api/locale/nl/LC_MESSAGES/django.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index bcd1945c..28757ed3 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -117,16 +117,16 @@ msgstr "De student is succesvol verwijderd uit de opleiding." #: views/group_view.py:73 msgid "group.success.students.add" -msgstr "De student i" +msgstr "De student is succesvol toegevoegd aan de groep." #: views/group_view.py:92 msgid "group.success.students.remove" -msgstr "" +msgstr "De student is succesvol verwijderd uit de groep." #: views/group_view.py:111 msgid "group.success.submissions.add" -msgstr "" +msgstr "De indiening is succesvol toegevoegd aan de groep." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "" +msgstr "De groepp is succesvol toegevoegd aan het project." From 964e9bbc36bc994bd0c5f457d9ec89b4802ff778 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Thu, 7 Mar 2024 14:57:25 +0100 Subject: [PATCH 350/397] chore: another rebase #57 --- backend/ypovoli/handlers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/ypovoli/handlers.py diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py new file mode 100644 index 00000000..1a027b7a --- /dev/null +++ b/backend/ypovoli/handlers.py @@ -0,0 +1,14 @@ +from rest_framework.views import exception_handler +from django.utils.translation import gettext_lazy as _ + + +def translate_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response.status_code == 401: + response.data['detail'] = _('Given token not valid for any token type') + + if response.status_code == 404: + response.data['detail'] = _('Not found.') + + return response From 6502aa4c0bcb415c4229d71702e8eb48186329f4 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 12:57:04 +0100 Subject: [PATCH 351/397] chore: English translation zip checks + course_serializer + group_serializer #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 50 +++++++++++---------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 8b2226d6..183a0594 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 21:52+0100\n" +"POT-Creation-Date: 2024-03-13 12:56+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,73 +20,77 @@ msgstr "" #: helpers/check_folder_structure.py:141 msgid "zip.errors.invalid_structure.blocked_extension_found" -msgstr "" +msgstr "The submitted zip file contains a file with a non-allowed extension." #: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 msgid "zip.success" -msgstr "" +msgstr "The submitted zip file succeeds in all checks." #: helpers/check_folder_structure.py:148 msgid "zip.errors.invalid_structure.obligated_extension_not_found" msgstr "" +"The submitted zip file doesn't have any file with a certain file extension " +"that's obligated." #: helpers/check_folder_structure.py:175 msgid "zip.errors.invalid_structure.directory_not_defined" -msgstr "" +msgstr "An obligated directory was not found in the submitted zip file." #: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" msgstr "" +"There was a directory found in the submitted zip file, which was not asked " +"for." #: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "" +msgstr "The course is not supplied in the context." #: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "" +msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "" +msgstr "The course is not from this year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" -msgstr "" +msgstr "The student is not present in the course." -#: serializers/group_serializer.py:33 +#: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" -msgstr "" +msgstr "The score exceeds the group's max score." -#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 msgid "group.error.context" -msgstr "" +msgstr "The group is not supplied in the context." -#: serializers/group_serializer.py:51 +#: serializers/group_serializer.py:65 msgid "group.errors.full" -msgstr "" +msgstr "This group is already full." -#: serializers/group_serializer.py:55 +#: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" -msgstr "" +msgstr "The student is not present in the related course." -#: serializers/group_serializer.py:59 +#: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" -msgstr "" +msgstr "The student is already in the group." -#: serializers/group_serializer.py:77 +#: serializers/group_serializer.py:91 msgid "group.errors.not_present" -msgstr "" +msgstr "The student is currently not in this group." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" msgstr "" -#: serializers/project_serializer.py:63 +#: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" msgstr "" -#: serializers/project_serializer.py:66 +#: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" msgstr "" From b707646802334b3cbaf6bd7c03aadd448dc0ac49 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 12:59:09 +0100 Subject: [PATCH 352/397] chore: English translation project_serializer #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 183a0594..fa5481f1 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -84,15 +84,15 @@ msgstr "The student is currently not in this group." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" -msgstr "" +msgstr "The deadline of the project has already passed." #: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" -msgstr "" +msgstr "The project is currently in a non-visible state." #: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" -msgstr "" +msgstr "The project is archived." #: views/course_view.py:57 msgid "courses.success.assistants.add" From c04a8fdbd4636a3f157f0a87ab9890720c3e19ad Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:02:59 +0100 Subject: [PATCH 353/397] chore: English translation course_view + group_view + project_view #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index fa5481f1..0f18f3e9 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -96,32 +96,32 @@ msgstr "The project is archived." #: views/course_view.py:57 msgid "courses.success.assistants.add" -msgstr "" +msgstr "The assistant was successfully added to the course." #: views/course_view.py:76 msgid "courses.success.assistants.remove" -msgstr "" +msgstr "The assistant was successfully removed from the course." #: views/course_view.py:110 msgid "courses.success.students.add" -msgstr "" +msgstr "The student was successfully added to the course." #: views/course_view.py:130 msgid "courses.success.students.remove" -msgstr "" +msgstr "The student was successfully removed from the course." #: views/group_view.py:73 msgid "group.success.students.add" -msgstr "" +msgstr "The student was successfully added to the group." #: views/group_view.py:92 msgid "group.success.students.remove" -msgstr "" +msgstr "The student was successfully removed from the group." #: views/group_view.py:111 msgid "group.success.submissions.add" -msgstr "" +msgstr "The submission was successfully added to the group." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "" +msgstr "A group was successfully created for the project." From 6095adf1e98de3a5ccbcf0c6b8bb2a0a22a5127e Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:03:16 +0100 Subject: [PATCH 354/397] chore: slight adjustments #100 --- backend/api/locale/nl/LC_MESSAGES/django.po | 20 ++++++++++---------- backend/api/models/checks.py | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 28757ed3..0f71f78a 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-12 21:52+0100\n" +"POT-Creation-Date: 2024-03-13 13:02+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -62,40 +62,40 @@ msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." msgid "courses.error.students.not_present" msgstr "De student bevindt zich niet in de opleiding." -#: serializers/group_serializer.py:33 +#: serializers/group_serializer.py:47 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:43 serializers/group_serializer.py:69 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:51 +#: serializers/group_serializer.py:65 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:55 +#: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:59 +#: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in deze groep." -#: serializers/group_serializer.py:77 +#: serializers/group_serializer.py:91 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:60 +#: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum is gepasseerd voor het project." -#: serializers/project_serializer.py:63 +#: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/project_serializer.py:66 +#: serializers/project_serializer.py:67 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index 0c5e20c3..d3b7f338 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -39,6 +39,7 @@ class StructureCheck(models.Model): ) + class ExtraCheck(models.Model): """Model that represents an extra check for a project. These checks are not obligated to pass.""" From 1bc43c219d7e5ab42c72c740141bc9e5073bb47e Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:04:26 +0100 Subject: [PATCH 355/397] chore: adjustment to courses.error.students.past_course #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 0f18f3e9..cc0fd7f8 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -52,7 +52,7 @@ msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "The course is not from this year, thus cannot be manipulated." +msgstr "The course is not from a past year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" From f7a71cb0f62314c1660caa205697b38b3c0246a8 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:05:02 +0100 Subject: [PATCH 356/397] chore: adjustment to group.errors.full translation #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index cc0fd7f8..f608a8b3 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -68,7 +68,7 @@ msgstr "The group is not supplied in the context." #: serializers/group_serializer.py:65 msgid "group.errors.full" -msgstr "This group is already full." +msgstr "The group is already full." #: serializers/group_serializer.py:69 msgid "group.errors.not_in_course" From b113fd987fe65d44269abc71e1444bbbb4724c19 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 13:11:22 +0100 Subject: [PATCH 357/397] chore: translation remove spelling mistakes #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 4 ++-- backend/api/locale/nl/LC_MESSAGES/django.po | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index f608a8b3..c224881b 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -52,7 +52,7 @@ msgstr "The student is already present in the course." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" -msgstr "The course is not from a past year, thus cannot be manipulated." +msgstr "The course is from a past year, thus cannot be manipulated." #: serializers/course_serializer.py:83 msgid "courses.error.students.not_present" @@ -80,7 +80,7 @@ msgstr "The student is already in the group." #: serializers/group_serializer.py:91 msgid "group.errors.not_present" -msgstr "The student is currently not in this group." +msgstr "The student is currently not in the group." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 0f71f78a..8f760d02 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -40,19 +40,15 @@ msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." #: helpers/check_folder_structure.py:195 msgid "zip.errors.invalid_structure.directory_not_found_in_template" -msgstr "Het ingediende zip-bestand bevat een map die niet toegestaan is." +msgstr "Het ingediende zip-bestand bevat een map die niet gevraagd is." #: serializers/course_serializer.py:58 serializers/course_serializer.py:77 msgid "courses.error.context" -msgstr "" -"De opleiding is niet meegeleverd als context bij het aanmaken van " -"StudentJoinSerializer." +msgstr "De opleiding is niet meegeleverd als context." #: serializers/course_serializer.py:64 msgid "courses.error.students.already_present" -msgstr "" -"De student die men probeert toe te voegen aan een opleiding, is daar al aan " -"toegevoegd." +msgstr "De student bevindt zich al in de opleiding." #: serializers/course_serializer.py:68 serializers/course_serializer.py:87 msgid "courses.error.students.past_course" @@ -81,7 +77,7 @@ msgstr "" #: serializers/group_serializer.py:73 msgid "group.errors.already_in_group" -msgstr "De student bevindt zich al in deze groep." +msgstr "De student bevindt zich al in de groep." #: serializers/group_serializer.py:91 msgid "group.errors.not_present" @@ -89,7 +85,7 @@ msgstr "De student bevindt zich niet in de groep." #: serializers/project_serializer.py:61 msgid "project.error.submissions.past_project" -msgstr "De uiterste inleverdatum is gepasseerd voor het project." +msgstr "De uiterste inleverdatum voor het project is gepasseerd." #: serializers/project_serializer.py:64 msgid "project.error.submissions.non_visible_project" @@ -129,4 +125,4 @@ msgstr "De indiening is succesvol toegevoegd aan de groep." #: views/project_view.py:79 msgid "project.success.groups.created" -msgstr "De groepp is succesvol toegevoegd aan het project." +msgstr "De groep is succesvol toegevoegd aan het project." From db3e97aaa25ec9e2ef9fdd3500aa94a31ee176d1 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 19:34:41 +0100 Subject: [PATCH 358/397] test: add little test #100 --- backend/api/tests/test_locale.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 backend/api/tests/test_locale.py diff --git a/backend/api/tests/test_locale.py b/backend/api/tests/test_locale.py new file mode 100644 index 00000000..1ad80cfd --- /dev/null +++ b/backend/api/tests/test_locale.py @@ -0,0 +1,34 @@ +import json +from django.urls import reverse +from rest_framework.test import APITestCase + +from api.models.course import Course +from api.models.student import Student +from authentication.models import User + + +class TestLocaleAddAlreadyPresentStudentToCourse(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate(User.get_dummy_admin()) + + course = Course.objects.create(id=1, name="Test Course", academic_startyear=2024) + student = Student.objects.create(id=1, first_name="John", last_name="Doe", email="john.doe@example.com") + + student.courses.add(course) + + def test_default_locale(self): + response = self.client.post(reverse("course-students", args=["1"]), + {"student_id": 1}) + + self.assertEqual(response.status_code, 400) + body = json.loads(response.content.decode('utf-8')) + self.assertEqual(body["non_field_errors"][0], "The student is already present in the course.") + + def test_nl_locale(self): + response = self.client.post(reverse("course-students", args=["1"]), + {"student_id": 1}, + headers={"accept-language": "nl"}) + + self.assertEqual(response.status_code, 400) + body = json.loads(response.content.decode('utf-8')) + self.assertEqual(body["non_field_errors"][0], "De student bevindt zich al in de opleiding.") From 44516cff4e4066fdfee9cfbc09b05d05a6b53421 Mon Sep 17 00:00:00 2001 From: francis Date: Wed, 13 Mar 2024 20:31:52 +0100 Subject: [PATCH 359/397] fix: extra group in test --- backend/api/tests/test_project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index b1b2f6e4..bf17f5ec 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -680,6 +680,7 @@ def test_submission_status_non_empty_groups(self): # Create example groups group1 = create_group(project=project) group2 = create_group(project=project) + group3 = create_group(project=project) # noqa: F841 # Add the students to some of the groups group1.students.add(student1) From 158043a5fd9903f2cfdee6ada5aacfd38c8da7a7 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 22:34:23 +0100 Subject: [PATCH 360/397] chore: fix for tests and linter #100 --- backend/api/models/checks.py | 1 - backend/api/tests/test_file_structure.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index f1170b83..b27c3bdc 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -42,7 +42,6 @@ class StructureCheck(models.Model): # ID check should be generated automatically - class ExtraCheck(models.Model): """Model that represents an extra check for a project. These checks are not obligated to pass.""" diff --git a/backend/api/tests/test_file_structure.py b/backend/api/tests/test_file_structure.py index d16b2ea2..f29f6a17 100644 --- a/backend/api/tests/test_file_structure.py +++ b/backend/api/tests/test_file_structure.py @@ -212,5 +212,4 @@ def test_your_checking(self): obligated=[fileExtensionTS, fileExtensionTSX], blocked=[]) - succes = (True, 'zip.success') - self.assertEqual(check_zip_file(project=project, dir_path="structures/zip_struct1.zip"), succes) + self.assertTrue(check_zip_file(project=project, dir_path="structures/zip_struct1.zip")[0]) From dedade74640c50b57517d632adc43dd7363a07eb Mon Sep 17 00:00:00 2001 From: Topvennie Date: Wed, 13 Mar 2024 22:39:27 +0100 Subject: [PATCH 361/397] chore: More environment variables --- .dev.env | 5 +++-- .prod.env | 11 ++++++----- development.sh | 32 ++++++++++++++++++++++++++------ development.yml | 2 +- production.yml | 2 +- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.dev.env b/.dev.env index 3902da29..4995c47b 100644 --- a/.dev.env +++ b/.dev.env @@ -7,16 +7,17 @@ TZ=Europe/Brussels DATADIR=./data # Directory to store container data BACKEND_DIR=./backend FRONTEND_DIR=./frontend +SSL_DIR="${DATADIR}/nginx/ssl" # Directory to store SSL certificates # Redis REDIS_IP=192.168.90.10 REDIS_PORT=6379 # Django -DJANGO_SECRET_KEY= # Set to a random string +DJANGO_SECRET_KEY="" # Set to a random string DJANGO_DEBUG=True # Django debug mode DJANGO_DOMAIN_NAME=localhost # Domain name for the Django server -DJANGO_CAS_URL_PREFIX= # URL prefix for the CAS server. Should be empty for development +DJANGO_CAS_URL_PREFIX="" # URL prefix for the CAS server. Should be empty for development DJANGO_CAS_PORT=8080 # Port for the CAS server. Should be 8080 if DJANGO_DOMAIN_NAME is localhost DJANGO_DB_ENGINE=django.db.backends.sqlite3 # Database engine DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration diff --git a/.prod.env b/.prod.env index ab98b3c9..d5dfaa4a 100644 --- a/.prod.env +++ b/.prod.env @@ -7,24 +7,25 @@ TZ=Europe/Brussels DATADIR=./data # Directory to store container data BACKEND_DIR=./backend FRONTEND_DIR=./frontend +SSL_DIR="" # Directory to store SSL certificates # Postgress DB POSTGRES_IP=192.168.90.9 POSTGRES_PORT=5432 POSTGRES_DB=selab POSTGRES_USER=selab_user -POSTGRES_PASSWORD= # Set to desired password +POSTGRES_PASSWORD="" # Set to desired password # Redis REDIS_IP=192.168.90.10 REDIS_PORT=6379 # Django -DJANGO_SECRET_KEY= # Set to a random string +DJANGO_SECRET_KEY="" # Set to a random string DJANGO_DEBUG=False # Django debug mode -DJANGO_DOMAIN_NAME= # Domain name for the Django server -DJANGO_CAS_URL_PREFIX= # URL prefix for the CAS server -DJANGO_CAS_PORT= # Port for the CAS server +DJANGO_DOMAIN_NAME="" # Domain name for the Django server +DJANGO_CAS_URL_PREFIX="" # URL prefix for the CAS server +DJANGO_CAS_PORT="" # Port for the CAS server DJANGO_DB_ENGINE=django.db.backends.postgresql # Database engine configuration DJANGO_DB_NAME=${POSTGRES_DB} DJANGO_DB_USER=${POSTGRES_USER} diff --git a/development.sh b/development.sh index fc495302..fdf0e030 100755 --- a/development.sh +++ b/development.sh @@ -1,12 +1,24 @@ #!/bin/bash +backend=false +frontend=false build=false -while getopts ":b" opt; do +while getopts ":bfc" opt; do case ${opt} in - b ) build=true ;; - \? ) echo "Usage: $0 [-b]" >&2 - exit 1 ;; + b ) + backend=true + ;; + f ) + frontend=true + ;; + c ) + build=true + ;; + \? ) + echo "Usage: $0 [-b] [-f] [-c]" + exit 1 + ;; esac done @@ -36,17 +48,25 @@ if [ "$build" = true ]; then echo "Building Docker images..." echo "This can take a while..." docker-compose -f development.yml build --no-cache +else + echo "$build" fi echo "Starting services..." docker-compose -f development.yml up -d echo "-------------------------------------" -echo "Following backend logs..." +echo "Following logs..." echo "Press CTRL + C to stop all containers" echo "-------------------------------------" -docker-compose -f development.yml logs --follow --tail 50 backend +if [ "$backend" = true ] && [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 backend frontend +elif [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 frontend +else + docker-compose -f development.yml logs --follow --tail 50 backend +fi echo "Cleaning up..." diff --git a/development.yml b/development.yml index 7c093801..51aa1243 100644 --- a/development.yml +++ b/development.yml @@ -39,7 +39,7 @@ services: - 8080:8080 volumes: - $DATADIR/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro - - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro + - $SSL_DIR:/etc/nginx/ssl:ro depends_on: - backend - frontend diff --git a/production.yml b/production.yml index d0ab4212..3e1274a4 100644 --- a/production.yml +++ b/production.yml @@ -38,7 +38,7 @@ services: - 443:443 volumes: - $DATADIR/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro - - $DATADIR/nginx/ssl:/etc/nginx/ssl:ro + - $SSL_DIR:/etc/nginx/ssl:ro depends_on: - backend - frontend From 3bb04836a413ba31db15113d767b015f383a7bba Mon Sep 17 00:00:00 2001 From: francis Date: Wed, 13 Mar 2024 22:47:52 +0100 Subject: [PATCH 362/397] build: compile .po files --- .github/workflows/backend-tests.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index c8ec08da..398a238d 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 @@ -21,5 +21,10 @@ jobs: python -m pip install --upgrade pip pip install flake8 pip install -r ./backend/requirements.txt + - name: Compile translations + run: | + apt-get update + apt-get install -y gettext + django-admin compilemessages - name: Execute tests run: cd backend; python manage.py test From 4c27fbaac2f885b59b4d70057c065fcb007cd181 Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 23:06:55 +0100 Subject: [PATCH 363/397] test: fix test to be solely dependent on translation key, not translations #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 5 +++-- backend/api/locale/nl/LC_MESSAGES/django.po | 5 +++-- backend/api/tests/test_locale.py | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index c224881b..9ad864ec 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 12:56+0100\n" +"POT-Creation-Date: 2024-03-13 23:05+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,8 @@ msgstr "" msgid "courses.error.context" msgstr "The course is not supplied in the context." -#: serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: tests/test_locale.py:38 msgid "courses.error.students.already_present" msgstr "The student is already present in the course." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 8f760d02..20d171af 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 13:02+0100\n" +"POT-Creation-Date: 2024-03-13 23:05+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,7 +46,8 @@ msgstr "Het ingediende zip-bestand bevat een map die niet gevraagd is." msgid "courses.error.context" msgstr "De opleiding is niet meegeleverd als context." -#: serializers/course_serializer.py:64 +#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: tests/test_locale.py:38 msgid "courses.error.students.already_present" msgstr "De student bevindt zich al in de opleiding." diff --git a/backend/api/tests/test_locale.py b/backend/api/tests/test_locale.py index 1ad80cfd..43403caa 100644 --- a/backend/api/tests/test_locale.py +++ b/backend/api/tests/test_locale.py @@ -1,5 +1,7 @@ import json from django.urls import reverse +from django.utils.translation import activate +from django.utils.translation import gettext as _ from rest_framework.test import APITestCase from api.models.course import Course @@ -22,7 +24,8 @@ def test_default_locale(self): self.assertEqual(response.status_code, 400) body = json.loads(response.content.decode('utf-8')) - self.assertEqual(body["non_field_errors"][0], "The student is already present in the course.") + activate("en") + self.assertEqual(body["non_field_errors"][0], _("courses.error.students.already_present")) def test_nl_locale(self): response = self.client.post(reverse("course-students", args=["1"]), @@ -31,4 +34,5 @@ def test_nl_locale(self): self.assertEqual(response.status_code, 400) body = json.loads(response.content.decode('utf-8')) - self.assertEqual(body["non_field_errors"][0], "De student bevindt zich al in de opleiding.") + activate("nl") + self.assertEqual(body["non_field_errors"][0], _("courses.error.students.already_present")) From 7555246e2095b33f0b78aa9f39d8189fd4057f6f Mon Sep 17 00:00:00 2001 From: bsilkyn Date: Wed, 13 Mar 2024 23:15:42 +0100 Subject: [PATCH 364/397] chore: translate extra translation keys from project logic #100 --- backend/api/locale/en/LC_MESSAGES/django.po | 48 +++++++++++++++------ backend/api/locale/nl/LC_MESSAGES/django.po | 48 +++++++++++++++------ 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index 9ad864ec..ac864a0a 100644 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 23:05+0100\n" +"POT-Creation-Date: 2024-03-13 23:12+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -63,54 +63,74 @@ msgstr "The student is not present in the course." msgid "group.errors.score_exceeds_max" msgstr "The score exceeds the group's max score." -#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:87 msgid "group.error.context" msgstr "The group is not supplied in the context." -#: serializers/group_serializer.py:65 +#: serializers/group_serializer.py:65 serializers/group_serializer.py:99 +msgid "group.errors.locked" +msgstr "The group is currently locked." + +#: serializers/group_serializer.py:69 msgid "group.errors.full" msgstr "The group is already full." -#: serializers/group_serializer.py:69 +#: serializers/group_serializer.py:73 msgid "group.errors.not_in_course" msgstr "The student is not present in the related course." -#: serializers/group_serializer.py:73 +#: serializers/group_serializer.py:77 msgid "group.errors.already_in_group" msgstr "The student is already in the group." -#: serializers/group_serializer.py:91 +#: serializers/group_serializer.py:95 msgid "group.errors.not_present" msgstr "The student is currently not in the group." -#: serializers/project_serializer.py:61 +#: serializers/project_serializer.py:56 +msgid "project.errors.context" +msgstr "The project is not supplied in the context." + +#: serializers/project_serializer.py:60 +msgid "project.errors.start_date_in_past" +msgstr "The start date of the project lies in the past." + +#: serializers/project_serializer.py:64 +msgid "project.errors.deadline_before_start_date" +msgstr "The deadline of the project lies before the start date of the project." + +#: serializers/project_serializer.py:89 msgid "project.error.submissions.past_project" msgstr "The deadline of the project has already passed." -#: serializers/project_serializer.py:64 +#: serializers/project_serializer.py:92 msgid "project.error.submissions.non_visible_project" msgstr "The project is currently in a non-visible state." -#: serializers/project_serializer.py:67 +#: serializers/project_serializer.py:95 msgid "project.error.submissions.archived_project" msgstr "The project is archived." -#: views/course_view.py:57 +#: views/course_view.py:58 msgid "courses.success.assistants.add" msgstr "The assistant was successfully added to the course." -#: views/course_view.py:76 +#: views/course_view.py:77 msgid "courses.success.assistants.remove" msgstr "The assistant was successfully removed from the course." -#: views/course_view.py:110 +#: views/course_view.py:111 msgid "courses.success.students.add" msgstr "The student was successfully added to the course." -#: views/course_view.py:130 +#: views/course_view.py:131 msgid "courses.success.students.remove" msgstr "The student was successfully removed from the course." +#: views/course_view.py:186 +msgid "course.success.project.add" +msgstr "The project was successfully added to the course." + #: views/group_view.py:73 msgid "group.success.students.add" msgstr "The student was successfully added to the group." @@ -123,6 +143,6 @@ msgstr "The student was successfully removed from the group." msgid "group.success.submissions.add" msgstr "The submission was successfully added to the group." -#: views/project_view.py:79 +#: views/project_view.py:80 msgid "project.success.groups.created" msgstr "A group was successfully created for the project." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 20d171af..320b5cb0 100644 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-13 23:05+0100\n" +"POT-Creation-Date: 2024-03-13 23:07+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -63,55 +63,75 @@ msgstr "De student bevindt zich niet in de opleiding." msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:57 serializers/group_serializer.py:83 +#: serializers/group_serializer.py:57 serializers/group_serializer.py:87 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:65 +#: serializers/group_serializer.py:65 serializers/group_serializer.py:99 +msgid "group.errors.locked" +msgstr "De groep is momenteel vergrendeld." + +#: serializers/group_serializer.py:69 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:69 +#: serializers/group_serializer.py:73 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:73 +#: serializers/group_serializer.py:77 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in de groep." -#: serializers/group_serializer.py:91 +#: serializers/group_serializer.py:95 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:61 +#: serializers/project_serializer.py:56 +msgid "project.errors.context" +msgstr "Het project is niet meegegeven als context waar dat nodig is." + +#: serializers/project_serializer.py:60 +msgid "project.errors.start_date_in_past" +msgstr "De startdatum van het project ligt in het verleden." + +#: serializers/project_serializer.py:64 +msgid "project.errors.deadline_before_start_date" +msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." + +#: serializers/project_serializer.py:89 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum voor het project is gepasseerd." -#: serializers/project_serializer.py:64 +#: serializers/project_serializer.py:92 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/project_serializer.py:67 +#: serializers/project_serializer.py:95 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: views/course_view.py:57 +#: views/course_view.py:58 msgid "courses.success.assistants.add" msgstr "De assistent is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:76 +#: views/course_view.py:77 msgid "courses.success.assistants.remove" msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: views/course_view.py:110 +#: views/course_view.py:111 msgid "courses.success.students.add" msgstr "De student is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:130 +#: views/course_view.py:131 msgid "courses.success.students.remove" msgstr "De student is succesvol verwijderd uit de opleiding." +#: views/course_view.py:186 +msgid "course.success.project.add" +msgstr "Het project is succesvol toegevoegd aan de opleiding." + #: views/group_view.py:73 msgid "group.success.students.add" msgstr "De student is succesvol toegevoegd aan de groep." @@ -124,6 +144,6 @@ msgstr "De student is succesvol verwijderd uit de groep." msgid "group.success.submissions.add" msgstr "De indiening is succesvol toegevoegd aan de groep." -#: views/project_view.py:79 +#: views/project_view.py:80 msgid "project.success.groups.created" msgstr "De groep is succesvol toegevoegd aan het project." From 2b0b035f3390f01d95f171e365d9fda84911d5c9 Mon Sep 17 00:00:00 2001 From: Francis Vauterin <159532420+francisvaut@users.noreply.github.com> Date: Wed, 13 Mar 2024 23:21:45 +0100 Subject: [PATCH 365/397] fix: backend-tests.yaml --- .github/workflows/backend-tests.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml index 398a238d..0d56f627 100644 --- a/.github/workflows/backend-tests.yaml +++ b/.github/workflows/backend-tests.yaml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - name: Set up Python 3.11 @@ -22,9 +22,6 @@ jobs: pip install flake8 pip install -r ./backend/requirements.txt - name: Compile translations - run: | - apt-get update - apt-get install -y gettext - django-admin compilemessages + run: django-admin compilemessages - name: Execute tests run: cd backend; python manage.py test From e8e6fa0d32aff7e07db7a70c750be6539d9e86f2 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 08:00:28 +0100 Subject: [PATCH 366/397] chore: started endpoint for deleting structure_checks of a project but doesnt work --- backend/api/serializers/project_serializer.py | 19 ++++++++++++- backend/api/views/project_view.py | 27 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index 4db1fb3e..bd42dd4c 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -4,7 +4,7 @@ from api.models.group import Group from rest_framework.exceptions import ValidationError from api.models.submission import Submission, SubmissionFile -from api.models.checks import FileExtension +from api.models.checks import FileExtension, StructureCheck from api.serializers.submission_serializer import SubmissionSerializer from api.serializers.checks_serializer import StructureCheckSerializer from rest_framework.request import Request @@ -97,3 +97,20 @@ def validate(self, data): data["blocked_extensions"] = block_ext return data + + +class StructureCheckDeleteSerializer(StructureCheckSerializer): + def validate(self, data): + if "project" not in self.context: + raise ValidationError(gettext("project.error.context")) + + project: Project = self.context["project"] + + # Get the struture_check + structureCheck: StructureCheck = data["structure_check_id"] + + # Make sure the struture_check was in the project + if True or not project.structure_checks.filter(id=structureCheck.id).exists(): + raise ValidationError(gettext("struture_check.errors.not_present")) + + return data diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index fb9f01ad..0735a7d1 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -7,9 +7,10 @@ from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group from ..models.project import Project +from api.models.checks import StructureCheck from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer -from api.serializers.project_serializer import StructureCheckAddSerializer +from api.serializers.project_serializer import StructureCheckAddSerializer, StructureCheckDeleteSerializer from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer from rest_framework.request import Request @@ -118,6 +119,30 @@ def _add_structure_check(self, request: Request, **_): "message": gettext("project.success.structure_check.add") }) + @structure_checks.mapping.delete + def _delete_structure_check(self, request: Request, **_): # TODO test + """Remove an structure_check to the project""" + + project: Project = self.get_object() + + # Add submission to course + serializer = StructureCheckDeleteSerializer( + data=request.data, + context={ + "project": project, + "request": request + } + ) + + if serializer.is_valid(raise_exception=True): + project.structure_checks.remove( + serializer.validated_data["structure_check_id"] + ) + + return Response({ + "message": gettext("project.success.structure_check.remove") + }) + @action(detail=True, methods=["get"]) def extra_checks(self, request, **_): """Returns the extra checks for the given project""" From 8370eedf274a94fc9d4823e574603558cab23d29 Mon Sep 17 00:00:00 2001 From: Lander Maes Date: Sun, 10 Mar 2024 21:44:34 +0100 Subject: [PATCH 367/397] temp --- backend/api/views/admin_view.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index ad0862d0..7386072d 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,25 @@ -from rest_framework import viewsets -from authentication.serializers import UserSerializer +from django.utils.translation import gettext +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +from authentication.serializers import UserSerializer, UserIDSerializer from authentication.models import User -class AdminViewSet(viewsets.ReadOnlyModelViewSet): +class AdminViewSet(ReadOnlyModelViewSet): queryset = User.objects.filter(is_staff=True) serializer_class = UserSerializer + permission_classes = [IsAdminUser] + + def create(self, request): + # Add an Admin + serializer = UserIDSerializer(data=request.data) + + if serializer.is_valid(raise_exception=True): + user = User.objects.get(serializer.validated_data["user_id"]) + user.make_admin() + + return Response({ + "message": gettext("admins.success.add") + }) \ No newline at end of file From 2300246b38f72cf2797dbc514e2227cf80433c87 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Sun, 10 Mar 2024 23:01:38 +0100 Subject: [PATCH 368/397] fix: add admins --- backend/api/serializers/assistant_serializer.py | 2 +- backend/api/views/admin_view.py | 13 +++++++------ backend/api/views/course_view.py | 4 ++-- backend/authentication/serializers.py | 6 ++++++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 639b184b..c5569924 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -27,6 +27,6 @@ class Meta: class AssistantIDSerializer(serializers.Serializer): - assistant_id = serializers.PrimaryKeyRelatedField( + assistant = serializers.PrimaryKeyRelatedField( queryset=Assistant.objects.all() ) diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 7386072d..5e7d633a 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,8 @@ from django.utils.translation import gettext from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework.permissions import IsAdminUser from rest_framework.response import Response - +from rest_framework.request import Request +from rest_framework.permissions import IsAdminUser from authentication.serializers import UserSerializer, UserIDSerializer from authentication.models import User @@ -12,13 +12,14 @@ class AdminViewSet(ReadOnlyModelViewSet): serializer_class = UserSerializer permission_classes = [IsAdminUser] - def create(self, request): + def create(self, request: Request) -> Response: # Add an Admin - serializer = UserIDSerializer(data=request.data) + serializer = UserIDSerializer( + data=request.data + ) if serializer.is_valid(raise_exception=True): - user = User.objects.get(serializer.validated_data["user_id"]) - user.make_admin() + serializer.validated_data["user"].make_admin() return Response({ "message": gettext("admins.success.add") diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 991aeff9..cc16a1f5 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -51,7 +51,7 @@ def _add_assistant(self, request: Request, **_): if serializer.is_valid(raise_exception=True): course.assistants.add( - serializer.validated_data["assistant_id"] + serializer.validated_data["assistant"] ) return Response({ @@ -70,7 +70,7 @@ def _remove_assistant(self, request: Request, **_): if serializer.is_valid(raise_exception=True): course.assistants.remove( - serializer.validated_data["assistant_id"] + serializer.validated_data["assistant"] ) return Response({ diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 3005d86f..6fbd8d69 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -12,6 +12,7 @@ ModelSerializer, Serializer, ValidationError, + PrimaryKeyRelatedField ) from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, RefreshToken @@ -108,3 +109,8 @@ class Meta: def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) + +class UserIDSerializer(Serializer): + user = PrimaryKeyRelatedField( + queryset=User.objects.all() + ) From 83e7a0c4a3585caf377453f851972be27f6e72c4 Mon Sep 17 00:00:00 2001 From: lander Date: Mon, 11 Mar 2024 12:10:47 +0100 Subject: [PATCH 369/397] bugfix admins #88 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 73b3aa42..0409767f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ .tool-versions .env .venv +.idea +.vscode data/* data/nginx/ssl/* data/postres* data/redis/* +/node_modules backend/staticfiles/* !data/nginx/ssl/.gitkeep From ab3bebac6b393a6bb58b6e661e94f3b96f1e95c5 Mon Sep 17 00:00:00 2001 From: lander Date: Mon, 11 Mar 2024 15:43:34 +0100 Subject: [PATCH 370/397] close #90 --- .../api/permissions/assistant_permissions.py | 23 +++++++++++++++++++ backend/api/views/assistant_view.py | 10 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 backend/api/permissions/assistant_permissions.py diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py new file mode 100644 index 00000000..b9b7e2ac --- /dev/null +++ b/backend/api/permissions/assistant_permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.models.course import Course + + +class AssistantPermission(BasePermission): + """Permission class used as default policy for assistant endpoint.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general assistant endpoint.""" + user: User = request.user + # Only admins and teachers can fetch all assistants. + return request.method in SAFE_METHODS and is_teacher(user) + + + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + """Check if user has permission to view a detailed assistant endpoint""" + user: User = request.user + + # Logged-in users can fetch assistant details. + return request.method in SAFE_METHODS and user.is_authenticated \ No newline at end of file diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index ea75fc8b..8bc8d23b 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,14 +1,22 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.permissions.role_permissions import IsTeacher +from api.permissions.assistant_permissions import AssistantPermission +from api.models.assistant import Assistant from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer +### TODO : - courses kan enkel door assistenten en admins opgevraagd worden (teachers?) +### - Enkel Teachers en admins kunnen assistenten maken +class AssistantViewSet(ReadOnlyModelViewSet): -class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer + permission_classes = [IsAdminUser, AssistantPermission] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): From 8993826435c5470cc455a43673713d2e81ce6d04 Mon Sep 17 00:00:00 2001 From: lander Date: Mon, 11 Mar 2024 15:45:14 +0100 Subject: [PATCH 371/397] Revert "close #90" This reverts commit a694b6606d06800370b19bf26accae05559adf0b. --- .../api/permissions/assistant_permissions.py | 23 ------------------- backend/api/views/assistant_view.py | 10 +------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 backend/api/permissions/assistant_permissions.py diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py deleted file mode 100644 index b9b7e2ac..00000000 --- a/backend/api/permissions/assistant_permissions.py +++ /dev/null @@ -1,23 +0,0 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS -from rest_framework.request import Request -from rest_framework.viewsets import ViewSet -from authentication.models import User -from api.permissions.role_permissions import is_student, is_assistant, is_teacher -from api.models.course import Course - - -class AssistantPermission(BasePermission): - """Permission class used as default policy for assistant endpoint.""" - def has_permission(self, request: Request, view: ViewSet) -> bool: - """Check if user has permission to view a general assistant endpoint.""" - user: User = request.user - # Only admins and teachers can fetch all assistants. - return request.method in SAFE_METHODS and is_teacher(user) - - - def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: - """Check if user has permission to view a detailed assistant endpoint""" - user: User = request.user - - # Logged-in users can fetch assistant details. - return request.method in SAFE_METHODS and user.is_authenticated \ No newline at end of file diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 8bc8d23b..ea75fc8b 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,22 +1,14 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework.permissions import IsAdminUser -from api.permissions.role_permissions import IsTeacher -from api.permissions.assistant_permissions import AssistantPermission -from api.models.assistant import Assistant from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer -### TODO : - courses kan enkel door assistenten en admins opgevraagd worden (teachers?) -### - Enkel Teachers en admins kunnen assistenten maken -class AssistantViewSet(ReadOnlyModelViewSet): +class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer - permission_classes = [IsAdminUser, AssistantPermission] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): From ead90adab5ac4d33bd689ffeafd011eccb76c63b Mon Sep 17 00:00:00 2001 From: lander Date: Mon, 11 Mar 2024 15:51:49 +0100 Subject: [PATCH 372/397] Revert "Revert "close #90"" This reverts commit 658670630dc594ceeb2a797b9e6aba47fa23a4a6. --- .../api/permissions/assistant_permissions.py | 23 +++++++++++++++++++ backend/api/views/assistant_view.py | 10 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 backend/api/permissions/assistant_permissions.py diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py new file mode 100644 index 00000000..b9b7e2ac --- /dev/null +++ b/backend/api/permissions/assistant_permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.models.course import Course + + +class AssistantPermission(BasePermission): + """Permission class used as default policy for assistant endpoint.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general assistant endpoint.""" + user: User = request.user + # Only admins and teachers can fetch all assistants. + return request.method in SAFE_METHODS and is_teacher(user) + + + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + """Check if user has permission to view a detailed assistant endpoint""" + user: User = request.user + + # Logged-in users can fetch assistant details. + return request.method in SAFE_METHODS and user.is_authenticated \ No newline at end of file diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index ea75fc8b..8bc8d23b 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,14 +1,22 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.permissions.role_permissions import IsTeacher +from api.permissions.assistant_permissions import AssistantPermission +from api.models.assistant import Assistant from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer +### TODO : - courses kan enkel door assistenten en admins opgevraagd worden (teachers?) +### - Enkel Teachers en admins kunnen assistenten maken +class AssistantViewSet(ReadOnlyModelViewSet): -class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer + permission_classes = [IsAdminUser, AssistantPermission] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): From 6eba736bf8411bf566fbf1313aaf33b30f5b7267 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Mon, 11 Mar 2024 16:01:11 +0100 Subject: [PATCH 373/397] chore: assistant course action refactor --- .../api/permissions/assistant_permissions.py | 4 ++-- backend/api/views/assistant_view.py | 24 +++++++------------ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py index b9b7e2ac..61989877 100644 --- a/backend/api/permissions/assistant_permissions.py +++ b/backend/api/permissions/assistant_permissions.py @@ -11,8 +11,8 @@ class AssistantPermission(BasePermission): def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general assistant endpoint.""" user: User = request.user - # Only admins and teachers can fetch all assistants. - return request.method in SAFE_METHODS and is_teacher(user) + # Only teachers can fetch all assistants. + return is_teacher(user) def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 8bc8d23b..3223e84e 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -19,22 +19,14 @@ class AssistantViewSet(ReadOnlyModelViewSet): permission_classes = [IsAdminUser, AssistantPermission] @action(detail=True, methods=["get"]) - def courses(self, request, pk=None): + def courses(self, request, **_): """Returns a list of courses for the given assistant""" + assistant = self.get_object() + courses = assistant.courses() - try: - queryset = Assistant.objects.get(id=pk) - courses = queryset.courses.all() + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Assistant.DoesNotExist: - # Invalid assistant ID - return Response( - status=status.HTTP_404_NOT_FOUND, - data={"message": "Assistant not found"}, - ) + return Response(serializer.data) \ No newline at end of file From a187908caf5ca0f35075d2d793afbedb3a5c6eb7 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 10:35:52 +0100 Subject: [PATCH 374/397] chore: moved data to backend folder and added development enviroment --- backend/api/serializers/project_serializer.py | 17 -------------- backend/api/views/project_view.py | 22 +----------------- .../data}/production/structures/empty.zip | Bin .../production/structures/mixed_template.zip | Bin .../structures/only_files_template.zip | Bin .../structures/only_folders_template.zip | Bin .../data}/production/structures/remake.zip | Bin .../data}/production/structures/root.zip | Bin .../production/structures/zip_struct1.zip | Bin .../data}/production/tests/mixed.zip | Bin .../data}/production/tests/only_files.zip | Bin .../data}/production/tests/only_folders.zip | Bin .../production/tests/test_zip1struct1.zip | Bin .../production/tests/test_zip2struct1.zip | Bin .../production/tests/test_zip3struct1.zip | Bin .../production/tests/test_zip4struct1.zip | Bin .../testing/structures/mixed_template.zip | Bin .../structures/only_files_template.zip | Bin .../structures/only_folders_template.zip | Bin .../data}/testing/structures/remake.zip | Bin .../data}/testing/structures/root.zip | Bin .../data}/testing/structures/zip_struct1.zip | Bin .../data}/testing/tests/mixed.zip | Bin .../data}/testing/tests/only_files.zip | Bin .../data}/testing/tests/only_folders.zip | Bin .../data}/testing/tests/test_zip1struct1.zip | Bin .../data}/testing/tests/test_zip2struct1.zip | Bin .../data}/testing/tests/test_zip3struct1.zip | Bin .../data}/testing/tests/test_zip4struct1.zip | Bin backend/ypovoli/settings.py | 2 +- 30 files changed, 2 insertions(+), 39 deletions(-) rename {data => backend/data}/production/structures/empty.zip (100%) rename {data => backend/data}/production/structures/mixed_template.zip (100%) rename {data => backend/data}/production/structures/only_files_template.zip (100%) rename {data => backend/data}/production/structures/only_folders_template.zip (100%) rename {data => backend/data}/production/structures/remake.zip (100%) rename {data => backend/data}/production/structures/root.zip (100%) rename {data => backend/data}/production/structures/zip_struct1.zip (100%) rename {data => backend/data}/production/tests/mixed.zip (100%) rename {data => backend/data}/production/tests/only_files.zip (100%) rename {data => backend/data}/production/tests/only_folders.zip (100%) rename {data => backend/data}/production/tests/test_zip1struct1.zip (100%) rename {data => backend/data}/production/tests/test_zip2struct1.zip (100%) rename {data => backend/data}/production/tests/test_zip3struct1.zip (100%) rename {data => backend/data}/production/tests/test_zip4struct1.zip (100%) rename {data => backend/data}/testing/structures/mixed_template.zip (100%) rename {data => backend/data}/testing/structures/only_files_template.zip (100%) rename {data => backend/data}/testing/structures/only_folders_template.zip (100%) rename {data => backend/data}/testing/structures/remake.zip (100%) rename {data => backend/data}/testing/structures/root.zip (100%) rename {data => backend/data}/testing/structures/zip_struct1.zip (100%) rename {data => backend/data}/testing/tests/mixed.zip (100%) rename {data => backend/data}/testing/tests/only_files.zip (100%) rename {data => backend/data}/testing/tests/only_folders.zip (100%) rename {data => backend/data}/testing/tests/test_zip1struct1.zip (100%) rename {data => backend/data}/testing/tests/test_zip2struct1.zip (100%) rename {data => backend/data}/testing/tests/test_zip3struct1.zip (100%) rename {data => backend/data}/testing/tests/test_zip4struct1.zip (100%) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index cfdb73bb..47af0ed5 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -126,20 +126,3 @@ def validate(self, data): data["blocked_extensions"] = block_ext return data - - -class StructureCheckDeleteSerializer(StructureCheckSerializer): - def validate(self, data): - if "project" not in self.context: - raise ValidationError(gettext("project.error.context")) - - project: Project = self.context["project"] - - # Get the struture_check - structureCheck: StructureCheck = data["structure_check_id"] - - # Make sure the struture_check was in the project - if True or not project.structure_checks.filter(id=structureCheck.id).exists(): - raise ValidationError(gettext("struture_check.errors.not_present")) - - return data diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 7776a493..cd5beeea 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -11,7 +11,7 @@ from api.models.checks import StructureCheck from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer -from api.serializers.project_serializer import StructureCheckAddSerializer, StructureCheckDeleteSerializer, SubmissionStatusSerializer +from api.serializers.project_serializer import StructureCheckAddSerializer, SubmissionStatusSerializer from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer from rest_framework.request import Request @@ -120,26 +120,6 @@ def _add_structure_check(self, request: Request, **_): "message": gettext("project.success.structure_check.add") }) - @structure_checks.mapping.delete - def _delete_structure_check(self, request: Request, **_): # TODO test - """Remove an structure_check to the project""" - - project: Project = self.get_object() - - # Add submission to course - serializer = StructureCheckDeleteSerializer( - data=request.data, - context={ - "project": project, - "request": request - } - ) - - if serializer.is_valid(raise_exception=True): - project.structure_checks.remove( - serializer.validated_data["structure_check_id"] - ) - return Response({ "message": gettext("project.success.structure_check.remove") }) diff --git a/data/production/structures/empty.zip b/backend/data/production/structures/empty.zip similarity index 100% rename from data/production/structures/empty.zip rename to backend/data/production/structures/empty.zip diff --git a/data/production/structures/mixed_template.zip b/backend/data/production/structures/mixed_template.zip similarity index 100% rename from data/production/structures/mixed_template.zip rename to backend/data/production/structures/mixed_template.zip diff --git a/data/production/structures/only_files_template.zip b/backend/data/production/structures/only_files_template.zip similarity index 100% rename from data/production/structures/only_files_template.zip rename to backend/data/production/structures/only_files_template.zip diff --git a/data/production/structures/only_folders_template.zip b/backend/data/production/structures/only_folders_template.zip similarity index 100% rename from data/production/structures/only_folders_template.zip rename to backend/data/production/structures/only_folders_template.zip diff --git a/data/production/structures/remake.zip b/backend/data/production/structures/remake.zip similarity index 100% rename from data/production/structures/remake.zip rename to backend/data/production/structures/remake.zip diff --git a/data/production/structures/root.zip b/backend/data/production/structures/root.zip similarity index 100% rename from data/production/structures/root.zip rename to backend/data/production/structures/root.zip diff --git a/data/production/structures/zip_struct1.zip b/backend/data/production/structures/zip_struct1.zip similarity index 100% rename from data/production/structures/zip_struct1.zip rename to backend/data/production/structures/zip_struct1.zip diff --git a/data/production/tests/mixed.zip b/backend/data/production/tests/mixed.zip similarity index 100% rename from data/production/tests/mixed.zip rename to backend/data/production/tests/mixed.zip diff --git a/data/production/tests/only_files.zip b/backend/data/production/tests/only_files.zip similarity index 100% rename from data/production/tests/only_files.zip rename to backend/data/production/tests/only_files.zip diff --git a/data/production/tests/only_folders.zip b/backend/data/production/tests/only_folders.zip similarity index 100% rename from data/production/tests/only_folders.zip rename to backend/data/production/tests/only_folders.zip diff --git a/data/production/tests/test_zip1struct1.zip b/backend/data/production/tests/test_zip1struct1.zip similarity index 100% rename from data/production/tests/test_zip1struct1.zip rename to backend/data/production/tests/test_zip1struct1.zip diff --git a/data/production/tests/test_zip2struct1.zip b/backend/data/production/tests/test_zip2struct1.zip similarity index 100% rename from data/production/tests/test_zip2struct1.zip rename to backend/data/production/tests/test_zip2struct1.zip diff --git a/data/production/tests/test_zip3struct1.zip b/backend/data/production/tests/test_zip3struct1.zip similarity index 100% rename from data/production/tests/test_zip3struct1.zip rename to backend/data/production/tests/test_zip3struct1.zip diff --git a/data/production/tests/test_zip4struct1.zip b/backend/data/production/tests/test_zip4struct1.zip similarity index 100% rename from data/production/tests/test_zip4struct1.zip rename to backend/data/production/tests/test_zip4struct1.zip diff --git a/data/testing/structures/mixed_template.zip b/backend/data/testing/structures/mixed_template.zip similarity index 100% rename from data/testing/structures/mixed_template.zip rename to backend/data/testing/structures/mixed_template.zip diff --git a/data/testing/structures/only_files_template.zip b/backend/data/testing/structures/only_files_template.zip similarity index 100% rename from data/testing/structures/only_files_template.zip rename to backend/data/testing/structures/only_files_template.zip diff --git a/data/testing/structures/only_folders_template.zip b/backend/data/testing/structures/only_folders_template.zip similarity index 100% rename from data/testing/structures/only_folders_template.zip rename to backend/data/testing/structures/only_folders_template.zip diff --git a/data/testing/structures/remake.zip b/backend/data/testing/structures/remake.zip similarity index 100% rename from data/testing/structures/remake.zip rename to backend/data/testing/structures/remake.zip diff --git a/data/testing/structures/root.zip b/backend/data/testing/structures/root.zip similarity index 100% rename from data/testing/structures/root.zip rename to backend/data/testing/structures/root.zip diff --git a/data/testing/structures/zip_struct1.zip b/backend/data/testing/structures/zip_struct1.zip similarity index 100% rename from data/testing/structures/zip_struct1.zip rename to backend/data/testing/structures/zip_struct1.zip diff --git a/data/testing/tests/mixed.zip b/backend/data/testing/tests/mixed.zip similarity index 100% rename from data/testing/tests/mixed.zip rename to backend/data/testing/tests/mixed.zip diff --git a/data/testing/tests/only_files.zip b/backend/data/testing/tests/only_files.zip similarity index 100% rename from data/testing/tests/only_files.zip rename to backend/data/testing/tests/only_files.zip diff --git a/data/testing/tests/only_folders.zip b/backend/data/testing/tests/only_folders.zip similarity index 100% rename from data/testing/tests/only_folders.zip rename to backend/data/testing/tests/only_folders.zip diff --git a/data/testing/tests/test_zip1struct1.zip b/backend/data/testing/tests/test_zip1struct1.zip similarity index 100% rename from data/testing/tests/test_zip1struct1.zip rename to backend/data/testing/tests/test_zip1struct1.zip diff --git a/data/testing/tests/test_zip2struct1.zip b/backend/data/testing/tests/test_zip2struct1.zip similarity index 100% rename from data/testing/tests/test_zip2struct1.zip rename to backend/data/testing/tests/test_zip2struct1.zip diff --git a/data/testing/tests/test_zip3struct1.zip b/backend/data/testing/tests/test_zip3struct1.zip similarity index 100% rename from data/testing/tests/test_zip3struct1.zip rename to backend/data/testing/tests/test_zip3struct1.zip diff --git a/data/testing/tests/test_zip4struct1.zip b/backend/data/testing/tests/test_zip4struct1.zip similarity index 100% rename from data/testing/tests/test_zip4struct1.zip rename to backend/data/testing/tests/test_zip4struct1.zip diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 5e4ce6de..5a3f7c23 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -18,7 +18,7 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent -MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "../data/production")) +MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "data/production")) TESTING_BASE_LINK = "http://testserver" From 0307675663b9d2cbccce7d783e9546ed28ce49ef Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 14 Mar 2024 11:34:18 +0100 Subject: [PATCH 375/397] feat: assistant permissions --- .../api/permissions/assistant_permissions.py | 24 +++++++++---------- backend/api/views/admin_view.py | 4 +++- backend/api/views/assistant_view.py | 7 +----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py index 61989877..6cbf5133 100644 --- a/backend/api/permissions/assistant_permissions.py +++ b/backend/api/permissions/assistant_permissions.py @@ -1,23 +1,23 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.viewsets import ViewSet -from authentication.models import User -from api.permissions.role_permissions import is_student, is_assistant, is_teacher -from api.models.course import Course +from api.permissions.role_permissions import is_teacher, is_assistant +from api.models.assistant import Assistant class AssistantPermission(BasePermission): """Permission class used as default policy for assistant endpoint.""" def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general assistant endpoint.""" - user: User = request.user - # Only teachers can fetch all assistants. - return is_teacher(user) + user = request.user + if view.action == "list": + # Only teachers can query the assistant list. + return user.is_authenticated and is_teacher(user) - def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: - """Check if user has permission to view a detailed assistant endpoint""" - user: User = request.user + return is_teacher(user) or is_assistant(user) - # Logged-in users can fetch assistant details. - return request.method in SAFE_METHODS and user.is_authenticated \ No newline at end of file + def has_object_permission(self, request: Request, view: ViewSet, assistant: Assistant) -> bool: + # Teachers can view the details of all assistants. + # Users can view their own assistant object. + return is_teacher(request.user) or request.user.id == assistant.id \ No newline at end of file diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index 5e7d633a..e5e9900f 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -13,7 +13,9 @@ class AdminViewSet(ReadOnlyModelViewSet): permission_classes = [IsAdminUser] def create(self, request: Request) -> Response: - # Add an Admin + """ + Make the provided user admin by setting `is_staff` = true. + """ serializer = UserIDSerializer( data=request.data ) diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 3223e84e..d1f161cc 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -1,22 +1,17 @@ -from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.permissions import IsAdminUser -from api.permissions.role_permissions import IsTeacher from api.permissions.assistant_permissions import AssistantPermission -from api.models.assistant import Assistant from ..models.assistant import Assistant from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer -### TODO : - courses kan enkel door assistenten en admins opgevraagd worden (teachers?) -### - Enkel Teachers en admins kunnen assistenten maken class AssistantViewSet(ReadOnlyModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer - permission_classes = [IsAdminUser, AssistantPermission] + permission_classes = [IsAdminUser | AssistantPermission] @action(detail=True, methods=["get"]) def courses(self, request, **_): From ac6e22fd5406b1b1845fc5d9fd2c4da0e922a8e7 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 12:28:50 +0100 Subject: [PATCH 376/397] chore: checks structure checks --- backend/api/tests/test_submission.py | 62 ++++++++++++++++++++++++++++ backend/api/views/project_view.py | 4 -- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index a9aa6c9f..f053cdb5 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -1,4 +1,5 @@ import json +from django.utils.translation import gettext from datetime import timedelta from django.utils import timezone from django.urls import reverse @@ -10,6 +11,7 @@ from api.models.course import Course from api.models.checks import ExtraCheck from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile def create_course(name, academic_startyear, description=None, parent_course=None): @@ -32,6 +34,15 @@ def create_project(name, description, days, course): ) +def create_past_project(name, description, days, course, daysStartDate): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + startDate = timezone.now() + timedelta(days=daysStartDate) + return Project.objects.create( + name=name, description=description, deadline=deadline, course=course, score_visible=True, start_date=startDate + ) + + def create_group(project, score): """Create a Group with the given arguments.""" return Group.objects.create(project=project, score=score) @@ -292,3 +303,54 @@ def test_submission_extra_checks(self): self.assertEqual( retrieved_extra_check["passed"], extra_check_result.passed ) + + def test_submission_before_deadline(self): + """ + Able to subbmit to a project before the deadline. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + + def test_submission_after_deadline(self): + """ + Not able to subbmit to a project after the deadline. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_startyear=2023) + project = create_past_project( + name="Project 1", description="Description 1", days=-7, course=course, daysStartDate=-84 + ) + # project.increase_deadline(days=-10) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), {'non_field_errors': [gettext("project.error.submissions.past_project")]}) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index cd5beeea..abe0a9f1 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -120,10 +120,6 @@ def _add_structure_check(self, request: Request, **_): "message": gettext("project.success.structure_check.add") }) - return Response({ - "message": gettext("project.success.structure_check.remove") - }) - @action(detail=True, methods=["get"]) def extra_checks(self, request, **_): """Returns the extra checks for the given project""" From e7e32654a2bef40717718352f760ec2db18bbd77 Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 14 Mar 2024 13:05:16 +0100 Subject: [PATCH 377/397] chore: permission cleanup --- backend/api/models/teacher.py | 4 ++ backend/api/permissions/course_permissions.py | 6 +- backend/api/permissions/role_permissions.py | 9 +++ backend/api/views/assistant_view.py | 2 +- backend/api/views/group_view.py | 3 +- backend/api/views/project_view.py | 16 ++--- backend/api/views/student_view.py | 64 ++++++++----------- backend/api/views/teacher_view.py | 37 ++++++----- backend/api/views/user_view.py | 8 ++- 9 files changed, 73 insertions(+), 76 deletions(-) diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 89f3d471..92943b26 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -16,3 +16,7 @@ class Teacher(User): related_name="teachers", blank=True, ) + + def has_course(self, course: Course) -> bool: + """Checks if the teacher has the given course.""" + return self.courses.contains(course) \ No newline at end of file diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 5bce23e7..a6c5ef19 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -22,11 +22,11 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: """Check if user has permission to view a detailed course endpoint""" - user: User = request.user + user = request.user # Logged-in users can fetch course details. if request.method in SAFE_METHODS: - return user.is_authenticated + return is_student(user) or is_teacher(user) or is_assistant(user) # We only allow teachers and assistants to modify their own courses. return is_teacher(user) and user.teacher.courses.contains(course) or \ @@ -44,7 +44,7 @@ def has_object_permission(self, request: Request, view: ViewSet, course: Course) return user.is_authenticated # Only teachers can modify assistants of their own courses. - return is_teacher(user) and user.teacher.courses.contains(course) + return is_teacher(user) and user.teacher.has_course(course) class CourseStudentPermission(CoursePermission): diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 1e9c275c..a3c814bc 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,5 +1,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request +from rest_framework.viewsets import ViewSet from authentication.models import User from api.models.student import Student from api.models.assistant import Assistant @@ -37,3 +38,11 @@ def has_permission(self, request, view): """Returns true if the request contains a user, with said user being a student""" return super().has_permission(request, view) and is_assistant(request.user) + +class IsSameUser(IsAuthenticated): + def has_permission(self, request, view): + return False + + def has_object_permission(self, request: Request, view: ViewSet, user: User): + """Returns true if the request's user is the same as the given user""" + return super().has_permission(request, view) and user.id == request.user.id \ No newline at end of file diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index d1f161cc..3a91b1c4 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -17,7 +17,7 @@ class AssistantViewSet(ReadOnlyModelViewSet): def courses(self, request, **_): """Returns a list of courses for the given assistant""" assistant = self.get_object() - courses = assistant.courses() + courses = assistant.courses # Serialize the course objects serializer = CourseSerializer( diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index ef82db0d..98550fdb 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -95,8 +95,7 @@ def _remove_student(self, request, **_): @submissions.mapping.post @submissions.mapping.put def _add_submission(self, request: Request, **_): - """Add an submission to the group""" - + """Add a submission to the group""" group: Group = self.get_object() # Add submission to course diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 08eb61d3..65a9c8ef 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,14 +1,14 @@ from django.utils.translation import gettext from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin from rest_framework.permissions import IsAdminUser -from rest_framework import viewsets +from rest_framework.viewsets import GenericViewSet from rest_framework.decorators import action from rest_framework.response import Response from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission from api.models.group import Group from api.models.submission import Submission -from ..models.project import Project -from ..serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer +from api.models.project import Project +from api.serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer, SubmissionStatusSerializer from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer @@ -18,7 +18,7 @@ class ProjectViewSet(CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, - viewsets.GenericViewSet): + GenericViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer @@ -39,12 +39,9 @@ def groups(self, request, **_): return Response(serializer.data) - """ - @action(detail=True, permission_classes=[IsAdminUser]) + @action(detail=True) def submissions(self, request, **_): - # Returns a list of subbmisions for the given project - # This automatically fetches the group from the URL. - # It automatically gives back a 404 HTTP response in case of not found. + """Returns a list of submissions for the given project""" project = self.get_object() submissions = project.submissions.all() @@ -54,7 +51,6 @@ def submissions(self, request, **_): ) return Response(serializer.data) - """ @groups.mapping.post def _create_groups(self, request, **_): diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index 4fe6f92c..8517a0b8 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -1,52 +1,40 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from ..models.student import Student -from ..serializers.student_serializer import StudentSerializer -from ..serializers.course_serializer import CourseSerializer -from ..serializers.group_serializer import GroupSerializer +from rest_framework.permissions import IsAdminUser +from api.permissions.role_permissions import IsSameUser, IsTeacher +from api.models.student import Student +from api.serializers.student_serializer import StudentSerializer +from api.serializers.course_serializer import CourseSerializer +from api.serializers.group_serializer import GroupSerializer class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer + permission_classes = [IsAdminUser | IsTeacher | IsSameUser] - @action(detail=True, methods=["get"]) - def courses(self, request, pk=None): + @action(detail=True) + def courses(self, request, **_): """Returns a list of courses for the given student""" + student = self.get_object() + courses = student.courses.all() - try: - queryset = Student.objects.get(id=pk) - courses = queryset.courses.all() + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) + return Response(serializer.data) - except Student.DoesNotExist: - # Invalid student ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} - ) - - @action(detail=True, methods=["get"]) - def groups(self, request, pk=None): + @action(detail=True) + def groups(self, request, **_): """Returns a list of groups for the given student""" - - try: - queryset = Student.objects.get(id=pk) - groups = queryset.groups.all() - - # Serialize the group objects - serializer = GroupSerializer( - groups, many=True, context={"request": request} - ) - return Response(serializer.data) - - except Student.DoesNotExist: - # Invalid student ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} - ) + student = self.get_object() + groups = student.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) + return Response(serializer.data) diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 49038133..c16d4167 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -1,31 +1,30 @@ -from rest_framework import viewsets, status +from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response -from ..models.teacher import Teacher -from ..serializers.teacher_serializer import TeacherSerializer -from ..serializers.course_serializer import CourseSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.models.course import Course +from api.models.teacher import Teacher +from api.serializers.teacher_serializer import TeacherSerializer +from api.serializers.course_serializer import CourseSerializer +from api.permissions.role_permissions import IsSameUser -class TeacherViewSet(viewsets.ModelViewSet): + +class TeacherViewSet(ReadOnlyModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer + permission_classes = [IsAdminUser | IsSameUser] @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" + teacher = self.get_object() + courses = teacher.courses.all() - try: - queryset = Teacher.objects.get(id=pk) - courses = queryset.courses.all() - - # Serialize the course objects - serializer = CourseSerializer( - courses, many=True, context={"request": request} - ) - return Response(serializer.data) + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) - except Teacher.DoesNotExist: - # Invalid teacher ID - return Response( - status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} - ) + return Response(serializer.data) diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py index 7868eacf..f584f6fe 100644 --- a/backend/api/views/user_view.py +++ b/backend/api/views/user_view.py @@ -1,19 +1,21 @@ from api.permissions.notification_permissions import NotificationPermission +from api.permissions.role_permissions import IsSameUser from authentication.models import User from authentication.serializers import UserSerializer from notifications.models import Notification from notifications.serializers import NotificationSerializer from rest_framework.decorators import action -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_200_OK -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser -class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): +class UserViewSet(ReadOnlyModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer + permission_classes = [IsAdminUser | IsSameUser] @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) def notifications(self, request: Request, pk: str): From d4ed3fe6b55b0162502ad62ccc59427403bc4daa Mon Sep 17 00:00:00 2001 From: EwoutV Date: Thu, 14 Mar 2024 13:10:02 +0100 Subject: [PATCH 378/397] chore: linting --- backend/api/models/teacher.py | 2 +- backend/api/permissions/assistant_permissions.py | 2 +- backend/api/permissions/role_permissions.py | 3 ++- backend/api/views/admin_view.py | 2 +- backend/api/views/assistant_view.py | 3 ++- backend/authentication/serializers.py | 1 + 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 92943b26..6866dd7b 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -19,4 +19,4 @@ class Teacher(User): def has_course(self, course: Course) -> bool: """Checks if the teacher has the given course.""" - return self.courses.contains(course) \ No newline at end of file + return self.courses.contains(course) diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py index 6cbf5133..5cb76fc1 100644 --- a/backend/api/permissions/assistant_permissions.py +++ b/backend/api/permissions/assistant_permissions.py @@ -20,4 +20,4 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: def has_object_permission(self, request: Request, view: ViewSet, assistant: Assistant) -> bool: # Teachers can view the details of all assistants. # Users can view their own assistant object. - return is_teacher(request.user) or request.user.id == assistant.id \ No newline at end of file + return is_teacher(request.user) or request.user.id == assistant.id diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index a3c814bc..2c50aa5b 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -39,10 +39,11 @@ def has_permission(self, request, view): with said user being a student""" return super().has_permission(request, view) and is_assistant(request.user) + class IsSameUser(IsAuthenticated): def has_permission(self, request, view): return False def has_object_permission(self, request: Request, view: ViewSet, user: User): """Returns true if the request's user is the same as the given user""" - return super().has_permission(request, view) and user.id == request.user.id \ No newline at end of file + return super().has_permission(request, view) and user.id == request.user.id diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index e5e9900f..a1292c88 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -25,4 +25,4 @@ def create(self, request: Request) -> Response: return Response({ "message": gettext("admins.success.add") - }) \ No newline at end of file + }) diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 3a91b1c4..e20bec81 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -7,6 +7,7 @@ from ..serializers.assistant_serializer import AssistantSerializer from ..serializers.course_serializer import CourseSerializer + class AssistantViewSet(ReadOnlyModelViewSet): queryset = Assistant.objects.all() @@ -24,4 +25,4 @@ def courses(self, request, **_): courses, many=True, context={"request": request} ) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 6fbd8d69..86c44cf7 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -110,6 +110,7 @@ def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" return User.objects.get_or_create(**validated_data) + class UserIDSerializer(Serializer): user = PrimaryKeyRelatedField( queryset=User.objects.all() From e3b87ea28dd52047de24737d5faf203a9f0c97c6 Mon Sep 17 00:00:00 2001 From: Ewout Verlinde Date: Thu, 14 Mar 2024 13:44:24 +0100 Subject: [PATCH 379/397] chore: update README --- README.md | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 35ab8ec3..8dadb34f 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,12 @@ This application was developed within the framework of the course "Software Engineering Lab 2" within the Computer Science program at Ghent University. -## Development +## Documentation -Run `development.sh`. -It starts the development environment and attaches itself to the output of the backend. -The backend will auto reload when changing a file. -Acces the server by going to `https://localhost/api` for the backend and `https://localhost` for the frontend. +See our wiki at [https://github.com/SELab-2/UGent-7/wiki](https://github.com/SELab-2/UGent-7/wiki) for more detailed information on the project's architecture. -If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. - -### Backend - -Instructions for the setup of the Django backend are to be found in `backend/README.md`. - -### Frontend - -Instructions for the setup of the Frontend backend are to be found in `frontend/README.md`. \ No newline at end of file +A shorter summary for building the project locally can be found here: +- Run `development.sh`. + - It starts the development environment and attaches itself to the output of the backend. The backend will auto reload when changing a file. +- Access the server by going to `https://localhost/api` for the backend and `https://localhost` for the frontend. +- If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. From bb205027f9e9a46262b8d82e59e795324b482f49 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 13:46:31 +0100 Subject: [PATCH 380/397] chore: added tests --- .../api/serializers/submission_serializer.py | 2 +- backend/api/tests/test_submission.py | 106 +++++++++++++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index d75cd853..6059e96d 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -84,7 +84,7 @@ def create(self, validated_data): if not status: pas = False - # Set structure_checks_passed to True + # Set structure_checks_passed submission.structure_checks_passed = pas submission.save() return submission diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index f053cdb5..c47a84f3 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -12,6 +12,7 @@ from api.models.checks import ExtraCheck from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile +from django.db.models import Max def create_course(name, academic_startyear, description=None, parent_course=None): @@ -341,7 +342,6 @@ def test_submission_after_deadline(self): project = create_past_project( name="Project 1", description="Description 1", days=-7, course=course, daysStartDate=-84 ) - # project.increase_deadline(days=-10) group = create_group(project=project, score=10) @@ -353,4 +353,106 @@ def test_submission_after_deadline(self): self.assertEqual(response.status_code, 400) self.assertEqual(response.accepted_media_type, "application/json") - self.assertEqual(json.loads(response.content), {'non_field_errors': [gettext("project.error.submissions.past_project")]}) + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.past_project")]}) + + def test_submission_number_increases_by_1(self): + """ + When submiting a submission the submission number should be the prev one + 1 + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + max_submission_number_before = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + + if max_submission_number_before is None: + max_submission_number_before = 0 + + old_submissions = group.submissions.count() + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + group.refresh_from_db() + new_submissions = group.submissions.count() + + max_submission_number_after = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + + if max_submission_number_after is None: + max_submission_number_after = 0 + self.assertEqual(max_submission_number_after - max_submission_number_before, 1) + self.assertEqual(new_submissions - old_submissions, 1) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + + def test_submission_invisible_project(self): + """ + Not able to subbmit to a project if its not visible. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + project.toggle_visible() + project.save() + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.non_visible_project")]}) + + def test_submission_archived_project(self): + """ + Not able to subbmit to a project if its archived. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + project.toggle_archived() + project.save() + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.archived_project")]}) \ No newline at end of file From 680013269976162a534375b01b69e8f1313f00a6 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 14:23:52 +0100 Subject: [PATCH 381/397] chore: tests --- backend/api/tests/test_submission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index c47a84f3..441a0c3f 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -455,4 +455,4 @@ def test_submission_archived_project(self): self.assertEqual(response.status_code, 400) self.assertEqual(response.accepted_media_type, "application/json") self.assertEqual(json.loads(response.content), { - 'non_field_errors': [gettext("project.error.submissions.archived_project")]}) \ No newline at end of file + 'non_field_errors': [gettext("project.error.submissions.archived_project")]}) From 14b0eaddfb94791eb084dcd1add0e4bd54af41b3 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 14:41:30 +0100 Subject: [PATCH 382/397] fix: fix github problems --- .env | 13 ------------- .gitignore | 3 +-- backend/data/production/structures/empty.zip | Bin 22 -> 0 bytes .../production/structures/mixed_template.zip | Bin 822 -> 0 bytes .../structures/only_files_template.zip | Bin 474 -> 0 bytes .../structures/only_folders_template.zip | Bin 638 -> 0 bytes backend/data/production/structures/remake.zip | Bin 3234 -> 0 bytes backend/data/production/structures/root.zip | Bin 226 -> 0 bytes .../data/production/structures/zip_struct1.zip | Bin 2136 -> 0 bytes backend/data/production/tests/mixed.zip | Bin 1660 -> 0 bytes backend/data/production/tests/only_files.zip | Bin 618 -> 0 bytes backend/data/production/tests/only_folders.zip | Bin 990 -> 0 bytes .../data/production/tests/test_zip1struct1.zip | Bin 2136 -> 0 bytes .../data/production/tests/test_zip2struct1.zip | Bin 1988 -> 0 bytes .../data/production/tests/test_zip3struct1.zip | Bin 2258 -> 0 bytes .../data/production/tests/test_zip4struct1.zip | Bin 1822 -> 0 bytes 16 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 .env delete mode 100644 backend/data/production/structures/empty.zip delete mode 100644 backend/data/production/structures/mixed_template.zip delete mode 100644 backend/data/production/structures/only_files_template.zip delete mode 100644 backend/data/production/structures/only_folders_template.zip delete mode 100644 backend/data/production/structures/remake.zip delete mode 100644 backend/data/production/structures/root.zip delete mode 100644 backend/data/production/structures/zip_struct1.zip delete mode 100644 backend/data/production/tests/mixed.zip delete mode 100644 backend/data/production/tests/only_files.zip delete mode 100644 backend/data/production/tests/only_folders.zip delete mode 100644 backend/data/production/tests/test_zip1struct1.zip delete mode 100644 backend/data/production/tests/test_zip2struct1.zip delete mode 100644 backend/data/production/tests/test_zip3struct1.zip delete mode 100644 backend/data/production/tests/test_zip4struct1.zip diff --git a/.env b/.env deleted file mode 100644 index 0d3f97be..00000000 --- a/.env +++ /dev/null @@ -1,13 +0,0 @@ -PUID=1000 -PGID=1000 -TZ="Europe/Brussels" - -DATADIR="./data" - -BACKEND_DIR="./backend" - -FRONTEND_DIR="./frontend" - -REDIS_IP="192.168.90.10" -REDIS_PORT=6379 -REDIS_PASSWORD="oqOsNX1PXGOX5soJtKkw" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 73b3aa42..34d3f12c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,8 @@ data/* data/nginx/ssl/* data/postres* data/redis/* +backend/data/* backend/staticfiles/* !data/nginx/ssl/.gitkeep -!data/production/* -!data/testing/* \ No newline at end of file diff --git a/backend/data/production/structures/empty.zip b/backend/data/production/structures/empty.zip deleted file mode 100644 index 15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22 NcmWIWW@Tf*000g10H*)| diff --git a/backend/data/production/structures/mixed_template.zip b/backend/data/production/structures/mixed_template.zip deleted file mode 100644 index 456c258b7a948ec1e68ae92e48baa5437412adde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 822 zcmWIWW@h1H0D-^BX%S!sl;B{HVMxo*Nl7g-)DI2eWMI|}d6D`$_(f`I1vdjD%L`@( z1~3tT(*$886UtJHigOav^$Jqb;D&(=#A(=LoX#q9(d ztPAxTGtea<^KiNb-7IOWW|b$Fq!#I=XF|LPG8BX{3}s}JW5yNc641~WV0h~YVj^OI z6%qp^m?q2sGmU{^Nuv{zX(+Kl*kH`KfEf&OWw>PZU#n{ z7t9O{U?Ko+jxL%xoIrCja}(23^$POR!A7(JwSzEDBecMSO(WoE hbkk5>c>&2ZQ0|N^X`Y})LL8nLpm^1YecoxR!lv3JKiw)%eF`_An9-tL2X;~K%wohRLW z{xJTzL0TlGN&2)q=!KN6B`F&Q=~{d5@l{fPzcr#~P{X6^q}CYK3E@AxMzvLgS{N}J zqyu)m!w&ox(FtYVVUi;dV?=Bz4Yv%4WcaM_$K%E?LGg`EbyHDYaj3qeEa~|NUN{Jj zc;BBv9n0AFeT8t_K}cyo?)h*O!ul_u{b3sYjmr8t$wQtP2XR=&anxg2cHlC@;;_s8 z!V|$V0s_S^`#G-*m;Qm`g-z90R81FD+@AF|z6$rE9!&Hn^>EMxPJ6oh>>2m~%qFBlVkfI60upxRS~a1zC^G~;7F z1KNaDBGhJX)lGZ;=6K`Jk>8v6sNeccvGu1=UL~0_A#Y!88!uGI8aWrS?eiDUqpHWehToC_HZ=ZPG&e0Rh90xa#yHnTQz@9?O; zR%YeYEHT_@_1Kmt(06C5nab0{eS+oeD0=$Z3d1A;f_4c@C(AjK5zxs$4uX;@fYT<5 z#Hj-GHH%eWs z^EnEKp$?QtA)q)`YdM25%kVB-R-hCT32@pplKz$RsEAu=+Wo_!WI6#&n@p0XTDX#1 bh{K&$pvj~%S=pS${F#IAJ9zp9nuPoZU*!%e diff --git a/backend/data/production/structures/root.zip b/backend/data/production/structures/root.zip deleted file mode 100644 index 4e703157f5a0929e00ca8b7dd224e0f3e31150ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 226 zcmWIWW@Zs#0D+R2vO?J3$a1~ hY7?3*2(8%cL1@)Rbs)%ARyL3{6A+dG=`aw70RSa#9%=vp diff --git a/backend/data/production/structures/zip_struct1.zip b/backend/data/production/structures/zip_struct1.zip deleted file mode 100644 index bd156cd2a3301c7dd2b28cc7520b8b7447a1ddb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2136 zcma)-y-ve05XaLGSYfEdz(Dwz7!ulUAQmK8I?#~~g~V+m5*pbl(C!R80(}D}76cLx zz{<=6u(BZ-v1`b=t}l(F8}k84(18XDOW05}_|S^lSkZ<{i5JAgi37ak zDxS@#uate$%A#U5T#+bd$?=l}D!*$0!^FvXkpMn8=fvr$gjbC)r-f0N@R3c1&_ZFo zo5~OW)~cd?H(frg=}{OF=A=v+lTVGXhG{MdBD>uxm1`Ehs+_=&J$V*gTp*fskf>!I z4*0EE0W2~O5vweZfL!q#fn@v0MFyN{wI$Bx;i(8N1OG2N&`qlj^lAxhc6#vvXj**$ z9?l0&i~xA~pX3JP31a|=$r_-k7aXD(1<>@CgjL8RKJX7tlg0rclXXat%|pG6h_4$7 z08Lf`K$;I!j0K1$YXRb8J`s!tfVz0%-%%bR-WLWG#sd(O^+<@#!*&G`ZpMg!XtE+8 XM)^c(Obd%5VToUNRm-~PDVFsMDkRQV diff --git a/backend/data/production/tests/mixed.zip b/backend/data/production/tests/mixed.zip deleted file mode 100644 index 509911007ed853f9249aa3ede21c717e5d25b976..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1660 zcmb8wPfo%>6bA5z1*5nTFvbXB>5dYL9zeIcAV!ICCDMWz3ojTb(&}VVmy9#fj%LY`SHyZlEq*1@T*aW9`J`WCX zJ-uu}COdZk!b{64loVkWOdf}wzB~4N0wd6h{aWLAEY2DJy0MB-5OKWvY~TkY_hEb( zh9QcUHB7D>pG{Gr*o?edG^J~Fj+R-DX6+lTZfEM=kAr@guZM2@^!40TF%BB;m(h>cDSZ#g zGVwjoGj`VaY^YkO8LJ=@>t!g$61Npitr@y0ZP4AaaB0#XMpvnv?E$%UJW`Oy?+E`m Ievkq91EnNEO#lD@ diff --git a/backend/data/production/tests/only_files.zip b/backend/data/production/tests/only_files.zip deleted file mode 100644 index 55a7402c58fd5025e0827be9af1f98886c1802d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 618 zcmWIWW@h1H0D;9xX%S!sl;C5KVaQ0$$;mIzFUm>L%StQ?4dG;9UKscy6@*JGxEUB( zUNAE-fQbOOIcw0&;RKqKnVXoNs#lPg4mM&lP&)|YG-3{#5gb4xit|$o^hzp9z$Q!w z>IPw)CQL&!fg5N-S!z*nPGY)VK}s6fkZzy>AdF!MBa<96t}u~+2hoy75EDIwSRo;V z$4t!dK{nF^XeLr<0gb~NQV1tvh841L-9Y0IAqO-KYp5Yi!wffM)6N1-LkUHofmlP3 Ul?|wpfdvSi7#SFZfS7>+00f?N$p8QV diff --git a/backend/data/production/tests/only_folders.zip b/backend/data/production/tests/only_folders.zip deleted file mode 100644 index 012a030475b2e794d5b13dfec96d37dce82581db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 990 zcmb8t%?g4*5C`xvHLNb-sYCb%B6;j)bcrqo1<@%YJq1P|(6J}znR=S=(mOPxtGnr% zrtGp8e=|FO{kf`jfu7?oTqg&%88Cz}I_!hf$XP8rpiUQYFvfMg^+3^L6(HICTsRj5 zQxKSByQwuphrGs?CAD(ZW<8QitI~}c^vEm^9a@#vy&-s`pP1{UcXP9w zkwvT2jncBzssslt%Ran4I`TaAG|wMeIZaWU2wI*3W{a+L{=CbgYk}xKq;6gznMQq$ za>`5q5J)@# zD>Dzk%7$RXt|8~TzBI0q>vF%lobUf{w<^_|B~Im_dph~^_uAsIJBR{8J1nI`Kdrk# zNQs{g=;(y@yo+#5sMBZdjIi68Cala7*zhd&lDdm0t>Z~4iId1n2~*PV_NQy^6iLrO zj=)_*GtU}r6Q^Ag$|A4l1Os0IJZON8`G6$oLIZ_I*idHhp%t~Uq79c4FNlc~2T*cP zyR}t3n^9jW`=*seht+UJqSz(JPZFs7t^o`iC-+4H_~M)sr>_!THNu=0#$m!&HW@(+ z`p2Uy+f+XMTdRup-E{e~rpIAKn3FPPO+Gck8a8uD5ZUcksa&&gsd558_T*huxIi@R zokT73aL6Cc3P8v>L`Yd40lDH20?G8qhYUEAv?b2w;i(8N1OE#h=q9NHy;?$>ofbX- zP0|P8(R|><2!NM=B{vvP7z02|)&Nbt;1I|f`4$DG!6intV4oq9_nRe z__~n*&}1b5r1?O_Sb%7<79c+66TxTzs1HwAjXYv_Ul>pr4?s-TBOx{q+ZAMRGe!hN alNA9m$|p)=T38ebOZ<0RwXA!dVp+et6wGG; diff --git a/backend/data/production/tests/test_zip2struct1.zip b/backend/data/production/tests/test_zip2struct1.zip deleted file mode 100644 index fbce9306ed2d477e01496bd8c176376a7840c84d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1988 zcmb7_u};G<5QbA)7!gAy1_nZ5Vn}Ga!G;739q7mgA#qEjgj%)>v^y`sOE4gT!~?MM z6i7_0Fk;t`bB(VJ7bTbFzPp_7zjQm*THRAt-Rqr9UVa{1()R{gLf9!6Y#57Xn52xv zV!%entRG#bW5R+9-Yp1?vuVQWUjiSV=Mim&c+dtOBuGxPNDyv>-|bH4+$j@YfC7cP zfnuI4+9poBB8+EIKS&0#0l2pSjrjnY^q_#!Eo>P*c(aoFSkhL=NR;Fx$P+x}8XnDA zY@~g)(xT03g(^_ZQV>%LnP2_~7^hSY9326mbhp|@*`HQc$DHdaZHIEy#9^8d9tdt6 z@`puO$2z77D!SXLEYv+*Xp$h!pFGRT4v4NkXw-^y`|`Gg3^K$aN{HGNao)Hq+d$1=DA{3}zSyMzk#S_Lh3TDAam2^W9|^MMl?18??`++sW>Yk;_f4MjcU z5JTobvrAG!*QO5m3#Vz>1IQ(O4f3UHE+Eaf*8l)rivWOZHW1bV0PRmzeWsu%Mj1|1~c=ijsO4v diff --git a/backend/data/production/tests/test_zip3struct1.zip b/backend/data/production/tests/test_zip3struct1.zip deleted file mode 100644 index 315fd52e9e46cda79198cf42847465798587c850..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2258 zcma)+y-ve05P;Jkm^)NrU?BWV3<+(SfC&i}20C_wkhpC`LL(;y+MSo+4VYLENIU>5 zGfOu%#D-wRt|8~RzBH~9cZk2cT<*U1QKecp_^Rx7JJUaZw+*&;2Z2ZENu1E3n>1|C zr^HPLbaYI6&V@fF)au9Wl+ZcN5|(BO@$f8kqK1thTE!0~B#Z(lA+dD)b|)KitH|*b zWO3XzB=e-u)^XY;p>g2!EN|dSfO`e7HXjgqT}YsC37b+4-j$#x7PM(o;&>skLJtqQ zjGvCFD}{Yk!lK1$+Wa8zCCiN>i2SAiG#w}UMF3dioD-`r6J8X;oD{}>#9B5QK?;TI z-I8AXTZxM1-LhG;CWn4NVk?QIHu+QtD_G41fuDw)KlZXMkB-?k%ehpp8~A*)uxy#r zd(mkJqON!XRb$&eyA*%G^y3gd|6*~-6}u2fI>ec`)cHW*Otw81!KL7TJ3z8JKLEX2 zLX*wf0)RSS0PtWwaB2_W<$sbJjK^&P5S>pTsb?G_*#&57O587E@elli(}ZmRr1Omc znQUv6k>acN0ie!50Hpaq$wolb`3Q&)`9!c20M+90Sr>~J?+XKRTLDDpD*-Xtwpl?6 rH)Ag#>ih-7AfG5~1~8q^1dPAq9R}leTUg`|*4S@SHH-;+)G&Sl8(!uQ diff --git a/backend/data/production/tests/test_zip4struct1.zip b/backend/data/production/tests/test_zip4struct1.zip deleted file mode 100644 index 9acfea0241389ce71f27778ac5980ed9b7272931..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1822 zcma)+F;9a)6o6?_Hxq{@4w^{R#7RioO*aRZ4l=qa!cj&tHBY`y$5EaYX0^<8&7Ct{0`0gnUZp zXLJ}`rwc;eg!z92epx1L{wJ{6)g;Ke9wa>-m6P`Hq#HunI2gLoG`s`AqXyWm2V_wU z`Hj2o6}nz(MIEeY&!Z%WCd8dYc*!k1o6)dP_Ejs34y)(MM0tE}m}OA;O#>JXSsjrA zh_f0IHz^WcG(t@ai!>8oNj8TT8qd35*!Y)L745t4al-g%Iws8Jtnit9YJ?qZ=87P* z{XwhUv2Z&CK`bYKHgyGvra36oh6pF(*6ag!0AjL7K^zfoaY)%E0Gez9;B!4NcMEvc zyZpv@(lvmXY=fo_Tga$N?g342N!q8D9Q+8UDHj1`vQdE?5gw{R;3fb~b^>r&4J^0{ zM3b#RJZPhnlY0es0jLj8{^Qk>!%HxrbQvHfn-#eiOTiWHJPw3 Mt`FO?w#98(KeNV#0RR91 From 36430ed58a9ae7f2341334144af8bf7748407541 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 14:43:30 +0100 Subject: [PATCH 383/397] chore: cleanup gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 34d3f12c..17cb6539 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ data/* data/nginx/ssl/* data/postres* data/redis/* -backend/data/* +backend/data/production* backend/staticfiles/* From 68adeca024178a9086143128d3058930fa7536a0 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 14:52:28 +0100 Subject: [PATCH 384/397] chore: cleanup linting --- backend/api/views/project_view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 0ee3a6aa..16d4aa7d 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -10,7 +10,11 @@ from api.models.project import Project from api.models.checks import StructureCheck from api.serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer -from api.serializers.project_serializer import ProjectSerializer, TeacherCreateGroupSerializer, StructureCheckAddSerializer, SubmissionStatusSerializer +from api.serializers.project_serializer import ( + StructureCheckAddSerializer, SubmissionStatusSerializer, + ProjectSerializer, TeacherCreateGroupSerializer +) + from api.serializers.group_serializer import GroupSerializer from api.serializers.submission_serializer import SubmissionSerializer from rest_framework.request import Request From 23e0fb8a9ae3e087e652d39a0bf929eeb25d1165 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 14:52:57 +0100 Subject: [PATCH 385/397] feat(deployment): added test flag --- .dev.env | 11 +++++------ .env | 13 ------------- .prod.env | 8 ++++---- development.sh | 31 ++++++++++++++++++++----------- development.yml | 6 +++--- production.yml | 8 ++++---- 6 files changed, 36 insertions(+), 41 deletions(-) delete mode 100644 .env diff --git a/.dev.env b/.dev.env index 4995c47b..624d2c4b 100644 --- a/.dev.env +++ b/.dev.env @@ -4,10 +4,10 @@ PGID=1000 TZ=Europe/Brussels # File directories -DATADIR=./data # Directory to store container data -BACKEND_DIR=./backend -FRONTEND_DIR=./frontend -SSL_DIR="${DATADIR}/nginx/ssl" # Directory to store SSL certificates +DATA_DIR="./data" +BACKEND_DIR="./backend" +FRONTEND_DIR="./frontend" +SSL_DIR="./data/nginx/ssl" # Redis REDIS_IP=192.168.90.10 @@ -21,5 +21,4 @@ DJANGO_CAS_URL_PREFIX="" # URL prefix for the CAS server. Should be empty for de DJANGO_CAS_PORT=8080 # Port for the CAS server. Should be 8080 if DJANGO_DOMAIN_NAME is localhost DJANGO_DB_ENGINE=django.db.backends.sqlite3 # Database engine DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration -DJANGO_REDIS_PORT=${REDIS_PORT} -``` \ No newline at end of file +DJANGO_REDIS_PORT=${REDIS_PORT} \ No newline at end of file diff --git a/.env b/.env deleted file mode 100644 index 0d3f97be..00000000 --- a/.env +++ /dev/null @@ -1,13 +0,0 @@ -PUID=1000 -PGID=1000 -TZ="Europe/Brussels" - -DATADIR="./data" - -BACKEND_DIR="./backend" - -FRONTEND_DIR="./frontend" - -REDIS_IP="192.168.90.10" -REDIS_PORT=6379 -REDIS_PASSWORD="oqOsNX1PXGOX5soJtKkw" \ No newline at end of file diff --git a/.prod.env b/.prod.env index d5dfaa4a..f7a8f530 100644 --- a/.prod.env +++ b/.prod.env @@ -4,10 +4,10 @@ PGID=1000 TZ=Europe/Brussels # File directories -DATADIR=./data # Directory to store container data -BACKEND_DIR=./backend -FRONTEND_DIR=./frontend -SSL_DIR="" # Directory to store SSL certificates +DATA_DIR="./data" +BACKEND_DIR="./backend" +FRONTEND_DIR="./frontend" +SSL_DIR="" # Postgress DB POSTGRES_IP=192.168.90.9 diff --git a/development.sh b/development.sh index fdf0e030..a4e62ae1 100755 --- a/development.sh +++ b/development.sh @@ -3,8 +3,9 @@ backend=false frontend=false build=false +test=false -while getopts ":bfc" opt; do +while getopts ":bfct" opt; do case ${opt} in b ) backend=true @@ -15,6 +16,9 @@ while getopts ":bfc" opt; do c ) build=true ;; + t ) + test=true + ;; \? ) echo "Usage: $0 [-b] [-f] [-c]" exit 1 @@ -55,17 +59,22 @@ fi echo "Starting services..." docker-compose -f development.yml up -d -echo "-------------------------------------" -echo "Following logs..." -echo "Press CTRL + C to stop all containers" -echo "-------------------------------------" - -if [ "$backend" = true ] && [ "$frontend" = true ]; then - docker-compose -f development.yml logs --follow --tail 50 backend frontend -elif [ "$frontend" = true ]; then - docker-compose -f development.yml logs --follow --tail 50 frontend +if [ "$test" = true ]; then + echo "Running tests..." + docker-compose -f development.yml exec backend python manage.py test else - docker-compose -f development.yml logs --follow --tail 50 backend + echo "-------------------------------------" + echo "Following logs..." + echo "Press CTRL + C to stop all containers" + echo "-------------------------------------" + + if [ "$backend" = true ] && [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 backend frontend + elif [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 frontend + else + docker-compose -f development.yml logs --follow --tail 50 backend + fi fi echo "Cleaning up..." diff --git a/development.yml b/development.yml index 51aa1243..7b096836 100644 --- a/development.yml +++ b/development.yml @@ -38,8 +38,8 @@ services: - 443:443 - 8080:8080 volumes: - - $DATADIR/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro - - $SSL_DIR:/etc/nginx/ssl:ro + - ${DATA_DIR}/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro depends_on: - backend - frontend @@ -94,5 +94,5 @@ services: - $REDIS_PORT entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - - $DATADIR/redis:/data + - ${DATA_DIR}/redis:/data \ No newline at end of file diff --git a/production.yml b/production.yml index 3e1274a4..40f93d81 100644 --- a/production.yml +++ b/production.yml @@ -37,8 +37,8 @@ services: - 80:80 - 443:443 volumes: - - $DATADIR/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro - - $SSL_DIR:/etc/nginx/ssl:ro + - ${DATA_DIR}/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro depends_on: - backend - frontend @@ -57,7 +57,7 @@ services: expose: - $POSTGRES_PORT volumes: - - $DATADIR/postgres:/var/lib/postgresql/data + - ${DATA_DIR}/postgres:/var/lib/postgresql/data backend: <<: *common-keys-selab @@ -82,7 +82,7 @@ services: - $REDIS_PORT entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - - $DATADIR/redis:/data + - ${DATA_DIR}/redis:/data celery: <<: *common-keys-selab From f5e14c57b8b7bc739054873f32ed0d201e25dcec Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 15:00:35 +0100 Subject: [PATCH 386/397] chore: add python naming conventions --- backend/api/tests/test_submission.py | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index 441a0c3f..e5959167 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -15,13 +15,13 @@ from django.db.models import Max -def create_course(name, academic_startyear, description=None, parent_course=None): +def create_course(name, academic_start_year, description=None, parent_course=None): """ Create a Course with the given arguments. """ return Course.objects.create( name=name, - academic_startyear=academic_startyear, + academic_startyear=academic_start_year, description=description, parent_course=parent_course, ) @@ -35,10 +35,10 @@ def create_project(name, description, days, course): ) -def create_past_project(name, description, days, course, daysStartDate): +def create_past_project(name, description, days, course, days_start_ate): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) - startDate = timezone.now() + timedelta(days=daysStartDate) + startDate = timezone.now() + timedelta(days=days_start_ate) return Project.objects.create( name=name, description=description, deadline=deadline, course=course, score_visible=True, start_date=startDate ) @@ -86,7 +86,7 @@ def test_submission_exists(self): """ Able to retrieve a single submission after creating it. """ - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -125,7 +125,7 @@ def test_multiple_submission_exists(self): """ Able to retrieve multiple submissions after creating them. """ - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -177,7 +177,7 @@ def test_submission_detail_view(self): """ Able to retrieve details of a single submission. """ - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -214,7 +214,7 @@ def test_submission_group(self): """ Able to retrieve group of a single submission. """ - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -266,7 +266,7 @@ def test_submission_extra_checks(self): """ Able to retrieve extra checks of a single submission. """ - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -311,9 +311,9 @@ def test_submission_before_deadline(self): """ zip_file_path = "data/testing/tests/mixed.zip" - with open(zip_file_path, 'rb') as f: - files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_startyear=2023) + with open(zip_file_path, 'rb') as file: + files = {'files': SimpleUploadedFile('mixed.zip', file.read())} + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -338,9 +338,9 @@ def test_submission_after_deadline(self): with open(zip_file_path, 'rb') as f: files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_past_project( - name="Project 1", description="Description 1", days=-7, course=course, daysStartDate=-84 + name="Project 1", description="Description 1", days=-7, course=course, days_start_ate=-84 ) group = create_group(project=project, score=10) @@ -365,7 +365,7 @@ def test_submission_number_increases_by_1(self): with open(zip_file_path, 'rb') as f: files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -406,7 +406,7 @@ def test_submission_invisible_project(self): with open(zip_file_path, 'rb') as f: files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) @@ -436,7 +436,7 @@ def test_submission_archived_project(self): with open(zip_file_path, 'rb') as f: files = {'files': SimpleUploadedFile('mixed.zip', f.read())} - course = create_course(name="sel2", academic_startyear=2023) + course = create_course(name="sel2", academic_start_year=2023) project = create_project( name="Project 1", description="Description 1", days=7, course=course ) From 262f85ca4e593b410d9f3d7824b6af7e01e9adbd Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 15:06:50 +0100 Subject: [PATCH 387/397] fix: fixed gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 06a5c57d..ef08eecc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ data/* data/nginx/ssl/* data/postres* data/redis/* -backend/data/production* +backend/data/production/* /node_modules backend/staticfiles/* From 8c905e4c7dec15b9ee94e29870366dd89c1cd434 Mon Sep 17 00:00:00 2001 From: Tybo Verslype Date: Thu, 14 Mar 2024 15:07:51 +0100 Subject: [PATCH 388/397] =?UTF-8?q?fix:=20fixed=20ty=C3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/tests/test_submission.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py index e5959167..cc2d58ca 100644 --- a/backend/api/tests/test_submission.py +++ b/backend/api/tests/test_submission.py @@ -35,10 +35,10 @@ def create_project(name, description, days, course): ) -def create_past_project(name, description, days, course, days_start_ate): +def create_past_project(name, description, days, course, days_start_date): """Create a Project with the given arguments.""" deadline = timezone.now() + timedelta(days=days) - startDate = timezone.now() + timedelta(days=days_start_ate) + startDate = timezone.now() + timedelta(days=days_start_date) return Project.objects.create( name=name, description=description, deadline=deadline, course=course, score_visible=True, start_date=startDate ) @@ -340,7 +340,7 @@ def test_submission_after_deadline(self): course = create_course(name="sel2", academic_start_year=2023) project = create_past_project( - name="Project 1", description="Description 1", days=-7, course=course, days_start_ate=-84 + name="Project 1", description="Description 1", days=-7, course=course, days_start_date=-84 ) group = create_group(project=project, score=10) From 6594064d044cb8fca1fb1e0c1492b73f9ca6c606 Mon Sep 17 00:00:00 2001 From: Ewout Verlinde Date: Thu, 14 Mar 2024 15:41:32 +0100 Subject: [PATCH 389/397] chore: update README.md Added action badges --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8dadb34f..a06239d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Ypovoli +![backend linting](https://github.com/SELab-2/UGent-7/actions/workflows/backend-linting.yaml/badge.svg) +![backend tests](https://github.com/SELab-2/UGent-7/actions/workflows/backend-tests.yaml/badge.svg) + This application was developed within the framework of the course "Software Engineering Lab 2" within the Computer Science program at Ghent University. ## Documentation From 8bca63dd568a0e72787bf128203a437a5b911bc9 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 19:47:15 +0100 Subject: [PATCH 390/397] chore: change subnet of development --- .dev.env | 2 +- development.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.dev.env b/.dev.env index 624d2c4b..a63d9371 100644 --- a/.dev.env +++ b/.dev.env @@ -10,7 +10,7 @@ FRONTEND_DIR="./frontend" SSL_DIR="./data/nginx/ssl" # Redis -REDIS_IP=192.168.90.10 +REDIS_IP=192.168.91.10 REDIS_PORT=6379 # Django diff --git a/development.yml b/development.yml index 7b096836..5b738e9f 100644 --- a/development.yml +++ b/development.yml @@ -8,7 +8,7 @@ networks: driver: bridge ipam: config: - - subnet: 192.168.90.0/24 + - subnet: 192.168.91.0/24 ############################# EXTENSIONS @@ -50,7 +50,7 @@ services: build: context: $BACKEND_DIR dockerfile: Dockerfile.dev - command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.91.2:8080" expose: - 8080 volumes: From 6a57d20cdcd7085036c22b758cb38a66425f6e9a Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 19:53:42 +0100 Subject: [PATCH 391/397] Revert "chore: change subnet of development" This reverts commit 8bca63dd568a0e72787bf128203a437a5b911bc9. --- .dev.env | 2 +- development.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.dev.env b/.dev.env index a63d9371..624d2c4b 100644 --- a/.dev.env +++ b/.dev.env @@ -10,7 +10,7 @@ FRONTEND_DIR="./frontend" SSL_DIR="./data/nginx/ssl" # Redis -REDIS_IP=192.168.91.10 +REDIS_IP=192.168.90.10 REDIS_PORT=6379 # Django diff --git a/development.yml b/development.yml index 5b738e9f..7b096836 100644 --- a/development.yml +++ b/development.yml @@ -8,7 +8,7 @@ networks: driver: bridge ipam: config: - - subnet: 192.168.91.0/24 + - subnet: 192.168.90.0/24 ############################# EXTENSIONS @@ -50,7 +50,7 @@ services: build: context: $BACKEND_DIR dockerfile: Dockerfile.dev - command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.91.2:8080" + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" expose: - 8080 volumes: From dade6b4c3e159cdcc6bb4563b76123378fed4b21 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 20:16:56 +0100 Subject: [PATCH 392/397] added testing environment --- test.sh | 71 ++++++++++++++++++++++++++++++++++++++ testing.yml | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100755 test.sh create mode 100644 testing.yml diff --git a/test.sh b/test.sh new file mode 100755 index 00000000..a70f382a --- /dev/null +++ b/test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +backend=false +frontend=false +build=false + +while getopts ":bfc" opt; do + case ${opt} in + b ) + backend=true + ;; + f ) + frontend=true + ;; + c ) + build=true + ;; + \? ) + echo "Usage: $0 [-b] [-f] [-c]" + exit 1 + ;; + esac +done + +echo "Checking environment file..." + +if ! [ -f .env ]; then + cp .dev.env .env + sed -i "s/^DJANGO_SECRET_KEY=.*/DJANGO_SECRET_KEY=totally_random_key_string/" .env + echo "Created environment file" +fi + +echo "Checking for existing SSL certificates..." + +if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then + echo "Generating SSL certificates..." + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout data/nginx/ssl/private.key \ + -out data/nginx/ssl/certificate.crt \ + -subj "/C=BE/ST=/L=/O=/OU=/CN=" > /dev/null + echo "SSL certificates generated." +else + echo "SSL certificates already exist, skipping generation." +fi + +if [ "$build" = true ]; then + echo "Building Docker images..." + echo "This can take a while..." + docker-compose -f testing.yml build --no-cache +else + echo "$build" +fi + +echo "Starting services..." +docker-compose -f testing.yml up -d + +if [ "$frontend" = true ]; then + echo "Running frontend tests..." + echo "Not implemented yet" +fi + +if [ "$backend" = true ]; then + echo "Running backend tests..." + docker-compose -f testing.yml exec backend python manage.py test +fi + +echo "Cleaning up..." + +docker-compose -f testing.yml down + +echo "Done." diff --git a/testing.yml b/testing.yml new file mode 100644 index 00000000..1c6dbf00 --- /dev/null +++ b/testing.yml @@ -0,0 +1,98 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + expose: + - 80 + - 443 + - 8080 + volumes: + - ${DATA_DIR}/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" + expose: + - 8080 + volumes: + - $BACKEND_DIR:/code + + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: celery -A ypovoli worker -l DEBUG + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile.dev + command: bash -c "npm install && npm run host" + expose: + - 5173 + volumes: + - $FRONTEND_DIR:/app + depends_on: + - backend + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - ${DATA_DIR}/redis:/data + \ No newline at end of file From 2c5c9aa3f1c485a74579d41bddba8db568981353 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 20:18:19 +0100 Subject: [PATCH 393/397] chore: remove testing from development script --- development.sh | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/development.sh b/development.sh index a4e62ae1..fdf0e030 100755 --- a/development.sh +++ b/development.sh @@ -3,9 +3,8 @@ backend=false frontend=false build=false -test=false -while getopts ":bfct" opt; do +while getopts ":bfc" opt; do case ${opt} in b ) backend=true @@ -16,9 +15,6 @@ while getopts ":bfct" opt; do c ) build=true ;; - t ) - test=true - ;; \? ) echo "Usage: $0 [-b] [-f] [-c]" exit 1 @@ -59,22 +55,17 @@ fi echo "Starting services..." docker-compose -f development.yml up -d -if [ "$test" = true ]; then - echo "Running tests..." - docker-compose -f development.yml exec backend python manage.py test -else - echo "-------------------------------------" - echo "Following logs..." - echo "Press CTRL + C to stop all containers" - echo "-------------------------------------" +echo "-------------------------------------" +echo "Following logs..." +echo "Press CTRL + C to stop all containers" +echo "-------------------------------------" - if [ "$backend" = true ] && [ "$frontend" = true ]; then - docker-compose -f development.yml logs --follow --tail 50 backend frontend - elif [ "$frontend" = true ]; then - docker-compose -f development.yml logs --follow --tail 50 frontend - else - docker-compose -f development.yml logs --follow --tail 50 backend - fi +if [ "$backend" = true ] && [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 backend frontend +elif [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 frontend +else + docker-compose -f development.yml logs --follow --tail 50 backend fi echo "Cleaning up..." From 16e2ce4b2cfdeda99f621a65b802c08cb238ccdc Mon Sep 17 00:00:00 2001 From: Vincent Vallaeys Date: Thu, 14 Mar 2024 20:37:38 +0100 Subject: [PATCH 394/397] Update README.md --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index a06239d1..4c81c019 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,3 @@ This application was developed within the framework of the course "Software Engi ## Documentation See our wiki at [https://github.com/SELab-2/UGent-7/wiki](https://github.com/SELab-2/UGent-7/wiki) for more detailed information on the project's architecture. - -A shorter summary for building the project locally can be found here: -- Run `development.sh`. - - It starts the development environment and attaches itself to the output of the backend. The backend will auto reload when changing a file. -- Access the server by going to `https://localhost/api` for the backend and `https://localhost` for the frontend. -- If you change something to one of the docker files run `docker-compose -f development.yml up --build` to rebuild. From 44f6add3ba97258f4a5fd03a9fc25a39b0e13089 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 20:53:00 +0100 Subject: [PATCH 395/397] feat(deployement): auto pull --- .github/workflows/deployement.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/deployement.yml diff --git a/.github/workflows/deployement.yml b/.github/workflows/deployement.yml new file mode 100644 index 00000000..0bf37dd7 --- /dev/null +++ b/.github/workflows/deployement.yml @@ -0,0 +1,22 @@ +name: Deploy +on: + push: + branches: + - deployement + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + cd UGent-7 + ${{ secrets.PULL_SCRIPT }} + docker-compose -f production.yml build --no-cache + docker-compose -f production.yml up -d From 40e5b9560cbec12032159ef676ba9e3b7eb28362 Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 20:55:34 +0100 Subject: [PATCH 396/397] chore: take containers down first --- .github/workflows/deployement.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deployement.yml b/.github/workflows/deployement.yml index 0bf37dd7..ffc70419 100644 --- a/.github/workflows/deployement.yml +++ b/.github/workflows/deployement.yml @@ -17,6 +17,7 @@ jobs: port: ${{ secrets.PORT }} script: | cd UGent-7 + docker-compose -f production.yml down ${{ secrets.PULL_SCRIPT }} docker-compose -f production.yml build --no-cache docker-compose -f production.yml up -d From ed1764eef28287c511092fb878457c81b8ef52be Mon Sep 17 00:00:00 2001 From: Topvennie Date: Thu, 14 Mar 2024 20:58:51 +0100 Subject: [PATCH 397/397] chore: change branch to main --- .github/workflows/deployement.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployement.yml b/.github/workflows/deployement.yml index ffc70419..ac9d3110 100644 --- a/.github/workflows/deployement.yml +++ b/.github/workflows/deployement.yml @@ -2,7 +2,7 @@ name: Deploy on: push: branches: - - deployement + - main jobs: deploy: