From 2f88001fd4bb28e813dea4aaf1378cfbabf7393f Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Sat, 2 Mar 2024 17:46:29 +0100 Subject: [PATCH 001/138] init and remove .env --- .env | 12 -- frontend/README.md | 2 +- frontend/package-lock.json | 255 +++++++++++------------ frontend/package.json | 14 +- frontend/pages/index.tsx | 14 ++ frontend/src/app/globals.css | 33 --- frontend/src/app/layout.tsx | 22 -- frontend/src/app/page.tsx | 37 ---- frontend/src/components/ui/LoginForm.tsx | 16 ++ frontend/src/components/ui/button.tsx | 56 +++++ frontend/src/lib/utils.ts | 6 + frontend/src/styles/globals.css | 76 +++++++ frontend/tailwind.config.ts | 86 ++++++-- 13 files changed, 371 insertions(+), 258 deletions(-) delete mode 100644 .env create mode 100644 frontend/pages/index.tsx delete mode 100644 frontend/src/app/globals.css delete mode 100644 frontend/src/app/layout.tsx delete mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/ui/LoginForm.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/styles/globals.css diff --git a/.env b/.env deleted file mode 100644 index 316039e1..00000000 --- a/.env +++ /dev/null @@ -1,12 +0,0 @@ -DEBUG=1 -SECRET_KEY=development_key -DJANGO_ALLOWED_HOSTS='localhost 127.0.0.1 [::1] django' -SQL_ENGINE=django.db.backends.postgresql -SQL_DATABASE=pigeonhole_dev -SQL_USER=pigeonhole -SQL_PASSWORD=password -SQL_HOST=db -SQL_PORT=5432 -DATABASE=postgres -DJANGO_SUPERUSER_PASSWORD=abc -DJANGO_SUPERUSER_EMAIL=abc@example.com \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index c4033664..08e12e04 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -16,7 +16,7 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +You can start editing the page by modifying `app/index.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d72dd96b..9d34338f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,15 @@ "name": "pigeonhole", "version": "0.1.0", "dependencies": { + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.344.0", "next": "14.1.0", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@types/node": "^20", @@ -37,7 +43,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "engines": { "node": ">=10" }, @@ -49,7 +54,6 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -150,7 +154,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -167,7 +170,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -179,7 +181,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -194,7 +195,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -208,7 +208,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -217,7 +216,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -225,14 +223,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==", - "dev": true + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -391,7 +387,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -404,7 +399,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "engines": { "node": ">= 8" } @@ -413,7 +407,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -426,12 +419,46 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.7.2.tgz", @@ -465,13 +492,13 @@ "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.2.57", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.57.tgz", "integrity": "sha512-ZvQsktJgSYrQiMirAN60y4O/LRevIV8hUzSOSNB6gfR3/o3wCBFQx3sPwIYtuDMeiVgsSS3UzCV26tEzgnfvQw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -491,7 +518,7 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "devOptional": true }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", @@ -667,7 +694,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -676,7 +702,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -690,14 +715,12 @@ "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -709,8 +732,7 @@ "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, "node_modules/argparse": { "version": "2.0.1", @@ -968,14 +990,12 @@ "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 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, "engines": { "node": ">=8" } @@ -994,7 +1014,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -1077,7 +1096,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "engines": { "node": ">= 6" } @@ -1121,7 +1139,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1145,7 +1162,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -1153,16 +1169,42 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1173,14 +1215,12 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "engines": { "node": ">= 6" } @@ -1195,7 +1235,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1209,7 +1248,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -1221,7 +1259,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -1298,8 +1336,7 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -1316,8 +1353,7 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, "node_modules/doctrine": { "version": "3.0.0", @@ -1334,8 +1370,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/electron-to-chromium": { "version": "1.4.676", @@ -1346,8 +1381,7 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/enhanced-resolve": { "version": "5.15.0", @@ -1951,7 +1985,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -1967,7 +2000,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -1991,7 +2023,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -2012,7 +2043,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2069,7 +2099,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -2104,7 +2133,6 @@ "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": [ @@ -2118,7 +2146,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2202,7 +2229,6 @@ "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.5", @@ -2224,7 +2250,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -2236,7 +2261,6 @@ "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" } @@ -2245,7 +2269,6 @@ "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" }, @@ -2402,7 +2425,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2521,7 +2543,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -2561,7 +2582,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -2588,7 +2608,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -2609,7 +2628,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -2633,7 +2651,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2666,7 +2683,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2820,8 +2836,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/iterator.prototype": { "version": "1.1.2", @@ -2840,7 +2855,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -2858,7 +2872,6 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, "bin": { "jiti": "bin/jiti.js" } @@ -2969,7 +2982,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, "engines": { "node": ">=10" } @@ -2977,8 +2989,7 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/locate-path": { "version": "6.0.0", @@ -3016,16 +3027,22 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, "engines": { "node": "14 || >=16.14" } }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -3034,7 +3051,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -3068,7 +3084,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -3083,7 +3098,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -3195,7 +3209,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3213,7 +3226,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3222,7 +3234,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "engines": { "node": ">= 6" } @@ -3427,7 +3438,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -3435,14 +3445,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, "dependencies": { "lru-cache": "^9.1.1 || ^10.0.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -3472,7 +3480,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -3484,7 +3491,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3493,7 +3499,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "engines": { "node": ">= 6" } @@ -3511,7 +3516,6 @@ "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3539,7 +3543,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -3556,7 +3559,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -3575,7 +3577,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3610,7 +3611,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "dev": true, "engines": { "node": ">=14" }, @@ -3622,7 +3622,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -3641,7 +3640,6 @@ "version": "6.0.15", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3653,8 +3651,7 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -3689,7 +3686,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -3738,7 +3734,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "dependencies": { "pify": "^2.3.0" } @@ -3747,7 +3742,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -3779,8 +3773,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", @@ -3804,7 +3797,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -3839,7 +3831,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -3884,7 +3875,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -4009,7 +3999,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4021,7 +4010,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4048,7 +4036,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -4085,7 +4072,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -4103,7 +4089,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4116,14 +4101,12 @@ "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -4135,7 +4118,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4215,7 +4197,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4228,7 +4209,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4283,7 +4263,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -4317,7 +4296,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4325,11 +4303,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.1.tgz", + "integrity": "sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==", + "dependencies": { + "@babel/runtime": "^7.23.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", - "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4362,6 +4351,14 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -4381,7 +4378,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "dependencies": { "any-promise": "^1.0.0" } @@ -4390,7 +4386,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -4402,7 +4397,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -4425,8 +4419,7 @@ "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, "node_modules/tsconfig-paths": { "version": "3.15.0", @@ -4611,14 +4604,12 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -4709,7 +4700,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -4727,7 +4717,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -4743,14 +4732,12 @@ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4764,7 +4751,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -4776,7 +4762,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -4788,7 +4773,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4815,7 +4799,6 @@ "version": "2.3.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", - "dev": true, "engines": { "node": ">= 14" } diff --git a/frontend/package.json b/frontend/package.json index 47c304b2..b5685b8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,19 +9,25 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.344.0", + "next": "14.1.0", "react": "^18", "react-dom": "^18", - "next": "14.1.0" + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.1.0", "postcss": "^8", "tailwindcss": "^3.3.0", - "eslint": "^8", - "eslint-config-next": "14.1.0" + "typescript": "^5" } } diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx new file mode 100644 index 00000000..e7a1e184 --- /dev/null +++ b/frontend/pages/index.tsx @@ -0,0 +1,14 @@ +// pages/index.tsx +import React from 'react'; +import LoginForm from '@/components/ui/LoginForm'; + + +const Home = () => { + return ( +
+ +
+ ); +}; + +export default Home; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css deleted file mode 100644 index 875c01e8..00000000 --- a/frontend/src/app/globals.css +++ /dev/null @@ -1,33 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } -} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx deleted file mode 100644 index 3314e478..00000000 --- a/frontend/src/app/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import "./globals.css"; - -const inter = Inter({ subsets: ["latin"] }); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); -} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx deleted file mode 100644 index 9fa72924..00000000 --- a/frontend/src/app/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import Image from "next/image"; -import React, { useState, useEffect } from 'react'; - - -export default function Home() { - - - const [data, setData] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch('http://127.0.0.1:8000/groups/'); - if (response.ok) { - const json = await response.json(); - setData(json); - } else { - console.error('Failed to fetch data'); - } - } catch (error) { - console.error('Error:', error); - } - }; - - fetchData(); - }, []); - - - return ( -
-

Lijst van groepen als test van API:

-

{JSON.stringify(data)}

-
- ); -} diff --git a/frontend/src/components/ui/LoginForm.tsx b/frontend/src/components/ui/LoginForm.tsx new file mode 100644 index 00000000..b953d712 --- /dev/null +++ b/frontend/src/components/ui/LoginForm.tsx @@ -0,0 +1,16 @@ +// src/app/components/LoginForm.tsx + +import { Button } from "@/components/ui/button" + +const LoginForm = () => { + return ( +
+

Pigeonhole

+
+ +
+
+ ); +}; + +export default LoginForm; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 00000000..0ba42773 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 00000000..d084ccad --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 00000000..6a757250 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index e9a0944e..84287e82 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,20 +1,80 @@ -import type { Config } from "tailwindcss"; +import type { Config } from "tailwindcss" -const config: Config = { +const config = { + darkMode: ["class"], content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", }, }, }, - plugins: [], -}; -export default config; + plugins: [require("tailwindcss-animate")], +} satisfies Config + +export default config \ No newline at end of file From b5b24f2b97fe6318cd4368bbd5563e410df1cbb6 Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Sat, 2 Mar 2024 18:16:37 +0100 Subject: [PATCH 002/138] basic login page --- frontend/pages/index.tsx | 10 ++--- frontend/src/components/ui/LoginForm.tsx | 52 ++++++++++++++++++---- frontend/src/components/ui/button.tsx | 56 ------------------------ frontend/src/lib/utils.ts | 6 +-- 4 files changed, 51 insertions(+), 73 deletions(-) delete mode 100644 frontend/src/components/ui/button.tsx diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index e7a1e184..6f4f5a52 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -4,11 +4,11 @@ import LoginForm from '@/components/ui/LoginForm'; const Home = () => { - return ( -
- -
- ); + return ( +
+ +
+ ); }; export default Home; diff --git a/frontend/src/components/ui/LoginForm.tsx b/frontend/src/components/ui/LoginForm.tsx index b953d712..5b0ef3a8 100644 --- a/frontend/src/components/ui/LoginForm.tsx +++ b/frontend/src/components/ui/LoginForm.tsx @@ -1,16 +1,50 @@ // src/app/components/LoginForm.tsx -import { Button } from "@/components/ui/button" +import React, {useState} from 'react'; const LoginForm = () => { - return ( -
-

Pigeonhole

-
- -
-
- ); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + const handleLogin = () => { + // Implement your login logic here + }; + + const handleCASLogin = () => { + // Implement your CAS login logic here + }; + + return ( +
+

Pigeon Hole

+
+ +
+ +
+ + +
+
+ ); }; export default LoginForm; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx deleted file mode 100644 index 0ba42773..00000000 --- a/frontend/src/components/ui/button.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export { Button, buttonVariants } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d084ccad..0189d300 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" +import {type ClassValue, clsx} from "clsx" +import {twMerge} from "tailwind-merge" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)) } From 59393c70e98e0400bc41543bb9c05eed4e8b8d92 Mon Sep 17 00:00:00 2001 From: avoyen Date: Sat, 2 Mar 2024 20:47:38 +0100 Subject: [PATCH 003/138] First implementation of a view. Much work to do. --- backend/pigeonhole/apps/projects/views.py | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 backend/pigeonhole/apps/projects/views.py diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py new file mode 100644 index 00000000..0480004e --- /dev/null +++ b/backend/pigeonhole/apps/projects/views.py @@ -0,0 +1,35 @@ +from django.http import JsonResponse +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from models import Project + + +class ProjectAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, subject_id, project_id): + # TODO still figuring out how to implement views properly. + if request.user.teacher: + # TODO should be able to get every project as an admin, not as a teacher. (check the is_admin field) + # This happens when the user is a teacher or admin. + project = Project.objects.get(project_id=project_id) + return JsonResponse({"id": project.project_id, + "name": project.name, + "deadline": project.deadline, + "description": project.description}, status=200) + elif request.user.student: + # When the user is a student. + student = request.user.student + if student.projects.filter(project_id=project_id).exists(): + project = Project.objects.get(project_id=project_id) + + return JsonResponse({"id": project.project_id, + "name": project.name, + "deadline": project.deadline, + "description": project.description}, status=200) + else: + return JsonResponse({"message": "Project not found"}, status=404) + else: + # User isn't recognized. + return JsonResponse({"message": "User role not recognized"}, status=403) From 66e106185ee2e50ef7b33a32fcc10811a0c85ce0 Mon Sep 17 00:00:00 2001 From: avoyen Date: Sat, 2 Mar 2024 20:53:33 +0100 Subject: [PATCH 004/138] model manager and put function --- backend/pigeonhole/apps/projects/models.py | 1 + backend/pigeonhole/apps/projects/views.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index fec3aa04..97a93276 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -6,6 +6,7 @@ # Create your models here. class Project(models.Model): + objects = models.Manager() project_id = models.BigAutoField(primary_key=True) course_id = models.ForeignKey(Course, on_delete=models.CASCADE) name = models.CharField(max_length=256) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 0480004e..ee962511 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -33,3 +33,13 @@ def get(self, request, subject_id, project_id): else: # User isn't recognized. return JsonResponse({"message": "User role not recognized"}, status=403) + + def put(self, request, subject_id, project_id): + if request.user.teacher: + # update project data, such as name, description, ... + project = Project.objects.get(project_id=project_id) + elif request.user.student: + student = request.user.student + project = Project.objects.get(project_id=project_id) + + From 8165e90fc6030586e382b6982e62dd43ee34912a Mon Sep 17 00:00:00 2001 From: robinpdev Date: Tue, 5 Mar 2024 14:43:45 +0100 Subject: [PATCH 005/138] add swagger ui on /swagger and remove .env from tracked files --- .env | 12 ------------ backend/pigeonhole/settings.py | 10 ++++++++++ backend/pigeonhole/urls.py | 29 ++++++++++++++++++++++++----- backend/requirements.txt | 5 ++++- 4 files changed, 38 insertions(+), 18 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 316039e1..00000000 --- a/.env +++ /dev/null @@ -1,12 +0,0 @@ -DEBUG=1 -SECRET_KEY=development_key -DJANGO_ALLOWED_HOSTS='localhost 127.0.0.1 [::1] django' -SQL_ENGINE=django.db.backends.postgresql -SQL_DATABASE=pigeonhole_dev -SQL_USER=pigeonhole -SQL_PASSWORD=password -SQL_HOST=db -SQL_PORT=5432 -DATABASE=postgres -DJANGO_SUPERUSER_PASSWORD=abc -DJANGO_SUPERUSER_EMAIL=abc@example.com \ No newline at end of file diff --git a/backend/pigeonhole/settings.py b/backend/pigeonhole/settings.py index 36aaecaa..4dda9d6e 100644 --- a/backend/pigeonhole/settings.py +++ b/backend/pigeonhole/settings.py @@ -48,6 +48,7 @@ 'backend.pigeonhole.apps.projects', 'backend.pigeonhole.apps.submissions', 'backend.pigeonhole.apps.groups', + 'drf_yasg', ] MIDDLEWARE = [ @@ -156,3 +157,12 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Basic': { + 'type': 'basic' + } + } +} \ No newline at end of file diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 2cb677dd..1e106798 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,17 +1,36 @@ from django.urls import include, path -from rest_framework import routers +from rest_framework import routers, permissions -from backend.testapi import views +from backend.testapi import views as test_views +#from backend.pigeonhole.apps.projects import views as project_views +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="My API", + default_version='v1', + description="My API description", + terms_of_service="https://www.example.com/terms/", + contact=openapi.Contact(email="contact@example.com"), + license=openapi.License(name="Awesome License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) router = routers.DefaultRouter() -router.register(r'users', views.UserViewSet) -router.register(r'groups', views.GroupViewSet) +router.register(r'users', test_views.UserViewSet) +router.register(r'groups', test_views.GroupViewSet) +#router.register(r'projects', project_views.ProjectAPIView) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), ] urlpatterns += router.urls diff --git a/backend/requirements.txt b/backend/requirements.txt index 3d0d187c..28ae5bb6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,7 @@ djangorestframework-simplejwt~=5.2.2 djangorestframework~=3.14.0 flake8==7.0.0 psycopg2-binary~=2.9.5 -pytz~=2022.7.1 \ No newline at end of file +pytz~=2022.7.1 +pyyaml==6.0.1 +uritemplate==4.1.1 +drf-yasg==1.21.7 From 546f63b3c6ebc5c9123cbc5a90426ba0ae4ecf2b Mon Sep 17 00:00:00 2001 From: Reinhard Date: Thu, 7 Mar 2024 21:57:17 +0100 Subject: [PATCH 006/138] course api --- backend/pigeonhole/apps/courses/views.py | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 backend/pigeonhole/apps/courses/views.py diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py new file mode 100644 index 00000000..2a69e8ec --- /dev/null +++ b/backend/pigeonhole/apps/courses/views.py @@ -0,0 +1,48 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from .models import Course, CourseSerializer + + +class CourseViewSet(viewsets.ModelViewSet): + queryset = Course.objects.all() + serializer_class = CourseSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + serializer = CourseSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = CourseSerializer(instance, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def list(self, request, *args, **kwargs): + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = CourseSerializer(instance) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = CourseSerializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 2baf464d40359d9e7505312fc5865d7363aac70c Mon Sep 17 00:00:00 2001 From: avoyen Date: Fri, 8 Mar 2024 18:45:48 +0100 Subject: [PATCH 007/138] Project viewset and custom permission class --- .../pigeonhole/apps/projects/permissions.py | 20 +++++ backend/pigeonhole/apps/projects/views.py | 87 ++++++++++--------- 2 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 backend/pigeonhole/apps/projects/permissions.py diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py new file mode 100644 index 00000000..e03e7af6 --- /dev/null +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -0,0 +1,20 @@ +from rest_framework import permissions + + +class CanAccessProject(permissions.BasePermission): + # Custom permission class to determine if the currect user has access + # to the project data. + def has_permission(self, request, view): + if request.user.is_authenticated: + # If the user is a teacher, grant access + if request.user.teacher or request.user.teacher.is_admin: + return True + # If the user is a student, grant access only to their own projects + elif request.user.student: + subject_id = view.kwargs.get('subject_id') + project_id = view.kwargs.get('pk') + # TODO check if the student is subscribed to project + student = request.user.student + if student.course.filter(id=subject_id).exists(): + return True + return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index ee962511..5b722fd7 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -1,45 +1,50 @@ -from django.http import JsonResponse +from rest_framework import status +from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView - -from models import Project - - -class ProjectAPIView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request, subject_id, project_id): - # TODO still figuring out how to implement views properly. - if request.user.teacher: - # TODO should be able to get every project as an admin, not as a teacher. (check the is_admin field) - # This happens when the user is a teacher or admin. - project = Project.objects.get(project_id=project_id) - return JsonResponse({"id": project.project_id, - "name": project.name, - "deadline": project.deadline, - "description": project.description}, status=200) - elif request.user.student: - # When the user is a student. - student = request.user.student - if student.projects.filter(project_id=project_id).exists(): - project = Project.objects.get(project_id=project_id) - - return JsonResponse({"id": project.project_id, - "name": project.name, - "deadline": project.deadline, - "description": project.description}, status=200) - else: - return JsonResponse({"message": "Project not found"}, status=404) - else: - # User isn't recognized. - return JsonResponse({"message": "User role not recognized"}, status=403) +from rest_framework.response import Response + +from models import Project, ProjectSerializer +from permissions import CanAccessProject - def put(self, request, subject_id, project_id): - if request.user.teacher: - # update project data, such as name, description, ... - project = Project.objects.get(project_id=project_id) - elif request.user.student: - student = request.user.student - project = Project.objects.get(project_id=project_id) +class ProjectViewSet(viewsets.ModelViewSet): + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permission_classes = [IsAuthenticated & CanAccessProject] + def create(self, request, *args, **kwargs): + serializer = ProjectSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + subject_id = kwargs.get('subject_id') + project_id = kwargs.get('pk') + project = Project.objects.get(pk=project_id) + project.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def retrieve(self, request, *args, **kwargs): + subject_id = kwargs.get('subject_id') + project_id = kwargs.get('pk') + + serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + return Response(serializer.data, status=status.HTTP_200_OK) + + def update(self, request, *args, **kwargs): + subject_id = kwargs.get('subject_id') + project_id = kwargs.get('pk') + project = Project.objects.get(pk=project_id) + serializer = ProjectSerializer(project, data=request.data) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def archive(self, request, *args, **kwargs): + # Not sure what to do here yet + subject_id = kwargs.get('subject_id') + project_id = kwargs.get('pk') + serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + return Response(serializer, status=status.HTTP_200_OK) From f224e8a62cfe730d3d4478a8e481a603d1522b48 Mon Sep 17 00:00:00 2001 From: Thibaud Collyn Date: Fri, 8 Mar 2024 22:33:43 +0100 Subject: [PATCH 008/138] Added Material UI dependence and tested it out with some buttons. --- frontend/package-lock.json | 802 ++++++++++++++++++++++- frontend/package.json | 5 + frontend/pages/index.tsx | 2 + frontend/src/components/ui/LoginCard.tsx | 17 + 4 files changed, 810 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/ui/LoginCard.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d34338f..92e63922 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,11 @@ "name": "pigeonhole", "version": "0.1.0", "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.12", + "@mui/icons-material": "^5.15.12", + "@mui/material": "^5.15.12", "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -50,6 +55,186 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/runtime": { "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", @@ -61,6 +246,152 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/react": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + }, + "node_modules/@emotion/styled": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", + "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -117,6 +448,45 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "dependencies": { + "@floating-ui/dom": "^1.6.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, + "node_modules/@fontsource/roboto": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.12.tgz", + "integrity": "sha512-x0o17jvgoSSbS9OZnUX2+xJmVRvVCfeaYJjkS7w62iN7CuJWtMf5vJj8LqgC7ibqIkitOHVW+XssRjgrcHn62g==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -234,6 +604,261 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.38.tgz", + "integrity": "sha512-AsjD6Y1X5A1qndxz8xCcR8LDqv31aiwlgWMPxFAX/kCKiIGKlK65yMeVZ62iQr/6LBz+9hSKLiD1i4TZdAHKcQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.12.tgz", + "integrity": "sha512-brRO+tMFLpGyjEYHrX97bzqeF6jZmKpqqe1rY0LyIHAwP6xRVzh++zSecOQorDOCaZJg4XkGT9xfD+RWOWxZBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.12.tgz", + "integrity": "sha512-3BXiDlOd3AexZoEXa/VqpIpVIvosCzjLHsdMWzKMXbZdnBiJjmb9ECdqfjn5SpTClO49qvkKLhkTqdBH3fSFGw==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.12.tgz", + "integrity": "sha512-vXJGg6KNKucsvbW6l7w9zafnpOp0CWc0Wx4mDykuABTpQ5QQBnZxP7+oB4yAS1hDZQ1WobbeIl0CjxK4EEahkA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.38", + "@mui/core-downloads-tracker": "^5.15.12", + "@mui/system": "^5.15.12", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^18.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "node_modules/@mui/private-theming": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.12.tgz", + "integrity": "sha512-cqoSo9sgA5HE+8vZClbLrq9EkyOnYysooepi5eKaKvJ41lReT2c5wOZAeDDM1+xknrMDos+0mT2zr3sZmUiRRA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.12", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.11.tgz", + "integrity": "sha512-So21AhAngqo07ces4S/JpX5UaMU2RHXpEA6hNzI6IQjd/1usMPxpgK8wkGgTe3JKmC2KDmH8cvoycq5H3Ii7/w==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.11.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.12.tgz", + "integrity": "sha512-/pq+GO6yN3X7r3hAwFTrzkAh7K1bTF5r8IzS79B9eyKJg7v6B/t4/zZYMR6OT9qEPtwf6rYN2Utg1e6Z7F1OgQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.12", + "@mui/styled-engine": "^5.15.11", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.13", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.12.tgz", + "integrity": "sha512-8SDGCnO2DY9Yy+5bGzu00NZowSDtuyHP4H8gunhHGQoIlhlY2Z3w64wBzAOLpYw/ZhJNzksDTnS/i8qdJvxuow==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@types/prop-types": "^15.7.11", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, "node_modules/@next/env": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", @@ -424,6 +1049,15 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", @@ -488,17 +1122,20 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { "version": "18.2.57", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.57.tgz", "integrity": "sha512-ZvQsktJgSYrQiMirAN60y4O/LRevIV8hUzSOSNB6gfR3/o3wCBFQx3sPwIYtuDMeiVgsSS3UzCV26tEzgnfvQw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -514,11 +1151,18 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", @@ -987,6 +1631,20 @@ "dequal": "^2.0.3" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1087,7 +1745,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -1231,6 +1888,34 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1258,8 +1943,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -1367,6 +2051,15 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1396,6 +2089,14 @@ "node": ">=10.13.0" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.22.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.4.tgz", @@ -1557,7 +2258,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2050,6 +2750,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2432,6 +3137,14 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2445,7 +3158,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2512,6 +3224,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -2899,6 +3616,11 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3408,7 +4130,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -3416,6 +4137,23 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3466,7 +4204,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -3666,7 +4403,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -3727,8 +4463,22 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } }, "node_modules/read-cache": { "version": "1.0.0", @@ -3813,7 +4563,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -4052,6 +4801,14 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -4259,6 +5016,11 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -4393,6 +5155,14 @@ "node": ">=0.8" } }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b5685b8f..81adcf9f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,11 @@ "lint": "next lint" }, "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.12", + "@mui/icons-material": "^5.15.12", + "@mui/material": "^5.15.12", "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 6f4f5a52..ae5ebfeb 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -1,12 +1,14 @@ // pages/index.tsx import React from 'react'; import LoginForm from '@/components/ui/LoginForm'; +import LoginCard from '@/components/ui/LoginCard'; const Home = () => { return (
+
); }; diff --git a/frontend/src/components/ui/LoginCard.tsx b/frontend/src/components/ui/LoginCard.tsx new file mode 100644 index 00000000..541689d5 --- /dev/null +++ b/frontend/src/components/ui/LoginCard.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; + +export default function ContainedButtons() { + return ( + + + + + + ); +} \ No newline at end of file From d1aaba84317d965a175b939e96d8e70745d5aa10 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Sat, 9 Mar 2024 10:54:53 +0100 Subject: [PATCH 009/138] corrected instances --- backend/pigeonhole/apps/courses/views.py | 25 ++++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 2a69e8ec..81880e95 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -1,6 +1,6 @@ from rest_framework import viewsets, status -from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from .models import Course, CourseSerializer @@ -18,16 +18,18 @@ def create(self, request, *args, **kwargs): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def update(self, request, *args, **kwargs): - instance = self.get_object() - serializer = CourseSerializer(instance, data=request.data) + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): - instance = self.get_object() - instance.delete() + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + course.delete() return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, *args, **kwargs): @@ -35,14 +37,7 @@ def list(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, *args, **kwargs): - instance = self.get_object() - serializer = CourseSerializer(instance) + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, many=False) return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, *args, **kwargs): - instance = self.get_object() - serializer = CourseSerializer(instance, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From d30a0aa711ad3d9da5dd28de5540e0d2131c9abc Mon Sep 17 00:00:00 2001 From: Reinhard Date: Sat, 9 Mar 2024 11:30:39 +0100 Subject: [PATCH 010/138] added permissions --- .../pigeonhole/apps/courses/permissions.py | 34 +++++++++++++++++++ backend/pigeonhole/apps/courses/views.py | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/pigeonhole/apps/courses/permissions.py diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py new file mode 100644 index 00000000..8c93264c --- /dev/null +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -0,0 +1,34 @@ +from rest_framework import permissions +from backend.pigeonhole.apps.users.models import Student, Teacher + + +class CourseUserPermissions(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + if request.user.is_admin: + return True + + if request.user.is_teacher: + return True + + if request.user.is_student: + return view.action in ['list', 'retrieve'] + + return False + + def has_object_permission(self, request, view, obj): + if not request.user.is_authenticated: + return False + + if request.user.is_admin: + return True + + if request.user.is_teacher: + return Teacher.objects.filter(id=request.user.id, course=obj).exists() + + if request.user.is_student: + return Student.objects.filter(id=request.user.id, course=obj).exists() and view.action in ['list', 'retrieve'] + + return False diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 81880e95..fc1715a7 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -3,12 +3,13 @@ from rest_framework.response import Response from .models import Course, CourseSerializer +from .permissions import CourseUserPermissions class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, CourseUserPermissions] def create(self, request, *args, **kwargs): serializer = CourseSerializer(data=request.data) From 470806c32b0906b21d55c233e029f44588d4b272 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Sat, 9 Mar 2024 11:42:42 +0100 Subject: [PATCH 011/138] added courses to urls --- backend/pigeonhole/urls.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 1e106798..29086460 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -2,6 +2,7 @@ from rest_framework import routers, permissions from backend.testapi import views as test_views +from backend.pigeonhole.apps.courses.views import CourseViewSet #from backend.pigeonhole.apps.projects import views as project_views from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -22,6 +23,7 @@ router = routers.DefaultRouter() router.register(r'users', test_views.UserViewSet) router.register(r'groups', test_views.GroupViewSet) +router.register(r'courses', CourseViewSet) #router.register(r'projects', project_views.ProjectAPIView) # Wire up our API using automatic URL routing. From fb2692fc2a7336c55ac4d35764b129daca2b8de0 Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Sat, 9 Mar 2024 14:25:58 +0100 Subject: [PATCH 012/138] styled login page --- frontend/pages/_document.js | 26 ++++ frontend/pages/index.tsx | 3 +- frontend/src/components/ui/LoginForm.tsx | 147 +++++++++++++++++------ frontend/src/lib/utils.ts | 2 + 4 files changed, 139 insertions(+), 39 deletions(-) create mode 100644 frontend/pages/_document.js diff --git a/frontend/pages/_document.js b/frontend/pages/_document.js new file mode 100644 index 00000000..c732e018 --- /dev/null +++ b/frontend/pages/_document.js @@ -0,0 +1,26 @@ +import Document, {Head, Html, Main, NextScript} from 'next/document'; +import {PRIMARY_COLOR} from "../src/lib/utils"; + +class MyDocument extends Document { + render() { + return ( + + + {/* PWA primary color */} + + + {/* Add other head elements here as needed */} + + +
+ + + + ); + } +} + +export default MyDocument; diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index ae5ebfeb..55d46771 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -1,14 +1,13 @@ // pages/index.tsx import React from 'react'; -import LoginForm from '@/components/ui/LoginForm'; import LoginCard from '@/components/ui/LoginCard'; +import LoginForm from '@/components/ui/LoginForm'; const Home = () => { return (
-
); }; diff --git a/frontend/src/components/ui/LoginForm.tsx b/frontend/src/components/ui/LoginForm.tsx index 5b0ef3a8..b84149bf 100644 --- a/frontend/src/components/ui/LoginForm.tsx +++ b/frontend/src/components/ui/LoginForm.tsx @@ -1,49 +1,122 @@ -// src/app/components/LoginForm.tsx - import React, {useState} from 'react'; +import {Box, Button, Container, CssBaseline, TextField, Typography} from '@mui/material'; +import {createTheme, ThemeProvider} from '@mui/material/styles'; +import SchoolIcon from '@mui/icons-material/School'; + +const theme = createTheme({ + palette: { + background: { + default: '#f4f5fd' + }, + primary: { + main: '#1976d2', + }, + secondary: { + main: '#9c27b0', + }, + }, + typography: { + fontFamily: 'Quicksand, sans-serif', + h4: { + fontWeight: 700, + }, + }, + components: { + MuiTextField: { + defaultProps: { + InputLabelProps: { + shrink: true, + }, + margin: 'normal', + required: true, + fullWidth: true, + }, + }, + MuiButton: { + defaultProps: { + variant: 'contained', + color: 'primary', + fullWidth: true, + style: {margin: '10px 0'}, + }, + }, + }, +}); -const LoginForm = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); +const LoginForm: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); - const handleLogin = () => { + const handleLogin = (): void => { // Implement your login logic here + console.log('Login with:', email, password); }; - const handleCASLogin = () => { - // Implement your CAS login logic here + const handleCASLogin = (): void => { + // Implement CAS login logic here + console.log('Login with CAS'); }; return ( -
-

Pigeon Hole

-
- -
- -
- - -
-
+ + + + + + Pigeonhole + +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + + OR + + + + +
+
+
); }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0189d300..c8ec6dab 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,5 @@ import {twMerge} from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const PRIMARY_COLOR = "#1976d2"; From e0e423514e20ef96d082cb770b7fea75af528ced Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Sat, 9 Mar 2024 15:48:30 +0100 Subject: [PATCH 013/138] automatic submission number, student max in 1 group for every project --- backend/pigeonhole/apps/groups/models.py | 15 ++++++++++++++- backend/pigeonhole/apps/submissions/models.py | 16 +++++++++++++--- .../tests/test_models/test_submissions.py | 12 ------------ backend/pigeonhole/tests/test_views/__init__.py | 0 4 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/__init__.py diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 9f57317e..5e54a528 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -1,5 +1,6 @@ from django.db import models from rest_framework import serializers +from django.core.exceptions import ValidationError from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import Student @@ -15,12 +16,24 @@ class Group(models.Model): objects = models.Manager() + # a student can only be in one group per project + def clean(self): + for student in self.student.all(): + existing_groups = Group.objects.filter( + project_id=self.project_id, student=student).exclude( + group_id=self.group_id) + if existing_groups.exists(): + raise ValidationError(f"Student {student} is already part of " + "another group in this project.") + + # a student can only be in one group per project, group_nr is + # automatically assigned and unique per project def save(self, *args, **kwargs): if not self.group_id: if self.group_nr is None: max_group_nr = Group.objects.filter( project_id=self.project_id).aggregate( - models.Max('group_nr'))['group_nr__max'] or 0 + models.Max('group_nr'))['group_nr__max'] or 0 self.group_nr = max_group_nr + 1 super().save(*args, **kwargs) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 7025fb02..e8bb5593 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,5 +1,6 @@ from django.db import models from rest_framework import serializers +from django.core.exceptions import ValidationError from backend.pigeonhole.apps.groups.models import Group @@ -10,16 +11,25 @@ class Submissions(models.Model): group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=False) submission_nr = models.IntegerField() file = models.FileField(upload_to='uploads/submissions/files/' + - str(group_id) + '/' + str(submission_nr) + '/', + str(group_id) + '/' + str(submission_nr) + '/', null=True, blank=False, max_length=255) timestamp = models.DateTimeField(auto_now_add=True) output_test = models.FileField(upload_to='uploads/submissions/outputs/' + - str(group_id) + '/' + str(submission_nr) + - '/output_test/', null=True, blank=False, + str(group_id) + '/' + str(submission_nr) + + '/output_test/', null=True, blank=False, max_length=255) objects = models.Manager() + # submission_nr is automatically assigned and unique per group, and + # increments + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + if not self.submission_id: + max_submission_nr = Submissions.objects.filter( + group_id=self.group_id).aggregate( + models.Max('submission_nr'))['submission_nr__max'] or 0 + self.submission_nr = max_submission_nr + 1 + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) class SubmissionsSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index c6efed08..e9f0b16d 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -51,7 +51,6 @@ def setUp(self): # Create submission Submissions.objects.create( group_id=group, - submission_nr=1, ) def test_submission_student_relation(self): @@ -66,17 +65,6 @@ def test_submission_project_relation(self): project = submission.group_id.project_id self.assertEqual(submission.group_id.project_id, project) - def test_update_and_delete_submission(self): - submission = Submissions.objects.get(submission_nr=1) - submission.submission_nr = 2 - submission.save() - updated_submission = Submissions.objects.get(submission_nr=2) - self.assertEqual(updated_submission.submission_nr, 2) - - submission.delete() - with self.assertRaises(Submissions.DoesNotExist): - Submissions.objects.get(submission_nr=2) - def test_submission_file_upload_and_retrieval(self): submission = Submissions.objects.get(submission_nr=1) diff --git a/backend/pigeonhole/tests/test_views/__init__.py b/backend/pigeonhole/tests/test_views/__init__.py new file mode 100644 index 00000000..e69de29b From 008aecccce818d5a336f31217a0756a5896cd64f Mon Sep 17 00:00:00 2001 From: robinpdev Date: Sat, 9 Mar 2024 17:07:00 +0100 Subject: [PATCH 014/138] add admin panel --- backend/pigeonhole/urls.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 29086460..16c3d037 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,4 +1,5 @@ from django.urls import include, path +from django.contrib import admin from rest_framework import routers, permissions from backend.testapi import views as test_views @@ -33,6 +34,7 @@ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path("admin/", admin.site.urls), ] urlpatterns += router.urls From ada320962e1d36728ce9201d104ff5beb7e399a5 Mon Sep 17 00:00:00 2001 From: avoyen Date: Sat, 9 Mar 2024 19:42:48 +0100 Subject: [PATCH 015/138] fixed permission class, added url --- .../pigeonhole/apps/projects/permissions.py | 25 +++++++++++-------- backend/pigeonhole/apps/projects/views.py | 12 ++++----- backend/pigeonhole/urls.py | 3 ++- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index e03e7af6..d08a56b5 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -1,20 +1,23 @@ from rest_framework import permissions +from backend.pigeonhole.apps.users.models import Teacher, Student class CanAccessProject(permissions.BasePermission): # Custom permission class to determine if the currect user has access # to the project data. def has_permission(self, request, view): - if request.user.is_authenticated: - # If the user is a teacher, grant access - if request.user.teacher or request.user.teacher.is_admin: + user = request.user + subject_id = view.kwargs.get('course_id') + # If the user is a teacher, grant access. + if isinstance(user, Teacher): + if user.course.filter(id=subject_id).exists(): return True - # If the user is a student, grant access only to their own projects - elif request.user.student: - subject_id = view.kwargs.get('subject_id') - project_id = view.kwargs.get('pk') - # TODO check if the student is subscribed to project - student = request.user.student - if student.course.filter(id=subject_id).exists(): - return True + elif isinstance(user, Teacher) and user.is_admin: + return True + # If the user is a student, grant access only to their own projects. + elif isinstance(user, Student): + if user.course.filter(id=subject_id).exists(): + return True + elif request.user.is_superuser: + return True return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 5b722fd7..692c2cfa 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -3,8 +3,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from models import Project, ProjectSerializer -from permissions import CanAccessProject +from .models import Project, ProjectSerializer +from .permissions import CanAccessProject class ProjectViewSet(viewsets.ModelViewSet): @@ -21,21 +21,21 @@ def create(self, request, *args, **kwargs): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): - subject_id = kwargs.get('subject_id') + course_id = kwargs.get('course_id') project_id = kwargs.get('pk') project = Project.objects.get(pk=project_id) project.delete() return Response(status=status.HTTP_204_NO_CONTENT) def retrieve(self, request, *args, **kwargs): - subject_id = kwargs.get('subject_id') + course_id = kwargs.get('course_id') project_id = kwargs.get('pk') serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) return Response(serializer.data, status=status.HTTP_200_OK) def update(self, request, *args, **kwargs): - subject_id = kwargs.get('subject_id') + course_id = kwargs.get('course_id') project_id = kwargs.get('pk') project = Project.objects.get(pk=project_id) serializer = ProjectSerializer(project, data=request.data) @@ -44,7 +44,7 @@ def update(self, request, *args, **kwargs): def archive(self, request, *args, **kwargs): # Not sure what to do here yet - subject_id = kwargs.get('subject_id') + course_id = kwargs.get('course_id') project_id = kwargs.get('pk') serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) return Response(serializer, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 29086460..b08a5df0 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -3,6 +3,7 @@ from backend.testapi import views as test_views from backend.pigeonhole.apps.courses.views import CourseViewSet +from backend.pigeonhole.apps.projects.views import ProjectViewSet #from backend.pigeonhole.apps.projects import views as project_views from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -24,7 +25,7 @@ router.register(r'users', test_views.UserViewSet) router.register(r'groups', test_views.GroupViewSet) router.register(r'courses', CourseViewSet) -#router.register(r'projects', project_views.ProjectAPIView) +router.register(r'courses//project/', ProjectViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From fa9f4229cced1ce6793a95248d19b1299065cede Mon Sep 17 00:00:00 2001 From: avoyen Date: Sat, 9 Mar 2024 20:09:50 +0100 Subject: [PATCH 016/138] new url for creating project, added field to project model --- backend/pigeonhole/apps/projects/models.py | 1 + backend/pigeonhole/urls.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index 2621223d..f651f5bd 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -11,6 +11,7 @@ class Project(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) name = models.CharField(max_length=256) description = models.TextField() + deadline = models.DateTimeField() visible = models.BooleanField(default=False) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 99bc70b4..7a860619 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -26,7 +26,7 @@ router.register(r'users', test_views.UserViewSet) router.register(r'groups', test_views.GroupViewSet) router.register(r'courses', CourseViewSet) -router.register(r'courses//project/', ProjectViewSet) +router.register(r'courses//projects/', ProjectViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. @@ -36,6 +36,7 @@ path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path("admin/", admin.site.urls), + path('courses//projects/', ProjectViewSet.as_view({'post': 'create'}), name='project-create'), ] urlpatterns += router.urls From 25e4146d3d369806c7048f2022ce61f19758d5a0 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Sat, 9 Mar 2024 22:02:51 +0100 Subject: [PATCH 017/138] course permissions fixed --- .../pigeonhole/apps/courses/permissions.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 8c93264c..201b666e 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -4,31 +4,27 @@ class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): - if not request.user.is_authenticated: - return False - - if request.user.is_admin: + if request.user.is_superuser: return True - - if request.user.is_teacher: + if isinstance(request.user, Teacher): return True - if request.user.is_student: + if isinstance(request.user, Student): return view.action in ['list', 'retrieve'] return False def has_object_permission(self, request, view, obj): - if not request.user.is_authenticated: - return False - - if request.user.is_admin: + if request.user.is_superuser: return True + if isinstance(request.user, Teacher): + if request.user.is_admin: + return True + elif Teacher.objects.filter(id=request.user.id, course=obj).exists(): + return True + return view.action in ['list', 'retrieve'] - if request.user.is_teacher: - return Teacher.objects.filter(id=request.user.id, course=obj).exists() - - if request.user.is_student: - return Student.objects.filter(id=request.user.id, course=obj).exists() and view.action in ['list', 'retrieve'] + if isinstance(request.user, Student): + return view.action in ['list', 'retrieve'] return False From 54be0226af20d683b6ec41aa9f36b9f5c9b6aa21 Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Sun, 10 Mar 2024 13:00:21 +0100 Subject: [PATCH 018/138] fix pages structure --- frontend/pages/index.tsx | 15 -------- frontend/src/app/layout.tsx | 16 +++++++++ frontend/src/app/page.tsx | 44 ++++++------------------ frontend/src/components/ui/LoginCard.tsx | 17 --------- frontend/src/components/ui/LoginForm.tsx | 1 + 5 files changed, 27 insertions(+), 66 deletions(-) delete mode 100644 frontend/pages/index.tsx create mode 100644 frontend/src/app/layout.tsx delete mode 100644 frontend/src/components/ui/LoginCard.tsx diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx deleted file mode 100644 index 55d46771..00000000 --- a/frontend/pages/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// pages/index.tsx -import React from 'react'; -import LoginCard from '@/components/ui/LoginCard'; -import LoginForm from '@/components/ui/LoginForm'; - - -const Home = () => { - return ( -
- -
- ); -}; - -export default Home; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 00000000..a14e64fc --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ee07830a..b7f71cda 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,37 +1,13 @@ -"use client" +import React from 'react'; +import LoginForm from '@/components/ui/LoginForm'; -import Image from "next/image"; -import React, { useState, useEffect } from 'react'; - -export default function Home() { - - - const [data, setData] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch('http://127.0.0.1:8000/groups/'); - if (response.ok) { - const json = await response.json(); - setData(json); - } else { - console.error('Failed to fetch data'); - } - } catch (error) { - console.error('Error:', error); - } - }; - - fetchData(); - }, []); - - - return ( -
-

Deployment test:

-

{JSON.stringify(data)}

-
- ); +const Login = () => { + return ( +
+ +
+ ) } + +export default Login diff --git a/frontend/src/components/ui/LoginCard.tsx b/frontend/src/components/ui/LoginCard.tsx deleted file mode 100644 index 541689d5..00000000 --- a/frontend/src/components/ui/LoginCard.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from 'react'; -import Button from '@mui/material/Button'; -import Stack from '@mui/material/Stack'; - -export default function ContainedButtons() { - return ( - - - - - - ); -} \ No newline at end of file diff --git a/frontend/src/components/ui/LoginForm.tsx b/frontend/src/components/ui/LoginForm.tsx index b84149bf..49008083 100644 --- a/frontend/src/components/ui/LoginForm.tsx +++ b/frontend/src/components/ui/LoginForm.tsx @@ -1,3 +1,4 @@ +"use client" import React, {useState} from 'react'; import {Box, Button, Container, CssBaseline, TextField, Typography} from '@mui/material'; import {createTheme, ThemeProvider} from '@mui/material/styles'; From 0ddd30cd0779ec19cddeb60f45e3af463dc25f9c Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Sun, 10 Mar 2024 14:47:29 +0100 Subject: [PATCH 019/138] GET requests support for groups+submissions, but error for groups when needing deadline --- backend/pigeonhole/apps/groups/models.py | 2 +- backend/pigeonhole/apps/groups/views.py | 17 +++++++++++++++++ backend/pigeonhole/apps/submissions/models.py | 2 +- backend/pigeonhole/apps/submissions/views.py | 17 +++++++++++++++++ backend/pigeonhole/urls.py | 5 ++++- 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/views.py create mode 100644 backend/pigeonhole/apps/submissions/views.py diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 2c8d67b1..cf9bab6e 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -19,4 +19,4 @@ class Group(models.Model): class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ["group_id", "group_nr", "final_score", "project_id", "student"] + fields = ["group_id", "group_nr", "final_score", "project_id", "student", "feedback", "project_id"] diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py new file mode 100644 index 00000000..fe5473ec --- /dev/null +++ b/backend/pigeonhole/apps/groups/views.py @@ -0,0 +1,17 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from backend.pigeonhole.apps.groups.models import Group, GroupSerializer + + +class GroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer + permission_classes = [IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def list(self, request, *args, **kwargs): + serializer = GroupSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index da12e031..2ac9ead2 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -21,4 +21,4 @@ class Submissions(models.Model): class SubmissionsSerializer(serializers.ModelSerializer): class Meta: model = Submissions - fields = ['submission_id', 'group_id', 'file', 'timestamp', 'submission_nr'] + fields = ['submission_id', 'group_id', 'file', 'timestamp', 'submission_nr', 'output_test'] diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py new file mode 100644 index 00000000..7079c1d5 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/views.py @@ -0,0 +1,17 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer + + +class SubmissionsViewset(viewsets.ModelViewSet): + queryset = Submissions.objects.all() + serializer_class = SubmissionsSerializer + permission_classes = [IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def list(self, request, *args, **kwargs): + serializer = SubmissionsSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 7a860619..c965476d 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -5,6 +5,8 @@ from backend.testapi import views as test_views from backend.pigeonhole.apps.courses.views import CourseViewSet from backend.pigeonhole.apps.projects.views import ProjectViewSet +from backend.pigeonhole.apps.submissions.views import SubmissionsViewset +from backend.pigeonhole.apps.groups.views import GroupViewSet #from backend.pigeonhole.apps.projects import views as project_views from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -24,9 +26,10 @@ router = routers.DefaultRouter() router.register(r'users', test_views.UserViewSet) -router.register(r'groups', test_views.GroupViewSet) +router.register(r'groups', GroupViewSet) router.register(r'courses', CourseViewSet) router.register(r'courses//projects/', ProjectViewSet) +router.register(r'submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From b14d75bf5623bfb4f94109edc52cf586ff3e525a Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Sun, 10 Mar 2024 16:56:40 +0100 Subject: [PATCH 020/138] removed deadline temporary to find bug --- backend/pigeonhole/apps/projects/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index f651f5bd..d998a3a9 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -11,21 +11,20 @@ class Project(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) name = models.CharField(max_length=256) description = models.TextField() - deadline = models.DateTimeField() + # deadline = models.DateTimeField() visible = models.BooleanField(default=False) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ['project_id', 'course_id', 'name', 'description', 'deadline', 'visible'] + fields = ['project_id', 'course_id', 'name', 'description', 'visible'] class Conditions(models.Model): condition_id = models.BigAutoField(primary_key=True) submission_id = models.ForeignKey(Project, on_delete=models.CASCADE) condition = models.CharField(max_length=256) - deadline = models.DateTimeField() test_file_location = models.CharField(max_length=512, null=True) test_file_type = models.CharField(max_length=256, null=True) From ca408dd4990ba9c160b34d623c4c6f600558c4d9 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Sun, 10 Mar 2024 17:01:45 +0100 Subject: [PATCH 021/138] without deadline in project seems to work --- .../apps/courses/migrations/0001_initial.py | 3 +- .../apps/groups/migrations/0001_initial.py | 3 +- .../apps/groups/migrations/0002_initial.py | 3 +- .../apps/projects/migrations/0001_initial.py | 13 +++--- backend/pigeonhole/apps/projects/models.py | 10 ++--- .../submissions/migrations/0001_initial.py | 11 ++--- ...ions_file_alter_submissions_output_test.py | 26 ------------ .../apps/users/migrations/0001_initial.py | 40 +++++-------------- 8 files changed, 31 insertions(+), 78 deletions(-) delete mode 100644 backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py diff --git a/backend/pigeonhole/apps/courses/migrations/0001_initial.py b/backend/pigeonhole/apps/courses/migrations/0001_initial.py index 9d548232..48a29d53 100644 --- a/backend/pigeonhole/apps/courses/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/courses/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ diff --git a/backend/pigeonhole/apps/groups/migrations/0001_initial.py b/backend/pigeonhole/apps/groups/migrations/0001_initial.py index fc8925aa..1102b309 100644 --- a/backend/pigeonhole/apps/groups/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ diff --git a/backend/pigeonhole/apps/groups/migrations/0002_initial.py b/backend/pigeonhole/apps/groups/migrations/0002_initial.py index 354b0401..649b27b5 100644 --- a/backend/pigeonhole/apps/groups/migrations/0002_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0002_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index c4390c94..46f86d20 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -26,7 +27,7 @@ class Migration(migrations.Migration): name='ForbiddenExtension', fields=[ ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.IntegerField()), + ('extension', models.CharField(max_length=512)), ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), @@ -34,19 +35,17 @@ class Migration(migrations.Migration): name='Conditions', fields=[ ('condition_id', models.BigAutoField(primary_key=True, serialize=False)), - ('condition', models.CharField(max_length=256)), - ('deadline', models.DateTimeField()), + ('condition', models.TextField(max_length=256)), ('test_file_location', models.CharField(max_length=512, null=True)), ('test_file_type', models.CharField(max_length=256, null=True)), - ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, - to='projects.project')), + ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), migrations.CreateModel( name='AllowedExtension', fields=[ ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.IntegerField()), + ('extension', models.CharField(max_length=512)), ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index d998a3a9..fbc15548 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -24,7 +24,7 @@ class Meta: class Conditions(models.Model): condition_id = models.BigAutoField(primary_key=True) submission_id = models.ForeignKey(Project, on_delete=models.CASCADE) - condition = models.CharField(max_length=256) + condition = models.TextField(max_length=256) test_file_location = models.CharField(max_length=512, null=True) test_file_type = models.CharField(max_length=256, null=True) @@ -32,17 +32,17 @@ class Conditions(models.Model): @property def get_forbidden_extensions(self): - return ForbiddenExtension.objects.filter(project_id=self.submission_id) + return ForbiddenExtension.objects.filter(project_id=self.project_id) @property def get_allowed_extensions(self): - return AllowedExtension.objects.filter(project_id=self.submission_id) + return AllowedExtension.objects.filter(project_id=self.project_id) class AllowedExtension(models.Model): extension_id = models.BigAutoField(primary_key=True) project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.IntegerField() + extension = models.CharField(max_length=512) objects = models.Manager() @@ -50,6 +50,6 @@ class AllowedExtension(models.Model): class ForbiddenExtension(models.Model): extension_id = models.BigAutoField(primary_key=True) project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.IntegerField() + extension = models.CharField(max_length=512) objects = models.Manager() diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index f393bd58..8c4d718c 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -17,13 +18,9 @@ class Migration(migrations.Migration): fields=[ ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), ('submission_nr', models.IntegerField()), - ('file', models.FileField( - upload_to='uploads///')), + ('file', models.FileField(null=True, upload_to='uploads///')), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField( - upload_to='uploads///output_test/')), + ('output_test', models.FileField(null=True, upload_to='uploads///output_test/')), ('group_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='groups.group')), ], ), diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index 56752ac0..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('submissions', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField(null=True, - upload_to='uploads///'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(null=True, - upload_to='uploads///output_test/'), - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index 42953dcd..016b1bb0 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-10 16:00 import django.contrib.auth.models import django.contrib.auth.validators @@ -9,6 +9,7 @@ class Migration(migrations.Migration): + initial = True dependencies = [ @@ -23,35 +24,16 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, - help_text='Designates that this user has all permissions ' - 'without explicitly assigning them.', - verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, - help_text='Required. 150 characters or fewer. Letters, digits ' - 'and @/./+/-/_ only.', - max_length=150, unique=True, - validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], - verbose_name='username')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, - help_text='Designates whether the user can log into this admin site.', - verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, - help_text='Designates whether this user should be treated as active. ' - 'Unselect this instead of deleting accounts.', - verbose_name='active')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, - help_text='The groups this user belongs to. A user will get all ' - 'permissions granted to each of their groups.', - related_name='user_set', related_query_name='user', to='auth.group', - verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', - related_name='user_set', related_query_name='user', - to='auth.permission', verbose_name='user permissions')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], options={ 'verbose_name': 'user', @@ -66,8 +48,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Student', fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), + ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), ('number', models.IntegerField()), ('course', models.ManyToManyField(to='courses.course')), ], @@ -75,8 +56,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Teacher', fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), + ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), ('is_admin', models.BooleanField(default=False)), ('is_assistent', models.BooleanField(default=False)), ('course', models.ManyToManyField(to='courses.course')), From 2f9a381e990c4293284e68c913c398a3250bbc52 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Sun, 10 Mar 2024 17:12:08 +0100 Subject: [PATCH 022/138] deadline for projects fixed --- .../migrations/0002_project_deadline.py | 18 ++++++++++++++++++ .../migrations/0003_alter_project_deadline.py | 18 ++++++++++++++++++ .../migrations/0004_alter_project_deadline.py | 18 ++++++++++++++++++ backend/pigeonhole/apps/projects/models.py | 4 ++-- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py create mode 100644 backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py create mode 100644 backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py diff --git a/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py new file mode 100644 index 00000000..330da245 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-10 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='deadline', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py new file mode 100644 index 00000000..639d1bc8 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-10 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_project_deadline'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='deadline', + field=models.DateTimeField(null=True), + ), + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py new file mode 100644 index 00000000..f9b12a86 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-10 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_alter_project_deadline'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='deadline', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index fbc15548..8834cf12 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -11,14 +11,14 @@ class Project(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) name = models.CharField(max_length=256) description = models.TextField() - # deadline = models.DateTimeField() + deadline = models.DateTimeField(null=True, blank=True) visible = models.BooleanField(default=False) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ['project_id', 'course_id', 'name', 'description', 'visible'] + fields = ['project_id', 'course_id', 'name', 'description', 'visible', 'deadline'] class Conditions(models.Model): From 82c29e2310d3ba1f29065f7dff85e9a382d28f32 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Sun, 10 Mar 2024 18:57:58 +0100 Subject: [PATCH 023/138] Everything fixed to create submissions --- backend/pigeonhole/apps/groups/models.py | 2 +- backend/pigeonhole/apps/groups/views.py | 9 ++++ backend/pigeonhole/apps/projects/views.py | 3 ++ backend/pigeonhole/apps/submissions/views.py | 9 ++++ backend/pigeonhole/apps/users/models.py | 3 +- backend/pigeonhole/apps/users/views.py | 48 ++++++++++++++++++++ backend/pigeonhole/urls.py | 4 +- 7 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 backend/pigeonhole/apps/users/views.py diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index cf9bab6e..c806f3c9 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -19,4 +19,4 @@ class Group(models.Model): class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ["group_id", "group_nr", "final_score", "project_id", "student", "feedback", "project_id"] + fields = ["group_id", "group_nr", "final_score", "project_id", "student", "feedback"] diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index fe5473ec..23bbeca7 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -1,6 +1,7 @@ from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response + from backend.pigeonhole.apps.groups.models import Group, GroupSerializer @@ -15,3 +16,11 @@ def perform_create(self, serializer): def list(self, request, *args, **kwargs): serializer = GroupSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, *args, **kwargs): + serializer = GroupSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 692c2cfa..e5db62ff 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -12,6 +12,9 @@ class ProjectViewSet(viewsets.ModelViewSet): serializer_class = ProjectSerializer permission_classes = [IsAuthenticated & CanAccessProject] + def perform_create(self, serializer): + serializer.save(user=self.request.user) + def create(self, request, *args, **kwargs): serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 7079c1d5..8a03e474 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,6 +1,7 @@ from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response + from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer @@ -15,3 +16,11 @@ def perform_create(self, serializer): def list(self, request, *args, **kwargs): serializer = SubmissionsSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, *args, **kwargs): + serializer = SubmissionsSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index ce1c243d..4a35e705 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -17,7 +17,7 @@ def name(self): class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'e_mail', 'first_name', 'last_name'] + fields = ['id', 'email', 'first_name', 'last_name'] class Student(models.Model): @@ -29,6 +29,7 @@ class Student(models.Model): class StudentSerializer(serializers.ModelSerializer): + id = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) class Meta: model = Student fields = ['number', 'course', 'id'] diff --git a/backend/pigeonhole/apps/users/views.py b/backend/pigeonhole/apps/users/views.py new file mode 100644 index 00000000..0af8843f --- /dev/null +++ b/backend/pigeonhole/apps/users/views.py @@ -0,0 +1,48 @@ +from rest_framework import viewsets, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.pigeonhole.apps.users.models import Student, StudentSerializer, User, UserSerializer + + +class StudentViewSet(viewsets.ModelViewSet): + queryset = Student.objects.all() + serializer_class = StudentSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + serializer = StudentSerializer(data=request.data) + print(serializer) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def list(self, request, *args, **kwargs): + serializer = StudentSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def list(self, request, *args, **kwargs): + serializer = UserSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, *args, **kwargs): + serializer = UserSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index c965476d..3676ea4a 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -7,6 +7,7 @@ from backend.pigeonhole.apps.projects.views import ProjectViewSet from backend.pigeonhole.apps.submissions.views import SubmissionsViewset from backend.pigeonhole.apps.groups.views import GroupViewSet +from backend.pigeonhole.apps.users.views import StudentViewSet, UserViewSet #from backend.pigeonhole.apps.projects import views as project_views from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -25,7 +26,8 @@ ) router = routers.DefaultRouter() -router.register(r'users', test_views.UserViewSet) +router.register(r'users', UserViewSet) +router.register(r'students', StudentViewSet) router.register(r'groups', GroupViewSet) router.register(r'courses', CourseViewSet) router.register(r'courses//projects/', ProjectViewSet) From c5fcf3ee720b1074bb60ca851245ce9eec940002 Mon Sep 17 00:00:00 2001 From: avoyen Date: Sun, 10 Mar 2024 19:37:13 +0100 Subject: [PATCH 024/138] API works now, fixed URLs and the requests --- backend/pigeonhole/apps/projects/views.py | 28 ++++++++++++++++------- backend/pigeonhole/urls.py | 3 +-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index e5db62ff..c5f18bbe 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -15,7 +15,16 @@ class ProjectViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(user=self.request.user) + def list(self, request, *args, **kwargs): + course_id = kwargs.get('course_id') + project_id = kwargs.get('pk') + + serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + return Response(serializer.data, status=status.HTTP_200_OK) + def create(self, request, *args, **kwargs): + print("ceating new project") + course_id = kwargs.get('course_id') serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): serializer.save() @@ -26,9 +35,10 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') + project = Project.objects.get(pk=project_id) project.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response({"message": "Project has been deleted successfully."}, status=status.HTTP_204_NO_CONTENT) def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('course_id') @@ -40,14 +50,16 @@ def retrieve(self, request, *args, **kwargs): def update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') + project = Project.objects.get(pk=project_id) serializer = ProjectSerializer(project, data=request.data) - serializer.save() + if serializer.is_valid(): + serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - def archive(self, request, *args, **kwargs): - # Not sure what to do here yet - course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) - return Response(serializer, status=status.HTTP_200_OK) + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 3676ea4a..50d52d06 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -30,8 +30,8 @@ router.register(r'students', StudentViewSet) router.register(r'groups', GroupViewSet) router.register(r'courses', CourseViewSet) -router.register(r'courses//projects/', ProjectViewSet) router.register(r'submissions', SubmissionsViewset) +router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. @@ -41,7 +41,6 @@ path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path("admin/", admin.site.urls), - path('courses//projects/', ProjectViewSet.as_view({'post': 'create'}), name='project-create'), ] urlpatterns += router.urls From 8cbc51fad012604bd22aaafa7842575b7c38e3a6 Mon Sep 17 00:00:00 2001 From: avoyen Date: Sun, 10 Mar 2024 20:22:35 +0100 Subject: [PATCH 025/138] list fixed --- backend/pigeonhole/apps/projects/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index c5f18bbe..f748799d 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -17,9 +17,7 @@ def perform_create(self, serializer): def list(self, request, *args, **kwargs): course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - - serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): From ec129d7f5eb24ca380b467a6c4fb9f0e8cf44a70 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Sun, 10 Mar 2024 22:06:46 +0100 Subject: [PATCH 026/138] fix merge --- ...ions_file_alter_submissions_output_test.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py new file mode 100644 index 00000000..483e5352 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.2 on 2024-03-02 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('submissions', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='submissions', + name='file', + field=models.FileField(null=True, + upload_to='uploads///'), + ), + migrations.AlterField( + model_name='submissions', + name='output_test', + field=models.FileField(null=True, + upload_to='uploads///output_test/'), + ), + ] \ No newline at end of file From cefeb9874314d9a2c1afb71cda5398df614f1c43 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Sun, 10 Mar 2024 23:04:22 +0100 Subject: [PATCH 027/138] course api tests, permission fixes, test fixes --- .../pigeonhole/apps/courses/permissions.py | 29 +-- .../tests/test_models/test_conditions.py | 2 +- .../tests/test_models/test_groups.py | 1 + .../tests/test_models/test_project.py | 1 + .../tests/test_models/test_submissions.py | 1 + .../pigeonhole/tests/test_views/__init__.py | 0 .../tests/test_views/test_course.py | 173 ++++++++++++++++++ 7 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/__init__.py create mode 100644 backend/pigeonhole/tests/test_views/test_course.py diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 201b666e..9e456045 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -1,30 +1,19 @@ from rest_framework import permissions -from backend.pigeonhole.apps.users.models import Student, Teacher +from backend.pigeonhole.apps.users.models import Teacher, Student class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): if request.user.is_superuser: return True - if isinstance(request.user, Teacher): - return True - - if isinstance(request.user, Student): - return view.action in ['list', 'retrieve'] - - return False - - def has_object_permission(self, request, view, obj): - if request.user.is_superuser: - return True - if isinstance(request.user, Teacher): - if request.user.is_admin: + if Teacher.objects.filter(id=request.user.id).exists(): + teacher = Teacher.objects.get(id=request.user.id) + if teacher.is_admin: return True - elif Teacher.objects.filter(id=request.user.id, course=obj).exists(): + # Check if the teacher is assigned to the course + course = view.kwargs.get('pk') + if teacher.course.filter(course_id=course).exists(): return True + return view.action in ['list', 'retrieve', 'create'] + elif Student.objects.filter(id=request.user.id).exists(): return view.action in ['list', 'retrieve'] - - if isinstance(request.user, Student): - return view.action in ['list', 'retrieve'] - - return False diff --git a/backend/pigeonhole/tests/test_models/test_conditions.py b/backend/pigeonhole/tests/test_models/test_conditions.py index 37307ab7..b6748042 100644 --- a/backend/pigeonhole/tests/test_models/test_conditions.py +++ b/backend/pigeonhole/tests/test_models/test_conditions.py @@ -34,6 +34,7 @@ def setUp(self): project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description" ) @@ -41,7 +42,6 @@ def setUp(self): self.conditions = Conditions.objects.create( submission_id=project, condition="Condition 1", - deadline="2021-12-12 12:12:12", test_file_location="path/to/test", test_file_type="txt" ) diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 0ece53b9..b7d52642 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -47,6 +47,7 @@ def setUp(self): project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", ) diff --git a/backend/pigeonhole/tests/test_models/test_project.py b/backend/pigeonhole/tests/test_models/test_project.py index 11285c91..4f66e0f1 100644 --- a/backend/pigeonhole/tests/test_models/test_project.py +++ b/backend/pigeonhole/tests/test_models/test_project.py @@ -34,6 +34,7 @@ def setUp(self): self.project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", ) diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index c6efed08..3dc59031 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -37,6 +37,7 @@ def setUp(self): project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", ) diff --git a/backend/pigeonhole/tests/test_views/__init__.py b/backend/pigeonhole/tests/test_views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_course.py b/backend/pigeonhole/tests/test_views/test_course.py new file mode 100644 index 00000000..5c3f8bd6 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course.py @@ -0,0 +1,173 @@ +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status +from backend.pigeonhole.apps.users.models import User, Teacher, Student +from backend.pigeonhole.apps.courses.models import Course + +API_ENDPOINT = '/courses/' # Updated the API_ENDPOINT + + +class CourseTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", description="This is not of the teacher") + + # Create a regular user (teacher) + self.user = User.objects.create_user( + username="teacher_username", + email="teacher@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + # Create a Teacher instance and use .set() to assign the course + self.teacher = Teacher.objects.create(id=self.user) + self.teacher.course.set([self.course]) + + # Authenticate the test client with the teacher user + self.client.force_authenticate(user=self.user) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + # TODO + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + # TODO + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.course.name) + self.assertEqual(response.data['description'], self.course.description) + + +class CourseTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a regular user (teacher) + self.user = User.objects.create_user( + username="teacher_username", + email="kermit@gmail.com", + first_name="Kermit", + last_name="The Frog" + ) + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + # Provide a value for the "number" field when creating the Student instance + self.student = Student.objects.create(id=self.user, number=123456) + self.student.course.set([self.course]) + + # Authenticate the test client with the regular user + self.client.force_authenticate(user=self.user) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + +class CourseTestUnauthorized(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 1) \ No newline at end of file From c3dd5a47fc4c97ce7ae3481f78c06e98307d0661 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Mon, 11 Mar 2024 10:25:20 +0100 Subject: [PATCH 028/138] partial projects tests, todo invalid project & unautherised --- .../pigeonhole/apps/projects/permissions.py | 24 +- backend/pigeonhole/apps/projects/views.py | 14 +- .../tests/test_views/test_course.py | 86 +++- .../tests/test_views/test_project.py | 478 ++++++++++++++++++ 4 files changed, 584 insertions(+), 18 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/test_project.py diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index d08a56b5..49a292a2 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -3,21 +3,25 @@ class CanAccessProject(permissions.BasePermission): - # Custom permission class to determine if the currect user has access - # to the project data. def has_permission(self, request, view): user = request.user subject_id = view.kwargs.get('course_id') - # If the user is a teacher, grant access. - if isinstance(user, Teacher): - if user.course.filter(id=subject_id).exists(): + + if Teacher.objects.filter(id=user.id).exists(): + teacher = Teacher.objects.get(id=user.id) + + # Check if the teacher is assigned to the specified course + if teacher.course.filter(course_id=subject_id).exists(): return True - elif isinstance(user, Teacher) and user.is_admin: - return True - # If the user is a student, grant access only to their own projects. - elif isinstance(user, Student): - if user.course.filter(id=subject_id).exists(): + elif teacher.is_admin: return True + return view.action in ['list', 'retrieve', 'create'] and teacher.course.filter( + course_id=subject_id).exists() + # If the user is a student, grant access only to their own projects. + elif Student.objects.filter(id=user.id).exists(): + student = Student.objects.get(id=user.id) + if student.course.filter(course_id=subject_id).exists(): + return view.action in ['list', 'retrieve'] elif request.user.is_superuser: return True return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index f748799d..ba735ab4 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .models import Project, ProjectSerializer +from .models import Project, ProjectSerializer, Course from .permissions import CanAccessProject @@ -18,6 +18,9 @@ def perform_create(self, serializer): def list(self, request, *args, **kwargs): course_id = kwargs.get('course_id') serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) + # Check whether the course exists + if not Course.objects.filter(course_id=course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): @@ -41,8 +44,17 @@ def destroy(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') + + # Check whether the course exists + if not Course.objects.filter(course_id=course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + + # Check whether the project exists + if not Project.objects.filter(pk=project_id).exists(): + return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + return Response(serializer.data, status=status.HTTP_200_OK) def update(self, request, *args, **kwargs): diff --git a/backend/pigeonhole/tests/test_views/test_course.py b/backend/pigeonhole/tests/test_views/test_course.py index 5c3f8bd6..984f508a 100644 --- a/backend/pigeonhole/tests/test_views/test_course.py +++ b/backend/pigeonhole/tests/test_views/test_course.py @@ -7,6 +7,78 @@ API_ENDPOINT = '/courses/' # Updated the API_ENDPOINT +class CourseTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") + + self.course = Course.objects.create(**self.course_data) + + # Create a regular user (teacher) + self.user = User.objects.create_user( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + # Create a Teacher instance and use .set() to assign the course + self.teacher = Teacher.objects.create(id=self.user, is_admin=True) + self.teacher.course.set([self.course]) + + self.client.force_authenticate(user=self.user) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course_not_of_teacher.refresh_from_db() + self.assertEqual(self.course_not_of_teacher.name, updated_data['name']) + self.assertEqual(self.course_not_of_teacher.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Course.objects.count()) + + class CourseTestTeacher(TestCase): def setUp(self): self.client = APIClient() @@ -18,7 +90,8 @@ def setUp(self): self.course = Course.objects.create(**self.course_data) - self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", description="This is not of the teacher") + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") # Create a regular user (teacher) self.user = User.objects.create_user( @@ -51,13 +124,13 @@ def test_update_course(self): self.assertEqual(self.course.name, updated_data['name']) self.assertEqual(self.course.description, updated_data['description']) - # TODO def test_update_course_not_of_teacher(self): updated_data = { 'name': 'Updated Course', 'description': 'This course has been updated.' } - response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, format='json') + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_course(self): @@ -65,7 +138,6 @@ def test_delete_course(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Course.objects.count(), 1) - # TODO def test_delete_course_not_of_teacher(self): response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -73,7 +145,7 @@ def test_delete_course_not_of_teacher(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), Course.objects.count()) def test_retrieve_course(self): response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') @@ -132,7 +204,7 @@ def test_delete_course(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) + self.assertEqual(len(response.data), 2) class CourseTestUnauthorized(TestCase): @@ -170,4 +242,4 @@ def test_delete_course(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(len(response.data), 1) \ No newline at end of file + self.assertEqual(len(response.data), Course.objects.count()) diff --git a/backend/pigeonhole/tests/test_views/test_project.py b/backend/pigeonhole/tests/test_views/test_project.py new file mode 100644 index 00000000..2fad0a71 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project.py @@ -0,0 +1,478 @@ +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status +from backend.pigeonhole.apps.users.models import User, Teacher, Student +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project + +API_ENDPOINT = '/courses/' # Updated the API_ENDPOINT + + +class ProjectTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + self.teacher = Teacher.objects.create( + id=self.user, + is_admin=True + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.user) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 2) + self.assertEqual(Project.objects.get(project_id=2).name, "Test Project 2") + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + + +class ProjectTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + self.teacher = Teacher.objects.create( + id=self.user, + is_admin=False + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.user) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Updated assertion to use the correct project_id from the response + created_project_id = response.data.get('project_id') + self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") + self.assertEqual(Project.objects.count(), 2) + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with a course not of the teacher + + def test_create_project_course_not_of_teacher(self): + response = self.client.post( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_teacher.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + +class ProjectTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + self.student = Student.objects.create( + id=self.user, + number=1234567890 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_student = Course.objects.create( + name="Test Course 2", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.user) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with a course not of the student + + def test_create_project_course_not_of_student(self): + response = self.client.post( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + + def test_retrieve_project_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + + def test_update_project_course_not_of_student(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) \ No newline at end of file From c3e2adc5a7f1dcd59d17d7c6992f2fb7547c73d3 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Mon, 11 Mar 2024 10:38:31 +0100 Subject: [PATCH 029/138] fix views invalid objects --- backend/pigeonhole/apps/projects/views.py | 35 ++++++++- .../tests/test_views/test_project.py | 73 ++++++++++++------- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index ba735ab4..7b37a4c9 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -18,14 +18,21 @@ def perform_create(self, serializer): def list(self, request, *args, **kwargs): course_id = kwargs.get('course_id') serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) + # Check whether the course exists if not Course.objects.filter(course_id=course_id).exists(): return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): print("ceating new project") course_id = kwargs.get('course_id') + + # Check whether the course exists + if not Course.objects.filter(course_id=course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): serializer.save() @@ -37,6 +44,14 @@ def destroy(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') + # Check whether the course exists + if not Course.objects.filter(course_id=course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + + # Check whether the project exists + if not Project.objects.filter(pk=project_id).exists(): + return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) + project = Project.objects.get(pk=project_id) project.delete() return Response({"message": "Project has been deleted successfully."}, status=status.HTTP_204_NO_CONTENT) @@ -44,11 +59,11 @@ def destroy(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') - + # Check whether the course exists if not Course.objects.filter(course_id=course_id).exists(): return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) - + # Check whether the project exists if not Project.objects.filter(pk=project_id).exists(): return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) @@ -61,6 +76,14 @@ def update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') + # Check whether the course exists + if not Course.objects.filter(course_id=course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + + # Check whether the project exists + if not Project.objects.filter(pk=project_id).exists(): + return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) + project = Project.objects.get(pk=project_id) serializer = ProjectSerializer(project, data=request.data) if serializer.is_valid(): @@ -69,6 +92,14 @@ def update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs): instance = self.get_object() + # Check whether the course exists + if not Course.objects.filter(course_id=instance.course_id.course_id).exists(): + return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + + # Check whether the project exists + if not Project.objects.filter(pk=instance.project_id).exists(): + return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) + serializer = self.get_serializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() diff --git a/backend/pigeonhole/tests/test_views/test_project.py b/backend/pigeonhole/tests/test_views/test_project.py index 2fad0a71..f74d92c1 100644 --- a/backend/pigeonhole/tests/test_views/test_project.py +++ b/backend/pigeonhole/tests/test_views/test_project.py @@ -107,7 +107,7 @@ def test_create_project_invalid_course(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.count(), 1) def test_retrieve_project_invalid_course(self): @@ -121,16 +121,42 @@ def test_list_projects_invalid_course(self): API_ENDPOINT + f'100/projects/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + # test with invalid project - + def test_retrieve_invalid_project(self): response = self.client.get( API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_partial_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) class ProjectTestTeacher(TestCase): def setUp(self): @@ -280,15 +306,16 @@ def test_list_projects_course_not_of_teacher(self): API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + # test with invalid project - + def test_retrieve_invalid_project(self): response = self.client.get( API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + class ProjectTestStudent(TestCase): def setUp(self): self.client = APIClient() @@ -334,7 +361,6 @@ def test_create_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 1) - def test_retrieve_project(self): response = self.client.get( @@ -362,14 +388,14 @@ def test_update_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - + def test_delete_project(self): response = self.client.delete( API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 1) - + def test_partial_update_project(self): response = self.client.patch( API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', @@ -380,10 +406,9 @@ def test_partial_update_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - + # tests with an invalid course - + def test_create_project_invalid_course(self): response = self.client.post( API_ENDPOINT + f'100/projects/', @@ -396,21 +421,21 @@ def test_create_project_invalid_course(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 1) - + def test_retrieve_project_invalid_course(self): response = self.client.get( API_ENDPOINT + f'100/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_list_projects_invalid_course(self): response = self.client.get( API_ENDPOINT + f'100/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + # tests with a course not of the student - + def test_create_project_course_not_of_student(self): response = self.client.post( API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/', @@ -423,21 +448,19 @@ def test_create_project_course_not_of_student(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 1) - - + def test_retrieve_project_course_not_of_student(self): response = self.client.get( API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_list_projects_course_not_of_student(self): response = self.client.get( API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - + def test_update_project_course_not_of_student(self): response = self.client.patch( API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/', @@ -450,15 +473,15 @@ def test_update_project_course_not_of_student(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - + # test with invalid project - + def test_retrieve_invalid_project(self): response = self.client.get( API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + def test_update_invalid_project(self): response = self.client.patch( API_ENDPOINT + f'{self.course.course_id}/projects/100/', @@ -470,9 +493,9 @@ def test_update_invalid_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_delete_invalid_project(self): response = self.client.delete( API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) \ No newline at end of file + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 629ef6665c77afb0e70ef463e2bcb4cfe3b0d975 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Mon, 11 Mar 2024 10:51:12 +0100 Subject: [PATCH 030/138] cleanup projects view --- backend/pigeonhole/apps/projects/views.py | 46 ++++++------------- .../tests/test_views/test_project.py | 36 ++++++++++++++- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 7b37a4c9..eb8f2c57 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -2,6 +2,7 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from django.shortcuts import get_object_or_404 from .models import Project, ProjectSerializer, Course from .permissions import CanAccessProject @@ -20,18 +21,16 @@ def list(self, request, *args, **kwargs): serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) # Check whether the course exists - if not Course.objects.filter(course_id=course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + get_object_or_404(Course, course_id=course_id) return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): - print("ceating new project") + print("creating new project") course_id = kwargs.get('course_id') # Check whether the course exists - if not Course.objects.filter(course_id=course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + get_object_or_404(Course, course_id=course_id) serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): @@ -45,14 +44,10 @@ def destroy(self, request, *args, **kwargs): project_id = kwargs.get('pk') # Check whether the course exists - if not Course.objects.filter(course_id=course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + get_object_or_404(Course, course_id=course_id) # Check whether the project exists - if not Project.objects.filter(pk=project_id).exists(): - return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) - - project = Project.objects.get(pk=project_id) + project = get_object_or_404(Project, pk=project_id) project.delete() return Response({"message": "Project has been deleted successfully."}, status=status.HTTP_204_NO_CONTENT) @@ -61,14 +56,11 @@ def retrieve(self, request, *args, **kwargs): project_id = kwargs.get('pk') # Check whether the course exists - if not Course.objects.filter(course_id=course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) + get_object_or_404(Course, course_id=course_id) # Check whether the project exists - if not Project.objects.filter(pk=project_id).exists(): - return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) - - serializer = ProjectSerializer(instance=Project.objects.get(pk=project_id), many=False) + project = get_object_or_404(Project, pk=project_id) + serializer = ProjectSerializer(instance=project, many=False) return Response(serializer.data, status=status.HTTP_200_OK) @@ -76,15 +68,7 @@ def update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') - # Check whether the course exists - if not Course.objects.filter(course_id=course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) - - # Check whether the project exists - if not Project.objects.filter(pk=project_id).exists(): - return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) - - project = Project.objects.get(pk=project_id) + project = get_object_or_404(Project, pk=project_id) serializer = ProjectSerializer(project, data=request.data) if serializer.is_valid(): serializer.save() @@ -93,13 +77,11 @@ def update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs): instance = self.get_object() # Check whether the course exists - if not Course.objects.filter(course_id=instance.course_id.course_id).exists(): - return Response({"message": "Course does not exist."}, status=status.HTTP_404_NOT_FOUND) - + get_object_or_404(Course, course_id=instance.course_id.course_id) + # Check whether the project exists - if not Project.objects.filter(pk=instance.project_id).exists(): - return Response({"message": "Project does not exist."}, status=status.HTTP_404_NOT_FOUND) - + get_object_or_404(Project, pk=instance.project_id) + serializer = self.get_serializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() diff --git a/backend/pigeonhole/tests/test_views/test_project.py b/backend/pigeonhole/tests/test_views/test_project.py index f74d92c1..c42e2fb2 100644 --- a/backend/pigeonhole/tests/test_views/test_project.py +++ b/backend/pigeonhole/tests/test_views/test_project.py @@ -97,6 +97,7 @@ def test_partial_update_project(self): self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") # tests with an invalid course + def test_create_project_invalid_course(self): response = self.client.post( API_ENDPOINT + f'100/projects/', @@ -109,7 +110,40 @@ def test_create_project_invalid_course(self): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.count(), 1) - + + """TODO + def test_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + """ + def test_delete_project_invalid_course(self): + response = self.client.delete( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.count(), 1) + + """TODO + def test_partial_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + """ def test_retrieve_project_invalid_course(self): response = self.client.get( API_ENDPOINT + f'100/projects/{self.project.project_id}/' From 853bdacb4925ce63229d7820731124b937a7df91 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Mon, 11 Mar 2024 11:18:15 +0100 Subject: [PATCH 031/138] Update test_project.py --- .../tests/test_views/test_project.py | 182 +++++++++++++++++- 1 file changed, 178 insertions(+), 4 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_project.py b/backend/pigeonhole/tests/test_views/test_project.py index c42e2fb2..1cd2ddb4 100644 --- a/backend/pigeonhole/tests/test_views/test_project.py +++ b/backend/pigeonhole/tests/test_views/test_project.py @@ -97,7 +97,7 @@ def test_partial_update_project(self): self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") # tests with an invalid course - + def test_create_project_invalid_course(self): response = self.client.post( API_ENDPOINT + f'100/projects/', @@ -110,7 +110,7 @@ def test_create_project_invalid_course(self): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.count(), 1) - + """TODO def test_update_project_invalid_course(self): response = self.client.patch( @@ -125,13 +125,14 @@ def test_update_project_invalid_course(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") """ + def test_delete_project_invalid_course(self): response = self.client.delete( API_ENDPOINT + f'100/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.count(), 1) - + """TODO def test_partial_update_project_invalid_course(self): response = self.client.patch( @@ -144,6 +145,7 @@ def test_partial_update_project_invalid_course(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") """ + def test_retrieve_project_invalid_course(self): response = self.client.get( API_ENDPOINT + f'100/projects/{self.project.project_id}/' @@ -175,7 +177,7 @@ def test_update_invalid_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + def test_partial_update_invalid_project(self): response = self.client.patch( API_ENDPOINT + f'{self.course.course_id}/projects/100/', @@ -192,6 +194,7 @@ def test_delete_invalid_project(self): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + class ProjectTestTeacher(TestCase): def setUp(self): self.client = APIClient() @@ -533,3 +536,174 @@ def test_delete_invalid_project(self): API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class ProjectTestUnauthenticated(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + ) + + self.student = Student.objects.create( + id=self.user, + number=1234567890 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + def test_create_project_unauthenticated(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with an invalid course + def test_create_project_invalid_course_unauthenticated(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_invalid_course_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_invalid_course_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_invalid_course_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 2d00aab340e32f0ac675de42d650abd5d8b7584b Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Mon, 11 Mar 2024 11:21:14 +0100 Subject: [PATCH 032/138] fix lint --- .../apps/projects/migrations/0001_initial.py | 4 +-- .../submissions/migrations/0001_initial.py | 8 +++-- ...ions_file_alter_submissions_output_test.py | 2 +- .../apps/users/migrations/0001_initial.py | 36 ++++++++++++++----- backend/pigeonhole/apps/users/models.py | 1 + backend/pigeonhole/settings.py | 3 +- backend/pigeonhole/urls.py | 10 +++--- 7 files changed, 41 insertions(+), 23 deletions(-) diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index 46f86d20..d849fb34 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -38,7 +37,8 @@ class Migration(migrations.Migration): ('condition', models.TextField(max_length=256)), ('test_file_location', models.CharField(max_length=512, null=True)), ('test_file_type', models.CharField(max_length=256, null=True)), - ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), + ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to='projects.project')), ], ), migrations.CreateModel( diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index 8c4d718c..715328f3 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -18,9 +17,12 @@ class Migration(migrations.Migration): fields=[ ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), ('submission_nr', models.IntegerField()), - ('file', models.FileField(null=True, upload_to='uploads///')), + ('file', models.FileField(null=True, upload_to='uploads//' + '/')), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField(null=True, upload_to='uploads///output_test/')), + ('output_test', models.FileField(null=True, upload_to='uploads///output_test/')), ('group_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='groups.group')), ], ), diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py index 483e5352..56752ac0 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py +++ b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py @@ -23,4 +23,4 @@ class Migration(migrations.Migration): upload_to='uploads///output_test/'), ), - ] \ No newline at end of file + ] diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index 016b1bb0..36858182 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -9,7 +9,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -24,16 +23,33 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has ' + 'all permissions without explicitly ' + 'assigning them.', + verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, + help_text='Required. 150 characters or fewer. Letters, digits and ' + '@/./+/-/_ only.', max_length=150, unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name='username')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into ' + 'this admin site.', + verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be ' + 'treated as active. Unselect this instead ' + 'of deleting accounts.', + verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will ' + 'get all permissions granted to each of their ' + 'groups.', related_name='user_set', + related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', + related_name='user_set', related_query_name='user', + to='auth.permission', verbose_name='user permissions')), ], options={ 'verbose_name': 'user', @@ -48,7 +64,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Student', fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, + to=settings.AUTH_USER_MODEL)), ('number', models.IntegerField()), ('course', models.ManyToManyField(to='courses.course')), ], @@ -56,7 +73,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Teacher', fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, + to=settings.AUTH_USER_MODEL)), ('is_admin', models.BooleanField(default=False)), ('is_assistent', models.BooleanField(default=False)), ('course', models.ManyToManyField(to='courses.course')), diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index 7d8dcd6e..be3154de 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -30,6 +30,7 @@ class Student(models.Model): class StudentSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + class Meta: model = Student fields = ['number', 'course', 'id'] diff --git a/backend/pigeonhole/settings.py b/backend/pigeonhole/settings.py index 8c5525e1..fa9fe0e9 100644 --- a/backend/pigeonhole/settings.py +++ b/backend/pigeonhole/settings.py @@ -162,11 +162,10 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - SWAGGER_SETTINGS = { 'SECURITY_DEFINITIONS': { 'Basic': { 'type': 'basic' } } -} \ No newline at end of file +} diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 50d52d06..5a76951a 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,16 +1,14 @@ -from django.urls import include, path from django.contrib import admin +from django.urls import include, path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view from rest_framework import routers, permissions -from backend.testapi import views as test_views from backend.pigeonhole.apps.courses.views import CourseViewSet +from backend.pigeonhole.apps.groups.views import GroupViewSet from backend.pigeonhole.apps.projects.views import ProjectViewSet from backend.pigeonhole.apps.submissions.views import SubmissionsViewset -from backend.pigeonhole.apps.groups.views import GroupViewSet from backend.pigeonhole.apps.users.views import StudentViewSet, UserViewSet -#from backend.pigeonhole.apps.projects import views as project_views -from drf_yasg.views import get_schema_view -from drf_yasg import openapi schema_view = get_schema_view( openapi.Info( From 2c8ee17b7716f0534c2c5321112a5242c3748efe Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Mon, 11 Mar 2024 11:36:57 +0100 Subject: [PATCH 033/138] splitting client and server components --- frontend/src/app/components/CASButton.tsx | 27 +++++ frontend/src/app/components/LoginCard.tsx | 51 +++++++++ frontend/src/app/components/LoginForm.tsx | 64 +++++++++++ frontend/src/app/page.tsx | 4 +- frontend/src/components/ui/LoginForm.tsx | 124 ---------------------- frontend/src/styles/theme.ts | 44 ++++++++ 6 files changed, 188 insertions(+), 126 deletions(-) create mode 100644 frontend/src/app/components/CASButton.tsx create mode 100644 frontend/src/app/components/LoginCard.tsx create mode 100644 frontend/src/app/components/LoginForm.tsx delete mode 100644 frontend/src/components/ui/LoginForm.tsx create mode 100644 frontend/src/styles/theme.ts diff --git a/frontend/src/app/components/CASButton.tsx b/frontend/src/app/components/CASButton.tsx new file mode 100644 index 00000000..6af7e5c7 --- /dev/null +++ b/frontend/src/app/components/CASButton.tsx @@ -0,0 +1,27 @@ +"use client"; +import React from 'react' +import SchoolIcon from "@mui/icons-material/School"; +import {Button} from "@mui/material"; + +const CASButton = () => { + const handleCASLogin = (): void => { + // Implement CAS login logic here + console.log('Login with CAS'); + }; + + return ( +
+ +
+ ) +} + +export default CASButton diff --git a/frontend/src/app/components/LoginCard.tsx b/frontend/src/app/components/LoginCard.tsx new file mode 100644 index 00000000..af1b2b86 --- /dev/null +++ b/frontend/src/app/components/LoginCard.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {Box, Container, CssBaseline, Typography} from '@mui/material'; +import {ThemeProvider} from '@mui/material/styles'; +import CASButton from "@/app/components/CASButton"; +import LoginForm from "@/app/components/LoginForm"; +import loginTheme from '../../styles/theme'; + +const LoginCard: React.FC = () => { + return ( + + + + + + Pigeonhole + +
+ + + + OR + + + +
+
+
+
+ ); +}; + +export default LoginCard; diff --git a/frontend/src/app/components/LoginForm.tsx b/frontend/src/app/components/LoginForm.tsx new file mode 100644 index 00000000..abb5f37c --- /dev/null +++ b/frontend/src/app/components/LoginForm.tsx @@ -0,0 +1,64 @@ +"use client"; +import React, {useState} from 'react'; +import {Button, IconButton, InputAdornment, TextField} from "@mui/material"; +import Visibility from "@mui/icons-material/Visibility"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; + +const LoginForm = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleLogin = (): void => { + // Implement your login logic here + console.log('Login with:', email, password); + }; + + const handleClickShowPassword = () => { + setShowPassword(!showPassword); + }; + + const handleMouseDownPassword = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + return ( +
+ setEmail(e.target.value)} + fullWidth + /> + setPassword(e.target.value)} + fullWidth + InputProps={{ + endAdornment: ( + + + {showPassword ? ( + // Set the icon to small + ) : ( + // Set the icon to small + )} + + + ), + }} + /> + +
+ ); +} + +export default LoginForm; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index b7f71cda..66d47abf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import LoginForm from '@/components/ui/LoginForm'; +import LoginCard from '@/app/components/LoginCard'; const Login = () => { return (
- +
) } diff --git a/frontend/src/components/ui/LoginForm.tsx b/frontend/src/components/ui/LoginForm.tsx deleted file mode 100644 index 49008083..00000000 --- a/frontend/src/components/ui/LoginForm.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client" -import React, {useState} from 'react'; -import {Box, Button, Container, CssBaseline, TextField, Typography} from '@mui/material'; -import {createTheme, ThemeProvider} from '@mui/material/styles'; -import SchoolIcon from '@mui/icons-material/School'; - -const theme = createTheme({ - palette: { - background: { - default: '#f4f5fd' - }, - primary: { - main: '#1976d2', - }, - secondary: { - main: '#9c27b0', - }, - }, - typography: { - fontFamily: 'Quicksand, sans-serif', - h4: { - fontWeight: 700, - }, - }, - components: { - MuiTextField: { - defaultProps: { - InputLabelProps: { - shrink: true, - }, - margin: 'normal', - required: true, - fullWidth: true, - }, - }, - MuiButton: { - defaultProps: { - variant: 'contained', - color: 'primary', - fullWidth: true, - style: {margin: '10px 0'}, - }, - }, - }, -}); - -const LoginForm: React.FC = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - - const handleLogin = (): void => { - // Implement your login logic here - console.log('Login with:', email, password); - }; - - const handleCASLogin = (): void => { - // Implement CAS login logic here - console.log('Login with CAS'); - }; - - return ( - - - - - - Pigeonhole - -
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - OR - - - - -
-
-
- ); -}; - -export default LoginForm; diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts new file mode 100644 index 00000000..3ff4e7f9 --- /dev/null +++ b/frontend/src/styles/theme.ts @@ -0,0 +1,44 @@ +"use client"; +import { createTheme } from '@mui/material/styles'; + +const loginTheme = createTheme({ + palette: { + background: { + default: '#f4f5fd' + }, + primary: { + main: '#1976d2', + }, + secondary: { + main: '#9c27b0', + }, + }, + typography: { + fontFamily: 'Quicksand, sans-serif', + h4: { + fontWeight: 700, + }, + }, + components: { + MuiTextField: { + defaultProps: { + InputLabelProps: { + shrink: true, + }, + margin: 'normal', + required: true, + fullWidth: true, + }, + }, + MuiButton: { + defaultProps: { + variant: 'contained', + color: 'primary', + fullWidth: true, + style: {margin: '10px 0'}, + }, + }, + }, +}); + +export default loginTheme; From 6da1e6d97a5fe89a2259724f14ce055cc1ac3236 Mon Sep 17 00:00:00 2001 From: robinpdev Date: Mon, 11 Mar 2024 13:13:19 +0100 Subject: [PATCH 034/138] user uploads toevoegen --- .gitignore | 1 + Makefile | 5 ++++- backend/pigeonhole/urls.py | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3ed07993..e4642805 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env django/db.sqlite3 backend/db.sqlite3 +backend/uploads venv/ .venv/ diff --git a/Makefile b/Makefile index 01c1abef..9c0b630b 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,7 @@ stop: lint: docker exec pigeonhole-backend flake8 . - docker exec pigeonhole-frontend npm run lint \ No newline at end of file + docker exec pigeonhole-frontend npm run lint + +backendshell: + docker exec -it pigeonhole-backend sh \ No newline at end of file diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 5a76951a..d9d206f9 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,5 +1,7 @@ from django.contrib import admin from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import routers, permissions @@ -39,6 +41,6 @@ path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path("admin/", admin.site.urls), -] +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += router.urls From 49824957a56938954928ba803ac0580d35574208 Mon Sep 17 00:00:00 2001 From: robinpdev Date: Mon, 11 Mar 2024 13:32:40 +0100 Subject: [PATCH 035/138] add testing workflow --- .github/workflows/test.yml | 23 +++++++++++++++++++++++ Makefile | 5 ++++- backend/runtests.sh | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 backend/runtests.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..0def4b64 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Backend test CI + +on: + - pull_request + +jobs: + flake8: + runs-on: self-hosted + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + working-directory: ./backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Running Django tests + working-directory: ./ + run: | + sh run backend/runtests.sh \ No newline at end of file diff --git a/Makefile b/Makefile index 01c1abef..2d57da69 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,7 @@ stop: lint: docker exec pigeonhole-backend flake8 . - docker exec pigeonhole-frontend npm run lint \ No newline at end of file + docker exec pigeonhole-frontend npm run lint + +backendtest: + docker exec -it pigeonhole-backend sh /usr/src/app/backend/runtests.sh \ No newline at end of file diff --git a/backend/runtests.sh b/backend/runtests.sh new file mode 100644 index 00000000..65d41fa8 --- /dev/null +++ b/backend/runtests.sh @@ -0,0 +1 @@ +coverage run manage.py test backend/pigeonhole/tests/test_views \ No newline at end of file From 6c6cc3908c99dde2151dc5e11c94dc3ea4ced461 Mon Sep 17 00:00:00 2001 From: robinpdev Date: Mon, 11 Mar 2024 13:34:59 +0100 Subject: [PATCH 036/138] try fixing test workflow --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0def4b64..f0fd0d19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,5 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - name: Running Django tests - working-directory: ./ run: | - sh run backend/runtests.sh \ No newline at end of file + sh run ./backend/runtests.sh \ No newline at end of file From 057c25ed5132bd80cdaf2044cb44d7a79a961238 Mon Sep 17 00:00:00 2001 From: robinpdev Date: Mon, 11 Mar 2024 13:42:33 +0100 Subject: [PATCH 037/138] same --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0fd0d19..ef85c7b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ on: - pull_request jobs: - flake8: + backend-test: runs-on: self-hosted steps: - uses: actions/checkout@v3 @@ -13,10 +13,10 @@ jobs: with: python-version: "3.11" - name: Install dependencies - working-directory: ./backend + working-directory: ./ run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r ./backend/requirements.txt - name: Running Django tests run: | sh run ./backend/runtests.sh \ No newline at end of file From c297348eb5d81162c9734695f54c0c1f21f63d2b Mon Sep 17 00:00:00 2001 From: robinpdev Date: Mon, 11 Mar 2024 13:45:20 +0100 Subject: [PATCH 038/138] lol --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef85c7b8..10d45d48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,4 +19,4 @@ jobs: pip install -r ./backend/requirements.txt - name: Running Django tests run: | - sh run ./backend/runtests.sh \ No newline at end of file + sh ./backend/runtests.sh \ No newline at end of file From a0f5a924c6fa18354b682117dffa001a73419808 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Mon, 11 Mar 2024 14:20:56 +0100 Subject: [PATCH 039/138] users changed --- .../pigeonhole/apps/courses/permissions.py | 30 ++++----- .../0005_remove_group_student_group_user.py | 24 +++++++ backend/pigeonhole/apps/groups/models.py | 6 +- .../pigeonhole/apps/projects/permissions.py | 24 +++---- ...acher_course_remove_teacher_id_and_more.py | 64 +++++++++++++++++++ backend/pigeonhole/apps/users/models.py | 58 ++++++++--------- backend/pigeonhole/apps/users/views.py | 24 +------ .../tests/test_models/test_conditions.py | 6 +- .../tests/test_models/test_course.py | 10 +-- .../tests/test_models/test_groups.py | 12 ++-- .../tests/test_models/test_project.py | 10 +-- .../tests/test_models/test_submissions.py | 6 +- .../pigeonhole/tests/test_models/test_user.py | 28 ++++---- backend/pigeonhole/urls.py | 3 +- 14 files changed, 183 insertions(+), 122 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py create mode 100644 backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 201b666e..13119e03 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -1,30 +1,30 @@ from rest_framework import permissions -from backend.pigeonhole.apps.users.models import Student, Teacher +from backend.pigeonhole.apps.users.models import User class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): if request.user.is_superuser: return True - if isinstance(request.user, Teacher): - return True - - if isinstance(request.user, Student): - return view.action in ['list', 'retrieve'] + # if isinstance(request.user, Teacher): + # return True + # + # if isinstance(request.user, Student): + # return view.action in ['list', 'retrieve'] return False def has_object_permission(self, request, view, obj): if request.user.is_superuser: return True - if isinstance(request.user, Teacher): - if request.user.is_admin: - return True - elif Teacher.objects.filter(id=request.user.id, course=obj).exists(): - return True - return view.action in ['list', 'retrieve'] - - if isinstance(request.user, Student): - return view.action in ['list', 'retrieve'] + # if isinstance(request.user, Teacher): + # if request.user.is_admin: + # return True + # elif Teacher.objects.filter(id=request.user.id, course=obj).exists(): + # return True + # return view.action in ['list', 'retrieve'] + # + # if isinstance(request.user, Student): + # return view.action in ['list', 'retrieve'] return False diff --git a/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py b/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py new file mode 100644 index 00000000..34f5581e --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.2 on 2024-03-11 13:17 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0004_alter_group_final_score'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='group', + name='student', + ), + migrations.AddField( + model_name='group', + name='user', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index c7df2aee..164da31b 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -2,14 +2,14 @@ from rest_framework import serializers from backend.pigeonhole.apps.projects.models import Project -from backend.pigeonhole.apps.users.models import Student +from backend.pigeonhole.apps.users.models import User class Group(models.Model): group_id = models.BigAutoField(primary_key=True) group_nr = models.IntegerField(blank=True, null=True) project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - student = models.ManyToManyField(Student) + user = models.ManyToManyField(User) feedback = models.TextField(null=True) final_score = models.IntegerField(null=True, blank=True) @@ -28,4 +28,4 @@ def save(self, *args, **kwargs): class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ["group_id", "group_nr", "final_score", "project_id", "student", "feedback"] + fields = ["group_id", "group_nr", "final_score", "project_id", "user", "feedback"] diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index d08a56b5..77efdd16 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -1,5 +1,5 @@ from rest_framework import permissions -from backend.pigeonhole.apps.users.models import Teacher, Student +from backend.pigeonhole.apps.users.models import User class CanAccessProject(permissions.BasePermission): @@ -9,15 +9,15 @@ def has_permission(self, request, view): user = request.user subject_id = view.kwargs.get('course_id') # If the user is a teacher, grant access. - if isinstance(user, Teacher): - if user.course.filter(id=subject_id).exists(): - return True - elif isinstance(user, Teacher) and user.is_admin: - return True - # If the user is a student, grant access only to their own projects. - elif isinstance(user, Student): - if user.course.filter(id=subject_id).exists(): - return True - elif request.user.is_superuser: - return True + # if isinstance(user, Teacher): + # if user.course.filter(id=subject_id).exists(): + # return True + # elif isinstance(user, Teacher) and user.is_admin: + # return True + # # If the user is a student, grant access only to their own projects. + # elif isinstance(user, Student): + # if user.course.filter(id=subject_id).exists(): + # return True + # elif request.user.is_superuser: + # return True return False diff --git a/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py b/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py new file mode 100644 index 00000000..67dd6291 --- /dev/null +++ b/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.0.2 on 2024-03-11 13:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ('groups', '0005_remove_group_student_group_user'), + ('users', '0002_rename_is_assistent_teacher_is_assistant'), + ] + + operations = [ + migrations.RemoveField( + model_name='teacher', + name='course', + ), + migrations.RemoveField( + model_name='teacher', + name='id', + ), + migrations.AlterModelManagers( + name='user', + managers=[ + ], + ), + migrations.AddField( + model_name='user', + name='course', + field=models.ManyToManyField(to='courses.course'), + ), + migrations.AddField( + model_name='user', + name='role', + field=models.IntegerField(choices=[(1, 'Superuser'), (2, 'Teacher'), (3, 'Student')], default=3), + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, unique=True), + ), + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(max_length=30), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='user', + name='last_name', + field=models.CharField(max_length=150), + ), + migrations.DeleteModel( + name='Student', + ), + migrations.DeleteModel( + name='Teacher', + ), + ] diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index be3154de..80247fda 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -5,47 +5,43 @@ from backend.pigeonhole.apps.courses.models import Course -class User(AbstractUser): - class Meta(AbstractUser.Meta): - db_table = "auth_user" - - @property - def name(self): - return f"{self.first_name.strip()} {self.last_name.strip()}" +class Roles(models.IntegerChoices): + SUPERUSER = 1 + TEACHER = 2 + STUDENT = 3 -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ['id', 'email', 'first_name', 'last_name'] - - -class Student(models.Model): - id = models.ForeignKey(User, on_delete=models.CASCADE, primary_key=True) - number = models.IntegerField() +class User(AbstractUser): + id = models.BigAutoField(primary_key=True) + email = models.EmailField(unique=True) + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=150) course = models.ManyToManyField(Course) + role = models.IntegerField(choices=Roles.choices, default=Roles.STUDENT) objects = models.Manager() + class Meta(AbstractUser.Meta): + db_table = "auth_user" -class StudentSerializer(serializers.ModelSerializer): - id = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) - - class Meta: - model = Student - fields = ['number', 'course', 'id'] + @property + def name(self): + return f"{self.first_name.strip()} {self.last_name.strip()}" + @property + def is_super(self): + return self.role == Roles.SUPERUSER -class Teacher(models.Model): - id = models.ForeignKey(User, on_delete=models.CASCADE, primary_key=True) - course = models.ManyToManyField(Course) - is_admin = models.BooleanField(default=False) - is_assistant = models.BooleanField(default=False) + @property + def is_teacher(self): + return self.role == Roles.TEACHER - objects = models.Manager() + @property + def is_student(self): + return self.role == Roles.STUDENT -class TeacherSerializer(serializers.ModelSerializer): +class UserSerializer(serializers.ModelSerializer): class Meta: - model = Teacher - fields = ['course', 'id', 'is_admin', 'is_assistent'] + model = User + fields = ['id', 'email', 'first_name', 'last_name', 'course', 'role'] diff --git a/backend/pigeonhole/apps/users/views.py b/backend/pigeonhole/apps/users/views.py index 0af8843f..5fec4879 100644 --- a/backend/pigeonhole/apps/users/views.py +++ b/backend/pigeonhole/apps/users/views.py @@ -2,29 +2,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from backend.pigeonhole.apps.users.models import Student, StudentSerializer, User, UserSerializer - - -class StudentViewSet(viewsets.ModelViewSet): - queryset = Student.objects.all() - serializer_class = StudentSerializer - permission_classes = [IsAuthenticated] - - def create(self, request, *args, **kwargs): - serializer = StudentSerializer(data=request.data) - print(serializer) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def list(self, request, *args, **kwargs): - serializer = StudentSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) +from backend.pigeonhole.apps.users.models import User, UserSerializer class UserViewSet(viewsets.ModelViewSet): diff --git a/backend/pigeonhole/tests/test_models/test_conditions.py b/backend/pigeonhole/tests/test_models/test_conditions.py index 37307ab7..9998c3cd 100644 --- a/backend/pigeonhole/tests/test_models/test_conditions.py +++ b/backend/pigeonhole/tests/test_models/test_conditions.py @@ -1,5 +1,5 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project, Conditions, AllowedExtension, ForbiddenExtension @@ -22,8 +22,8 @@ def setUp(self): ) # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) + teacher = User.objects.create(id=teacher_user) + student = User.objects.create(id=student_user, number=1234) # Create course course = Course.objects.create(name="Math", description="Mathematics") diff --git a/backend/pigeonhole/tests/test_models/test_course.py b/backend/pigeonhole/tests/test_models/test_course.py index 2498a217..d576e67f 100644 --- a/backend/pigeonhole/tests/test_models/test_course.py +++ b/backend/pigeonhole/tests/test_models/test_course.py @@ -1,6 +1,6 @@ from django.test import TestCase from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from django.db.utils import DataError @@ -25,8 +25,8 @@ def setUp(self): ) # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) + teacher = User.objects.create(id=teacher_user) + student = User.objects.create(id=student_user, number=1234) # Create course course = Course.objects.create(name="Math", description="Mathematics") @@ -34,7 +34,7 @@ def setUp(self): student.course.add(course) def test_course_teacher_relationship(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id__email="teacher@gmail.com") course = Course.objects.get(name="Math") self.assertIn(course, teacher.course.all()) course_alter_ego = teacher.course.get(name="Math") @@ -42,7 +42,7 @@ def test_course_teacher_relationship(self): self.assertTrue(course_alter_ego, "Mathematics") def test_course_students_relationship(self): - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id__email="student@gmail.com") course = Course.objects.get(name="Math") self.assertIn(course, student.course.all()) course_alter_ego = student.course.get(name="Math") diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 0ece53b9..09f9d6bc 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -1,6 +1,6 @@ from django.test import TestCase from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project @@ -34,9 +34,9 @@ def setUp(self): ) # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - student2 = Student.objects.create(id=student_user2, number=5678) + teacher = User.objects.create(id=teacher_user) + student = User.objects.create(id=student_user, number=1234) + student2 = User.objects.create(id=student_user2, number=5678) # Create course course = Course.objects.create(name="Math", description="Mathematics") @@ -67,8 +67,8 @@ def test_group_project_relation(self): def test_group_student_relation(self): group = Group.objects.get(group_nr=1) - student = Student.objects.get(id__email="student@gmail.com") - student2 = Student.objects.get(id__email="student2@gmail.com") + student = User.objects.get(id__email="student@gmail.com") + student2 = User.objects.get(id__email="student2@gmail.com") self.assertIn(student, group.student.all()) self.assertIn(student2, group.student.all()) diff --git a/backend/pigeonhole/tests/test_models/test_project.py b/backend/pigeonhole/tests/test_models/test_project.py index 11285c91..da680d61 100644 --- a/backend/pigeonhole/tests/test_models/test_project.py +++ b/backend/pigeonhole/tests/test_models/test_project.py @@ -1,5 +1,5 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project @@ -22,8 +22,8 @@ def setUp(self): ) # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) + teacher = User.objects.create(id=teacher_user) + student = User.objects.create(id=student_user, number=1234) # Create course course = Course.objects.create(name="Math", description="Mathematics") @@ -41,11 +41,11 @@ def test_project_course_relation(self): self.assertEqual(self.project.course_id.name, "Math") def test_project_teacher_relation(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id__email="teacher@gmail.com") self.assertIn(self.project.course_id, teacher.course.all()) def test_project_student_relation(self): - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id__email="student@gmail.com") self.assertIn(self.project.course_id, student.course.all()) def test_course_name_length_validation(self): diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index c6efed08..31b946a4 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -1,5 +1,5 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.submissions.models import Submissions @@ -25,8 +25,8 @@ def setUp(self): ) # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) + teacher = User.objects.create(id=teacher_user) + student = User.objects.create(id=student_user, number=1234) # Create course course = Course.objects.create(name="Math", description="Mathematics") diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 043197e5..383933fd 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -1,5 +1,5 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User # python3 manage.py test backend/ @@ -22,51 +22,51 @@ def setUp(self): ) # Create teacher and student using the created users - Teacher.objects.create(id=teacher_user) - Student.objects.create(id=student_user, number=1234) + User.objects.create(id=teacher_user) + User.objects.create(id=student_user, number=1234) def test_student(self): - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id__email="student@gmail.com") self.assertEqual(student.id.email, "student@gmail.com") self.assertEqual(student.number, 1234) # update student number student.number = 5678 student.save() - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id__email="student@gmail.com") self.assertEqual(student.number, 5678) # delete student student.delete() - with self.assertRaises(Student.DoesNotExist): - Student.objects.get(id__email="student@gmail.com") + with self.assertRaises(User.DoesNotExist): + User.objects.get(id__email="student@gmail.com") def test_teacher(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id__email="teacher@gmail.com") self.assertEqual(teacher.id.email, "teacher@gmail.com") self.assertEqual(teacher.is_admin, False) # update teacher is_admin teacher.is_admin = True teacher.save() - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id__email="teacher@gmail.com") self.assertEqual(teacher.is_admin, True) self.assertEqual(teacher.is_assistant, False) # update teacher is_assistent teacher.is_assistant = True teacher.save() - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id__email="teacher@gmail.com") self.assertEqual(teacher.is_assistant, True) # delete teacher teacher.delete() - with self.assertRaises(Teacher.DoesNotExist): - Teacher.objects.get(id__email="teacher@gmail.com") + with self.assertRaises(User.DoesNotExist): + User.objects.get(id__email="teacher@gmail.com") def test_create_student_without_user(self): with self.assertRaises(Exception): - Student.objects.create(number=1234) + User.objects.create(number=1234) def test_create_teacher_without_user(self): with self.assertRaises(Exception): - Teacher.objects.create(is_admin=True, is_assistent=True) + User.objects.create(is_admin=True, is_assistent=True) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 5a76951a..abf24149 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -8,7 +8,7 @@ from backend.pigeonhole.apps.groups.views import GroupViewSet from backend.pigeonhole.apps.projects.views import ProjectViewSet from backend.pigeonhole.apps.submissions.views import SubmissionsViewset -from backend.pigeonhole.apps.users.views import StudentViewSet, UserViewSet +from backend.pigeonhole.apps.users.views import UserViewSet schema_view = get_schema_view( openapi.Info( @@ -25,7 +25,6 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) -router.register(r'students', StudentViewSet) router.register(r'groups', GroupViewSet) router.register(r'courses', CourseViewSet) router.register(r'submissions', SubmissionsViewset) From 68fc4c1e925d6a0c82de92865d0eb347eb376ce8 Mon Sep 17 00:00:00 2001 From: avoyen Date: Mon, 11 Mar 2024 18:39:44 +0100 Subject: [PATCH 040/138] naming change, permission for project api --- .../pigeonhole/apps/projects/permissions.py | 22 ++++++++----------- backend/pigeonhole/apps/users/models.py | 6 ++--- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index 77efdd16..587dfe0d 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -7,17 +7,13 @@ class CanAccessProject(permissions.BasePermission): # to the project data. def has_permission(self, request, view): user = request.user - subject_id = view.kwargs.get('course_id') - # If the user is a teacher, grant access. - # if isinstance(user, Teacher): - # if user.course.filter(id=subject_id).exists(): - # return True - # elif isinstance(user, Teacher) and user.is_admin: - # return True - # # If the user is a student, grant access only to their own projects. - # elif isinstance(user, Student): - # if user.course.filter(id=subject_id).exists(): - # return True - # elif request.user.is_superuser: - # return True + course_id = view.kwargs.get('course_id') + if user.is_student: + if user.course.filter(course_id=course_id).exists(): + return True + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + elif user.is_admin or user.is_superuser: + return True return False diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index 80247fda..5f6d02c9 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -6,7 +6,7 @@ class Roles(models.IntegerChoices): - SUPERUSER = 1 + ADMIN = 1 TEACHER = 2 STUDENT = 3 @@ -29,8 +29,8 @@ def name(self): return f"{self.first_name.strip()} {self.last_name.strip()}" @property - def is_super(self): - return self.role == Roles.SUPERUSER + def is_admin(self): + return self.role == Roles.ADMIN @property def is_teacher(self): From 3aa70150a500bbad88b49d77254678f4aaf356a5 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Mon, 11 Mar 2024 19:19:50 +0100 Subject: [PATCH 041/138] permissions updated, course api expanded --- .../pigeonhole/apps/courses/permissions.py | 28 ++++++++---------- backend/pigeonhole/apps/courses/views.py | 29 ++++++++++++++++++- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 13119e03..47c3494a 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -4,27 +4,23 @@ class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): - if request.user.is_superuser: + if request.user.is_admin or request.user.is_superuser: return True - # if isinstance(request.user, Teacher): - # return True - # - # if isinstance(request.user, Student): - # return view.action in ['list', 'retrieve'] + + if request.user.is_student or request.user.is_teacher: + return view.action in ['list', 'retrieve'] return False def has_object_permission(self, request, view, obj): - if request.user.is_superuser: + if request.user.is_admin or request.user.is_superuser: return True - # if isinstance(request.user, Teacher): - # if request.user.is_admin: - # return True - # elif Teacher.objects.filter(id=request.user.id, course=obj).exists(): - # return True - # return view.action in ['list', 'retrieve'] - # - # if isinstance(request.user, Student): - # return view.action in ['list', 'retrieve'] + if request.user.is_teacher: + if User.objects.filter(id=request.user.id, course=obj).exists(): + return True + return view.action in ['list', 'retrieve'] + + if request.user.is_student: + return view.action in ['list', 'retrieve'] return False diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index fc1715a7..89572901 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -1,7 +1,9 @@ from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.decorators import action +from backend.pigeonhole.apps.users.models import User from .models import Course, CourseSerializer from .permissions import CourseUserPermissions @@ -34,11 +36,36 @@ def destroy(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, *args, **kwargs): + if request.user.is_admin or request.user.is_superuser + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + if request.user.is_teacher or request.user.is_student: + courses = User.objects.get(id=request.user.id).course.all() + serializer = CourseSerializer(courses, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('pk') course = Course.objects.get(pk=course_id) serializer = CourseSerializer(course, many=False) return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def join_course(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + user = User.objects.get(id=request.user.id) + user.course.add(course) + return Response(status=status.HTTP_200_OK) + From 8bd347b35257eb830214886bc0b6590c9347ee03 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Mon, 11 Mar 2024 19:21:26 +0100 Subject: [PATCH 042/138] course api error fixed --- backend/pigeonhole/apps/courses/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 89572901..506c6f9b 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -36,7 +36,7 @@ def destroy(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, *args, **kwargs): - if request.user.is_admin or request.user.is_superuser + if request.user.is_admin or request.user.is_superuser: serializer = CourseSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) if request.user.is_teacher or request.user.is_student: From d43a669b25f2c71c2c2962558bf10fea2194a2b2 Mon Sep 17 00:00:00 2001 From: Thibaud Collyn Date: Mon, 11 Mar 2024 20:13:42 +0100 Subject: [PATCH 043/138] Created and applied global style for frontend. --- frontend/package-lock.json | 35 ++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/app/components/LoginCard.tsx | 2 -- frontend/src/app/layout.tsx | 32 ++++++++++++-------- frontend/src/styles/theme.ts | 36 ++++++++++++++++++++--- 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92e63922..d03e7e24 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@fontsource/roboto": "^5.0.12", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", + "@mui/material-nextjs": "^5.15.11", "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -713,6 +714,40 @@ } } }, + "node_modules/@mui/material-nextjs": { + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-5.15.11.tgz", + "integrity": "sha512-cp5RWYbBngyi7NKP91R9QITllfxumCVPFjqe4AKzNROVuCot0VpgkafxXqfbv0uFsyUU0ROs0O2M3r17q604Aw==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/cache": "^11.11.0", + "@emotion/server": "^11.11.0", + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "next": "^13.0.0 || ^14.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/cache": { + "optional": true + }, + "@emotion/server": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 81adcf9f..d0d115b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@fontsource/roboto": "^5.0.12", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", + "@mui/material-nextjs": "^5.15.11", "@radix-ui/react-slot": "^1.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/frontend/src/app/components/LoginCard.tsx b/frontend/src/app/components/LoginCard.tsx index af1b2b86..60bf9618 100644 --- a/frontend/src/app/components/LoginCard.tsx +++ b/frontend/src/app/components/LoginCard.tsx @@ -7,7 +7,6 @@ import loginTheme from '../../styles/theme'; const LoginCard: React.FC = () => { return ( - { - ); }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a14e64fc..c72bb9ed 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,16 +1,24 @@ +import React from 'react' +import {AppRouterCacheProvider} from '@mui/material-nextjs/v13-appRouter'; +import {ThemeProvider} from '@mui/material/styles'; +import loginTheme from '@/styles/theme'; + export const metadata = { - title: 'Next.js', - description: 'Generated by Next.js', + title: 'Next.js', + description: 'Generated by Next.js', } -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( - - {children} - - ) +export default function RootLayout(props) { + const {children} = props; + return ( + + + + + {children} + + + + + ); } diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 3ff4e7f9..61e57ed2 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -6,12 +6,20 @@ const loginTheme = createTheme({ background: { default: '#f4f5fd' }, - primary: { - main: '#1976d2', + primary:{ + main:'#1E64C8', + contrastText:'#FFFFFF' }, - secondary: { - main: '#9c27b0', + secondary:{ + main:'#D0E4FF', + contrastText:'#001D36' }, + failure:{ + main:'#E15E5E' + }, + success:{ + main:'#7DB47C' + } }, typography: { fontFamily: 'Quicksand, sans-serif', @@ -41,4 +49,24 @@ const loginTheme = createTheme({ }, }); +export const theme = createTheme({ + palette:{ + primary:{ + main:'#1E64C8', + contrastText:'#FFFFFF' + }, + secondary:{ + main:'#D0E4FF', + contrastText:'#001D36' + }, + background:{ + default:'#f4f5fd', + }, + text:{ + primary:'#001D36', + secondary:'#FFFFFF' + }, + }, +}); + export default loginTheme; From f8e13b619640386dcc8ecfe0d50fbd02250ce07a Mon Sep 17 00:00:00 2001 From: avoyen Date: Mon, 11 Mar 2024 22:40:57 +0100 Subject: [PATCH 044/138] more checks added to projects api, added more functionality to groups endpoint + url --- backend/pigeonhole/apps/groups/permission.py | 18 +++++ backend/pigeonhole/apps/groups/views.py | 43 +++++++--- .../pigeonhole/apps/projects/permissions.py | 11 ++- backend/pigeonhole/apps/projects/views.py | 80 +++++++++++-------- backend/pigeonhole/urls.py | 2 +- 5 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/permission.py diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py new file mode 100644 index 00000000..5672d2c3 --- /dev/null +++ b/backend/pigeonhole/apps/groups/permission.py @@ -0,0 +1,18 @@ +from rest_framework import permissions + + +class CanAccessProject(permissions.BasePermission): + # Custom user class to check if the user can join a group. + def has_permission(self, request, view): + user = request.user + course_id = view.kwargs.get('course_id') + + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + elif user.is_student: + if user.course.filter(course_id=course_id).exists(): + return True + return False diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 23bbeca7..5557f971 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -1,8 +1,11 @@ +from django.shortcuts import get_object_or_404 from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.groups.models import Group, GroupSerializer +from backend.pigeonhole.apps.projects.models import Project class GroupViewSet(viewsets.ModelViewSet): @@ -10,17 +13,39 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [IsAuthenticated] - def perform_create(self, serializer): - serializer.save(user=self.request.user) + def create(self, request, *args, **kwargs): + # TODO zorg dat er geen 2 groepen met hetzelfde nummer kunnen zijn. + course_id = kwargs.get('course_id') + + if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) + + serializer = GroupSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response({"message": "You are not allowed to create a new group."}, + status=status.HTTP_400_BAD_REQUEST) def list(self, request, *args, **kwargs): serializer = GroupSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, *args, **kwargs): - serializer = GroupSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def retrieve(self, request, *args, **kwargs): + course_id = kwargs.get('course_id') + project_id = kwargs.get('project_id') + group_id = kwargs.get('pk') + + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) + + # Check whether the project exists + get_object_or_404(Project, pk=project_id) + group = get_object_or_404(Group, group_id=group_id) + + serializer = GroupSerializer(instance=group, many=False) + + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index 6ff88ba8..6a1157ed 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -1,5 +1,4 @@ from rest_framework import permissions -from backend.pigeonhole.apps.users.models import User class CanAccessProject(permissions.BasePermission): @@ -8,12 +7,12 @@ class CanAccessProject(permissions.BasePermission): def has_permission(self, request, view): user = request.user course_id = view.kwargs.get('course_id') - if user.is_student: + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): return True - elif user.is_teacher: + elif user.is_student: if user.course.filter(course_id=course_id).exists(): return True - elif user.is_admin or user.is_superuser: - return True - return False \ No newline at end of file + return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index eb8f2c57..a929c8d2 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -1,21 +1,19 @@ +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from django.shortcuts import get_object_or_404 from .models import Project, ProjectSerializer, Course from .permissions import CanAccessProject +# TODO hier nog zorgen als een project niet visible is, dat de students het niet kunnen zien. class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated & CanAccessProject] - def perform_create(self, serializer): - serializer.save(user=self.request.user) - def list(self, request, *args, **kwargs): course_id = kwargs.get('course_id') serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) @@ -26,30 +24,36 @@ def list(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): - print("creating new project") course_id = kwargs.get('course_id') - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) + if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) - serializer = ProjectSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer = ProjectSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response({"message": "You are not allowed to create a new project."}, + status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) + if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) - # Check whether the project exists - project = get_object_or_404(Project, pk=project_id) - project.delete() - return Response({"message": "Project has been deleted successfully."}, status=status.HTTP_204_NO_CONTENT) + # Check whether the project exists + project = get_object_or_404(Project, pk=project_id) + project.delete() + return Response({"message": "Project has been deleted successfully."}, + status=status.HTTP_204_NO_CONTENT) + return Response({"message": "You are not allowed to delete this project."}, + status=status.HTTP_403_FORBIDDEN) def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('course_id') @@ -68,21 +72,31 @@ def update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('pk') - project = get_object_or_404(Project, pk=project_id) - serializer = ProjectSerializer(project, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: + get_object_or_404(Course, course_id=course_id) + + project = get_object_or_404(Project, pk=project_id) + serializer = ProjectSerializer(project, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response({"message": "You are not allowed to update this project."}, + status=status.HTTP_403_FORBIDDEN) def partial_update(self, request, *args, **kwargs): - instance = self.get_object() - # Check whether the course exists - get_object_or_404(Course, course_id=instance.course_id.course_id) + course_id = kwargs.get('course_id') + project_id = kwargs.get('pk') - # Check whether the project exists - get_object_or_404(Project, pk=instance.project_id) + if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) - serializer = self.get_serializer(instance, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + # Check whether the project exists + project = get_object_or_404(Project, pk=project_id) + + serializer = ProjectSerializer(project, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response({"message": "You are not allowed to update this project."}, + status=status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 04d7004b..0c0a9680 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -27,10 +27,10 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) -router.register(r'groups', GroupViewSet) router.register(r'courses', CourseViewSet) router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) +router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From f70db26a6cb1fb7ade234f52f1cecc54f7c37c9f Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 09:03:46 +0100 Subject: [PATCH 045/138] refactor testen --- .../pigeonhole/apps/courses/permissions.py | 3 + .../pigeonhole/apps/projects/permissions.py | 2 +- .../tests/test_views/test_course.py | 245 ------ .../tests/test_views/test_course/__init__.py | 0 .../tests/test_views/test_course/course.txt | 91 +++ .../test_views/test_course/test_admin.py | 77 ++ .../test_views/test_course/test_teacher.py | 82 ++ .../tests/test_views/test_project.py | 709 ------------------ .../tests/test_views/test_project/__init__.py | 0 .../test_views/test_project/test_admin.py | 189 +++++ .../test_views/test_project/test_student.py | 190 +++++ .../test_views/test_project/test_teacher.py | 163 ++++ .../test_project/test_unauthenticated.py | 176 +++++ 13 files changed, 972 insertions(+), 955 deletions(-) delete mode 100644 backend/pigeonhole/tests/test_views/test_course.py create mode 100644 backend/pigeonhole/tests/test_views/test_course/__init__.py create mode 100644 backend/pigeonhole/tests/test_views/test_course/course.txt create mode 100644 backend/pigeonhole/tests/test_views/test_course/test_admin.py create mode 100644 backend/pigeonhole/tests/test_views/test_course/test_teacher.py delete mode 100644 backend/pigeonhole/tests/test_views/test_project.py create mode 100644 backend/pigeonhole/tests/test_views/test_project/__init__.py create mode 100644 backend/pigeonhole/tests/test_views/test_project/test_admin.py create mode 100644 backend/pigeonhole/tests/test_views/test_project/test_student.py create mode 100644 backend/pigeonhole/tests/test_views/test_project/test_teacher.py create mode 100644 backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 2fe552a8..0361f12a 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -6,6 +6,9 @@ class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): if request.user.is_admin or request.user.is_superuser: return True + + if request.user.is_teacher: + return True if request.user.is_student or request.user.is_teacher: return view.action in ['list', 'retrieve'] diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index 6a1157ed..c350e5e5 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -14,5 +14,5 @@ def has_permission(self, request, view): return True elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return True + return view.action in ['list', 'retrieve'] return False diff --git a/backend/pigeonhole/tests/test_views/test_course.py b/backend/pigeonhole/tests/test_views/test_course.py deleted file mode 100644 index 984f508a..00000000 --- a/backend/pigeonhole/tests/test_views/test_course.py +++ /dev/null @@ -1,245 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIClient -from rest_framework import status -from backend.pigeonhole.apps.users.models import User, Teacher, Student -from backend.pigeonhole.apps.courses.models import Course - -API_ENDPOINT = '/courses/' # Updated the API_ENDPOINT - - -class CourseTestAdminTeacher(TestCase): - def setUp(self): - self.client = APIClient() - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", - description="This is not of the teacher") - - self.course = Course.objects.create(**self.course_data) - - # Create a regular user (teacher) - self.user = User.objects.create_user( - username="teacher_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - # Create a Teacher instance and use .set() to assign the course - self.teacher = Teacher.objects.create(id=self.user, is_admin=True) - self.teacher.course.set([self.course]) - - self.client.force_authenticate(user=self.user) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Course.objects.count(), 3) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.course.refresh_from_db() - self.assertEqual(self.course.name, updated_data['name']) - self.assertEqual(self.course.description, updated_data['description']) - - def test_update_course_not_of_teacher(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, - format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.course_not_of_teacher.refresh_from_db() - self.assertEqual(self.course_not_of_teacher.name, updated_data['name']) - self.assertEqual(self.course_not_of_teacher.description, updated_data['description']) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Course.objects.count(), 1) - - def test_delete_course_not_of_teacher(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Course.objects.count(), 1) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), Course.objects.count()) - - -class CourseTestTeacher(TestCase): - def setUp(self): - self.client = APIClient() - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course = Course.objects.create(**self.course_data) - - self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", - description="This is not of the teacher") - - # Create a regular user (teacher) - self.user = User.objects.create_user( - username="teacher_username", - email="teacher@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - # Create a Teacher instance and use .set() to assign the course - self.teacher = Teacher.objects.create(id=self.user) - self.teacher.course.set([self.course]) - - # Authenticate the test client with the teacher user - self.client.force_authenticate(user=self.user) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Course.objects.count(), 3) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.course.refresh_from_db() - self.assertEqual(self.course.name, updated_data['name']) - self.assertEqual(self.course.description, updated_data['description']) - - def test_update_course_not_of_teacher(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, - format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Course.objects.count(), 1) - - def test_delete_course_not_of_teacher(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), Course.objects.count()) - - def test_retrieve_course(self): - response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['name'], self.course.name) - self.assertEqual(response.data['description'], self.course.description) - - -class CourseTestStudent(TestCase): - def setUp(self): - self.client = APIClient() - - # Create a regular user (teacher) - self.user = User.objects.create_user( - username="teacher_username", - email="kermit@gmail.com", - first_name="Kermit", - last_name="The Frog" - ) - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course = Course.objects.create(**self.course_data) - - # Provide a value for the "number" field when creating the Student instance - self.student = Student.objects.create(id=self.user, number=123456) - self.student.course.set([self.course]) - - # Authenticate the test client with the regular user - self.client.force_authenticate(user=self.user) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.course.refresh_from_db() - self.assertNotEqual(self.course.name, updated_data['name']) - self.assertNotEqual(self.course.description, updated_data['description']) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 2) - - -class CourseTestUnauthorized(TestCase): - def setUp(self): - self.client = APIClient() - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course = Course.objects.create(**self.course_data) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.course.refresh_from_db() - self.assertNotEqual(self.course.name, updated_data['name']) - self.assertNotEqual(self.course.description, updated_data['description']) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(len(response.data), Course.objects.count()) diff --git a/backend/pigeonhole/tests/test_views/test_course/__init__.py b/backend/pigeonhole/tests/test_views/test_course/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_course/course.txt b/backend/pigeonhole/tests/test_views/test_course/course.txt new file mode 100644 index 00000000..cfcdd187 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/course.txt @@ -0,0 +1,91 @@ + +class CourseTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a regular user (teacher) + self.user = User.objects.create_user( + username="teacher_username", + email="kermit@gmail.com", + first_name="Kermit", + last_name="The Frog" + ) + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + # Provide a value for the "number" field when creating the Student instance + self.student = Student.objects.create(id=self.user, number=123456) + self.student.course.set([self.course]) + + # Authenticate the test client with the regular user + self.client.force_authenticate(user=self.user) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + +class CourseTestUnauthorized(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), Course.objects.count()) +""" \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py new file mode 100644 index 00000000..53e66a39 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -0,0 +1,77 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class CourseTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") + + self.course = Course.objects.create(**self.course_data) + + # Create a regular user (teacher) + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1, + ) + self.teacher.course.set([self.course]) + self.client.force_authenticate(user=self.teacher) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course_not_of_teacher.refresh_from_db() + self.assertEqual(self.course_not_of_teacher.name, updated_data['name']) + self.assertEqual(self.course_not_of_teacher.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Course.objects.count()) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py new file mode 100644 index 00000000..dd99f6a3 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -0,0 +1,82 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +# TODO + +class CourseTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") + + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.teacher.course.set([self.course]) + + self.client.force_authenticate(user=self.teacher) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Course.objects.count()) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.course.name) + self.assertEqual(response.data['description'], self.course.description) diff --git a/backend/pigeonhole/tests/test_views/test_project.py b/backend/pigeonhole/tests/test_views/test_project.py deleted file mode 100644 index 1cd2ddb4..00000000 --- a/backend/pigeonhole/tests/test_views/test_project.py +++ /dev/null @@ -1,709 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIClient -from rest_framework import status -from backend.pigeonhole.apps.users.models import User, Teacher, Student -from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project - -API_ENDPOINT = '/courses/' # Updated the API_ENDPOINT - - -class ProjectTestAdminTeacher(TestCase): - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="teacher_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - self.teacher = Teacher.objects.create( - id=self.user, - is_admin=True - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.teacher.course.set([self.course]) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - self.client.force_authenticate(self.user) - - def test_create_project(self): - response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Project.objects.count(), 2) - self.assertEqual(Project.objects.get(project_id=2).name, "Test Project 2") - - def test_retrieve_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) - - def test_list_projects(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - def test_delete_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Project.objects.count(), 0) - - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + f'100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - """TODO - def test_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - """ - - def test_delete_project_invalid_course(self): - response = self.client.delete( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - """TODO - def test_partial_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - """ - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - # test with invalid project - - def test_retrieve_invalid_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_partial_update_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_invalid_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class ProjectTestTeacher(TestCase): - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="teacher_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - self.teacher = Teacher.objects.create( - id=self.user, - is_admin=False - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.course_not_of_teacher = Course.objects.create( - name="Test Course 2", - ) - - self.teacher.course.set([self.course]) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - self.client.force_authenticate(self.user) - - def test_create_project(self): - response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Updated assertion to use the correct project_id from the response - created_project_id = response.data.get('project_id') - self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") - self.assertEqual(Project.objects.count(), 2) - - def test_retrieve_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) - - def test_list_projects(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - def test_delete_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Project.objects.count(), 0) - - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + f'100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with a course not of the teacher - - def test_create_project_course_not_of_teacher(self): - response = self.client.post( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course_not_of_teacher.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # test with invalid project - - def test_retrieve_invalid_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class ProjectTestStudent(TestCase): - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="student_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - self.student = Student.objects.create( - id=self.user, - number=1234567890 - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.course_not_of_student = Course.objects.create( - name="Test Course 2", - ) - - self.student.course.set([self.course]) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - self.client.force_authenticate(self.user) - - def test_create_project(self): - response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) - - def test_list_projects(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - def test_delete_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + f'100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with a course not of the student - - def test_create_project_course_not_of_student(self): - response = self.client.post( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course_not_of_student.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_course_not_of_student(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_course_not_of_student(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_project_course_not_of_student(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course_not_of_student.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - # test with invalid project - - def test_retrieve_invalid_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_invalid_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - -class ProjectTestUnauthenticated(TestCase): - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username="student_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - ) - - self.student = Student.objects.create( - id=self.user, - number=1234567890 - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.student.course.set([self.course]) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - def test_create_project_unauthenticated(self): - response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_project_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_project_unauthenticated(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_project_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with an invalid course - def test_create_project_invalid_course_unauthenticated(self): - response = self.client.post( - API_ENDPOINT + f'100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_project_invalid_course_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_project_invalid_course_unauthenticated(self): - response = self.client.delete( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_project_invalid_course_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # test with invalid project - - def test_retrieve_invalid_project_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_invalid_project_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_invalid_project_unauthenticated(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_invalid_project_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/__init__.py b/backend/pigeonhole/tests/test_views/test_project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py new file mode 100644 index 00000000..474bd2fe --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -0,0 +1,189 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class ProjectTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.admin) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 2) + self.assertEqual(Project.objects.get(project_id=2).name, "Test Project 2") + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.count(), 1) + + def test_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_delete_project_invalid_course(self): + response = self.client.delete( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.count(), 1) + + def test_partial_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_partial_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py new file mode 100644 index 00000000..438f9ed5 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -0,0 +1,190 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class ProjectTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_student = Course.objects.create( + name="Test Course 2", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.student) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with a course not of the student + + def test_create_project_course_not_of_student(self): + response = self.client.post( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_course_not_of_student(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py new file mode 100644 index 00000000..f49a0559 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -0,0 +1,163 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class ProjectTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.teacher) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Updated assertion to use the correct project_id from the response + created_project_id = response.data.get('project_id') + self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") + self.assertEqual(Project.objects.count(), 2) + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with a course not of the teacher + + def test_create_project_course_not_of_teacher(self): + response = self.client.post( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_teacher.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py new file mode 100644 index 00000000..f4e42afb --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -0,0 +1,176 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class ProjectTestUnauthenticated(TestCase): + def setUp(self): + self.client = APIClient() + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + def test_create_project_unauthenticated(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with an invalid course + def test_create_project_invalid_course_unauthenticated(self): + response = self.client.post( + API_ENDPOINT + f'100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_invalid_course_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_invalid_course_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_invalid_course_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From aa2a7b2a3dc1c796b8203f1275a6370280327e69 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 09:20:51 +0100 Subject: [PATCH 046/138] todo's checks --- backend/pigeonhole/apps/groups/views.py | 2 ++ backend/pigeonhole/apps/projects/views.py | 1 + backend/pigeonhole/apps/submissions/views.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 5557f971..2ec62913 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -7,6 +7,8 @@ from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.projects.models import Project +# TODO tests for score/max_score + class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index a929c8d2..f1f19cf9 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -8,6 +8,7 @@ from .permissions import CanAccessProject # TODO hier nog zorgen als een project niet visible is, dat de students het niet kunnen zien. +# TODO tests for visibility and deadline class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 8a03e474..67a42403 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -4,6 +4,8 @@ from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer +# TODO test timestamp, file, output_test + class SubmissionsViewset(viewsets.ModelViewSet): queryset = Submissions.objects.all() From 3376aa232b27a764d6efb6a64ec7b2033308c2b6 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 09:43:27 +0100 Subject: [PATCH 047/138] clean testen --- .../tests/test_views/test_complete/admin.py | 90 +++++++++++++++++++ .../tests/test_views/test_group/__init__.py | 0 .../test_views/test_project/test_teacher.py | 18 ++++ .../test_project/test_unauthenticated.py | 1 + .../test_views/test_submission/__init__.py | 0 5 files changed, 109 insertions(+) create mode 100644 backend/pigeonhole/tests/test_views/test_complete/admin.py create mode 100644 backend/pigeonhole/tests/test_views/test_group/__init__.py create mode 100644 backend/pigeonhole/tests/test_views/test_submission/__init__.py diff --git a/backend/pigeonhole/tests/test_views/test_complete/admin.py b/backend/pigeonhole/tests/test_views/test_complete/admin.py new file mode 100644 index 00000000..da4d7afd --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_complete/admin.py @@ -0,0 +1,90 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.submissions.models import Submission + +ROUTES_PREFIX = '/courses/' + + +class CompleteTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a teacher user + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="Teacher", + last_name="LastName", + role=2 # Teacher role + ) + + # Create a student user + self.student = User.objects.create( + username="student_username", + email="student@gmail.com", + first_name="Student", + last_name="LastName", + role=3 # Student role + ) + + # Authenticate the teacher user + self.client.force_authenticate(self.teacher) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + def test_create_course(self): + # Use the teacher to create the course + response = self.client.post( + ROUTES_PREFIX, + { + "name": "Test Course 2", + "description": "Test Course 2 Description", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 2) + self.assertEqual(Course.objects.get(course_id=2).name, "Test Course 2") + + def test_create_project(self): + # Use the teacher to create the project for the course + response = self.client.post( + ROUTES_PREFIX + f'{self.course.course_id}/projects/', + { + "name": "Test Project", + "description": "Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Project.objects.get(project_id=1).name, "Test Project") + + def test_create_submission(self): + # Authenticate the student user + self.client.force_authenticate(self.student) + + # Use the student to create the submission for the project + response = self.client.post( + ROUTES_PREFIX + f'{self.course.course_id}/projects/1/submissions/', + { + "project_id": 1, + "student_id": self.student.id, # Use the student's id + "submission": "Test Submission", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submission.objects.count(), 1) + self.assertEqual(Submission.objects.get(submission_id=1).submission, "Test Submission") + + \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_group/__init__.py b/backend/pigeonhole/tests/test_views/test_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index f49a0559..c2e565f7 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -161,3 +161,21 @@ def test_retrieve_invalid_project(self): API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_project_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_project_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index f4e42afb..4d54ae51 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -86,6 +86,7 @@ def test_partial_update_project_unauthenticated(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # tests with an invalid course + def test_create_project_invalid_course_unauthenticated(self): response = self.client.post( API_ENDPOINT + f'100/projects/', diff --git a/backend/pigeonhole/tests/test_views/test_submission/__init__.py b/backend/pigeonhole/tests/test_views/test_submission/__init__.py new file mode 100644 index 00000000..e69de29b From 84fcaca2481049519a402932fbc8764492b8473f Mon Sep 17 00:00:00 2001 From: Reinhard Date: Tue, 12 Mar 2024 16:38:05 +0100 Subject: [PATCH 048/138] course permissions fixed --- .../pigeonhole/apps/courses/permissions.py | 24 +++++++------------ backend/pigeonhole/apps/courses/views.py | 9 ++++--- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 0361f12a..f7939197 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -1,4 +1,5 @@ from rest_framework import permissions + from backend.pigeonhole.apps.users.models import User @@ -6,24 +7,17 @@ class CourseUserPermissions(permissions.BasePermission): def has_permission(self, request, view): if request.user.is_admin or request.user.is_superuser: return True - - if request.user.is_teacher: - return True - - if request.user.is_student or request.user.is_teacher: - return view.action in ['list', 'retrieve'] - - return False - def has_object_permission(self, request, view, obj): - if request.user.is_admin or request.user.is_superuser: - return True if request.user.is_teacher: - if User.objects.filter(id=request.user.id, course=obj).exists(): + if view.action in ['create', 'list', 'retrieve']: return True - return view.action in ['list', 'retrieve'] + elif view.action in ['update', 'partial_update', 'destroy'] and User.objects.filter(id=request.user.id, + course=view.kwargs[ + 'pk']).exists(): + return True + return - if request.user.is_student: + if request.user.is_student or request.user.is_teacher: return view.action in ['list', 'retrieve'] - return False \ No newline at end of file + return False diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 506c6f9b..a576fa6c 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -1,7 +1,7 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.decorators import action from backend.pigeonhole.apps.users.models import User from .models import Course, CourseSerializer @@ -36,6 +36,10 @@ def destroy(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, *args, **kwargs): + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def my_list(self, request, *args, **kwargs): if request.user.is_admin or request.user.is_superuser: serializer = CourseSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -67,5 +71,4 @@ def join_course(self, request, *args, **kwargs): course = Course.objects.get(pk=course_id) user = User.objects.get(id=request.user.id) user.course.add(course) - return Response(status=status.HTTP_200_OK) - + return Response(status=status.HTTP_200_OK) \ No newline at end of file From d2e8b10d68bd302f2516fffb5809deef163296af Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 16:55:50 +0100 Subject: [PATCH 049/138] model changes, test fixes --- .../groups/migrations/0006_group_max_score.py | 18 +++ .../migrations/0007_remove_group_max_score.py | 17 +++ backend/pigeonhole/apps/groups/models.py | 2 +- ...emove_conditions_submission_id_and_more.py | 59 ++++++++++ backend/pigeonhole/apps/projects/models.py | 47 +++----- backend/pigeonhole/apps/projects/views.py | 7 +- .../users/migrations/0004_alter_user_role.py | 18 +++ .../users/migrations/0005_alter_user_role.py | 18 +++ backend/pigeonhole/apps/users/models.py | 2 +- .../tests/test_models/test_conditions.py | 106 ----------------- .../tests/test_models/test_course.py | 22 ++-- .../tests/test_models/test_groups.py | 41 ++++--- .../tests/test_models/test_project.py | 20 ++-- .../tests/test_models/test_submissions.py | 24 ++-- .../pigeonhole/tests/test_models/test_user.py | 108 +++++++++--------- .../test_views/test_project/test_admin.py | 27 ++++- .../test_views/test_project/test_teacher.py | 3 +- 17 files changed, 288 insertions(+), 251 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py create mode 100644 backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py create mode 100644 backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py create mode 100644 backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py create mode 100644 backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py delete mode 100644 backend/pigeonhole/tests/test_models/test_conditions.py diff --git a/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py b/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py new file mode 100644 index 00000000..ab42a7c8 --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-12 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0005_remove_group_student_group_user'), + ] + + operations = [ + migrations.AddField( + model_name='group', + name='max_score', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py b/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py new file mode 100644 index 00000000..bd3e2c46 --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.3 on 2024-03-12 15:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0006_group_max_score'), + ] + + operations = [ + migrations.RemoveField( + model_name='group', + name='max_score', + ), + ] diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index cf42ba04..6024f144 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -18,7 +18,7 @@ class Group(models.Model): # a student can only be in one group per project def clean(self): - for student in self.student.all(): + for student in self.user.all(): existing_groups = Group.objects.filter( project_id=self.project_id, student=student).exclude( group_id=self.group_id) diff --git a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py new file mode 100644 index 00000000..5663b76a --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.0.3 on 2024-03-12 15:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_alter_project_deadline'), + ] + + operations = [ + migrations.CreateModel( + name='Test', + fields=[ + ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='projects.project')), + ('test_nr', models.IntegerField()), + ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), + ], + ), + migrations.RemoveField( + model_name='conditions', + name='submission_id', + ), + migrations.RemoveField( + model_name='forbiddenextension', + name='project_id', + ), + migrations.AddField( + model_name='project', + name='file_structure', + field=models.CharField(max_length=1024, null=True), + ), + migrations.AddField( + model_name='project', + name='group_size', + field=models.IntegerField(default=1), + ), + migrations.AddField( + model_name='project', + name='max_score', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='number_of_groups', + field=models.IntegerField(default=5), + ), + migrations.DeleteModel( + name='AllowedExtension', + ), + migrations.DeleteModel( + name='Conditions', + ), + migrations.DeleteModel( + name='ForbiddenExtension', + ), + ] diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index 8834cf12..79638120 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -4,7 +4,6 @@ from backend.pigeonhole.apps.courses.models import Course -# Create your models here. class Project(models.Model): objects = models.Manager() project_id = models.BigAutoField(primary_key=True) @@ -13,43 +12,25 @@ class Project(models.Model): description = models.TextField() deadline = models.DateTimeField(null=True, blank=True) visible = models.BooleanField(default=False) + max_score = models.IntegerField(null=True, blank=True) + number_of_groups = models.IntegerField(default=5) + group_size = models.IntegerField(default=1) + file_structure = models.CharField(max_length=1024, null=True) + max_score = models.IntegerField(null=True, blank=True) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ['project_id', 'course_id', 'name', 'description', 'visible', 'deadline'] + fields = ["project_id", "course_id", "name", "description", "deadline", "visible", "number_of_groups", + "group_size", "max_score", "file_structure", "max_score"] -class Conditions(models.Model): - condition_id = models.BigAutoField(primary_key=True) - submission_id = models.ForeignKey(Project, on_delete=models.CASCADE) - condition = models.TextField(max_length=256) - test_file_location = models.CharField(max_length=512, null=True) - test_file_type = models.CharField(max_length=256, null=True) +class Test(models.Model): + project_id = models.ForeignKey(Project, primary_key=True, on_delete=models.CASCADE) + test_nr = models.IntegerField() + test_file_type = models.FileField(upload_to='uploads/projects/' + + str(project_id) + '/' + str(test_nr), null=True, blank=False, + max_length=255) - objects = models.Manager() - - @property - def get_forbidden_extensions(self): - return ForbiddenExtension.objects.filter(project_id=self.project_id) - - @property - def get_allowed_extensions(self): - return AllowedExtension.objects.filter(project_id=self.project_id) - - -class AllowedExtension(models.Model): - extension_id = models.BigAutoField(primary_key=True) - project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.CharField(max_length=512) - - objects = models.Manager() - - -class ForbiddenExtension(models.Model): - extension_id = models.BigAutoField(primary_key=True) - project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.CharField(max_length=512) - - objects = models.Manager() + objects = models.Manager() \ No newline at end of file diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index f1f19cf9..0b9cec90 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -5,6 +5,7 @@ from rest_framework.response import Response from .models import Project, ProjectSerializer, Course +from backend.pigeonhole.apps.groups.models import Group from .permissions import CanAccessProject # TODO hier nog zorgen als een project niet visible is, dat de students het niet kunnen zien. @@ -33,7 +34,11 @@ def create(self, request, *args, **kwargs): serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): - serializer.save() + project = serializer.save() # Save the project and get the instance + # make NUMBER OF GROUP groups + for i in range(serializer.validated_data['number_of_groups']): + group = Group.objects.create(group_nr=i+1, project_id=project) # Assign the Project instance + group.save() return Response(serializer.data, status=status.HTTP_201_CREATED) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py b/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py new file mode 100644 index 00000000..e3d92cdf --- /dev/null +++ b/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-12 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_remove_teacher_course_remove_teacher_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='role', + field=models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=3), + ), + ] diff --git a/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py b/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py new file mode 100644 index 00000000..9ec9bc9d --- /dev/null +++ b/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-12 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_alter_user_role'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='role', + field=models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=1), + ), + ] diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index 5f6d02c9..285e6b7b 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -17,7 +17,7 @@ class User(AbstractUser): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=150) course = models.ManyToManyField(Course) - role = models.IntegerField(choices=Roles.choices, default=Roles.STUDENT) + role = models.IntegerField(choices=Roles.choices, default=Roles.ADMIN) objects = models.Manager() diff --git a/backend/pigeonhole/tests/test_models/test_conditions.py b/backend/pigeonhole/tests/test_models/test_conditions.py deleted file mode 100644 index 33544689..00000000 --- a/backend/pigeonhole/tests/test_models/test_conditions.py +++ /dev/null @@ -1,106 +0,0 @@ -from django.test import TestCase -from backend.pigeonhole.apps.users.models import User -from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project, Conditions, AllowedExtension, ForbiddenExtension - - -class ConditionsTestCase(TestCase): - def setUp(self): - # Create teacher user - teacher_user = User.objects.create_user( - username="teacher_username", - email="teacher@gmail.com", - first_name="Kermit", - last_name="The Frog" - ) - # Create student user - student_user = User.objects.create_user( - username="student_username", - email="student@gmail.com", - first_name="Miss", - last_name="Piggy" - ) - - # Create teacher and student using the created users - teacher = User.objects.create(id=teacher_user) - student = User.objects.create(id=student_user, number=1234) - - # Create course - course = Course.objects.create(name="Math", description="Mathematics") - teacher.course.add(course) - student.course.add(course) - - # Create project - project = Project.objects.create( - name="Project", - course_id=course, - deadline="2021-12-12 12:12:12", - description="Project Description" - ) - - # Create conditions - self.conditions = Conditions.objects.create( - submission_id=project, - condition="Condition 1", - test_file_location="path/to/test", - test_file_type="txt" - ) - - # Create allowed extension - AllowedExtension.objects.create( - project_id=project, - extension=123 - ) - - # Create forbidden extension - ForbiddenExtension.objects.create( - project_id=project, - extension=456 - ) - - def test_conditions_submission_relation(self): - self.assertEqual(self.conditions.submission_id, Project.objects.get(name="Project")) - - def test_conditions_forbidden_extensions(self): - self.assertEqual(len(self.conditions.get_forbidden_extensions), 1) - - def test_conditions_allowed_extensions(self): - self.assertEqual(len(self.conditions.get_allowed_extensions), 1) - - def test_create_conditions_without_submission(self): - with self.assertRaises(Exception): - Conditions.objects.create( - condition="Condition 2", - deadline="2021-12-12 12:12:12", - test_file_location="path/to/test", - test_file_type="txt" - ) - - def test_allowed_extension(self): - allowed_extension = AllowedExtension.objects.get(extension=123) - self.assertEqual(allowed_extension.project_id, Project.objects.get(name="Project")) - - def test_forbidden_extension(self): - forbidden_extension = ForbiddenExtension.objects.get(extension=456) - self.assertEqual(forbidden_extension.project_id, Project.objects.get(name="Project")) - - def test_update_and_delete_conditions(self): - self.conditions.condition = "Condition 2" - self.conditions.save() - updated_conditions = Conditions.objects.get(condition="Condition 2") - self.assertEqual(updated_conditions.condition, "Condition 2") - - # Delete associated extensions explicitly - AllowedExtension.objects.filter(project_id=self.conditions.submission_id).delete() - ForbiddenExtension.objects.filter(project_id=self.conditions.submission_id).delete() - - self.conditions.delete() - with self.assertRaises(Conditions.DoesNotExist): - Conditions.objects.get(condition="Condition 2") - - # Check if associated extensions are deleted as well - with self.assertRaises(AllowedExtension.DoesNotExist): - AllowedExtension.objects.get(extension=123) - - with self.assertRaises(ForbiddenExtension.DoesNotExist): - ForbiddenExtension.objects.get(extension=456) diff --git a/backend/pigeonhole/tests/test_models/test_course.py b/backend/pigeonhole/tests/test_models/test_course.py index d576e67f..d0904273 100644 --- a/backend/pigeonhole/tests/test_models/test_course.py +++ b/backend/pigeonhole/tests/test_models/test_course.py @@ -10,31 +10,31 @@ class CourseTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - - # Create teacher and student using the created users - teacher = User.objects.create(id=teacher_user) - student = User.objects.create(id=student_user, number=1234) - + # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) student.course.add(course) def test_course_teacher_relationship(self): - teacher = User.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id=1) course = Course.objects.get(name="Math") self.assertIn(course, teacher.course.all()) course_alter_ego = teacher.course.get(name="Math") @@ -42,7 +42,7 @@ def test_course_teacher_relationship(self): self.assertTrue(course_alter_ego, "Mathematics") def test_course_students_relationship(self): - student = User.objects.get(id__email="student@gmail.com") + student = User.objects.get(id=2) course = Course.objects.get(name="Math") self.assertIn(course, student.course.all()) course_alter_ego = student.course.get(name="Math") diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 0ca1335b..38d4fc96 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -11,55 +11,54 @@ class GroupTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) + # Create student user - student_user = User.objects.create_user( + student = User.objects.create( username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) # Create a second student user - student_user2 = User.objects.create_user( + student2 = User.objects.create( username="student_username2", email="student2@gmail.com", first_name="Fozzie", - last_name="Bear" + last_name="Bear", + role=3 ) - # Create teacher and student using the created users - teacher = User.objects.create(id=teacher_user) - student = User.objects.create(id=student_user, number=1234) - student2 = User.objects.create(id=student_user2, number=5678) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) student.course.add(course) # Create project - project = Project.objects.create( + Project.objects.create( name="Project", course_id=course, deadline="2021-12-12 12:12:12", description="Project Description", + number_of_groups=2, + group_size=2 ) - - # Create group + # get group with id 1 group = Group.objects.create( group_nr=1, - project_id=project, - final_score=0, + project_id=Project.objects.get(name="Project"), ) # Add students to the group - group.student.set([student, student2]) + group.user.set([student, student2]) def test_group_project_relation(self): group = Group.objects.get(group_nr=1) @@ -68,10 +67,10 @@ def test_group_project_relation(self): def test_group_student_relation(self): group = Group.objects.get(group_nr=1) - student = User.objects.get(id__email="student@gmail.com") - student2 = User.objects.get(id__email="student2@gmail.com") - self.assertIn(student, group.student.all()) - self.assertIn(student2, group.student.all()) + student = User.objects.get(id=1) + student2 = User.objects.get(id=2) + self.assertIn(student, group.user.all()) + self.assertIn(student2, group.user.all()) def test_group_final_score(self): group = Group.objects.get(group_nr=1) diff --git a/backend/pigeonhole/tests/test_models/test_project.py b/backend/pigeonhole/tests/test_models/test_project.py index 99f75f5f..b37f7890 100644 --- a/backend/pigeonhole/tests/test_models/test_project.py +++ b/backend/pigeonhole/tests/test_models/test_project.py @@ -7,24 +7,24 @@ class ProjectTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = User.objects.create(id=teacher_user) - student = User.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) @@ -42,11 +42,11 @@ def test_project_course_relation(self): self.assertEqual(self.project.course_id.name, "Math") def test_project_teacher_relation(self): - teacher = User.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id=1) self.assertIn(self.project.course_id, teacher.course.all()) def test_project_student_relation(self): - student = User.objects.get(id__email="student@gmail.com") + student = User.objects.get(id=2) self.assertIn(self.project.course_id, student.course.all()) def test_course_name_length_validation(self): diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index 698b012d..d3949d38 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -10,24 +10,22 @@ class SubmissionTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = User.objects.create(id=teacher_user) - student = User.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) @@ -47,7 +45,7 @@ def setUp(self): ) # Add student to the group - group.student.set([student]) + group.user.set([student]) # Create submission Submissions.objects.create( @@ -56,10 +54,10 @@ def setUp(self): def test_submission_student_relation(self): submission = Submissions.objects.get(submission_nr=1) - student = submission.group_id.student.first() - self.assertEqual(submission.group_id.student.count(), 1) - self.assertEqual(submission.group_id.student.first(), student) - self.assertEqual(submission.group_id.student.first().id, student.id) + student = submission.group_id.user.first() + self.assertEqual(submission.group_id.user.count(), 1) + self.assertEqual(submission.group_id.user.first(), student) + self.assertEqual(submission.group_id.user.first().id, student.id) def test_submission_project_relation(self): submission = Submissions.objects.get(submission_nr=1) diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 383933fd..6c7966a3 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -7,66 +7,72 @@ class UserTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) + # Create student user - student_user = User.objects.create_user( + User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - User.objects.create(id=teacher_user) - User.objects.create(id=student_user, number=1234) - - def test_student(self): - student = User.objects.get(id__email="student@gmail.com") - self.assertEqual(student.id.email, "student@gmail.com") - self.assertEqual(student.number, 1234) - - # update student number - student.number = 5678 - student.save() - student = User.objects.get(id__email="student@gmail.com") - self.assertEqual(student.number, 5678) - - # delete student - student.delete() - with self.assertRaises(User.DoesNotExist): - User.objects.get(id__email="student@gmail.com") - - def test_teacher(self): - teacher = User.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.id.email, "teacher@gmail.com") - self.assertEqual(teacher.is_admin, False) - - # update teacher is_admin - teacher.is_admin = True - teacher.save() - teacher = User.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.is_admin, True) - - self.assertEqual(teacher.is_assistant, False) - # update teacher is_assistent - teacher.is_assistant = True - teacher.save() - teacher = User.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.is_assistant, True) - # delete teacher - teacher.delete() - with self.assertRaises(User.DoesNotExist): - User.objects.get(id__email="teacher@gmail.com") - - def test_create_student_without_user(self): + def test_student_fields(self): + student = User.objects.get(id=1), + self.assertEqual(student[0].username, "teacher_username") + self.assertEqual(student[0].email, "teacher@gmail.com") + self.assertEqual(student[0].first_name, "Kermit") + self.assertEqual(student[0].last_name, "The Frog") + self.assertEqual(student[0].role, 2) + + def test_teacher_fields(self): + teacher = User.objects.get(id=2), + self.assertEqual(teacher[0].username, "student_username") + self.assertEqual(teacher[0].email, "student@gmail.com"), + self.assertEqual(teacher[0].first_name, "Miss") + self.assertEqual(teacher[0].last_name, "Piggy") + self.assertEqual(teacher[0].role, 3) + + def test_user_name_length_validation(self): with self.assertRaises(Exception): - User.objects.create(number=1234) - - def test_create_teacher_without_user(self): + User.objects.create( + username="A" * 300, + email="student@gmail.com", + first_name="Miss", + last_name="Piggy", + role=3 + ) + + # TODO + def test_user_correct_email(self): + with self.assertRaises(Exception): + User.objects.create( + username="student_username", + email="studentgmail.com", + first_name="Miss", + last_name="Piggy", + role=3 + ) + + def test_user_role_validation(self): with self.assertRaises(Exception): - User.objects.create(is_admin=True, is_assistent=True) + User.objects.create( + username="student_username", + email="student@gmail.com", + first_name="Miss", + last_name="Piggy", + role=4 + ) + + + + \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 474bd2fe..1f51ebf5 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -5,6 +5,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.groups.models import Group API_ENDPOINT = '/courses/' @@ -36,18 +37,40 @@ def setUp(self): self.client.force_authenticate(self.admin) def test_create_project(self): + # Assuming API_ENDPOINT is defined elsewhere in your test setup + # and self.course.course_id is correctly set up to point to an existing course + + # Make a POST request to create a new project response = self.client.post( API_ENDPOINT + f'{self.course.course_id}/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", - "course_id": self.course.course_id + "course_id": self.course.course_id, + "number_of_groups": 4, }, format='json' ) + + # Check that the response status code is 201 CREATED self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Check that the number of projects has increased by 1 + # This assumes that there was already one project before this test self.assertEqual(Project.objects.count(), 2) - self.assertEqual(Project.objects.get(project_id=2).name, "Test Project 2") + + # test whether 4 group objects are created + self.assertEqual(Group.objects.count(), 4) + + # Retrieve the newly created project + # Since we're creating a new project, it should be the last one in the list + # However, it's safer to filter by name or another unique field + # For demonstration, I'll use the name "Test Project 2" + new_project = Project.objects.get(name="Test Project 2") + + # Check that the name of the newly created project is correct + self.assertEqual(new_project.name, "Test Project 2") + def test_retrieve_project(self): response = self.client.get( diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index c2e565f7..1f7c9b88 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -44,7 +44,8 @@ def test_create_project(self): { "name": "Test Project 2", "description": "Test Project 2 Description", - "course_id": self.course.course_id + "course_id": self.course.course_id, + "number_of_groups": 4, }, format='json' ) From 46a622f50bfa1b6bc09dfb2496725311335dbb19 Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Tue, 12 Mar 2024 17:18:21 +0100 Subject: [PATCH 050/138] commit before pull --- frontend/public/logo.png | Bin 0 -> 136609 bytes frontend/public/logo_old.png | Bin 0 -> 143370 bytes frontend/src/styles/theme.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/logo_old.png diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1fbb1c1d9d5a20c6cc1a3217483d68e96dfd9a83 GIT binary patch literal 136609 zcmeFZV|Ql3vpyX2j_=sE?M$4BZF^$dHYc`i+nhKP8xz}p@;m=?&WCu`d9hco?zMM! zRqx(iS5;rNBNXH$5a4j&KtMncq$EX^KtMq6|8v1WeYZ?5ZAX3=piW8>!XP!%cqbqr zL?BY4KUCcH&bwjLs?Al&yLvo$L}H${4iM8RQBjSFSg4GM#>j#UT|b*M#WS``#H=lW zqQkCvicMb<$cwf=KtDi96R=Gcga*>$1O9N1%c^c>OkYneT&-=F3#~s3UMwKnPH#T& z{hp|V8_Y!ktAD6GBmd0D^uO?z5x@e8=i}@c&)^PeGM`M)AMZe;}ab z_uOp}9T5Nf5UlQ#KS>@86>~MpRsYla^SA^0zxw{}BY;~-2-XMD zwt(Ts|BR?0yv+aK59h)B0sX$+$sWxG_PekD*NWP4`5(dlO8^5zWCh{epv*Gq|CJ|s z)&=Q*hjQ}wM}5WH5Y-L*A9?bEYrOtfxioNy%q0xsAvyn(KgPd-kbf_HS1rFM;tQ4xc{z?u}$Qb6<`jp^}aKl0P} z;z;1B{q_e^8G{fC7woqw5QMdDV7M6)+exJLz2D()#eciulFf}LKdCtD_&hy5wKTKJ z2sO;B#%;2p&2?EH6P8&BHq@|?1rN0>tieTP3nT)WC6QIuNFmRC-#l@S)T<4no7`#V zh-?TpUN1Yvq5sv#WPn5e?D@W`w$#G0&^;-ai1+F z;1F`xU4^ucp#4UzJC|9_gZ0$7=_Z8+F`GJ;gXi)pDmJqpnt5lR33=23PM7^ULnTSlVs_CLJveo^2N_u}lkX@M_aS z7XZ*Dgl}f|%~xWcJ~(gR1Z>jzz{@N5Nn(~Yu+>IAe8T~c*wKS;yp4UXN!3kZ3_fD% zg}23K(rPsncHN0t7Olw)bNp^WSmM($&BDrKx`4`nm%?9M={d=rq<8X+{tl<+f=G(; zW~tk4ds!db*V)1J8CRnAZR}#3MB~8{9aL;b{~ImnwuX{i%Zl!dajSQk7(1PLy1d zDk4X)197qHyU_t^GK7H$2uvB#G18Grl{B!F=bESUA&sY(dzgXaEZXCVI}4Faf(Y7GP+59oUibH-G-o{u}7DE&waSl zqrTy?FCqBl2huK}@{PpU{tL2sJ0V}IGrSbjO%Ysq^xqE5Pl!mirCflNgfv+6PqbKZ z3ztO)WBb6vwnC=Ko15qq2ADQoTUDaNP2zI|q z1aADz(~QF)vC)zWf+kS@N1ssrk(m@3#t%l{&eO{>TyghC2r z6tVzj$v%Ad77j!G$u;+?a5C>cpI&?-Bz8oR5JG0)3{0CoJH`>G{d|lpt39> zTB2+(4a}pj!7wS>urH;-0|A%JO}R(SgCYzV`A9MoBs-_8n?IB@7JKUDtQlUBC?Y&a z2LX7Fg-==wC_wd2;~vXI^~kohskZa>-cEN;U}7Do#+1|<|K;=YmFKea z4)LYKp|kfzh)(z3KFecnnCqzPZs!@aL^HOkS`<`&fiCppD3tq+Ow&~p z#Dhz!agRA)y85cQbbN+KuDdrZyZM}-ywlqhCwCYWt?aZQDh;8}42g&Iv>{kk{g@ZS zvn8VEEH6jS{%wkLA3pbk?XvTOgD7Io$tF#VA=3TC)O;cr8WP+dzj9LLK=)$vF6-@W z8@qe*p&oadqX?*w5@L{g2)pO**nPJ4WqMR2-+Mu};@BZ7wv!q7?I>|Viu)vb2=(c< zKferH+$)F8c#WZ}I4Cdv>kzoDLN?4W?PLW{l?+cDVo-%qQs4L&AY%Le_!VV?sf z8P5(#`{N@$^Zexv-BG4n0hXv5Kl;%a*Zu0X>DtgK2eVvXHO`)nuvP+5q4evpg_s+v zF6WXP1F9wJOz?&puX~r`e8UqyUQTrOh?6{vC2Y`{-!B!KU_o?Cg{l$?p?1~vz(eO0 z^5@<t9B&~EgZwl+PCzr;!bqUZNFeDkB7`+MZ8KxDwE``Q>l z$Y;l-;f@8VJCC{qQga4}l4xx++L`-j^+~qC#T%*Q@TZDy5);2X$#I|&S4SNd2ZPtf zZ?1=Pr#ky#%Pw7&N8b3pa%J91E&HWY0EesIrBxBzbK?gJl|< z0Sz@dFfc&k>gsByiRcz8XmtZQZ_TszjnljoH*N-zB4vv)%VE56zfsf|hkp&DhY^th zBEz%l=!M|R?Ct`iX)b%A!;%*@$(=VL?Af;0&x`ce%;xJ|dn&=8-8>6?tRG0qYqbSp zSLcm4byuBWZ_@(s`xRmvSapRx$bO1TN+L&w`Az|>hHn^=kzr62eY_7mu*rmEr`?Ne zU)_|>XIUE^YAxMknJ2bpaLm#UU; zXvyzmE=DaZR7h!x?ny+Z`J4n#i90XC&9Ck4ijgCxj^r-y-1$yy!%0y8?0;Sm0&m>C zjB_2P{T#B%3&7aJKv9O`z>ao7#M0Xy*6b5Rv@UT17J%Leow5Qe3h_z6YDZdyxjx@b zm7}Olh)+V;O}E=_BjGDZtncm}Qzf3rXn^XxJ!Q}^P*BXplzv%;HJgN143e??E~P(% zCva9|oy8`1!qCXcYaje#dP?4#s5Q1Hg~XnO^m}4Rw(+}L>}bF)uLsQ0Ro|{=_@3Uc zWLe?b0q%LtH1X^q_ulJ0L1b~e+}Up#BG4%*K{9|vx$u$41RTVQt|8K#59_R(F$q!* zgah^>Jv~Q_$o&GtpWTi8y@HWu{F!|8P|Re^BMW3KQVwp?hga908q)s)0!es(?EtXH z<{v`TW%Q%iX}gzS=&}QKeRJVFTcXm>Ls}Ivb%W^@ISOrq6y26NMn`!KdifN_UFu7w ze4X=H=3qbF`OytjNU9_zGrYl98uOWB2E|17QXO1Ea|`_}^` z4Hxw9tC`z@kI&2+;|>ru6zO0~r~C_I*_hWF#&(kb*jC4jBeH8;f!FL+bJB5q%JAat ztZUxJj_=ZpFD14ZJ}hE8Kf=Ga*`Vu^6Y{S!l)6{UkGaneXukqOh+}A~OttVC^QlfM zCJmiFvWR+CZ0Wexw7ND#3J1gmco4yXX)4grO7~BA{pjBdGXry*C?)5`uGAL=d);D? zl~$V>#pPmga=xUwu6y?kdo~%XY^+NyomWyS-INAsqnt~NomZTw7;a#E#IDMy3uPLj zxni5%zH?3ER~kwZsWnZSF*-vIN~EO@Q*_3C$zJfu!V~lxC7n5r;-`+?>;|l0=CJ<7 zS1q9;lsp2lj;>29+LfgDs}GTL(QVs@_B!xAR)s%VAgtdCoi^F}cZXqGeO~QuPU5DofDmbTg0ZtshXnP20iF)g8+w#W9=H`tgN&5LO=us#ZaRwEHLlkvfLL4X$8EmK4UhjjD5KhM&IVyxSr zuH=%_`W`D#b@B=4mR<<1(S znDbbMTbA`ZYXEgDbLfZ;>bDLo5>~=ejSv$Q)=rs^v;EBtMRk*oq!*+u@Tu755}7xS zvMQ8)=NbGfpjFfSZSx16MQ-!}~w7ExTA~0U$MKD0 ze)x#5FygzC2YfM)SX{{c-Dl(fY`4oU}Y8dALpwuZ`L&Prg#Q z1kOa%12VTX7R;&hYv$6NgBr0Fx?V4 z2L*_K2%v7)@oscgIgaR+Gxw@bAILq2#L|uto>B~dt%ZK}Q_UeEry{^T1`mdIe-Zg! ze|Zw0?t+Zo;hFA`2Ej5$DnvR?#q+Q>?x5mr(}ChYmSPiPCP5PUu_ran59>oS4^8;HK@(j0P=@K8G=-poIQpPd^l_0wOIkDW9ppG&-<}4 zXSF8`;eQ}N?5ueN-F$UkG4mWTbRWn}h+j#wyu+N@9W+u_ei!sKtaIkANj4T_&Sr!r zpWNVnYm+TS&aWeG32nxmi3rD@XPq6~NJw5jCwsz_%Rf`O`YN;)JCeGdL_IcVQ)A2e zcNth4ak!b7nm=8Fr;|IYay1mIjuK{h$^8@$j>U3BZ#5J9nSHnc=^XV%4^}Zbc7t@R zqO!0~T~T-HNIUyi9+*A%2}q=Dzko@)RRCcX(`Xv$;ieJ>TnJwzhpYlZ1QVJp$b!G8B_b)kz9^jV9mXWm=NrTN%!4di|u~Zlm2zMg!k8_NpuPduJRnPj2&U4 zFBQllW`}1naK*gH@d{q`mxD=XgjN6D=-@{bUJHI(*SUKV2eJi(7ii z)PSzNTw?bn66Dj~2q~vb6 zDs)gw7}IhoeWERen(MD7=UDL{<={Wj(QfrvZ7^qeZoZ#{4x9Qt_>vSFCr^f{%4xEI zFk_P_j2v8&?0@!MLM%*r$3)WXyPKdbY)Bu7SC zi|2t^KD_j=pDuUX)d1Jbj(bhf-?OUt&tLz_5Ee$bq)`wt>n4xsuMgrrT@m;W#lBD; zCIo=7aj>wgr-n*P_GJ?kQcN8T+7LiLboPl*2lJ>D%gvmyqKhF5GSW^CLp9xw_D0TP zOfNTN`_)I)R^c=mb8|I%6gut6i7bMDHTT+1DO`o-^1v;!MLFaVKz|NS4!gBOvK0bW zEb$1NxU8RtvR#r)G8+Z--Uumlp-i0Ytn^XwT_bXxKVF&_( z@+O+u06{7OG(h*@!q5(y_xNAkmh&Qw7~HC6pJRw){ZN` z1I0kM{6o>jR5>wN)YcspV4iJcSl$nA`YmVF2o02lQdqCmh#*O|8DKvY{k-_%C2|}o=lUN@{)m5Vpxf&<5{8J!{dA*t_VcHXR=S3~Sk%QFgXdsCJoba0 zrX`WaM%aF)wnqE;Zrfh_j4^cjf zknYr=RD1$SC zzvth|!f{<7a->`A)Yg!6yH2h3LD=DiIRmpzAILYbEjc#F2n>rYC`gb)*mgpRbFFWz zclbzPmG>La<@DX@fj-j~s?|n$KKe$RXF2W)#C7%~g#@f?Ib9AOrVri=J8brqta$0^ z8Bx6HXVTSN)Y{1{VT(u!yr4O64XOEfbcUUl%*4dpu?N{we0<1~KB>lhv>Ewr1@mSh zI`c>L`i06~+C|ePl`))};Op#)?FeA-_7tS&Zg^e$s=d+DIqu}sJ?BQz0HP=F_LPby zgH8sD7rLJ*&)L<#(#j_~3qhi^|Az~J!3b5_MKI3dz4^))Hfr>}Ea_LcK(g9dd}GVqzjNubTu^B1$y~|J7o8hspGa-Tmo5R~B#{uqf#4i@_970RJ9?w1|LS7>5Jbr9ai3imb|47%h-`WJ`6lkd2S0PO; zcwL7QIwwPCPI9eVv;Yfe?7wtAJEF8&{`ePkDw~H{3G)vBpXpz6pCvM&*S-Dc>-U^= z|N5vyD3t<-;+9e-bC(kS4r(KZZ2p^SMVV803q-y97BfMdKN^6Et$Ye~){YKViK2)O zn>Zt8rMT`ew2&Qm(Qw9&M#-cd=_aFLe0x6V$rrJ;J0ayPlcN6s25+q96 z*;Y6_f)4jPyLXm42gY}fkULsI zJBV(DZ}_46)E9&Ux_^&UmF4k>hL^f9TbArAu!A2*!6PrdQ`0}Gv<#nm*y89?Nic2TYPyLoX-6W6S|jS8NaBv(XZC37mdnizvN z+cTR?RatBrrMSs*YFEz9A;4-^-uDD^*VfL^B|q%JD4OOzKo4ogeOFqc9b5(n?eSKC z3}JIx!>+Y+t!+H{4MLh0JbILRfGJ+(M(-_6bWiQ5TW_*UEsu&NLhZ@Kj>WBui~{OC zCyZ+vMVw&XJv!&Cj~f0))ovbZGB%>t_jJGQ((V(=-m~p?m@&lV6y==bk1boBx5Na% zI;hZHw!luq(SV6hUav48%Vvpk#_CjSRYq#~?g>mzJ~QH3w9K5jkR@t56Q^h0lys<9 z55}jqYtDxq@=g;t^9SUpc)?)V=-_hx6Q5xc0Z1apv5Sees6Bce(b^;CjHH)9RQBsx zJj$D;KM%8j4!%BVYNCo4fP)Z=-@Q*t8}`WWboZ|}m#=J_(({Ir9SkQwcd_;BN1glK zPVJ*S|3}4e$nU$-1ySJH$0u@@lw5w&1x~nfEbj$zu}9{1>JRD}&y&fWb3sVsL2>KV zl_{%WcpsTY480+0^X#1o3plpuXd7I}@r~lKD&P^bxJ$GNZb=bsNj802N>HfF{wi-N z)ru!n(>NkVB{27Skh8z%iRzIZy^7)+O<{4OJBCsFfOMnNWxIg(T&>RZ&&>fpf=Z1O zT`ujrpd4aYWur^c=ok>=7o5$^yw(zmKbphcIjTYi8oG~jP^`xlYtD1stRu|eE5^!) zszXQmu=I#(~poDlvejN|Qv@& z3#f2IY{*B=2{JflGk1!%ihg*z{1B0`eQiYwbPLrhAi3`Sjt16b?hT3c@!Ud}W;yl4 z>r(bTJ~F&^i$w^e=a{KvkeACqThT#l0*MfXf!(0K2ZvvD z?%RyLIJW6K_A`U-O#zE^Ce<|i&}Y-UanGt_Op$n(Tar4sk$1+! zjM95qNSVDL{wPhA24|I!QN&>lM@_Yl;A$`0H~55}bAQn9-V87WC6~}w;Z$@2(QR03 z)oQFITDXk;ZdQ{-R$j~F~G>yH>xfkWTpP*%S)TyF`+<5?55Yxsv78Al)(5+@Q zNN}z(CNX8ZbhlU$vBi}{D{->H2Ay!o-jknBW~f%QbedTGrwS>aS_Hb@x?=+SK8Mls z-t%PiKFny)&R~enEc00AQRS!XGNq92jY-5Qd#*%mk0yIz~b2Sxx|;0xpQJAGC3p@awd4 zhl9I|2NJ=zx+IxHa0ZsQK8v-nuG!);>BcJ;d-HBwA}h@fv76?<%?(t*!sq$@9i#6j z8!h?pwB+A9YZEbIzXR%yic)Nr^-;MtGPKqj@44O}X1u(E9pL&vvZdiTjh*qD;Tm6>#QS|o7#(Ku{-i?m;BSgXVh69b1c9t4Pj?~WU4CUs#V z?4iqV&J59NwoOEpDvRvR>Z?la{ylTqAvQG=sMdd>Vh{u zcBi&9nTC?T*HPxvUr-R)Acd}S5VMSwCT0$;c|2Ycr zo9%MB7nc9^ecCTrZubS-=6?3aP47BhI=8!-Hv}^nKVNB1W|Vz9DTBqT`3JJ0wbxkK z+jU{B4}GldGnd3JZjqdCj~E)qH_fuA|Qj!30*2cjWhB#5V{z;jz??I=&YV@yT#4(mN_kh+^5W z7fHvzq@bQC5IPL*vxl|HB62-O&be*;0##-lu+ycm@PH^Zm}T z8@*2W*M$MD$@;@e&BBS`C!MG-;UJWTjrjA*`WKc}?iU0Gn6`}Cu?FUxC;}o40sT2t z(k<8R>=ODZh7}SrvFGaITxQwLm*CT*}+wP*|Y1;#=NBcez81M!AAC@aJB_h~J zLNA~9T(Nq6_XHIn{rA*O)}G!k4U6>CDSrbO^fQl8aHLMWRKCk{ssRC(rZVfLK-%YS z0S>p3@PVGT?s`N$6PemI;o}tJ$jMBuhKS?nMItq`#(pZ@Y@I6g>YBp}Gd5@u5>K3b zAl}Hl8jI*byyhN$?>!3u>t$5=nPoouJV#L-Ha96~UrX8~j*Q28F-nJKCA_c(s#j;roQL zHZn#6?M8PsyspMp6h6;Z2rwvI}UJAEKtVZlqJAjp?DvM**~itPTl?}g(+Q! zF`W&kpVQls5Qb|}9b|>Sku#svQpJVAX>?Mvl98bx4bF#Ty{;P+k*@QX_&Amy+;XEx z9Fb_-F`N*vE)+*oNQA;F#K`B(^#2D!J-Hj*+Maxah;aub^g7GU>wcg(cS`#$Iftck zic59!^GG!Bx{8LkQf>0*Fu84~>um3}qt4?yrqLdvq8h#OjQtzgtWuRT_{=cb^sW~N zUE$ED8Fa13DK!_GP%gNkS)r~3NbDyX_t!+OALLUoG;{OYHMeC=l|sE+&c^}sl($S8 za840w)H)In&rIepFalDtY>6tdKx78klLAZSe-wjUhCj%;%^S$M&3N#FgC(amjwrt1 z$|=~sDvYG0a%*)st&7W}yYAWc1Fv%c{2(w!<|>QC(%T-nbXOl~5dSm4mU5n1AXDGd z=&D+O^Wv6`Wrx)b_y4S5M7Y1h?%zrCtWR(?Mj;m2;+QgO_%=3oDHhRgP}g~|^lb@l zjXqZ2N7I#nuzimJ`?lx?b`${rkXTULv5czZNH>#E5~qE%Efm(~W*71p+|n?u)R9-q zM>F=m7ux)(RjJ2EhBp(sib;7h&&7IN(SlO6D|WDM?Cg zChhSn(NkBR+IG}s^Cr$bCHpSTnneB?=b`mN**pJHQ>h>mry z3tRwWXylR&$_u{<>HYMp_qcO`i3IwjfX?ea2Y|+w($52I^o)`3$&5;``X4RMCZ1O) z1-(gnV(v45pfTSW2;N>OOazV(2@BI70rQRZ?D)ophCfseOjHsb51`WQ)gI?*Tx~Gp zyyl{q9l?h}p?iB_mK&KPI|W!v%=g&>Frntb*w^rlkwsa;vJkC+@*%|WCfQta;+2uV zsk6<-;IhNGhHY84l4LhbOT!tZ0ix{|37@;S2Y&GeXv-+ped+v~ z#E4`Io%FS576!$c5i|4H)1ky@XkwOiiik&uy0pMpn-G@*R~8h-e~8CM^nE_k3n*+4 zsb3yV<6jV%H6Ia}R9L$YU_|DTDJ<3CaPr=1)rY@(#(7Mq-}8o>;oRY-IL(5u75yP7 zSI&B^*or7q7@}=nb_$igFiNH|q^$orslf_f#6fRG&;1@Q#!$It3XAieCL02Rjv?;0 zD=X*?nzD|Q-%kQod4cG#Zi4ET>E`9tz;Y-ky-M zLt({hTD8%>kH(Rfyl(Zoakch4^bA&wEsHk%Tc1q%-k0*mhEI7sx5>zuK(@a=GWvOW zZg-U_uCI&oJo2AScl!B}*dX^NM;TG~+4-~ia7DQ;R2zNlSW=v%1*;vS3>uy(E-O?* z?}4uA4Uwc7JbDBhFbZxSC>@jCaxM!m$f5wW#{#-rvEla`SK()|cVunX!&Kc>JLIDYxHyXR!MIip z=|ca{`%{38E-AAvFePfSL4iXTIHTujnQN>w<4_D)=-PR<9}8AQMz%gkWp~~6^**W5 zDq;;mJfa#E1TP{(Va?sBPy&mA2One-jr1j03%+#KUk;A>;*8mX39G2)5l9nPzxXtp zLasH9|0HtWQer}60X`{k1rTVtAYgDGajN$|aWh=Mo>j6Q!{I^VE{S22s;gVU1Z&O& zi+aJ(bNTxtYbKeu`yRnS3%ndshZl6~dYN-y&m1zOH+mK_9<-Z7MD<@;7DzE9&JfNo z4m>2T9_HZyAAT7LYLX~KN61&%h?}X_-8I2aG*Ppty0ihCk-8}yP3^(r-XB{p&y0Y# zwv(u3U#!2f>b`GVT(1+6{@)2j0cfxRI~#8JJa-%BYV8qyyM!_&fzl2{PJ_sn{m!+Z z0Ck1h%h4qLdlEcOda{zZYN^l`W@bEbK%Ur)xSic-J+o}dHMo58q)h!nMsloO^Z+-8 zs%LDsP_RRjZ?zNLuG!W}mIA|r4mgy#8wNtwh_J4j|5@KX7P@P*PDSBcC+_p2m3{yu zX;6aPT%7lTYGjIXZ#;2TN!$63!1<+|{pjMz(7l2~GBxhH6~%?6nJ|ede>akfZcCq& z&B3sWWUmb!&^P#^X{ea-8Red#11kcJcpQ}R)G$M4wiThu3{v3v?F)U*1YCqvglAK9 zHp--gOUx)%v(L&A;srvMWq8#*CkXel19R10Olg_GykVUIZg9|ZzmMn0i0?Jb7jOR) znY7}Tn}TC{N75&Q8mi=s-a)UC0+-8r$o;T!W`G9dE4RRLX~qq->yzJYKW9Q>O?x=` zs1z#P%{YsruBcbgmQICE5eqoTo;bJj$367OW4+*7r>TV$JC^1u+83+6^E_|={5M;w zRly4qk|e^%rD+P^*=crOobO}Ra?%ce?Hziah>lQWCryw%{8~|9eNSxf*T?o~zG1lQ zksg<%Vmc%Z_qj*nE_3lk_lS?1@54To-OCo*34QT$0gT0W-q%im1QE|wb}62~-71-> ztI7I%P>x&A8eX<0HX~fOxgGrV_9diAJrg*q`Le#|t*5I&Y zcstcEl(hQRbj1#?q$vm+mVZgjHcN|A;wi*_i=&;8p(2>t`w(hEjTyoPhv9*hZKAcy z5%VLfKwrT`S87T|hnj_*&99eXEVW(ZG>CBz@!bC3;j8o|*`wu86~3u*fVUF5G%!(t3Kl=jpKha|rH!n@RCfjf&k@TpZ^C7r zH*)L8CuxY{m2_`s`>n@)kVSOqrH?ED!s6 z+4t+;=DU3)_WKuoor3q4D{s@nwg{q4VY4EJ;F@~I zG$A?b$Ty~sVXs!~@xfo3cIx5Pj@|IXOyQzpolHXv<38TXO;orLVgm&j>zJj=b+%bI zcFR`O8ok|LMFRpbqDPNcrV{i z-xR$cq>$cmCG{1*8QkCD>c^dgs4r&aY&zujv_#imAF2h9KW8`{trusFLS~ExxF++a0GLVVMGD| z(r}=QbysHygIsR!)@DdWzrCcW%#s#&O#qB%&S4_MoTM}g_wR~jbn(&v^(sTnOLMAG zyHq@C>_Q+|9FGr*;LVD(!`Wu0`^8^QgLUnMMMtZqa6U{kfstYJnw?6{ zjo>z%JlD5ye0aIzNtiSfHnE+Dc&9C%qd;hZy|s}~X@b}&f~O!Ve#}_aJyyR5+k@OM zl9UOE*vWG-8W)%4r}#tiERNv{^nIDu5!Pvb+@vWN6gt5=GW_DyUcEP z0aVJN66~Ewzc(YG#PmqZp|M-y_}v2-=8Zee&-;+?ouwK-SP`i|F{t`MHtX~VrW!{~ znl%GX{3{RZ7>Mol6_6aHgV$GK#66->OTh z5nJ3G0oIsNrUAw7fo|RZ&|xyMz*|LiYV4AURYnZIEeFIKUg(T*q@)e?~mf? za|2ysxgSInOP`?nmJokm(T%t&S+wLN^t~vmno8W)KrXY_@ydxojHt1pb^y5Y$D&F| zxhX5sVZi0ribb)G=CXndg=yVG5m?WvRMSzmaZJl9@q8MG`pMuBz#gq|9re;uAxtfe||GK0$wSK7!bR7 zpKGB^`wcAY=-%36RfZK$`a?(Q9pqDt9QdZ$r>io{l0}C{fq@T&>y#`>mw#OW5gX=* zCKBVStML@KMN(4o9Q2QQ<`G9Q2LApP16q3A^=u5B1qg9 zdaGAkQFSeYZ@5IcWL!;xL({GihBN(SC((9gYM6eYEqL>rwbex;{dtt2q<)h@maCFH z3w#jHuahHOD<NOV3S9B4ouELUXuUFYzGZm9b+0848jFli7ho7Tj`qbD8Kfs>Tn(#S6vT`<}i=K(^2JXOGCYR6n{+c4gf6ca< zaT<3q&%&<~jDFP`%*w`mp5WOiv1wf6MW@@9?ya&LGzbXb?=PZ0@8J4D2W{34(2iIP z5l$Wd`MG9;Cuciyrhlt*KG~|!M(e(tDHwq}De$v5tmw~fege*0-o_A;;7Psv3imm8 z?ais+JKDK>)Jh*n45yItfzk|;Frn0hvjY+}pGi!>AZAvamt8lC-tT+~bp4#j$m|5R z?3HMW5LN44gZe1K-UB*1c~0bX$a$Ke`mV@ced%jXE5wriauEC@E{B@xgiHY=T2bdq zv9{|9!a*t>QK{$?Y`dnhTcE6-VPB44kF}~{X=wNvR0`-!RlJ#TI=9_J3RA{>@@R52 zZ03USvM_=9e1mvgi*-W?`MSTRY2BJ|1I=XRTOEIF!ZL&BBy`~?CFJR zOwIy{WJ|pgh5Hw-9<*^sOn~m-80(xan?tBrYPB_FReW(E9B!snZ+eaH+a1zarj))Y z$}{FSuYI}t#-9lJ+;1gjk`{W}yt~a*TTLsHR^7PS2ysB`7aBcLi#OjsrhbUa;E1aY z$c`Z~=Wa{^XgOx#`nJH__rKf2Yx5ph+>=aJ1e(Vp@~VPitt)v4(+{5El*%izp}!4@ znoSU`4Y5h9r4L-eYa{Df^hOsLdGJeHJ2k-09=Iz?gwNXAD)INKIpvrU5hMEnu-zu@ z0V1kZ1bd$}IoI>*duVJNDHxa#ZsMojfRNy-JJ#`Cg#rbIGPMDxm4F=s zzVRJs{^%s2@1odY)Q3RB=027ZOuGwk#(hQPy6g^`iw!xZ(3@Z~!0-7MnA`SAfaiv( z$ap@gCT5(kwsB0db><#v2rSsk@_KB0A}Pw6oG!O>tX(CMWHHkWf$qi8Oo)m=iV%!~ zhp_eku|2XxmD=;PRE45NpAwDwZ?5wi$?gT5I*tzKTy)F;2pQVl!?iR=A9uGXU2;Ru zXM5HX=X($8A#xH$1-AYD{{rVtjBQT{&m*7P>ysmaj36bJmao}~MWh2HT9KS+D3WR9 zg^~URy_a8AnGC4Qscd$=oPBI&6Rbdzg2>e;u=MDWIJW7b@X)Ws^c``^Eu+o#8w)Uz zwJS4cT)esgR37|scY->Z=v8Q2To~<&RbJunJG_scFq6fBGA*v22nX$cSFJYFXdjn| zS82{2!q2^Gm^E2IH4Z~q^g*)>HoRMJZ&qs!Ygk zTUj(5dLx>RzplBzdDm&H{eGE0v&IkHy3;9Cp`c$G`O9(f35&tA2zA&*4SXm~9wx3O zo0E6}^7tUyX!J5C6Bw&Z3s{QW!#{ZY8FgHG%Jzd$H41RLWfie&xWJU6UEN^2PCjs- zN4;8i@Oc8W{ykbkmS!2{V-Jp%mi%zZSq+LXu*b zS2jmdKkqN%YSEx=NNfJjTJxI5v%p1y?j6< za)T-Wmh!B-{Aycw+bx#>d*4_fj4C$KU2713n;DV+{HJZ7>I1R<2sf|`*W&6ni-oP0 zf`Jm3WII%LI2MmaQ-89sv{<#@31bZoKW6{=6NFK3bafIOn`|Lt6UaeJSa|=mJ5#&f z?}J5hlrYvXr-4ATM5Be&`G}0!yR+TT=!47^4hw4ypg6t6LOI~7xKGXK$9;RKZ2wdg+So>t!2T9G+^%~rs4Nx38e;YT0gpg%ztCGd zVa>MdHD>D5cU|OEL0h)`!5&Y#(Vo7;R-;-7tV+g0+RP3#wvVJWQP{rtu19V+mbJ*c zpUVWZj10s2ssSS~P^1&FcRga%_~0+S8hw-2t|i)J2mK~Tk2Tn&z$N`0zx(V9aHv_6 znl{tycRC00qSwC-Ym4V%r-{1WX-;9QV;uBroZXJ_oQn?OX{Yt!S_RUekGtJ{c;(wa zft!*(wyO~KV2Bn8(kzB;(&5cTS=I0YA7iHdZ4b8Xp`0d&S_2_d#@P-Q{T9u13ezZ1 zP*`TQICbD-u>T0Yvg=^2aFKGf>&$bN?OAQ(V&W}e<&jt7obP)wrcNItipe|!S)QP8 zx*{}_S>y&VP7(zE5F!27Y5qd97&Mqnw-7}UPXY*O$j)}L?YnU(*}!kU_)$14{_iy$ zI`Xt^TkNzCVgt**^oHFHU0*vt-}J#R;w!h`jUW4=AHbP?;54=nw(K0Rr0@MWfVX}8 zK3u<(;aFjz2o^BP8id|phI#iUDCp52+7g|Y+&=-^7)^|JcM*j{q|!MNh#^l{rx+$9 zxSRLjLvMbgILt*B4+yzf;j;W*(_PT05rj5wzxAe%AinwW5AfSfUrrHFmAmeNZw&%x zT=>*W>eBckQ{Yn1rkaQ|&E@4NfU1$XAtvD}4n6*fIP22OFfr%^Zo>@wk^<)Lv`OX# z5C*{7#;%YRBRV6+R#Via`twEW%{TmKyEg00#k;xkTjE`M$a~+4t=1U;N3s}GOPrS zn4z>~il9x%ybm>v#jF z*2nRCKYAe!Ho(xN5e=u(nKH+m859|Q#}6)qRB4h%Y$2s zJZDnD!4`n+e|qSv-u_vP!WA5^12p~uirSTvLd{_`4#(RLWO@3%NWA-5 zy0IN!h635XfhNykry~sV82PPV!59AOudo=$aJeyU_Pb~B+2>Ay=ghKrG#R}qTmS3} zzcueJeO5(4BXr#Z4wnM@{?l8_LZgoGdg4T4;_G(qY$dQqwdoS8Xo*VXIgdDj`la{qrzz?8H34Kq1q@3p?Q z_jHHe>O}3g>%s%r*Y$Y zHlRL0Xr9-W1^w0^KHqEK_gzHJJSt}ZXpTI2p7OMbYLKu;|9k}@9q{Ncz!3tO{Pdc) zbc}-(wr^n?G?uw;5YPcFiG59_8QOh@d{?k9j zkKN`=-OX2EbB2C5e*^p^PT${p_E(Ko|7R@IWvkmHkXcOv8FR!l+roHKxsu2?Ym2!$UH{GbG0^xm6L%VLs1b6_zz}U5)VJf3 zKf4Uav^nTP@@uT*+n$YVbQC*_RlNATy|{3%4>ezf=g>J$LuJq5aNuKTZO3!p`aVSc z9at_6nC+ghyIDl|N7(%8aRQ+7qi&I-2eR+;Bo$j(&c3;J)QvM5lpChHYNA+)q_Ci~ zn+-J!VQu0Ue)p9);&!oCYfv;-o~sH2^q9C7B~Wb}>rn!yWkbmzp)?A;*_B4h#!VMz z^0ui#$tO5t(Z;WwXMyZ77HJl57W56L)+QNm<65y;H>&gI?;ro3Sn+RsjbC4z0f*KA za{&k(;FDj;ur$js&FI^2$>xKR0o?y2c?6w-Him&HO5u4H@+_B;P0R#4w}qG~be19B z{RMwMo?)ky0kkbeqNg{)-?ksocA!CaR2j> zdp)c~ISkvDS!s~O&}hSH+meCJc`V~SdBe7C1I31oYhE@tUzNqER*=;UcFEy%Oz{GD z;sD<0HoWr5XW*U|1fHeTiC*k1A}j>^0sQJ;+>B9Y2Wq>N%|g%U3M|ho?yO4T+6ICd zqeTr23>~}F7$1M>^U*>ApTR2v3s+)NvWPM6S>A>}c-CXk8ZBX9=75qStxM!)4=c_t zJo8UJi(}?CB(x{hEkrS4<{VuYP?xU7CFg9%S?vTD+;=bX$q|v=#hC`v*@0uZkH3#B z{J}>)k6~{+)=QI#-y#F#F>w8u3SP47C!?Q*x2m8s?1TlssNm_QxZriXmT(VPxcM^@ zH7eTu4Vho5AyPC%UIzPB^LwX_wY6c})K_?rb3f9naO_9E#rqK(Dbr6quUWA+o2FC+DKF;dP5@lJ# zbA5?6mMM~q7Q!7wNiJl| zmcxw-U>OF|h@1>dcpN1;0Ts|~1IZ+T9R=9sS=grQc*hHW9djC^=7*vVsA=A7H2LX8 zs>o|*MVzkQ@>$y3Z$36x;3vKUn+5$RzQlK<7x3@PoCTbRmT7~VYh*KbpBD6ZLz~^z;bmlZODVeNgGIXPWYG|@S zU_l9Ik#tjpkTXT0KMGu3gO!XipDyDSPdF8)7{FHE%wPtmz^5-jFz!b(XKP>DMEbq&98%(jD_*e`E9G^%8b1q9l>L_#Q2Rz zo`Y@`z$zl9hsadA)GVwR+wg|Z--@p+rI=X0B(Ukaha{Y!V^$bsYdE!D$IqS8!^QVm zgi}tSS#)O8pmPgRnpjaBeEIMc@3`^?98tSajJ{;3b7N5|HFElzbFNPr8zFd-?`q1m zO0g}qiDpEpkP@leCb}W<};>r6KrEa04<{{?6}_8>4@s0|;g zVTvQ0$|A_{6plmk9G=ZlNHh8%xr`W1OSgpFIGasj+bTL1Q`@F;9g~cXeN-wKa=ocK z5aT|gAF3iwZXB$OV_uS?>$j0{uFcm~OBpMuS&o7xLYg7Jn<*v~$C0EqA|V`$FwDi! z7=z2#jNth$0vN?mAq|jHOxYPRAx;$-e|hEbXEFWOrswCvq_7qiFbnD3M#)u^Pp%P`RT@urW|-Gy zftKeWoQ7z(Jxr$|S{)B@(g3k>m+>|xRDi%f)1@-pa3W-ynhFGL_B*qoYtbtL|4NsK%5|qQadqo9- zp(E5xU|}cj_vB}yuonQQEwl5us!&>nK=xW*!KN-fnWEEaV`X_LGBAakq_P>8HsC(u7qO~W>;kH&94-dBAXhO9F3xou>>J=9xoLYa&2i-Y-xy-1fFd` zr9)wvVy=v__Vq8~rmuVvZk|C)BRK5P4HZF538e;$e301~qv6uK5nOx8UF>7Oc{+En z71%83cd!d`XFVLHclIX+^V=U?sJ7gg4eE4hx2_c!#t8~%9+BqaA39s+brA;qZ_aEZfmjJ~v-7mskufsUP~?038ET15 zRfUxW(1*9-HJ986Cn`X9V{u8GI`Tu1`sMGv2K(zdk%(z@R^!Y%A?#{Htt4Me3QWol z($vr?A{_YSrC9&Y*WqPTw5iEvQJ~RGRbxlKiuXPBe%M_su~fp=8m&3mU1gde_jlru zvIj4E=f`oK>Lau}viaampX=ZyBMc_D;HeMTgT3V;Jmk#Nk!pSuG*ir=*-e3+mKJh% zH@@-x{pjyp6dgD-#I;J5cv1HAiWuyeI69o**d)OBZaa*dj;-OyG=kpkBOr5MF_0L( zu-qAALZ&%&>=bFMiVcO1dRl=X3nYI$w;fDN2hh5>?~|SjZDBhi&qQL`h#8BTS5Y6L!zT`jv+I6juJ`?n5RCv4d1-P_$#nk(C_%y=nj8`yU%<~H_!4>lu^Re z7MRgyH&&$5`KE+zjSkLv+*47Xcu$0GSIk-n)h2N!(5&kMS<-aoF5yeU=V>b1VBTm6 zwKLD0qfm(_rv{yEbJ^sR&jJ#X4CGlVVJu0U!Z&o0glVJ0c*+H5;z6e>h;!iR1bE|L zUIi?iD3PXN3aw2ldB znvTxe_4wp}zX-eXB^1RJx>iDEkO(RAx{j!JF||*^%m3%6ars0=KEF%!(*={pY7+x3 zLuY&w9)I=@+{c|@i=$x638yvl@N5ODl0uWsjD{>>KD&oW5koQ6rl1#!B&KQNq{I0( ziHN57um_Ad^LvP>Q`c?$&E=QltABqTCYpy#bLBV~-%6kO5)L%QZPuNFf4xMmX2>-i zY3(6Yd{jygPkZI>;reI_WpOKpDJ^LM@&hSPGZhjW6T}pibHhRio$PU~MigdHh~(ro zJX1lDXOhmQaMn*^9#IXEqY-K6Mqwjt#js@t&gxEU)~v{&x(-&yLu{Sz3+H0EI)-H% zNE4y~nr!~WvtP_#98pT-MFz{zktPX9++=7E*Eq>y40;`8S;PEKxZxm4GvzoYlMx2P zUHnc9VH9BC8}PFUPW4QD;KeURn;|o%iX<2zW7awkotXi$xixTjY5zAdyZU~2_z(2v z0p4j>V6&j#X)oO!=)kp;zc#;b?*rM>8mcAHn(RE;QRTdu_UBQZdQY7G^N+*Cp2s@5 zVk8q0VpUZbsv&TD5{Gac2W8nLWwA5mJd~Xq|J$}Kgkc8LY2ep1E70+EBCVJuBv6s3 zUZWuFYG_hK9L`xsj5>*-rU4v76_Qk-`Y5>h&|6SVvY-n@Nd%o9>C+G$+eXVW5Y8CI z3B=h9C$)53{_6jMelkIoOyF9CwHxO&t;nz#-iD7{av`>+w?S1HfU@|7I`V6v zcOJ9KgiY^s7B`&zTm!bZ|2r3q?^%LY+ogouWb2b$MNrZg_Vt790t2Hp5Lv5nx}}$NbtDAAZ#<(J5m1YA*WqFb+^_s=TKO z<@5S29X{|L%)a-WJJ6?h^HA=e`&y2*ecPWbmyX9`gGXH&nW*A>s4BaQp>B7ye2?!(XMhF;)O9HZA}wkmy5$8m$A--N z-Ij|W&Y*7P70 zC6+Fe1up7lc7@YV z*p7im=6)gqZDFH}l^VNBGPkMZZdmTM(h%E7`fAEKf4=d~7d)IA^^M=B+g`3&2s8&% z!^2p!Fw|Q3!}t9)j>RfwxeLYaLoWe8=^Pg$H7C(SlG5G^0h3O!ZvkimMfjJg~oHBVDY4ZOp>Ba{eL=x zUY9U6&Z85QnuGqFkE2ItnCtqmk;}T$ZkE~S+&I=cliyO9b|X+ECn8E>sJba8G4iMy z_}?M~uK|-W{SFwf3#3&k;}zFu&Ojz>$4#fs@eOqGcjp?q9UEy`V-`-KYBf5v(_LN1 z$~XTO;WgiZGaF*IegunKdWfnV>yt6sZIUf>=yioEneAOZcqKWRn~%*ExLsFZv!LIu z%X#;EGG}n^q0V4&2X*8up6P5N@tw79VJ!zzt&Imf_1VCllSOYn<&9TnqD=Qvk;e&4 z-dQUJhNdB8*DrghaNdcC$lr3G2+wD25F zBs$Zv3}M={c+*sc9`fwc6%v>n3rV2}LtZx;*;OVgvlA{$F_6{8VfMLPl8>VA}kentv-U`6#CYaaGSdW z&-ueY$Et?AH9 zMVR5u{~cFzZ*=NpcM*PQU6;@%z{ zoGCCm1DQQ@J0k9Brh__d$b~b@MAaJLk8rhF>LEtaGH%2z5&X$s|nd)1ktt zRV*Jo@TFw-?F;XIcZ0D?Y!{jPzg2__mOgAe?Hml&ZsUl~1 z7o|;#6oxd4rE_1{>Pg2Q1$25NM9*$ok-0p(u9`s_1vT0xbW0=h4K}h?9ir_xGE*0} zbT~$<-NO2KjJDTC&F~Qhci28ORB((8{b&VW{JocAzKn&J&*)5sz8N*A&k=T4NAccY zeh{{&hsAQ0X=BnQGbi2hQB{o#84-%ZN(Uo%J6`$DPhhC^FxEUoiYdj#i`Q_jJ;8;0 z+vpZ+xZvzl#eZFAj=2yBEU{^&yBh;xIA)My&H*%s#L+e`QUCxT07*naR2$lO`8OZ@ z`UWg7i@y;aD=lrIOTj6V38FHWd3s>>G4*!fOE;|G(yv^Dr37#~^BAQm%vKvyLePo> z)wGc+3TC>E&T03>y)S+omN}Cq#GDf;$>Eu<=&@-?$Lo<0Q>RCJ+ZNKAsryYz910Y9 z3{5FyBUUlI$8s9d zNQx3Z4Ry*Ci)xM5p+mUrEq{asWN?uo(NZb?wPvC!3b>^XOQ~^S|4qM(?0c`f3*R0# zPwhWp1vU%%f5M{Q6<+eRGcVpZ46++-w-if*@u>3D*+ z>!ZpQf&GglLrYK5ogT$k-t-DAWRc8VSx7C~tCBUXrZ`Dm#=D+;7ItLEB(76jQb-kR z`G{w9X>&NmZoJLgb6AZvl;#3n@|F+apuYphR7;-M%VX?~13czldoW)e!}(|LLCYs& zI)cIHrF@LeZhVQ2toNpF^W#jmdJ}CT`S0?z4Fvwj{}%SyhRY5KbEJ>E7Bb5~EdKQ- z619y3p@r9f@PA{?--YGcMFwoF#LFPefoG#)vaR-UBUqq2~c?%G#Xz_)J2r?0+2qGVlm>c`gc zU(PuLJE|p|xwnmNJ6ou$6h^@U$pTERJZI+rxTrUUe4{fb4s!gT8}BZessHF}H9b&8 z76vK~X!4@v=(JlS?Ezi~X~`M00<$(zsU`wdMQA#B zT7Y6&DB?n{b5>@^vI4%{662U@K0~WBK$I)6E#|vVp;W1$GjWu_^18y- z*NYrR5yPuv3BjR}f1*(;*FnM!1IGs%`2h4GNCo02;f=0@eGPv$3cdifq=F#4fS75WC-;uA* z9rW1xyB_d(-y2*?{co+QJi2&5T`>%+INT)**x_x^U5VoC#$uLx)inNT<_=@J0BOIqSNOf~F{< zcUF<7xe%mi3?gZ7RUus^yTl-j0gXf`6?K#g+*?}gYD;*Qi)a$SvK*@EMM^~jdJhcd z_&%=t;M*}MB7u~tAJ@rtu5y@qjgzfayzQ~Ov8PxCGK%mGI<-ilp)Q=sb0qs2gn^SO zcMOOb*V^BKN#*I|dyA0Rdx&qJivCA&-$UO#jcM6<#?|p)R=(!>UEwHPmDLG38 zXXTP%e!Nm|5Wu8|KK|IScBOUh#;5*xs7am0P};sEqh(1Tuz1^P$r^>Zj-s~{H>Mt5 z{TCm_vb}(%Sb^&Gkx?dB>#{*%QH%XH@{>-(1D^OyO!PjM!yMgy3$s}Ox8unMs-(w2 zoWfuZeNl*c5L5Ezx-E>R6X>P~MKduT2N)PCI>|D2n=zjL@H4P`5mgg|0Ph`kHb^8+BH4*-`Pm<9jl?$#q2-=j0D?L!r}XhnxvLLs=GZYZJ*dg00(7 zbPbiRp-?J8dAKRENt#PCx$dTA35-rsANA<$>^toaqG^blK|4xrS=;n+5$&cERolhwIFpduz8n*rT(n zrVgC3o5c);sv)*}nCTrn>_xA@y4^;sSxCuVCez$hkVOTMaQ5koG>Gs#QSdxu0-Gk! zMXh-hCeq--)J3bT#p~#471H%poOAkV*xJ=WGV{t5SXvw7rXwQ`dQmxT1R{%~1!yJn zbEc)maU|VYnJ1`dC{pN1!W;|j7Lp=Hf)u@e8~HeZ!7vlre=3H+<3t}?)fDNC-^F#8 zz6T3ciabkTQ6o>XmH5h6IMrIiTOPX?Clo8ta=VG^G{_ksV}J#On~Q>WXV46`{xO*2 znJ|S@aWO+1)9x-Dm{$10&T(YiP75Wp_>7eIwT7 zM+KeuJIl40yZ_?{#~Cz!kz}-<~zuU zZqPL@Pi4LyB^mgh%JTwRt%=|J`8zE<|KW3306=jXC-5r(-u#7CTr(_%r9UYZR2xJ~ z({NCx87yAIl4)uxr}Huww_FENMiM2LvKAwuLd}q+5xS}cHRC4Dl!9ODDhzH8bXAUv ztbT^t2yenr<@NZtQV}sykw*pMIE3!%m~ZRwf;D{Yuig*srdzO3*GQuo9G}KK0@jN* zK|_kA!#8{y)0-c4NBCTC9>bk~1vU%%o&OsCRL`I__kcIGJM+&c)0z5R7ATU3k~{=j zOyu%#&x@akdf%DI`f~^iy1G}eH66CCU^dHPQBz*kf*6m>LJIGk>tHgOLAQKzzK{k( zkrOeQtqP7|e)N4j@}9f#qz9e|qW}gQh&FGJ%*sAA1+KV$h7VqK9jxsqLGim7jsiG3 zb1_r|Q7pVL+fk9EDZKt1I9iQHQ?%M1l(vTHXo5L*(rJN8IyG9QR>-F780aaATfdL% zKlUN?s}M>#7Ju-n)M0ZT9S1nWnc|Iv+^cmcaV1fll|kuP0Znw{N;C7O4?SO;pFR@C z_?i`&+Vm#3k+b_Io1iYSvi}BDX^0c{?1JSuNXaXpdm5{fq{wilPEo&xJdR;FhRp7B z@)4--Pb=tLP^Q3$E6YYUsZya}hzRjz`lTx@qLrA_G-UazaqLlYq^5-#Y$Wa$eCJSv zH+}319J2Nxb{CLWxyaCzI2LdEHM0xr^s{mDMZbW&J3zqetT4ab6_TMV`QbSdB@vxE zM=g{&7G)C>!#O$$g`Q!-kCyQEXP%0^0QzDeRHN1v$vnRnzzg4YCGxpbklTHkQ4@_B zj%Mg}d_*(yA=)Chqw0ahcQT!dlOX-u=>spMf7!%TaEcU463Pg|Z!x%r>t!IRRRl#1 zqus-F7Ngs?kR_40z`34}gquewu(C?zjiWk(WmahC5w>L!KK+{4Vu2RISqPPqLXH$t zZVDXKc>+_55J$sD3=e$$b3fG&^5%=(C0Agxpx-4g>rd{awx9HS^4_3fc>%a#tzXfBxgHfiz zwR>VKItydi7QMAg6l6PBCc-$uVyBI15TLSE*qVk`QHvCex!(tmj-|u1l?;A4MS9b9 zxbc%8#iEu$EhkNEDml}6j*e1ccXVV@7UAJL*o+RtJ zS_UPHp=KE#bV3I&`WX}30JJ&l=cq!hF;op4Wm4X|zlL@HBn&y%uN8Q{hcL*{>6(~M zLh*^G4?N*+CfX5uAhFKSEwLIV_d!jD=xYV`&Uu)3DT$k5ZZMEo)q~4x_|{E_(b;(l zhKeojhKiEG;I*a4f0n~`d~p_ZDw6Ipl&*?2o55FAY$YU8iAIlh{^B6e%^re6YZfWlZT1A>Nv#F%rZQO4Ky8 z2c86^fX-=3gnapCyx@uV$A#O0{ze39p2qMk(w>?IwMqUg5JAT>fKT6&DxH{aw5JM#Jp;crT>$`T5h+RTop+^Z;|7@S9M$gE647m_K9JfK^hF7xFyoWg3^ zqWhN(fiUou=H)pu*~n3+AAF;NYdm#p&ecWrrlT@^OmYLqn9KfWpTUwg5Vtvnx^%+P ziiT7*F)9u8&igr>`m>M1#B?yC)|>DzS3QNz02ng&4PR6bW*x#PQnX72 z7oNO;=RHWnc9Pa)bkIb^u0jQe&d-1UcX6}2h`6;Nug{SU;eYD+C*yS?*wH{}e!hcX z8p^Djya(F(PDev*F*Dq|li-<;JQ>>uRm3SyRV;vh>3WX5sxeW0tO9t!`@W5XSirI|Kw-J?Ex@K`oX%lN ziAkKwW`JERiBpr|61H#OiDSp6a9Ta&HB-TIRAmCI3~-{SVd<(b;oxPTMz>7m_e@iV zS|>mjz^rm?i}vHM{@X*aBRdF%#xer03$EKV;^J1Op{|r4&9JFw|FJgxN9Hfb3%Vqa zl{CKL&f++{u8PmRknC(1WfVelOjK1Wg_!0%uG#^>1u%6M|oySi-I&3p{~n0(VdWE3XXhe z64MHo@U*k$@WeCcCAh~#B4Io94M<^<1}O#oO`qD2C2bx@Gevag(>TRoaUQFyWB3k_ z+eAnh-BdZ;kquiMYnZ2H?dmd4vUB{+TVD=)vWm81f%J(aoeIL$_{19sb+j z{1^(#@Y!>v*_r$+$1=^>7V*BoWkEW(aZj^Z{(zBBCCdI;<)56ca@TIsR zt6?l|$I;uCv2|g)bbx7DAhRIz)|N$IYf9A`4{bxI7q@}oa452~`Nc)#G+9eBw1@lg zvPYbPhwK7qxWyiSBX$IJ&6y+%CJP0-%*G%-gC|}3N3SdJ{;O_4Fn|trYKRQA#}ebkohbqVYKHF8ZE3QCE$8Uk^p9zz(HC5C}`S}hGOIdix72ADP2nnZIek}%fE}& z8}Zot%;TB&n*#;gy#CP-p>|&1cYS^tm)~#{_Ks7K>y8xiB2Ptf$K&PZPBScwW+8h0 z4&nreBhJ7bNotFO0Na%bUh;??IClWF3!tx84YxsLg$9msgQ)@Uy=sat+%&{UX+d*r zRJtzy^t8`a#rVjSj-GE$J*TCThA^?<+B|E(nH*O+L)WCAd>{@-L??*m zgux^j9sEi&SJ`}QuE4)@1vU%%zjNX4ZojtUtjFYOlDE^nN?Wlb#~>4|6H zK97G2rtL1GTF1b(kw*=b&f-}al$KE$#R$t%Vmb+dGHO!ScvEX2RYJRzuUU!#^|rsq zhkxyCoJ>PAi8Ymd0LnUtRz(7_B9}<6X6DNqEp&{bO9%JjKf4wO^e#fD4bOH2QRvV! zoG>*R^Jyv@iM@MvVYE7u0`GWC-zq2_hUXYi(jm5+Ij;DV*TafNaI!+|JL5@;PN#z) zBOAYfYiek%U5`tD?LzD>4?)XD;_jxDrsR*8N(G(%^rVHeh$~zSve5s33ObPwo+k-m zT<_yXF2RJ=az42F>%)fsRN5pU>jjg(E0zfvX&u-NB*OiTLH;`*Q^I=ipdvL2EH* zy+lQaJ=FpHc}&_{aNno@I@as~LfD9-33U=j<`xCVwQ-Ft+ksV9JL{rQ3kiQ zqoEbwjBY;06CZva`W_Hgz{kFD1CF4J%$vu0M0p}z2I&q*!d)sZgQnwRMy5Uqh#I$Z zCTtgoX9Zk`<}xY#=@MRa;feUUJ%C#SZJvK!ph#0Fwuk8kqyF7r9pkE{1V<7Ly6eEA z-$FnsqJ}b!VUm}?AfG5iivc=GfeI}_2RdyPR=AF{+7&+ZvX{V5O1N1JU5mx|hdJ-u z@Kt4om9?e!mBVYFes{aKY@YA!w*s35{q|eSyY4d$&iusO{Psti?1UJQGU#G5UWLV+ z@Z*xF^;(<>N9Abi9eZOZ%kS4n3<;m!Eh-tZN~ zTTepK?o09(bF8bf@pf-{bhfLI(j_~0!asCc!X^RQ$_!V0@Lh11Z$-O^ zL@8Dm6O!T@UT55++qZs%xTzSPE)J7AOj>~HAS4n0(Rc| zkH+WC8|WW*=tM(E&}w988;=cn8XeVW-cptHxFKg^wNy9lN+ zroFv*?WJGCmBAc_)||-a2)k4B?NMPcOOUv8IN|(Xgny3*pkCa9m9T=z7*_i4>!zHS zQY5G{Mw|K~-{aDh3SIkR^t%R}Y6W#PLf7!%+AaBpKR(8c(pL3~<$l-Vb+a z3HEFRi^NK9j&hzmJ?syY(6$u;NP_Zn+5&fwQP5vUpnFJ$8Js2{D@NM zLa<`-plch+Vqp!$b_cWC!g;^*`#7ex#0Hcyw2n(hu0&GQ_}`*2*Ho;G)`XF5(F>gP zbQ*q0oMno-Xnfp>0MjLGK0b2i;fM9- zCGWl#$E-fk=_3dy60@0=h8V_>bss08NSfq9JJXhp*<=mAUZF>RKr+FX-~C6>jU1if zGQ2uRuF^`@2ACL^kqAa9oQXIHu|u2U5sL|)`-n5)CrjdRrW!5Tz^EXBu#`|2fy>2T zo$xse`ad^-IUb}U@cMC^RvssdIsaQMbF+;Y+8T9QB6kKD_x9i=Z+Sls#5R`UBJFO$ zEZ3odJOUln^eEbTh_)J|mn`G0zxG7*k`n%jZb8(@7Fio$|k3wEm|(mb1CG7>_hqNJ)Ko}pV665ev<+unpO zM-$NydO`jOy$NzD=tQfI9y$1VOs~J_?sJFPJk#5A1vU%%?YWwF#pi0>^NWkyc0bfm zEV-Yv{!u@ym@1~KhG}bndp-IIsCM5QL&ZZ;X_6aVRq;leSt+V1VOJX(bZNC!7$it> zy)4dqQw%%{>#OTfO$V;!pbTT|tjBofS$#bE3|l%VgGGfzHbTu3)A0{It;HKM*~vLD zF@bmeZHP~Oe+6T2i$r?5+!4?x%xNJK*0my=hqcMNZ~-_AWu7>rH?!q~xZj+QkG%dB z(DM<@i28I*X0Mr&2{c{REJ*Y!FB==$S%7{q#A(_Re*ak)V|#H77RYjMvffo{vcxal zkx-EuG$t{Gfc4{#e}L3CzWyUG_J6_s?YLIFqzAc+BXtLF`Ra}M+>K*o?H!m>J8tNx z$RQ{#=sXT3gsvt~stkjC70*5I6kM>Ug)RK)GD4YE&`5NQ6WF%hK-R3hlIhZB+OvAUEybP6lEHGH&J(P$eLIM;AaWSTYiA6 zKmAeo;R?)j)?~G-H0P3l=vE0R5uK76qb2cBFEAVexfz5*cZ(6Rq-w*dsd|2Ib zdeQ+Zgse#fa|}%45NW56$oBEj7rzXFvlYV>$VvqUjbV}$Nft|p1_@vcM(Zc z+$Mxk4aHT2H1&(We;Leu=VA>$W;q?nLeyG`QZ<173ixFRi{9I%A<%7@&ET{tU81`gz1&qOC81;;tzsSR@%QnEzxo)Qlpn>w zOi|E(p2y72I?VvO)d~Dd7+sa_R|?3F&&K5oKi;)-FY^B===^=#!)ThIIN>Z@{JIZd zHh&scV+)nl621W+M$v4@Dm_D`6f(!|X6raPIe<4`@{8CK9Yj}&CGwPxcDcyFh%C@r zmIa+i0W)F911EHCVQh5qgSw07zvIJL>Fz^fFG}I9k{ni6iVbgK+Q?gNjMEJJ9`%c` z_MD2+=^(BZO!FL??ZBeH`#7NF9WHqb^!R6XIca;8hzo8F4&J^p;0*lqSC#IyM~SB3v(A?M#DukmqwlXx8Yp;tnw zsN9-~ff2Q3{5P-0ksA2oEx@H$UW>6d0Ng&Jgt_$eq^Y2hCy=EubrYs)z^{OQ5#zGg zzZ_P)j5&oq2F?4gEHc@w_$>#w{?^?2Z2$lu07*naRN$H`klu3s-S2L*dB*?I71%83 z|Irn^3;({g^?o1icNZS3R=RYwhQ_?UXdn5)!8l znwF=v#C)n2yQ^I4f?1I*T?iQndXrU6JNN0zGP>P9O5G4fG8_3~xPmR(7%zFk$vD?) zbm&GIz!c_pQxF-)T1h!?ZnQ@Lyzfg(xN^C`QrSjOn`m`BBykLdjCz>?r|?u0S(=DN zY(q+7rg;e~5239b#P{CwR&*`EBy*f@V{+?nv~y+ExPg&8S5_`i8?ba8Rz5^KIf&=} z{5g2U7NDQ6B1j$g4sG%&Zo! zSdQ_MPhE|p)($w%7Q`txQJF-&s--QKx4FTvn1;>n=Oc8EU5)p?_-Qz$JcLd$0rJKg zm9VvRS)NHGZi@HhHOqLErK31-9d5q#ofzaZq)9F_^psLUm);IBqIibUfh#ue&3EB@>rd*`Hs3Tqsg=D8 zJa6ZO^M|(O_k?%^ns%6oiX0P0NOpUeXciv&JFmfVX(CN16m~J9G_2Q=_(~SaIM1Lv zmPBImCdmvGvfCBIfT}l%YP9+^nOJ5R2udA_p(9NaI66a7V$?^k!`px3Je>4H4{ySw z?5ODLQt)?~kG=tml9kMk>CgYq-y-SlL~3<0i%Jwk7O0AMEHS~MsY}oE5sXLBEmJ(+ zrKnHVu~RScwYR?!b~%F+j3m37+1g5}B8g%+W(`A$kw;6gT@SgjfV6hRH{7TqIORG{ zG^cpOlOBS8yac@%qgAKSxoasZ6sm z|Kd0BiJ^~MiY^>uAo<%W%MJ^3k-r@;{P1PCzVI>Dwj$0g^ehLX$r_ZdhFO(B zHGLGCi#&6H&Q?6^m!5-TriC@vMWWjJ?`da3PaUHrH-F8^AqbQr1uEh|M*mgzdy*=4u;SUl%}SUm4h7{Zhu znX}8%G7%&xvM|G3zl$hNp;=95&xcO-G$C-prHYD*jN&Xs$EKHeiLg}Rv|E^k5q#f8 z8U@g1%NRtr;aNX_KF-~vVz3d^L70@!%5I4R$3On)&A56!$69G%Zu?FQr-2k&o#8Ab z#j6@@*OJ#M;s~y7iCM{bxQ_Yxc~q-Qn11UD9R223VFy$Ak}7IoIv%6fa$pu2+%kp{ z9>ITq>Lc*^FIV3sOcd4$Dc8IL=28=iDV7mM`>6^VQti?ACnI4x&QV?MarNaR#b{}o9y6NJF< z5UVbZwYTC$@AxooGj?LFriVJw5Dyt*BqezVw(u)-%|nhBQg;D+0KGs$zaM-ddS{-A zwOWHp2RYS1MDrlJ3P#PSKs5}cc?p~2nIwnXH4rgqhhDmbsp;tFx1>W4qENbBrEWGI z-s;VG->;mGJybR5fX-ShyC-7n!h&P$U-L&;l~hYuO+go!qA`8pcqF5{AH!_YNYd6A z(;GsOf=&YF3*UVMCgwZ_^L@IM4rXhG1i$cw!#u}=nnJtw3fyw}r=X11 z;cBYz2$~r>)de|*!(+E3E8pfGcJr~h0{`k2*evM(>ZQ7y{h+z`+jIL)zYp6Un~7+& zW@Q2PItWY~0dzd%S6+ad3Ln<|78q)dI1N#fmeBBGIyq<8fWFC9LX(}8P{Y2 zvA~-)Du#?CGa1vCngfH8s1;r0azwKLbDn`d0z^mlW4mYLl&uT+nX`6?e0mmy`10R< z8{c0`(LMDX97+u_8p*0oJyxVM^`ajJBl4zfp5Iy z&FGgA%sfGnrD7sNyw*_*Az3ZPV?6h<7hrEc5$n$Huax-2<=@0wn2DTfoU$8C@#~K~ z3+K!$*iubkMr$xxG*|=8j$~=+kzH2!#I=X; z)@vs?=In-6x#BloYheTE*N#Z!3OyIK93gW*o53C3iq}5#Vw~$uFjtRICX>ddmo9Pi zE@xe3_sio{DbeCKqLz)46m)KsY#$TTLpZkwPkr@!FmiVyvgZ)95KS5_5DOy0oN;EHmvIiqv5^o7u@#g_DBk?c zFt;4OUmD3A*HK7mnu9@f6d(NM6R<-zC4g1{9+Cfo90nG2osqW zYd19$lhF`e+kj;#NP|R#>6vR|nAeE)mO$uQOvkPU6|h(+qDqiwO38FxL8{gg+Ck)j zU+Z9o8^7>rM1S{9^x_#hEdE)H!ZO5Ah4HYx=~4XR32i**A!oqP4#Bm6{cAHUoOCaI zXa7-r@H1b-fte{c{4MG_{`dvYKzn!y?UJ|t6lGSRa>xXrjcJpk4$TrHlpmj?H^u$9 z;mmO|+`mxJHbu9d{THITcX&#)LA z#Cu-)EOb||#h_e=QgdTPVHvNE0j)Bb;38csi@B;14`D~!gv^kTM}9(S*_f$5g7zM~ z@=rgBYo-oDXAudr?#WWGNW|lbJD5_ZZI~!D6GcVFyp2;H{BZ2N|3h#?ULv+FXcmQX zjVzF)!cJ`qnX+bHea4)7P0$mj$z^4Qyv&*zbeYQBTGuQr7VCJ{88#ksufCAp`0|Wk zCA@8bZgM$P)fE(inFNhLFN^ht&w;2JDoKlDYMh~p-<`1G*`d=<;oI+PL_$?1BYpM8x(=WW`IYN+QLCZfvBw{>5be>t(oWjQ$bJY#xNhqUFhKbm&gCGyYcRW*c#8HSX z%;27^OIhr8=MV&$7~NFC6e{_6Ek_dA7}M#dQw-7-eEscz3@=VFr&Op42BN44NYWyi zE?>vC@DSeigoj~gb_5G&<>Kab1>d{=M%;Ssck#LxJO!;_1ueqE zRVH?$rZ;E|P}I25qjOlnB62*ReB%?z=CLX2O+ja2Yg+t<$|c_y8CijXVK>eI>(&lD z`vX_urg|RJ%00OaVfq@XU?N>A zITI*Mc}+0`5_p=sJksUqUPoF0RS$L}CxeMl>tkqc!>j-Da@;sG5Ynfh;LnxN7?uKd zyR{eyWl0K0cQH+Y*zF_t`yy{MckRNk%CH(tBv=Ne1~q1?n<-K_n!c1(qgOW#S2lo* zJM}$BV0doSGV(k|hjD6k5YK(WgR#wTRf~H-E@aC+qz;-}YtPR#>PO_Fy$rVYq!THYbh87z|pd(?qxow$XyBS(wEUbftt} z7no0nxZ<@hMb7{X@)qh6jz!P>0G6p@GFll=kA9 z`-ZJMcAQKbO`DqXqDB_g(EUEvY2DhL!$W`bWw>ia=nSUiAT2bgx+CE&^jP;@O>(2lLJ@5;4N1rt z&+-s0mz@mr#`&xa2dR*h6>Q%{z^>lVq<9fssFDI66XN0&mSbWV2WYigm`$eG*S2xZ zrGJjNhuay5 za~?MsjEtk9h;KIOU)&T)Tr5p#=uGXm2j~8_obB-&dXIC3T z7F|MAN`M*O%ttr6SS~xbMc+Kw>P>j_ zvoFB*Y8kGQK`#^NN+BC4=~FAlg_L>C!UMU#X#O76J!s$)qWJda}*R|2;W ztu6SA&wmS-->`yuzCm$H%M)k2D2m`(k`)KFHYE|QvRq7R8oC%b<1KfCXJ&OY^Px2IP^NJ0!Cf`k%KK}4|-z#xhu3aE_2 zUk3*vAPWSA2^K(6u`44gD4;-=8IeQ?0TPnjBsb;Oub#Sl`}_Rfy>BjUX035%l)2}u zb??_s+k3y~{k5kg30>1NG0!o)mZ%LK70K(&28-u08u#rZuI+{ql5*1D+15$#`z$NMMljq%P8|BD#^n0k)kaENIbU? zrfX9gHBH4Dle+PSM_zUpkF`RjT zmZgcu`aBC|mb^<3anAf6yzYCRfGgZF=Ev`czwc~>ag36)T+@)vy4CL^p0k!yymAH& z266G@)@aT`yEpRj8gUfz-fR98?PKKASl>?#zLm8pkE0Ig59OnM>aU?Ym87@rQ7F9K4KEgoD;e>O%;Oa~9?DHH9iY+A`Q|4mI zLRnq@C{A$Hkh^Q}u!j04 zbzW->sKj$=sfQ#fFr7}(%R`)-j`7L&{W{u{4Vdv94&CzzGcBX+Sa8X#KNCUJT8%dBTZg$NY9@3aIdL^GqtbkPIwC}9B<>Hnm(yElcBNa@|wC&tf z9d_xhD-jn#Vbj+1)HZx$051VwM?qIH$3~UqGM}EL6+&l-ku$_w-~X356lsW^u4tL- zc?>Vy!THNRUiN*@!X7iiAlZPI)AEyD0S28C8YN))epQP=iQMU}V~;w(jH=wLpN!KsE;(Ee48aOfQn zr=iHh5{r?CA(s1nL_s9$jFt3qv5gum=j68;Ax=Ny#0o`)3UOD)?y9>PY zlZO!Z4`4jmLd$Q7THrJcVYnScvC`Y;IkRYRUMF;-!Cz|(Fxc~baWuiT!UUj|g*dAU z@X=qn9^HI~UZp65d7?ZDyq1k=w27VjKDpSZ|GhWHZ;;ntER=7M3-y2NZ5sV6j_umL zZ#OGWI**aJP490bwJ>$sh|WG6SH1AXI1y^FySorGfO2E63NgN*yel^>23O^OZ6F9E z^m=VfX93oh+t}XRLdSQ-p0s2@_dMAksq|G*sf_&Tf-`Tru#s~dqucEw3{oV82FLFq zw=GPVRD;)_{sd8p+ zxsJSE6W|#Po9Z1@64NrNw4GdupNQLw1XC5)OZg?mPslw}L0sKx98sJNErFlkw5 zd6-8zN`D3GxrJi551;z{9b$$O&8PUTs~?Aa`xx}eI+ke?mXg|+sDNIJ+(>mPeSJ;l zzmj|iu7TH?_tT`R=GQ1nr7~r!I1XlTZCwcC#e52b^KYkvQD$PI4=^&<@Y>(_GdvhN zsD@`^uG_F$ZA|k5v!W1iTU)o}SS{DXm`;5Lg?kL_|CT4>tZ%vs#|hG0-GfQ?j#XeeW_sV`o4B1Xi-Lmh)A$w+H<1V8(f3-HYIboljroo6;r=91Jw zcd!KufBMB(0(!A>4koi5v^p)pam>RQw&RPz3eN=-$X1*5Gex=M?vQO`Bcu~gqCqcdxVvaJz()V-?(RB z5zxPJuigLlcYeVI-`T0U_LP-##Y&9Ia76T$7QCv3b<4q7&wl};)xEIYRZ&PIP)Zq` zsF^t*lhMx7agn9u@RFZh$fm}x+|Wp4)9K-D7~&7kYHCD%bw{DjhaDGH5TGm~%;#hD z2b@(;rCpw+1!h?VYiU_1Wulm!<*-c$xd?P9i&nmtL0CN7o>2(TZzH5Bhu_9%Ya}Wp z?L5MT?Fu*k+VyDX6Ij6vmf^rAsy&^<)l(SJ1lOED#4D~`#nSXaEOlJ0A3lV&{cCE5 zTyPd_$c8OKI*BrvVRg=ponSuxO)B$>!A8xU`MZ(#kQyO5 zVf_(3r@j_ig|tfG`96v{C*>x}D2GmqFWaxLpDuzh97RyUkLQb?-@Uena5{o-bB@oT z-Jt4KSAp$z`Az-eT0JjsIt_Ff<8kYifh0`@@TD1+;2R|6pu(Jh_xap9Qmci{!bRxs z!$04*fj|7{KVhu*Fw?r2qJ`4$i7*VuFbupztWTzMRHhHx=^#!@%yk_XUGrq@fBX}0 zaGoKxeRx(&0yG>CSy_q(f}yio=KCObyb|>_%EkuaTmj}XK7R|H@Hl?-v1j5r7b^SI zPMt^b{8&bYv@)@+IrzvC;8*|tFm~)EVd@c4KM!Z{+jME?b!Bp;t~V?1CZKc8w`jNO zDBJ+oM6X8-Kq3`A&2U;p0PEQO`26qwCOXl!bcWbvu#JweB3f+=hwi%VAfmf2_}~74 zUp(Reb7x=?(Ep#ir2p#&WUO5FuGOWLm(j1>wYfnmP#6cwv;p1HJv{M^Z^XeMhg2=N#BwNfh&>+ptaivn{@1^x9+8vy^ns7V|agt*;A7j^`hvVxf;IHfv5SCYD z$VXe~A;j(_ACG#(Ua?D^?u3|5=Q#A>F$Ay?`^%VCmb7PsB!%a-)JDnnx~_|92LpOW z9tIf^Q}V6#QheRBG==Z=kTWT=+CY*6rSj%1UbygT48Vj-;&DO0`&_Oa^EKhj6Y(#!!$M?hnH)Iems4_8z>%I* zt3{c?K34 zQW|ib4$2@!ZkyO7nS9SaJo@6qG+Ysni;-zA9s=;^pG)wkU)aHx(G!s*3MeHBRoyJ) zNhS$|%EpvMYm=~X+(Yhs^E-YX#LJ*4MA44G(ecJHJhJQHAKv)$SWc%fj2LN5W=C86 z4v1+OPfj3O|I}iy{$JnPPMb$A7RhOIssHB(YOg)!&ei3?h1`%b3d%Tb#5tux$-}jP zb1%gC*S!SmaNy=PXdsq)$_|rla;jZtF@;#!>ZU(*CUbPo#XZ}_cszz~lB=GgGw3U| zxHLvq zGfFz2mE7D#?zMt$TS7lek{N8hLNc47=kyUwa_rVi9RAoJA-ww*n867BmVGs+#j?MJ>77tHU5Jw1+;V+5l@%PkPcbF@ge%9LphZ%(@{m3SNka6QuXD3AVd z3zltJ21$a{WHwDv9k#P|^2XUBmdGld3vqyx%9%f?wl~dDs1?qnNkx4_wDCR1fyucx zO>me%@%`}mJr`LN%KJ$39Db*VvhJAJ4uN9@bX$!DRN|qZY#%eQc;w-;TIY2wb9h9% zSB1=_P2sJ@uy`z9TLKvs(X>~T2fswYER;;cRa+CCDE`T;re zs1Sf$jb;$f0MBQeJW*)#qQq`}j;F08c*!+qV}(goO%&ujU8WzkaLgM%a1U-CyGT2` zr2~-UnY7$3Lx;u@5wC|h#d*?pZ$>In6iJ0XpUvNcSlG0tV#5-(E>@*O9^y|vbqBs+0D1rbAOJ~3K~&y<>wO5kWq3wMlB=BRaE)v*`Rj&Y@DUa^ zB8v1Jcmyu^w(o$syn@g)uocG03k|;O!=v184q-P?M1}%E7RNdUlb{s(_y`W9NAT>& zJpxaD%r04LJpGfYxZGkC05{#Sg}?mgJCUqjEZzVdNANn5+n>_6fE@h1kfg2G>mm#z z1&$NIAN>gwAd+9F(;E0$Cr@76!pG0e!JP_wUBOP78b5lvLbqsv`ZLh^rIunO3ZuG2%S!FSh zEyo-9{&O_E;PTzr6`epj*+P45AHsN!GMh-^4DD*mSTC8ID!phHaP^2jg zw!o{Z-sm~!)lt$?mBHAcikqUk3G19LV|gZqGFh4+(`Zt{;8CHJU`oLi>G7)bLoPN! zXi16$7WCc<4o?!S=Y}A~cVGBO4E7!X`dxKD_M=&0)3%JeK;3??d+)0E%p3ofCO{Y} z&U+9bnvSruzKLM#81kLtKsZI8SOHeabg&aTpeLe2tm11(pc)y-yt)3+X#_l`FxyMm zNnJc(uHr|3_iu4nUxs6@AS`rbu8FYBMC*WEAU`jpTIXma?XDp*JNV|G{r_+xPEh)7 zOepbQ05@U!&MzZb=-4>3%m9zT<934M2%hx~6q5a~vI0Ikz3@ifrVsXeBFgjP< zvUmTP-xMZObo(8QCNns?10%K(bOsoAJ9zA?UW12X7na{gIG$sv=VLzFhNBforrYS* z6?#sFs~>YQF4|||!YkqfOYAGX_5ewgVA*pJ%qGyi4z`1- z%$t|`9?WX#coZ~Q&y*lc|ZdcB^F7xl3bZO-O9 zDNY94=O!snc^z!pVq}6KRqkn=7t?Y!il93VW@!nA(X$@)7?_XzCZM|tbeDlvpH3$= z{j34NF=*FX<~5dH3}l+mgrMDPFL%n+Ho!T4!uY z?#$+BlM`>dIGk%JoMk-jHNS{aRf={zC1|H%jMYIG(@`R`@|I&^8qI~}5b=Go^JAED z&ElAT<)#F90c~=5u?o*4Ns=<6>fJUft1Y+#ir^|$x|USyNvsmG1{cBW&pCNnR?-O( zx+JybZCl#%{KwxBncwn#Bw2-C65_021Al+RThKFS()Az)#DWdGa)f?#$_9K zEVSJhTlUvK1B-yZSRHZ(dTUP{8>ZXy2W?DdW4LW&^@WUll{66NV3e$rGqKfDRN z{cUfA;!}w zmOWE|Tm@aMh(udsgcNvk6k1 zfI1O2AwW)X&ovQDDNzvIXAsQAVC;y&N;r$*b^6Hc7EWXpLes(KJi@N?&c}IAcrvhO zKVY{c36c(nw7LmQthG($=Xzi)l{#A=5A)wob!VrRIaT8-o*T-Oir9@xeU#`qaRBw> zS=BRO=H#OrVz`6N4TRh47#}-=`PMp0n&$94As3W)mh7P8wNNGw#{L<2;cxymj#;}< z>K)NH(5h50129@Lg7EVkWtAf|42)U>AG-tWgz)bje}K_!F7~PHgcJ!e1H=L35`g1W&B#5E^uJJ0^0SaDmnOV`F1SIxtiy*v7+$LjYd*zgT?-%it+&J6xEDSxgSieh z3r^+A4Bqb9IDF#X_hakxKe*Vk7H8n=oq-N*y7!usvH8;t2L? znSC77Jv{k!Z@?EO1h9GN_LeaXrWm#jtk@;C@B9pY=e1A68i18rBb&Q@#i~xXmHCBK zKK04gDASrta4baLrH+Yp0^ru&VA6IfSNT)(#>L7OE`tiSv{~QsuCz{l@q0dp!|q;8 z%)Zds+$xrtcw87TbO&wMf=&MK>b7u}y4kZDDQqF68lC0yUk$7@X)y6!?v_xmwgkKA>!`2RYKhgw}i%F0bJ*K(?uSYBM4I3>K23w#3!#sePWvv+{neBVh_`NM3g9PO`~JtL9Kph$k=fZ z3L&qIx;`{8kt&`%dd|R$6sQzZUIIRYVKN7f-jDgl22MP%j>DmYSN`_jVO<+SH|Q6i zi7$|=oR%OC z1LUcJJrw`g_~V7Ti{Obidlq71tqc`25^{nXRg$#j8 zXj{#={N%|#mDk>|B1oJ6y?&H4~n!76R*&|0jPmviqHb{|lI8zP;1n4jA#Y1!8s#m=pN8zB| z>Er0J6Bw+lBF=M!Nh;TcbVW`LNLOSFT&uKN_{fof%htE;kVeT$-4($Zc0?#d#OucD znrMHv;n^8z@iQXlL}4uo0xh|2YV|)>?pz0aEnYtc@MgZw;7)-g259p2MLjOd(eoT6 z<4s)P+xY19Kaaib9Y6PN=VBQE zjX^y+g0c&1(L-Cc)|;)K?RQZwt*x|q>M2P|ql)I%EJ!Mul0L1}zUy&%eJ94F=6U%y zw(b9R1ibN2??K#O#fZFgyHfL72E=)c{g#7U{^a+7!*`(Xlb#%Q z%9lFv7_0dfUi$P)arJPD-Np<-5MpUC#Atm(WNG6tgx~KAADObYO|X`BZC$bQ?Ham) zJWUl%t>&kT+gh#lt{H5jS4ujUHMMELV1-WVU{S&DE@QeghwiUn1P4b`1CM>_PXfzp zs;v$aNvR1sU)LDSRgzQ-%_$|;MjxiB%+ zexvHe8&I9J@|E4psR^8%8?h~|Q##V>3J34_jlaS>-}O7NNR3N~2GCHNE=HA!(l%gJ zp~%jT0u5(9`Pne8d=f%`D6V^S>+@P|glPt+)5cV)b3>|gmfsX0s`3&o&qojwjn3py z0TCIj^E46qA4fKwULP?5axEW*ror=cL_}}0D?ukWop?kIVH_&48eU^l0pvtTh-!qV zj9!$kdmx_YOax5&4ls!z08gj3`xs|?CT@A#o6t%}aJ3QaGDAyiVLF<^upRha4@cMU zeQ|v3*7q&8t;HGmT4!Jp&=>0hg9A^D+J4W?DpR!8MF6G@q;*GTUa*WjmeCUheTM2Bk2o?I-zFG_>ou za%zy~_ch7sWKeH@=HK`jJ{PB$696{{z?*;nATV6UBu~+@4ViCoZGnZcZZm6 ztfM;|C<$27l#=8m0Ir#r6I4cdQjGzf6&>5SH84(QxkP=70gDQ3RJjv@8ZkT5FwMyI z@8U?NKUs)NDjQmyYo0tVIi2&$6j6lAs$FX!ovyrSDw&~q&U+8gMp_CA25LQZX^kpNR zvqBKepzDgF*60v4JoF~9V};K^&eh7qq1|>cnFJF239_UE924kK8e^|fVf>N5!OrJz zf>UmzYn9LI|H9$y3%!-9Hh9DKyJP zOes@ML&w!I%}Y#L13c-vpTr@3h^)IJKp)fAFq_R`ms2bo6RcVV!ei@L>-expD>5_$ zLN&56o=)Icj;NRUo-fF5jS6lC>(LUPbh+7f&ESzMT~JE2g5`E#yR143g!8e`wb<@7 zHB&ZL*D>G|jUUYs8f|RpJ#6F}wt_@7!B>|12)8z|tee=Ym-y)0-wb=UfuWwl(h8(h zIAd7JGcnW<4t7bEI0t^Xfqm1v@TQkM1N(|&*wdyidmy%#X2p$JM=~10Sy~dB8t2sv zpwvb7(hOE5>P>0OBmqMHD5nxpT8`+SZ(_7o~;(CMJ%Lk?rHM zSG)>vdw|iGkx8&71gip zBA8NC$Uw>po(hOrCJ9a3?_wT=vfkSs$0UIuI)sKsp8<;fKo?uzM!J>jpVza~^~8Et zo!U}VPL+Bd? zEQ;#{ba11@ZGpoN9J~+FoeTH1#a{k3&%mNWKg}0L@4#bT)9tMNa=~_c38X}?p%iM+ zR<0rLtlL7vv^2nsw!Ef^JWr*ukVK)}e@^k8!9aj+lzJ7FH?A92<5e!t z!RPGzT?y0~#dB&6|3-&^UnNn~G)$&rc?{PwlL9_=b#)b%sbgnn3oX}0yGU`iW8x!k zel6Cl2<7$>q0@;+g08_fEsS<{;@PpAywiN!S{(k@IRlG;e!4G>-o-cW+I{A?T7@UA zV|bNVIGUA#$mwCyUc*&C{Bj&FJfJ5M!da_vsya-`8~f1tUrKNk76N-%$s>1OUyeMSaPHc=%y{*0kd?m z9IxY|*==~ki=KjIV+1Fgh~izDu`*j#V6Sw9C<>rET^Ob#ZFrkhvs%tpbkvJNc+7Mr zW5=2RZ{8R{B2Lqzn+ob2Dx7-b_2Hbkxv`5N;x7__%Q`r7T2R8WS!t?2v*KjCnTM3pVUETu zHR!PcI?6o1&LXIv69zHDd5H0NiXtn;U5_}6_00)h`-gB&QS>pc zS>JDoXF1Od*CEyMh>oYgjBv$UE%`^*2jyP*Sfw_ng1km2LTG}F=lvgpt{88{uSzf!#wMb8G+@MSxB` z!k7N$&ta|~#-L14L=$O46P3wUBtujIP=cKq*Hdc~NTr~dzuK%H@Vp-dU z8IMHbvxp01sRoZ5dN~syo84KKgr?Q$pk#oT^HZ0jlo%yt%~C)qaayZ`WJ7Tit~h_^ z>7vFKW;!NTAIJQCc=RiO9_TEKxQ>Y0%X8Oggvu12wqYuo*u%D}U%sHfvP%D2_Y-A6 zk$xw_yLOJNpQZt_@ke&r8MOIoqe+a>c#7@qF?7vFN`n@SfIb^DQXY#Gt!_uIyX8{A zC01fChAne{hCxfS@uoMu1vlRGDfq)>%oEy$5)3H{2nfa_^g2G~X^z4lV5YZ#eHY+@ zCp`nHzlz-R;r2Y~&Z_uEp^$ z0{UVNe8m}P?|IB-ue-WzlqOQjrFj)nu>~~|w}!anxi3Pzb{1yt5@r<?;FbaLJ z^>r`Yz4!X6G#zx*BJzpr)qh{b=Nh(tpx}lameA&PHethp@?^-`ooRU}0 zYYma*-Q3y{N43rE5q!UkS)f|mw%bOU5hqZ{*ul_Ej7Ael482xMz+Sx0y+I#&K?OeG z_^}iC>@AjEF(8?_|V<|g8Z(l7Mt1P z41DDoSXAhz`I66h({$`-YN?BWQ1XCUoS zjcR3Ore^4Lwt|J#bPE?ocS%5R&mX|hjA83kc4LFv60h#@)=_jiZ5W=9*(s(htPmZx zXekC#2a3ikT>>X%Jn6PYnl|Zul>*eco(a0iZP-gV=f{2?Sh@(6*+y3D!I&joy*RTb z*iJ)L>T0%oG7PIg{k1>Pry$qsc6xK?rX|jMFpx7*<8y9|W88P>esufGn9OLj;tIXb zB$%*J61Re5uuW-WGuf%q0&Tm6&Gil3^pF3D+dg|MCX-cKTEtC;V(OCx5v-Kyi;2wgF=a6Khq&;2Ux>WB3wCb}(`hbdH9^KUH+}9qCoO6P zXRAGq4Egr}46Q)Z?G^h$Q!JAMMOV zH=5+ym_n`ADKvs-k}1$7ni3r{^Q&rrGr{spp^!8q_%;SdMuo~YZYvECDhUcUw1$u>P zGRGhO$sgh4ANhOCr-AUn^->cd7X~WcWHwDHRR&V>^2<`JRCQ9uP9_H4TKoi1|A#}zMn38r=j8;K6H)kl?7GUh1^Wn;_akmshU$jSX@2ZqT5*Bq~fbcv|e zUpwCM@5E&AanU@I%DvO}Wi8V@h0nQX&njj!%ElT>g4I*wBjN{=Bt^T`Q4V+Yac*p$ zkl;<013wpy{A-gOuq(jCR+;-MaU{W0BpLdy2d_x5tD51)8?Hw$96>KK+QlM;XITA88bMw&1fDcvT<_5Y9uXL-SGRyX-d%T-L3?AZmj%X{tg3a zM2oFwhj0J@AOJ~3K~za@l2lf+5F`OQ{jQj)aHD0rof4p{gCzu_u%&p~QJhG{WH7jJ zrdh<;Zx*=e*WZdx6(Qd|4&OB3dcL$yDeq}|4(3&gu&nT(-}n~1@t0nQRvg0O2G4+A zlrqm8WLr2pyc^eFcMUFZ#|VxdglX95bb6T1BWbOg7O8s^LGQByB~XfVGm$A}E4@ba zi@^#vY`VTtTa>}sP}D%8yeZyebyskl66m|n!GY^u0hnD>?IlD?1y*jt9VfmZui(fp zQ^ohD73nGM_T~fK1Q`|3n+m;|!Pipk?8wye?wrTpecwY!sc{Yit#$`toJn;~>D*4I zE9W2xB5AuHdFTk<_{KNl{`(Kr8uhC6&MKGZP(0x2HdmXGG{?ZvhWLqFuF$1jHV+2gE9$iJ`?<2##GW}(&17?FfZZT4uY-YSSthk%dfu)-70`vmg4@F zrJ?czuv$pd5XbNN@M7;ijrZ=acj${naT+fQYjEMMt7~Uoo)Zb2B3n zc`}*`uUs^?Z3AB07431(V4LcesBF4nv6AQW&TG&efmYlg89jY1$8IggXWsYg&~}ca z9gf7lGmR6()C2cBBGR%={q?qkSH1rAa9RVr>D8}>p9XL#7{fU?5$QV6OSW(zJc#SB zdm7Fxj>1cJV4DuCR!1y3gNPLydB+u;wxe9RGCE}Iu>CI5klhM3)owh+Nly~$6K!GZ zV8dXlTP`Mg3lHXPT=BBk0DI08(HNq)`2?)Nqa=qV)%e2#Jt$)Zlrvmmlb;M^^$b`2 ztmgF(-&8;s;5n=OTFLMDc8DO1F`3UqY-L8Omc{E@A&hhMdy3x0d+~UHK>@kuyM>*y^S#C8sXy-Q3<57Agg)$n9m8W>o1A# zefQEUaK*QM7mm(T#HNSJ_Cz#heQOilu8%lQ(ewMl9$?bOb=Ih}HLiF3%73Iu^8E8Y zf}J=CUW;X|@n^0ty;5Mf?d@&3FJFfhI}<{2wB!4&>1Z-%pUh>QHy}p_|7J$d_mYb= zta=Z8eWA~>Lgz?;dJL8N9GQg<&oE%?8J3F~Zhp_Nz@Kl!%~Mg$qZyL$*$apULl~~F zp1Aj>&C_`MS{(YpVEMv^pxl;+kHyF<((2{%186r1CZc+j~B0!83td!pBm7)=Grd-c1g@Y}dzA05Y zH!4>B91pXV*;G{wiVPsE!Xm1kQSc+F4KO?#-L{K(yn{|W#feY<1A@>0GyHfad|sjZ zK!-$LRHk5-f{KlY~nsUABi${KF^lwzs@Z1a_!F zZdocpqa-S+ghaS28(JQ(vI(U_8`%XscD}B7#8Vls(u4{}dL*m$d7Po}ItaBy1QpHv0IwY=uU`$rU45*FJ3xh8I&IFU| zi#M5_lLARoo<^=UR)M|gHnG*s8!PqZxdbbcr!^2#&EQXV0&3}B;kpNbBmgp#?{-xJ zBow$9!{GqiqmlR~@O=@zZt$FG6^7*$_kQsAkUw}A+60cq5gc1}Jxs%eZj>199R8m9 z@qhVmr}6f+IP^t8U#x*II|Kf?8@qO&v!aoz!3)Q(-VCoG~ z8V-`8#55iWg=V%h5!=f~lYBVriacdABX25|hRfT~iMa6zI80QvSg|UQ7(J%N%&N@b zupP_I-XPbUW+evZaU>f!+oI$R$I%Qc-4-y}0FD5aKx)4~fTJJ#AX>o)c07io(S}r) zAQaD2c>N`8mO7j>&%pCu^)n(%+czxy>MMQ%ojis)p9+ti)j3<_UbKn*`BA*}CEtmQ za17mS2YHxC(5A>rLS;MMlptN@k$~WWd33T(-Ei<}4&!^SICM2ManidubDxG0?Ct=G z)keDeTRGU+ML5x+ZvL<0GS8DprErDryNZaVhckxBnV${?yGv`J+57dEcx= zBSG?O&^b0KRG_Bt4@qwJ#;uvMWKFSt)=%6VM!Jri%oQwE8dpwr8Su-UT=~ zog-tyquKEN9{NrTlkE{4mx)t=$|Z2K!mu-xE(q5d*A_<~0(Psb@P>xR-?574+?}&% zb-b!u#10OX06kiuhN{EV+$bWWwD&7z#!|0c(l^B=&s21_1`*#Fx-fB&9tkxL*qPBZ zLCkaP)QpXIKA1#vq?85;{FaA2jnK=-uKRy6=%?{|=~C>f&3jeF+|Lml3oE&~#Te z%Uq)!Bxj!$+K%G$aJvt8kV`SlDlloNVp+2)x5}S66{01dQpgK8ci^=lze4d z(DR(#24R9$w}-S+if$BdAu`=FbQGiGIInBt#&^C2#%u=zq$pXrxwhz-vjTJ-A3HSj zf`gy?{XfKZsc}z4HJjo+ulogbW@GWACf2+O=uW(iJ>?1f;`6S?H#%}<;(8)@}g z;-n_+1lG`5jIzjKukA)Qnus>Lj1ZtnfRMI0?ZNmM>VW43GFEuHiNx*TXyM?Z=UfLo z^6`M(MMg1JjRBjT2!>u0s8mfZw^L^7r~LReR&k>a*VOd7Qf#exJ=q3RY(PS+6 z09NOG4c@0o3>u~a(e9!~N6S^RGr*)ERCGI2A^!P!Xg(9m`FfSkWFyD%`)fNHe7fCb(N7xeXih!ow4P^k;BlupbknjjH0toubuqFxuG`bbA&m zi^@h3j({LmcVdu13sFi|)-zq9^3=1{2Jy?vh8*P}n#>vLQaP&S5^$L1K4{8sy`9x;BSy>^|49sjFS3KtjaM?BA zhJb*zI6=oq@n<)@9r?k#;1iHnl`^MfoNkp<4C15s!N;79??2bWQoJq45l11~RGg!3 zX{Fl7oTGDKKHgCdT?}5X!wy5HG|n^XoSK+!ZlXI}LR!&tzCf!t#AfB9I`?wyd+rYb z&H$BWqa@f+aOG^N)98e+#VB3_+NkEI>5A%giWA*euWS|2l{_ocm0JBxX7a!T53VEA z4OFIuAWh+#$|a6t5GF%W6l2gG-~%7{0DkZH-Y-F%fVUZy{CpodRoE;8Q69=%JBSIG z>|h)u=nPgtWw<1z+8bxxOlpYevjN+Iwz`77XP$%Mz60p*JAg@?BQAB!lLD39hS}<1 z7N;;=CCDR{E;aXw(TG#kCKuqIi!x78)tCzraj8mqe^hqk{dujM5zuThkqVs^J-_m% zL|atXXcj~w$9n9$xS92$9t^O=;zecQ<84 zD?_TqRMVx>oy;k+kzz9C6#ma?`%tWgh>q>)gp@e9}ziVg2@0koLAR zQqh1r?>UaLGNemDxtbt}Mdy4znMwuB^Pk137r zG$jO8(%YR?x9!5&cP1QvfR){Qg_`I3eK~#_v5=@}`yE(q52Khq-|WmVnXvd5IVu6| zQ9_usd$#9IO%&Q3*AsvuArF+1WygjGf(XK`D=+Odf@3cx!}`(lnO@q5k3NmJuf?G+D)hw~XwJYR9{2R&Y+P=qxdGj9L}IcK-m-xiY((uP8Zw1E{1ST1th_*?IUosQts!@Sawgb^Iy71g($AcVhX zFGgvFpS|HXu+3JsOR8B0o0}^N+<(hI;h*03Zgi7K0y+Uq>~3qx4t5(Mo_0nHKYVcy z`>KglsPs?IGt!lEQFv@xRKQ+MPZ& zrXe~PJq9Z;`dPs4D`pvy@S2X4?ra)sLbf+*U|SQ=Nkia`t@Bzt{mWMMDFMCtme0rQ z%AaQm@TD)^54Y8UW?I;pO`zKfL%{@s(yX?@$~MDupZk4+O4k{%=_M}Dp%m9_uZ1wz z5yAs{D_Gfo0G&Piuyp3xm{ow&Tf)v{4mPF91-G3RU^)oX91&Z`6!IYe(z6l90otAq zkN3$+#6c|irOOt562c-d&0+$gc;>RsSfy7`GjPvQ_zybY)xnlQlY!i^Oi^;@&~C0|yUddG{LTX$U9X!libAPu}n=7@$I% zt@=t?^Cr5o!##fZ;4PTm@tDPqwm1We3VpE#aOD%e@9~F^Zv1!6aADaknS-jDPM3fl z`@3<~5B>xmbcV>Czsx3>|5jK`V9SGI#fZ6O|=z!|=c_H2xs|L6~3L{qfl9oTUo zVl7nrVnG`b&D|ehzCFc{z4JXN{T^ZzV(DtNOyNnFVS(__z4)77c>@MnBxrQ8A?L_C z8B0KaOh3bGpL!AY<|E<5yHt5agfQUjRVjjrlHG79(x?n}JeT9`P4wFALKFgsV+X?R z^~KgTq%xe_L8?1g`;O-UmtKRaHAE84>HuIVnwqPOO$}ZZ3F^GjuU<9Zzl@D`YM$#3@GwO0qV}W6@f#@(P~Y#?Hnzimbrq#x{QXl|Lhan3A}r-4ZdM zysSjUFNhKpofT+jU5-nh_zakS2YS1OgAX1-e{~gUSz$byV(*?ka$SVkz{;QCI0Eww zWrLeVAqKs^pzO&a;Efcy{rlH&|Gh_W;J`l7?q{&}dp@Fg2G65ZE(Lr_w0t$@n9rvY zNLhu8W_v9_#CAM`F9l>I5fD9j15;uLs7*kFp)3(VBj@HED^OlHt>ti;2#G{7t#v7P zOBF+Q3<@4kfh6NtK~?<38!%zz&*SMxJ_CH;Z0~PuY)B=~`>n05A&LVD&=ftQK#i7} z@_bdx#6Dk#asMs&%x}FD1IvV$Brr9KA8CjMOHpF9`Op`V&08O}*vb}XU=h$4YXIk6 z`ixh^Dc(Ut63d~>6@@MopLV9VF>CF?1>bcolHKQFM48cyvYX|6KG09PZSw<7}JpF6yy*4d(%HAri_Amu*zw;0rR_D5lAdZCJo)SfEIWiM= zZ4Y{-;bkv>+E7AIm zq9U~%+O{vFg-H~^M~FwZVtnFVZ$U2(mF=%qdk~NsiURY=)=$5Uw2(P10U$AUu}!I*vxpj| zXSr&lQ#zD?C&-8aez>$Es$#qmx!$rV1d~V=-tyZpO(hH?gsVyygB2p#lQKrhkWZf& zrv=AuRIf%n3y`Ablr=v=aG*V z%h!zPGzLOe;H1gXphMHKTBUgFkGud*IEU-B#c)H{xOwJq$_y*Bqj=5Z&%iU!8^V|! zC-DohR!XumXm=Fqn961Z^RYb~&c%7FNR(I!`Rd%r1q^6ZNDKpG&BeU42j_qHj{z56 z30Q5Ey0RWU^>(t*R?$RBw$=!B{^n$b{&hdW4+nI9$LYwC^%0J(k73bNr8Fc^`)%rw zX9$u;ZOue53Gw3R|FBZLD`~D{pv+>}mVEh zqtQ%S?G2CJB+!%^@I@}Dcp}?rnaYk9lP6`vsuIzvq|I;!hjO-~4V-I6`1EhS1%4Tz zodzJ0k!ww-Y%ofiy3EE8e4=}rZeNST{yJx15ztT5rLl7MQ~ugA+t1?cQRaIL%vr1i zQoJGJv%eV^JoPyk!9d}-QWcOAWb4Xyl9V!5S!q!z!w~|LvPh86tgtv^jZ%`ZDi~#o zVm8Hczbloh<+ZTBGexW0SKL}wWb72Mbzg?CIES3fmDMIJp`x|*00;CKpZbk=qCFpr z^r}I5*djqiaSq!C%rAY8AKlVcO zi6Yirlw}4^g$*LZ%L>c$bn%0aJ^(*D0i}=BL9yVR=q6kir z!mXG*QcItlaEOZ?(eebsb@~Hr1`%54U5fLc{UY2wPO-GQA2DTXJq^<_HPwNBhsgH; zQ82^4)m3b6@4(9(+u>1ZwO67!KF&Cqc? zv63tuHM8_=R%^=iWX&^?9ljGMZvF2_4}KAT9>ZXDoTYGWP07i|sdN&8f8AJL_wM)L zAffKEY|Lgv&<(FU&e#(2%MzZt8^ z2^i@JIz6r#_@WrC4nc1O@x`@vc_H8_qvIpqRyyOPB(XBnEnp5^Iv)AHN8qd<{5hbr zOM<}v>3~lDd07GXK5!gS&O+2eRw~O-YN<<*GfbgH5vl*7zxrGJ;d}l-C};GEPm2^) zPSrE=$Q?x8eR$-HUWsjc2-E9eN*{Qg{CkBmmqe`8|9|$r1J1I#Jo`Dl+#Vag_ulhA=brcfKJQb+OF&Qu(yablr>Hd5NQCwmSc< z)#^Z1HEh0f6V`25kJPu;nx zK}{psIJnsWW)`C1)UYt{;p4vRkXy`D(mD7O3nEab;j*AT_LAYBdaUYVT|k+j6=^rb^l{fonDVVV;(uDwoFv* zQnMR{yg6#(oED5ms1&q0Eu&ZIW;V(TG+Z5KFfZQJFMQ%r*k=k@^nmZ&1pM3gZ^6#k zfZ{gM_d+!2&7Op4jgJW_EQuAl!Brg%Zm@`n;T(SOckhR$g{aYfl{Ze21%=3&i%pW1 zTfw6*xg2W`Jp{2^gGUKAS@+AnsXVJoJq^24H}P@Zr~d)h{=YAwMo2Rbp{c3N(pl`r z(mwVY-i9|{d=}QmTVZ%}&|O=!&V<(+OCdAMWI%RhG9P9tmt8^V1p>SC222n9z_8)D zV_1F4xxmTi0fkF?|6LNN2jZf;w+?;z@Nc|z8#2v8MvZ@A2r|P_2uXV!SFoUSfj;(; zzrz>*<;$Xe@Z$^$<#(Bnl*$9mg|qQ6OrCxo!scqxMROA{3`Zq$!>}2}Lh6y-9|{Xs-J1CGlPj^z5*O=P&y!666$FAHSTtSDu|0t4}E&n5xyrEfpEBL2tm+?f$0+;iB(5G z3_G~}`d zrX`}qfFRnzCW)v>#BV+!Twhwkk_+GqqTvb~e|%~!(%wAwG-7<`qwhe~n?oh`WuCO$ zeQ~TI(Y)G1^Y$P7Xa9k?ZdRUS_gI0I4t@7HkH7IzRHlx*rcs|bTBJ|xxCs?n^h}R1 zw3=uh|2Q-cIT5o_2D90MW|u^_G-Q#|;Mg_jR%sp+3w5`kccCb6rL4|irOKKPnfV2l>0{!mZ_GXD8!6&_X(H{lHzJQ7FhbFfA`L_$Tb zK}OOQ?MI_bZp?0b3@M3)9EKN#6e|fL2_l$|DQi1Wblj1cIP59U0}gr^V73GdU)C)D z#wYP!e&L?8_p&qR^YeIb_}L~Y=rD*fEQRXCCAvTk0Lgqzo%_V!{XIVUcb`HS=1@tJ zvpANCX?25Gb>ZxBFxH;&1Pt9VXqt=Oa0r#m_sA0{eWE=DZ!yF~tL$_w*FdZi;UONB zN+{2Gvx7WUFdPLklcw1VEnO)cW6|X%fQko)u=K(l#g(Dz9fGm z_0xJc5_c)Kg@ljY~NByWXHhq|A#Yqt2T-l~%Dnd`M zp?1uq1kT0v{R_V&A9B; zeR0tN21v)%LJx^zVPxs}34rIm{aXmf4#IrOu#&FK=e1Nvt=>jAh+*cD_^RidZ^Yc! z{{w1wCvqi5t6qcd6LL=xDg}Bp2dP%@{HxxHfo=<=pRhaE)TM^J#GEBkl$v&uwhF*V z6HI3j-gf1ssA~muvWdl$oHyb^*v75?7F==S-gxq!4&3hTsJIrAG=OHA@?4ti5>$-X zbvCNvB*ubHxP9aYsCUN2kcM67$aJw)Z{YBky$Pso0Q5>(fR;>1e&dt)YZUb5@Kko- zH{84lVWNt`i(-_Kx29SkiAu{?ZiqN~`QG<_fUDpBKB$T%vv%67=1L;4ykD3|ofeLN z#!GR>h$JZ#{_cr`XL(b1kxxg3z?%LDm)0KKrIk?0UB;gB#Q=<{Zh`> zX>CfcbVjQJQRrd3;vksWfeA~+&;I-C2)5h;OADc;+<2ujHc=CRW~e#OiBj65GEz6p z6zQ;qnNCJJ6%6DyNHH#uDLQ=3#n5Qr_zSMUHm8H2+JTprK`k`7Wi)UB2ZkD>Y4vc~ zS?h7uG_XOwC8ovsJGDfBG{>q4Qhz*e)_|x?7SUr?7@I8gi<~qwfn+FF~h^|_|fOD zeITxzmFL(!R$!%|?;hvzH$Dozb@+7?lj{x^H!caEV5kNSL=q|J`oX8)RnU8JBGIN~ z=Z*%wQZLRp%YyD|wB$5VB)TL)(I&IzYH&0UwznCtzv5UN;Xt|?lX6}IppohffM;Lz zRoq^zMbE6mt(Q=2rErjOK5wY7vk^AH$M-&X6;y8yla3*s8*|aetttkd4^%2xG&HO} z?l?U9;>$1><*;jYxHiz8XLoE#5uqBpbnv3PnTkX$v&F}v5bu52^WgO7QB5NWLm~8> zX$9;e#1woSU61hQbB=(rDk=OJ7rhNV+>=po7ixFlw+Q2v>kr)p23VsCO zS?{<}P>)f}!HO;&KGWms%~t6YM>%Compw~-=P6sGCY^MMIYnp02iLLFCMqQ4uZ>c&khP8P=GA}F8Ygqg+Fm9dTJGgnM;zFliH$P z&XowYY>1iv{sO|Ae=G(v6)6k>9QuI=p%B!DnvG$W;>92S2o@>9V-#lqSW5;hl5(c8 zq~&sqbVMttjV7r(h0G`a`R#8-e%lSG#Gz~^c$3Z50!ERb4j-Mx+wjjXd_0s}zl{k~ z49}>(7Y8w}gV!Sjs+XDWu2JH#nvGyELayWz*ujlXSRKdK+BzKaoL2zu1agJuGY@`2 zXK^D`u{cPub#@VPp$S=#1%~tKGCeJkG`28`&~A3{xIcNU6gNd^_@mN- z6$KKnj}}dCy7QP&3S9HKe-?t667=9|sa#ideq+YC#M5RZ*Hz@2Bx#Qcbh@ULHm=l- zo0gnUZX)zTk*|57NO#UP;6`%avoC_V&*9jT0-|~#Tkc8 z;zbXiz{C<2gB|+U1K>q(xdye?7!=dRT+c&&Y#b5SAj_n1(_Dt?JL>K`t+o(7OMZYU z*H9ezs8!jedoY|DG_QvZdW0W;_T#X&--;S{ElC0D)u|jHilmzw!l-xb&YQnPkYMGp zvI6(m$geENLu>`~*5NmdPpmzV^F3;Yh0tY|c9tZ?VgHAtao}+?~Q^xsD;TvpXG0UsK~ye6Hz~W{^0S1emlFG!!QK+f>f3?sh5sl1u+KakbjNwf;;InW4OH{)EHc4Yy zfiR2UI=V=nDzi7^m5Lj{r55+#0J|D2!GROW!{rPWx8Qyn?&gWwanrbK*gp{uBM3mDxj%tEjvb8(S z7B=6x4P#?t@O&R@rdQz+C!Pj1=UBm!0u7sNcUrcZ7^x02qlTj|einA1jkzF0yIn=# z4`A80pcVa5By;&@vx?arv*oNh_&p^@&CC&nL+Dy6VZm6~-8>CTIc(z&vaqUQs>|JlVe zZjHsd(Hw^!4KWrj+*Q!qLO4}K&o3jPB9TqFrl-elz47xa1^qYw(CoIqvC^UMwx{;K z56tWwd0l60`e5E%2zQFP1v0rAvO}*PeGb+fcRCiy^reuiV63KrA?N0%DFvN!D3xIy zMT(ZABMbwK7tkJr7{nJm7x#U~`eEMs*;@jI&YzZ{vEYM~J^SwUm z^(K@khnWO0`rEPNny;X^{YR)LBgreLzjn`$rRzz;Au`*>s5OquU-=qXtESOSQekk@ z+l0hm-Zu+2%q$wzBnuQ#8mf}{E`H(6)XqJ;?OB(h5eJ-@N{5|OBb|j}4cZ>;B449poB*uyd?|Cx4IKsH_>2TB6Ncj;h)5Wtoo$Y=xdz!`kHFNqPX&xR z(t=}(`}-06)dig{XjJ8~YrN(59SBGiGg+u}>2^&j1fpDKxFiHpa`pFr`!W3Im%fHP zHKah%#f)xny5(Y!tB7IZwCB7WKZ{)CjY;$u=HNPI@*b~~Q4pd~OMmrFrv8CZA1BT+r*VVKul z7_N)OULUn;1A#w6nt8&sH;XcKC`to#oE-Z6CTy7MpdW`AWjaEwh196ZM!}C#8D)@~ z$I%0SpOQ}M*lXewUnUB2O&X0FX6F}SyEIwS5e&N}<2^_*Sv65x+=?6i!@idlxW9Lkl|}s{T>*Ri=?6X3jk;)%d845zh;)TsfM&&l z9(ssJ^QfB|`u#2{tqyXlie9L~Y0y|E5+8I$bCz)mgLgFY&GC}AOX zrGW^v86~2SqG;6d^f&x9^07(GjC{bbQ7B9Cl_7n;OY$4_&t>`vq@z|?)WtfL%%>C+ zT7vh#;sr1k7ho{9lV&+0HDd}_f;DE0RWsM)?N>e)Yr}0&2iss(P1(Q@qSa`2l51i( zQ)@U9`-wyoYuK>x;Z!Q{1_8X*S~Smi3fz6e6$hfoR}Rg+1;uxuNOuM{3JrXS`kZM+aI76 z3^4PfZv)%!z+mewm}uH4ib$N=?H29(RoU?9^^A@L$M_%xB+^J2Wt2$zTOZ_;dd6jwvL*|lK zY{bJ*lnRx41BF-(2e1vUfrH+X^=#nB!ZYB8iI%an045?sjtL7o+4>wQaK2BHV3I_V z=*AA+Cm)6Jt65aqBL@kZMu9PJ4nO|v$58KXgA??npmS{{LN<+L9h1!4ICImN@3{Zh z(8@FHE-SE7(07+J`5PXGJ9+GNwfguy3OZ+*oS_fQ2J(F$CZxEbYDz&@^fG;l%wj2w ziYXfa>TD@z<4l(HWJO%hSUA}$7YIi_rl+T|&>NNkA%-mlnf~$2F*ULf;b@3jbqu4? z5P9xnLJhHpzkqLi{QamG1K0}P*9?I(7lRafwTeQ`V69q(r@Z1d=&Fn`(P33cf+}a+ zNmw@HQtreLy?k(n&iXEJJV)Jx<_8Pd@|~~YJOB1+*j@;iW-la9QR2izuwWMJ!yS0W zsR!VsT7V7J9CPzK(Wtw^%;#o9)g35?4$V<9>@Gl0O82x>qn)mXFi6m?tir_Oo&oEa z(^pT2_<|>AuSAONpn^HKa=UG1xKvPhJ*kG3v$DB@TBuDfImnO zCW=_Vx?Fd%(KQ_leuQH6-Z=35XJB5dO7yGv@5{iRm_A`)7B*eXh6Y0_18js5z3oew zyzQD$M?Tq=((V&L|584NBeb--M zuZ@k;9hX5VG$vxyZvi~~k>_Fi&aPY#aazdt>5`Y~4vcySN1pX0EHw5)PjkgBk6rdC z2Rzyny9OM~kZgF(FeSFM-&;hJQpG$)s@t+2albDHND}UnrWi6v#`9zo$I%qg13Ja| zqd}?37idV6c{(Ta^nmC0EQ8j`3WB7777nl`8{+y;eGv9=25rP*fWuik*OR;u7WBLb z+ncZdLic`OLMud+2)B-WpJ zCI*TDkM|%$IKs%|GRD!+m=YDBRd*zDDo7a|V#xfO8v}!LYg);&vx^9YYjEpz-igp1 z4iQDZ-1uvZzRZd;TU}RCH4;n~BmDF;AA>e~2h6AshmdsyWQL8zwBZv&woJU{>i6Of zLiC+FC_x?$BVn-9c*75APGU;v%DwqxE_?3{- zdMq%^7&ce)$|Fvwmg(XhzBeUlf=7lQ|N4)(GI9X(Rw%b4wB$D!$6*^)? zgJ#uXtl0;9p7~_#%naD|D&|H30;?*v#j!U)5{1xoZgO)OQ8;Ama~xzCSR93d7%)|j ziF&lH;RY^ZQXt1z3@TDk+PY@3Fx0tx^NU^i_f}_I3cCay|^;12qPG|7g}VOA{k28$RQYaPk z0!#>ts2yhHr)J>9vmb*!k39~>#DqA)(KXJpI46}LIQTIg@Yr^h$z+mFwv=MV8~*Zx z=5}PWPyAeK{PsV8DQbfuw9!C1L6K5XpN;^HXb~H;89e*&aU3~eLP-O(s z;ZQ@aCCK6-YNiH_1yBs=sf$-cBB(NpK8x^8Y2qSBp*!d)4pL_VN1gvv%=#gWi7E8V8W#E<8m1*C zLF7shfohPQKI$VJ^`r>5+S3B7_q+3VCk+~9EHDc`Q6O%N;wTX4U1E0Ybs@`9Cr?Fu z%#nw@=M-@`kPQLXRq=aRhY_y*)ZfDCZ$k?aOw~jdM}lzVN+Iid(>0&GpWDIT_NrU? zy+70ySh*nnP|wVR{JzHac=#6$vvww5hP9429C58OO)MxbqKzkD^)aWRsJD^Q(>n6y zMx8M`+UC5QMlBhl*oS4>Ff?j-Rk>-iAhd@_plXSlcjOhx zJ|{`Y+qnf}X%BaP_sdYX-wb0gho-_@@Eoe;Vlna&a{k#K$MfIxR^+WFW>bjfxTcle z=^*kr2Y_C&5C=J2T7RZ@7j(v}@+nA?<6K$XYO@?pGQ#F-zJ>38>XXpuxviJvu0i4< zNk(X^DJDjD;Ms??aG>L3!|DlWiI1wK$ZRx94Vl%)*#KD-3*_FY){vW#*ycJ$6+5gc zG#>LT)Q^9ZAQ@8Bmy+Oq+tYoY1zkkwNeS(97efP{CD?uEoQIu@eJCpZ1Ps^=r=jVK zk%&br;ayvE@U^d8gLl8@Z!ic$L2s%Sqd;>x*S_Lnq_(mDBhH1k@c=BSHhkK}Mlqa% z@VJ70zmICWgF(hTaTVj14$N$UGrtwLee?f8>u*C{%LEZ&kV}!5@#oNg3& z{NvBYv!8t_rY03pFa+@kHPe#LJZ0LS$=F#EJUJ@|p8B*`;eWn&1LC+OK_)>U&^Pl6 z6qQ}NA|^+v-ax2WIONgiBJNCKr%{9EHc>BhdGAJ}j(+Hssd{{$IiJTy5tn=og-8@P zIbkTIJ;LlXTcTG3C3^f^)oN9-F*Yqq`a~d)6spKS>Ahg+407SPDTiBcp%^Vo45?m6EWs@>p4cr4?8y=nv6LVQSAuUacGMGkAmH z%}^ZUcylaL%ojG4eNMo}lh4M2YQb)CzCBtZJ7v<5(!P^$DsN_%X(IBNiEhhmeB_Ui zsboZ#KHUntV8wtx97$YfocZt*54!C@RjbG|%8D|y5Mot0gKPfw9W2clvoz=t;mAzL zQ2~?TYE7K>gePOK6CaMbq(EZnka4_6a(V*8H4x^Rq=#vW0-ZB;!tP55Gz+>A-GwN( zM2<@VSI;nQDEL25dJ@JgQ=G-<6HgYkt`*Qoo(p#30PiNe=1)(DJ=}qok-;%(2ofQR z3piB=8ZAz<9GR!Waa`m!<26YVjIq<6#Q1s7hkNv?04-RF$+Do|>rmj=yD)!cK^Ise zlcJt7`Ak=Zz{Nwq?xxL9jJk|7MuU-%8w19SItFY*7rEI)dmTW#;{;-;>VO zws;;5Bx(Z#tBw61dm*~jF+^4!qbNd^hb1JzI=aIV3d@G06)?Ipu;;hq_W$@4oNR=e z79sJt@pFNU^Lz&XWTM?4c>`2y7GD3lSK`rU9U|%ldD=XJLDYfoHP@giwD_e5zb4-O zl$5R7a6GKaIKr60YmSV57-L${@sKlZLjR2aO;tpygV3pA^-;%T?1+;P=oJimA(~?o zh*Sd$eKOFQryd~+`+^*p3Q2mY$N_DaUUd*1ImZNK#fz^)UMfSVJ7;%Jc_>-!T_OO@ z7P^Bz(m2A@#5fk`XHl)XLaZCJ6jZufaNWn>i%PtJX3oGMn)T@N-2BgvyzPU{-ZQ4f%BW7qL~n5h4L66v+;4jg$if<=1Go)Ky7mQU0`)U5NA<)stpqJ@rwpO7(7|HzH3x?1uy1d_t-4L}zoCK)QqBbaJ>-@^=oaZrFa)%?V&ft~y zSO=q_Uuwzo0*+(JOkAvYseeyXahD5bw_-oH!1q4#PK+fB&;|=avZHT6PW>Tr%q`q` zYu3GC?}zB^Yvr(i$qK9#^oQuBvG?KUp5h1be=!+cbjnH@B2lCTe9eJZo5I0oKN*9m zy)hR7HVk3x8dYY17m}t$T`Yr2=xffKJH5UsNn#hmA}>*qg7h-1#1{)tJ|uoMAq!fS z8{kB{gLpK=20Ow1~W_Vg>CiMhE2o@5k- z28$9w8m^=<<_inq^nuvx%qL(mchH(%kGZ)S@f8qfKawOP54CUsKmGJa(Z~m|qD2_& zG`X2nHMv&2;Rq@vc3Ff=o_aoB_Ohpg&97NbYLN4cQx3<3Hm(^jCz8;}_|Abr7{RVs zc-@=cgTMLU-$Rvr0bQb6D^3lIi;FN#gP|spmM1|xOfge5L6E#$Lr`y_bgv>XJcD+ z6}FEuxH=;sLpV(fMV>*4Ra9(8e$P(=(HPHlduTM(reXozZ&BDnQ>bg%g`8gUniw}1-GTWD-T*^=#IG0X7WtFOk;a1eyKu-TWh z&VT9Iy^n&<-(exh^9+`)LJxiHS*hSv=RXk*LqS!`5ynBODTWOt@=-0im<%@KIY+O- zF&$t+kKq~x!aPN{H-|Orr;zjmB%T3xVilrj5kWi@F2Rh}fO*VgFm=v_fZ>*&>Qc;q z_XVBD&J7WSq+MHNdy!ei zOypqQ$!EdY>tOT>N(gHbauX(z5F6XU2-kl0pMcSJ=wTOQcFCCMW{lTBx7$UXp6*$U zm;BlDaQP)?2*HgoyC5wRs*=dYO`}9^*+)P~adbGM%A!1;{SG_@szKz5ze~^fOjzc8 zaE>qBk7$X3uQ0YZ6G>gFXy}Fow9Yi94n7X`Jr6?9s-v%HFe|lkChj-*TdQa&n%LJcBO`pg`~hy z!)sR2&upCj+?V6V*hb{E;dQB_tznc5$^}_bm6X!4bU&oOOSQQL414o_n7##V8 z$Dt?L5E1LCELKg?Bj0sdbhZhb}VbL<$7;QWr8!vt?U{sK+ zW~l*Rdi<`s^X1ngK`vz2o(lZv`rF}Fn~0NK&Y|U2kY-F%3(;&g;rk)p|Nal)3;*`- zGJ>enQ7s50OI@{UNX<5efrcY5dnOh%8~wOIwb?=u4KZ%y_{rz~1@Wez!PaBcOa)%I ziyAj`WVG`f=CsV!@WK~78&A3DOt|E@Efw-ohe1MGQKqNyKi2W3_a23z*vNVb375bD z2OooKV@wJQ)>?VGI?PINC+bT4%^hVZe)2n4NX#9C$d)eGY^^ zy&kh(ERwRO<%+)oKQFwrs3WIcF4u?!owIVT3%hO>bk5YtKVh*JT{@$7T~{`<;!{wh z^6wSP70X~+4qHJVYesYU!9RZ(?RXwe>Wk$qH-Ge@XNSJy<{$hR#kRvA!nd!LL%)|5 zSSjca;mcxt&r=IUA)Hv|noHM4&ug=Wer)2jXTBJ>pboUAG3XKwtf4 zwPsDa6vMEv(CcD+yi+pWmy)4GsuTJG!_UpliPqMmM2NPX$f4!2f0W;2 z!RCCQ^ViT5Xm_=tHao*D=s^JZ#;55z?9O1MgtYs zK^W28qyiNNJZ}iQ3L)1i(1koU)7*%|{_J&t-A1N4rGEPN^bn&wZmLM3(10KRWRu9I z4AYU~RjJmcU@a_g^w5wl^O2{XA*cpPY%EgT%#HjIn(E5@ym8P`XzY6^l*u)in_tAF zo?~#!_1N^^|BkAf!e9?gQW*nX`~X$QM3#oIOa+FX;#=SPJZQAX&75@QdCaUFXzWaB zUbMuGkQ&dN_bisRr^6dN=vTbzUHHe(e7?+bCqH570w6R#81}K>e*56&n{S3wamCj^ z940U-6^xWb5CxaI$5ca{JBS+NIPSuWadVI&YfeIU8W_y=&}p|Z7ft7;(2YOi%49x~Cvs#8;SSoDVPL5)^VRh-0Wm|HCVv!~ZPDnpV0=Gv0 zYTmyKfyQ~`4T4B+Z2jQ~&6*1}8ez2-;YT068kKYaGa5l*{0cdynj7WH3M&=U#$C}egk0L?%$zh4af>ikKN!wo%8UbM6}!NT%{{ycDxY2 zA}q{y@wCgYfT{DGn?h_PLCz#xO~3lqH1<5{Gz^>;CMPE`|Ksmt_QoGT>ujjJM16U__`cYHb#C_T+@hX(?Nh9hc8@L8yir?Ir8g zHS3RoU1^Jajn|vl+0xrT^09H_23&dNm4YC>_{A@VHw;m?n+TWz;F1-pLS?SGqC-<{ zEF>Dj)&y3c_y~lJRmj{nOtXsKaDY|oR%32q4yNToRZ1IVUat~w%1tqI*|m~j*lM+; zh)V{6Sm3H9Tb=pr^vj{=L6D+WuVBPfy3q{mpIwic@BJ65c@J)pK#L0$iY5iUP-Dz) zx#dH-chl2;7x#&k*ZAXEft7;($8)Y8@ON6BKKiy=ZET}pu6fz%k{p+5E(V2-jVGOn zWDmxh)=;TWV4*)itx*wpRA>4V=g%60TMC(x6UrnTm~(YPitKpEc<1-op|hJ&#ikVk zGqx)hioqP(;R0^@+~33OZ9`orL5YwiUEdUpq5`4Q#vA_bW4NiGplBP2`2QOw-uce=qCeXcwtK-yQrnW;a)JLf3z1@Aq}7oyX7!9G zK$%zzPp@E+^KYA#SV0n|V#~U`agyL79v4##8NUh$3{RH(@*JI=I5`89&(S+Si!-Q{ zJvtUr#e?fFVD8JGhraDr=xGlQ5*QIJl@%nEZYmM{-pu90neYGY1AbFjdBQ*H6?ic3 zC4bZl^N@J+mA!9VwRYqFIfEZ4qhg1y6=KsFC>691JQ~GbM7@!RY7W3kv1oB$*bRrk)wc79~=vqS}stfuI`&9*9$bcM_ ziz!L@+!b4ek)~({A>MY$C77OyQ z3K1$Q77W zh5%vYLDy&oLpL}@^!;(v#m7JPL7a5bKBBgu>zheuxLt~QA=l?G?tD1I<`A8Jgykl2 z-MZtDD-}4^F@#Yp$7?daHt3@$LcH~@Z-&B5_b5W6)j=91`16;%5(e`HBAO(nB0nsY z6j_=;CDM{x2($(wYYc}!{zB~3Tx9hoydp=KCDK{9>J33K*md(W!5KP>_pU5=7W!p9 zI%nkd`_#%o{@N?BQqX_xrP}>|u(R>_FPUcjQJnA6^PC+A3wlTrky=Gqo5KE&eJZv_ zI<)FIGR=Z+aCWX^xY#cVKOtdw6LILH)vQ4+a`gI(QnY0rPjXzd1#d+CFP-6X(Xn&B z++h}X)WfwOzZ#CRD8U>Y>QcAPqLJ7(20u5kdEuL{!mK%l#3jR50@9G>C8SNbS6a)Z z2fOZFVfW>NzTBza)u}K25MAix_jhRkrLG`BEeY_&cfJ#&pWO&I@u8=sZMLcyD0CO5 zzlbUSHazx_2^`V(aX`m_n?{I-eaXPCw+ZLcQ{II)2vO}!p_}T+_B;%e7rzumrTw6G z=fC=jr8^r%_?*iw!Rtal0~Uvcs1`;6y>nHORdMsgW9PYf$^}nB7!ao~En$`1gIRU3 zm@q<=^J#a9#1WubkC7)Myzz~%#<`C<9ZVN371k*VhWBv(Ew87G zo1b{_2zim_I80f|k&Ba@gD?et_~YB~_{TpLhTTGvmPAjEK$6501;cQ(fVaKvEs~-q z#xIHr%XEZ2{^IBT8Hz}gF@s()5vCDMi$!j!MJhC_jrqKa>RyMSbL>g*8{^2EO)U0D zVmWM}lwlHL=jdq}RwwC=@H>k)32`j&{C93B*tOGYcdj>&np=Yw=1{U2VYY}poDA20 z=zVB~eJHfzEpix7{7qDcmd++uIs5Fz_N7d1|?iXNM{Ce z#YApaaHrb9!7qC|(4M~E*01;P^5tfxBt!DxQ;uDfn zhK1p<4~urjMvfzoJ_r|H_+$)5eIc{;hXc7jNn+D-4X=FutD!13Vzof36-Wyowq?Mf zo$)9|ZcU)?bTEDDSqR3a5jI;$R1-xIqRI`M=gIMkzC6#ROD9sbEYB1le1lQ7WylSG zrcAd}gbU&-CMu?aD4xYMeEj%B??HodH8n#Pg{T@8q%>G^RqUMEv?X5n!Mgi*?pJ<$ zcU*y$g1$SR)nD_N_BsCS>A_;Sl_VvmZDlA>n1ysvajasXR&nwbe};Z_5_c>{NL5>I zu9KZH?A*Q$)k;mmR@m{lPU(2X>}Sf=I7=odN_)-ug+;lEwVDlh{T^10w-K<5?e0ML zny(HEO;Of%bcOu%MTC zL}_rtF1_SZNVF>LOk?1dTkn9**>|DA&|Og}kfna+8RtURYEUI%kEFqaV zv2@Rw2n|(S_mk~7=iDbjGi!)q7U{Y;_?4NeDU#Ha_xFF__Sf)&0sJULv)zJ6^#MoW zxsGH$!CT+_PT^^m&Q8n1~KU6G4iGu|=(Dyh5`<`(QwlY4r))F>hmh$|n(sk3k zhS$e3)4e>F(p-jr*rhKMP&4}0bHtV8s2eUE7VmHeD!nbZ^I!iFjbI4N))56ET!Wj2 zk&s2VZ@cXVgg332%KTnFGk@4Gx>C^pu+Ptf{_Z+^o?KXF4SH@OW(=gjnHus;M_;Sq z@bjOB9a;mv)k3CMVQ8fWm>$$D@Nd{T(+=#F=-hAf{jQmK{4pE$=R&V3@<4!@RwFUuM@oFLd?^zIt z*$flGxS^vV%R+wyU31_tS3C|SLOZ$7|5|PR*ynPNhNj%wgvnuXzbbsTnM68Fy0GdvcWr!=Dei`=IxDJKROEE>J z3cxQNavcqP`XB!V*IoNFjN%aW@j7L)MqjDn@FzVLv$Yn|dK-Zs zi$RiqJcYI=gxPEDJg}r8FHf3lq7w2qT>n>*M0O@1PNR z$T$)K^91M@&*Sp3?Uoz<6WDV4@8_CXdDTDU6<8_gf5>O&cktfq$)j$q*E)MTdQ}Q~ ztb}rdR|*rpSx5bdQ!#PW!?DRzpxAXpl3K-ZmIbU|zaDc7Jxs4%g&o^=!ZH+rq-W-L zLe)*_^eKsJwL0ka7GY}&>b3#1h_Q3?CahKh-1&u1U<`qnb7;)FR;l?`(T`GCYu4jw zZ+bIk=sIV)LacL%QZiE0Ad*nr18%>!B@K%mIy+QNI`>@U zYDOGkO`PLxFL^PX!92!XTS$MJ&aTyh5-niLn8#GQ0~Z~=H`bts4RuPOhL~ziAc_MF z{1IC1h9C-yg@w`d0oe137X#Hv6nf=8rDYH9Wxy_$F{PyiD%XkywH!f|&gmQ*#94vu zJLeH{{mlwSUWBiHgg?(?u?!PI=)rYO6l#W7z3SyqOhvv&hIvACi5~I`KE|69`16;(T1cEhmY~|I zi92GJq|j0wFq`o0HZZ*xR-JSz{7M@mZZu6tpmi2%7IwnwBHMIbDeBAbpQJec`*LCD z_ZeJ@53E1!v9Q)2fMLp+CNs_h zsCgy^GPYYo5QS*fYDkhmqD~oj!3a&a-oWT92gGM4qvE@5og?`hw zQHwq4wm54o5F@5^&aB|{3ob_I=;P3*-*vSjF|1Ztz@&)?p>ZiZ_k9Wf;!J(lr7Nfd z7HgHe=kSbHWgRhchRqa6N#=@xY82q(Z~80fn{I_ewzgG5z<>*z2`|xh)Pl`8ww~gg z1J~lfF%6F2M_o7I+ByoE<*LY|1Pci;Y_7pR&w4p9xi1Q{ajymaS82!}sLMbK_>x{- zRNf*7DCnCWMh|mm3n! z!p&V)(2Xz0V;=J-IOE*2;FuXsIea6uA{FwRbmhyZlN$=YFC@aQFMIO4ZF2duL=oEK zhhluyIw-0poiblb*XY1U>tT{G3ppN26WNcy>)ltui#_Bj8R^U>*P(Kg5G#m+1eRIB z%m4gU&?+^#zA3{*l)!<1 z+_HI-WF{=Z@Z#G*x4HfZkP?7?kRzuKSi+vw|31(44 zH_Szn#ST3Qe9`&Vn{6rRB<}F$L{~4#o6ZY~N99~rHDWHduESxzHCgT?7wOtv1g?7)>bqmc z$ZB4G>?&a^Ie_f&OGuN@?XvrrK_itc6BCn>LL-mz8P*xVJ1%+(Y7vQ5G|`EX#bi_K zP^<`@qKE31YjN>O2Vgp##Xb!SEtRN+Dl`AANKv<4xQFf%hFzSK-#6PA0u4#g_n z;5d&Kn9&cQ)4+up(P0ncT7(~d=3^KOXJB|k6eKiphM$MfY87;Uu7!WuJKlpGlr2%u zohS1AdQ?DTNCh3hbiU`3yj7awFm){#^gyRvm(7`FNN5L(1Yza7A4`7Vh=sJ3lbFVz z7iY2pR?5}T5bF>!9&^;2X@teEeii@onSVqQ56ag`RGEkN(?Be1H)^y_>0&46<{?GLy-{$ z4PzQipc6|}cD6)qSWpMp`=Gt>z7PBjwr$-h#zF_~(ME*E%L0%Y$ zy$Bu4!Q3dqurZEfo_r~`Yb``}19_SwOBLvbjU;7aR8sn~D>gJm!Kl}h?M z(FiJY(47i~y)GK2g?8G<=-RJg;rj2O8v8Jn41~Jb#TdCJK`fqW;F(u{0G{q5ULt0( zShG+|p%H@@7J{O5q;m{}32)$>dBS~>nLI^E1QNQL8VABo}_s9YySdCcL6pv=Fv#HyFsRjTV74^QT4WA&teCj zeC*z+b??AxJHxuMCI*!EO-%!ajYUR>HdbSgGoJ<=c{1RXDKP;oQlMZ7`<%sZ6{BEjt#G7q;Y`JGv<|`@|s{l7jv#8b>slF7`K3Qoytd*WQc@J%Gtu7%O_v^+BZOh1;=zS8hEHwYw*%s>{oN!#N^>8Alm;p?1(kA z-G;E?y-XFUCQ}9je}E$QVLApB%NBDUMC{IioKJpA001BWNkl2TB_^t-gSK1Rj0G6)5Z)62kZ_wDg0QLdWK?ENHrtBc_M8O@DAeiz8GhN7^}u_r30oIOx15 z;_y>XL9VMM2`Y;)=#9{>PL#}cZWbuh;vfEfsf*W^DvGeYrMpj=w+8eE*sC*!S6}cH z*rtk39?ye$p_F>db@Y z_#P&U0JnVN1E@!{sOZDePB$~5D-No;3C|xOG`bslcYJHpgL*$$dGbHn6?hPD1%I?_ z^3Z$Z_5D7#dcz)P)6)g10f!#lzK2B7(6_6I8&lYD`V$aU#u4T^G?Pwmw70a7Fxrt& zxKWdta~_6JRRtM`>Fj(t2Nex*E`~B%7-J7R!w)|EM%2PZxQdD_jiAxnhA_I~U})Gl z{dq4$^PoczYi22*k}6)_;0dQP{Y)CvEQ9507{-h&9ig6uSjDuoORj`9xf=Pz6fSwe z3*c)7RGS@MiosxrW_4`IH@qz9JoH@)y2=+_g7Qhei<1yWk)dN*u)+w@&RKlm4X;OS z)P++NVtc9>CTflaEgr&%=20DN!)bfeaoV0^s1CMZ!p%YT5y$#^rXkfTSTw6R>`$)* zPCgr%UV~2`aD`nPVDtFOR9mw2kb+KiB=YId3hS2u5W#x}OG3%m3#y@>@y5rageN)st6LhL10AfkYZiJIiEF)E5i#UP?6Y9dla zWS3pGuzTB-GpCn7?|0?`8uL8S2xRe&&px<&=gyq@oipeAeed@c1@}GlIKJ_3Kfnz) zeHJON4b73Tlwu^J%lT0hcHoq{m2lH%KZ$C!4A*zzyJMI$T*aa}Gi18JnfoN_5X)@8 zKMG(68|KipBJ+Iq{Jo)-YtXC`B0q$sStx6CE%ec4463Q1SuLT>bU(d>IL^hZorsZw z2)pmG5+}d%c=7R1Qw5f7gAuHgT5<03cm_6Zd>kMC=yhV0L$aLf^~70D<7?$PCX6bU zoqQ%br6IK309L6fqX+Uax)b9txffGom^Kw3D&mN~1%Okw(Mwz?ybzeJPLf=&gIRfi z2mbL!G}0EzT2JD76F7n**r`ej+s^2=?^C%s^_W_LKez%@1%2wmKy%){2biVGj|{5< zuM>#%DydMZX27+o=p-igIpa+jQz{7c3cMJwO)`m7F%#)B2f8|pAc&w#TqIq_VyHU1 zmBKko2xPv8reUJe-iqIT^D~$Rj{{TDLbISpoLgtQ=+7^?@g}&`|MutC?9L@TQ#?>O z*)6o?+&A#GSxiexie~8H!)Kl)`O;~wB1jZm@tGSjn&}F-}rAWlb2>d~V$Cz)n-tQw2Zx@|UpXM|Wb-(J?YH22EF_>sR6swgJ?0 z54Fx_9I~*E{bpHcVgi=dgPkXsT^mN6C}=~0H+>;iUws`i)R0taU=8OaU8m%W4lg`L zleVcv*hc}>(v9U=PImbN-;GH|JV{+VG#bxrdBVd!`yPzu&`kIN$%>To^_krA8sxYq z*mJKvaKXjrg2|x{&EK<_bUUnGG7oAB7(`;^`9gt{u?o+6n(;glwg+}wdo0>s0;AHv zc&m*vVVR7)3X82Td{cv$1lVuSwb=OZZxFWIVh~@Wh?}hJj=PYQ`%g7hQc!GODIw z^Op7Z#NItq8R%2@?EiKJrV9Gh#UU#&Yxyx*7-R}(%UQ}`iWDtBKxkIrWe!%o{PplE zGcci);XxN88Q<*+dpJ&W#1SJlEtE|{gRVqWw%jfhJr|OkPsVQ$pvu|tBln>F^KWA) z??EG5U9{hsybh&7LA8Mkue}aqx`ja1rO-|m^x||CaU?@x)a0dfBEG0)( zS5bwA#*=L7zdidrD4lWCn>LUILZG4*E#1d#HHDS5ptUyOwFfW7u9Xmt-c~rl1O`ea z@xq7cV24)4E-(2j)Q)%wVAkNnhQl`x`juSl%uzKx2sMMN>5>4Cz6NazohIHtxmr>;6#VJ(R3p%c#zr^I5td81NYJ=h zsMKnR@>ntuf;fO3`~uv;5oyCdQ%rZa|; zb9CJjuk-nP?t_C*KON(`g_M8#1$`2Zos^!l8)l|~JFik_G$*oow!-G|ZdldzIvUrR3x#)vgQ^|^+=)@@14e7dlH-$yC zfNo-OhV}yw;Txa(G#rHRTRY%T*PN+H<02nEO^-O=-SRNz4V91u zE~X97L|Cq)Jun-q&%6kjwiqynko#)k2rIOEjx7q=RL0I+c;{I^W9(^d_Op3;{Grzt zBc6Vk%lkk08GPZNzJh?VLe)axlVHYFx;`l`^>M)k=i->-k3y?8iou~KMn|{9uno+f zF@#xlTlf@GaE0B^^PnSUTde^1{%Q+e`qI|`S_z6?5z^(rKtqUiba{=nDwO&FX78~E zhaGh|vepDX{gDr&n$n#RaN|%I@KXHK5SLzjF{(8O!Cte zU$ybN*Znn0PE`szM@KC7f>bzFj72t99DfozLvs<7n;4y-9IgaMLV^@{Q3%U&VD>F> z*_CU|DB$&}Dx%k)W++$?bn%maxDl0f0(F&XYJS1-5XNQ#-OkbTCgzN7{>hf-`aDm) z-_ElFQw4qJIakl_kyclKZkIXBUzf!_AuVZT9la!yj=75xvf5m%IN}v>2Nt3QRlM7~ z-3jSt#QL&Qhv$+toWisWc$`TZDh$&^x6^`SXmGL!)71#S`ph-33>QOcC~>O--?9iA z76RSIDHpv7&hlkwYs`l>q@YheCg-aBJL{dv8)q0|*w*p>Q%}J#nfhZRsFf>lneOE@ z(Oj_#N1S&S+J+&=Mnjm;58;>=#Gp(bPqVt2#3hVt2EY(z)y30BWJvY0fo`jZfpQr} z9HAUV_|6x-HT)Gwu&Uq`~%m5+|+b2nH+%U1su_xCBnP5RLmgSkrCC~09^82qY^05$Nva@-s zkAn_98ku4vmu{A6Y22&SJmTmk%Mp74GHzOxMB;{H&c!PZTD=K0 zGarMS*5i@ye*@LDg_05rVK0vpn2L!~*}(cu4?G^V@1F`}d2TQ9o$lSH3i?iW%ATDA zp1u0zbCP!ac+H`wyNATe5zt-hNys`in&`MXjyU^LY)}T!F-quk$3%`qnU8Ig@y-@E z7jq4=);S{;EpEd3X$B)rFr%y?e_}lz`QBGi@^_$=c43eNrc#)s!^?HFvK&|6a3gjw zosC8xN~%lDKYZ*%NVhzWYG(pXF{lZV8x|szFs7S$(@mem2&a-J z<1KR-3B=iJaCks`$Jq@D;jG{Oi{6=Y^=G8BlBv(nQ?KgijC&}VhGbP&q6jy?>pj5O zb{IhyYCjuBy;_9qG=aEP`rUVmx#7(MMzY zj6tkiwN%*V^faKO95`Ghbm16EasvSSmHpWFfE@rR)Wqax!h@_ch> zNeGJ<9P+K`{tS8?I(y#X(16|ChWR zZnY#c^8r7=b?2Rp0putrp`Zat77KYY2xA<1%+VMes$e)eT(hK4xpU@(}#`p@4avNa?vSSURpv@pMyn5o(!)#jEEAn64R_= zDd?JN2*el!45Nr)o4VvtJLS4$pA+^RgpUnByan-=U!xYgun~ykT+xj<2&@v4Su=6w zReytVmU_C+(Qz!|7_pda>nxH9&@rrUv)5dL@2Apy=VBTqE+ zbPIdG_EgN}x7)r$;FAc4+K=Q9GU6aj2AWZn8OBV;ZXE4gio12`u z)6TWT8NSP+p5U4@UXN^(Yg|M-AWeThJ%FbViPHN^#96{|O(v<|Z*2 zQWOhmPS>`=M?Lhrm^M6sPu=i$uxwR2bko!^-fm%_QAN;dW5v>iuneZ^6_~{GH7`OG za#N(k(Hxk%g|V?Qf$Mv552x|fcBzi$i%vj3XNk=6BbwQ!nV2bs8L!<0HeZ;#3^O$iz2 zi&22PZ~Z3j|H17r#>P;2@+Mv@Ss3rO5gWW|(stECE6ov@7S88 zeCv*%VE=>n15r-P1d^}_Dj{L%sW;?%7CfyYg{)7YBP1SDpWa6h4By8>yNpl$%{#E; zpv7Hfe=k^1TR`P6Dx;u3>pEH#6zc4!tqzV7g(|z zlGui>*FaC+jLdzD9UEZxM+8Lz%AfZ-_~TwJlPFok(6M=YfOUJn2!>r3Jvs|IXU#Oj zq2n7p3%+&h*AWCgn6`l^F4D(hrm_W*Tub|`TZAuv?M_^J#g)iZUF3z9ZX@LC>&sA46|8^k!8;H>eCQu{UZ$S?m$(AY>#g7~al!wue`a%F??)R$Lkkth zLXt)j9a*v}2x9|dk%2jT9S+P~2+Ur99bSZHt&UEoBOi-5=bVPxydg1-t=EHLm1Txp z&H~KTJpAl4A40gh*pw;tC|$}?sn(IYEezxV2GPNia)iTon}OkE8*)8`v3OS;bp92< z@L~WJfI4eMhu_aAHz=PH`@FvU%JXtzWkN7hRC?%GKs6_10soyV1kCYVBYkvORd04Z@ zY7zS%c<{dB1ekGGr!Xmv@5|9qkpM@6W1~8Rzxu1wVRLk%nSxC5oJ;77RYON_VA(59 zM^_(&r&x%R99Fr4cDIF6$q_kYo)*6C^?F^h;xn%er?&&x{17I7eg~>a2Z~Y1@aP3h zKYuNB;fA9xYi<3HuRSlPce)UESWnJiL z0OpcrY6eBKkw+zrDkjeP#I@+s3e>hm4rgh)=y!G1gyR&->paO(RZ?^}tj8DL_daN? zQOu}SK*?PM6^3PrM`#>JFfA3`IF@65((KV8sMg+?W0V)GX?=^V?Z4JA*-{maQS*lrIuzUSS@w{Js5PZ0ZU zk@{&FjdoP%^r-iIl=2j2>Ou)evFB_9FJI5dk7|uKQLW$pv>FZCvL!3$R92gja7pG`*eolrvQe67+Yw%cEKJumuPz`+8NhG=Vbkx55ct2)z9&UD zFA=h;Mql~s%$L=pb9b?)c+9*M@D`1jcr#CKa zdau((7AL6NRoMtkcoP^ZX(*3Aj`csh73TOxIEf2`Ui1u$fh9uFur<;f+4jTAv;WPX zdWRRn3Os-Jf)~Q-y#PPM;ysQXPL%47(eVj1n{|<3#Q`N=HDpE=Baw>Tk9{R}49>?$ zY6zQ~ow#Z62B#wGJ8scwj;>pZUJ~~>C-Gp8K8kI3eg%zaOqk##wcB6r4@fsX-=7oUM6n(cW0wYbe!sUEYs{Z_3iaY{B z>NFSPh6C7RuM^SgrBcvo0UL&tZ#gnUw;dfH{J?uLZ~jan#R=RlyCx0a{Q5WX>6<=_ zN~MZ0E;ff`lXLA&!^YTn8`bGEzz}d=V{g0R2DEh(6EuJ!dgB6?ULYv8l3-dM;&0DA z6ZJerDM>|nA$B}Od~^*n=FY;}J@ms<@9jt*J?!~8zfA$z$}{ztT4dG&w7JcW}1EnhND5z4Y+;=$E={lpqVs8;zV#`AEsHyXeYpX{`u?J zp{v-E#fUW>j-p`uHRDm0(QXJUPk(^}mUf*|;W37rclD!l-^kk<; zx5f}P1wEehGs03gauw#B2=$>`&+Sg-D)8>>ZXA(2F`f>+1S2)hjipzQ=k6q7L9yN z>E1;anX4F$QiM(gZ~x?{utQTZk*QD|T{`nJ2NQM?>XP=lSXegj-gC~tK$xIR8{)q9 zk)b}Zu3*NJ`B=GfS5yoG^|FK2YgWkh$S@(%!Hds(E*&~u;tbu!=l}j6@sl6@6pmdM zNgMYENfsh8%Lvp47Vmo;y7h$!D$@{Ak8di-6<0P2DH-#LiYmS3!w7-uW15!WiSK_4 z;f7zKqIIAtzA)d#6)`hl1lEeB7G{O6s|*BPFA-<@j(rV9Gbb-tdPqph#`^qjf7 zoI!I95`NKz^2xbiPsKTob;N0u4!xOCDWYH@sOiJ2fnt2e-FQh;8zTWWj zyK&p+{~kkuhjJbxOhR#+Go3opC_`1X1=5X8T7I$!n($Q%m0fqkiRYb%ZmkSoQK2OT z;q9k)-Hn3|+DCNNREV`bBCZez001BWNkl0EyN`icB&&~ zi)bp0p^*q#{ys+yhN-~La@awD2fq6~{PfnZqeKf*zl)kfJJUee-XxA8WARgwlUx|I zFee(vEY-u&d#?c;1IJ%<0cNaT1C&d}T!5V|-$Z${G#QB4vl=yI*8DttA?yyc6qaLGbUtJdUNJz)R6k<-v6Eebl_+{EHmNE51T zCV%tL27KwCzKpOxKPO*9&Y$6+8=IKB_HcwVRv~N7!Duf+sZoKdhwuYe@)7bxftA{b zBOjKNpc;+i(SQCNhV=+}+Jde#!d13GNTL)<8gJk6*!O4rPtW4i^QKl{s-RC@1OH_O z4n6cxeZyEIX6H!_aWC*;mQ47mD!JcTX$Uh{?gwqwGK9m6;E{`*yJ$^}z;qfi1Es`D z2v13ZJm{d72YBoUx5A!y1eIu9>^B*>p|fDCHYQXD;WXmWUoa3KIm(S+CY{_4U_d zOAhq7t)Ta~2Hf+&!@`1IwPu;D2W?)%uuyr6xeRMLT}YX#mZzetW|1(+5Xd!&VV(4T zkNZI{Z0WU(CN>cUl^ok1xF7#=&Bp}2FnkwvTR{}H1$w5pylGYCO_ab#C3Ml$LYTn> z8dVcVzT`+8a`fR?bHKiUp#j`9p&$;@1cyN$j8&Z!vOV?1=7I6SV%GO7nY7M*T8cMm zT=QQGdOrrIe~lEc^;Ce3ybvLGx}oW^SU>vFLpc46^NJa|Y9gXJiosDv3d7Rz(wDvz zr=4~hF1h3qJo@OPFmxSmyMw{zfW(-}>l1*mEJV4A9t~XRzx!l{{DtrR5ZbB^wcbRB zlLsyV_d-#EDh+BRjYS{sj3J< z7e^m;u(0q+;v*50uy=vZD~O{Mal~sV$2;EsUYV_PoWpe>LY0{|Cb(gkwgj{GI{{s5 z2#HZbqA5_+MC57Bas`1G!Lb_9_&&i_^w$3eIzPV?wYVn^c|>*izC?HoqDYTD@^1)! zbL`YDYH9`krz<$}zWi0lDoq43GcU%|Ke+SfSiE>KhKHLZ>dR{j$v!Ip zilRR!_AYdN8tHVNqwAYa+){31&$$TtcG!F}w%*Tg5ah=EljgljRu*nXcUuNdL8{{2G zQW-=F#@&WO23`L%5IGUT7dI%PuYYDC|HECngw)E86o}f2GDpWGr70Dalw55mr z4?Y6ARY5{`m|THE2_j8+6a{;%TZg;v{<&=SghZ(%DBBLat}Dfn=uWO0=p-=`yM)lP zp$`q=^7niIV^M}yltFWBvE=1PGs#xubwEc!svtDLRx=F8JzR6q1!#CaD%@1kNY;>ZIKr=F}G3%UfN5Shx;B3vaEE%t6T=GYC`| zNHhao)qvlejm5{DhB4HTnGP5Mn`ZQ758!A<-+fL+SqU+iZNq(E`78!97if>eW;!4< zG1?d3TzhGt{O=oYydQ+FQp0_fDB^M7IN+79mV%!2b-tFO!4D(YbyK8EO3=kX6ye%8UWi5#ODDsd zpJf)%we3{lMUixO?9e%fOu{0j)hw0qgdZUu7{KKpy9Ogf2dpyMK?>W@aNm8umLjwF zzPpP(DGP?wZvxwW#yh911$3*3R$WO8h*S!CANr)6+B)s%pZgJkTnkjuG@+l&U4zd4NXT!2`E^5w5|h>$l;g35=ZVclyH@X<)jkK~Z&d!vrQJd|oIPx(&yM5_%XaJGkZ} zAH)HNt|~m$2|*V@Y$AK&(HRC!XcDN>t@n2%a%{!tkW6*X5sK>myN_qw8A!h4xu6%h zd_+kBD;(XR#_*|TDAk33K+t?>?mE#|u#DEs4iV1<` zSa(1udn4Wv{&)k4HpAxJC?DDMlSZs$g(%5N@l5 zdDRT}fBiGCTbodhJjpqrB4lWIK+I=UEyvc!AN`xm{q?(EICqn&Px4%>z*IqhE>?Lb zeGk39^7eVN7auB&UPS}7>5;0V$IuG9g0`3Apx0i22Sx(ea~GkPX>dDT@v*LzY=Lq) zLua(;VAR4r|MK@J$1PNI9~yODtO_7W$wbGg;i%J2$Gm;_L(k9(+feBMIg6*WR)mrP zRI?O+_vTAb>2_ebu2dJ()DWdUG^Zluq~UpsuzdHu@crAqgL3A=&H^}bh&YH5nI$Oo z26lAYIODzVMYlQtwK^!6Iy>F_9(V+UZ!DS{w_HClFQ+f{6=zNwE~Zd2a5%rj&CWFyohHd6WuaR zDd+?`$fU570P~(q`g`x|PDP!Ky#R4b9z5n_t=wpuyI-}(UqQckY2GR4|S!>gei<33=m|B59Vg;rO`X8}ae`!yh zwdmlvnp)oEI2E`(UvAoFrG%E-L%CK%JJC>{w;I(Yd%`CgIUqiUMs0LJc^vZyo(kXj{-B{t^mwkKvU-3@o$@qHNGZKx()l;~DwT4;qpz&UB% zK`T!1<{NItjxd8$wJ_mAeB=M~-uvK(0ggOkFVK-qxa1twau&<6s=NzbX%6LMA>~m^SlG%M(t$2pwiKtGekxvc*r90D6(Rdc=gz`Jc0M;9^yQOH zNKx1e8r^^Yh-Iu%6nz#?;t>6?HJMp6{=8q?v3`9IS6p^A{^J+-Lt*qSi@8OTBF7ZD z6m!$j1$`*UUqu|OVms*MiiDY*aOOE!x!-|kM>+hQvOXQXFoS7wGr;R0gHc`$G`YEkpR}~O$335wRY{8L(BfRY z4ln?wH6?l)7P_kX_vQw9Bh zzlJa9S5RxKe>r#7F1yp+EM(}4;~+>wxtTKVl!h9+zvMLhIvoH8XCvo4n@liTZaNlv zVI&28T0D+>zx)rVt1)bLp@^ZY>>U;KG!t#rz;S1tgE@Qe`#S}lNHq}x-O^#iA*Lh6 z`(A&#bYA70omwX5ERLq22NUh`Of1>&FmZ;9y$&}2^m{NTwqb~&A6YI$t1iu5lpH-n zhq-Vm&VBp4@I=>_pScn6vtK;`TepO?w|e;^I3!s!6P<)A77<#4k}Ss}C;N`9u<2wc z&u*(fE)YL4T|v8-pwiIc1qG}hP-;bQnH+Ek>oV9yQ!%#w5&Yw|AB7!wsPpHM2a}jR zM-yaQ({O|xEsaw09JNLr6P-2;%amdu$HDNLEXDL`1K4fla$I=+8!>OrbkwWGNFeRM zH}?gsbDX9;^YQyPgvq}P%+9ewf1XYSK&$KF)^Fd2_r3Q+=(!;*ql`S$VH%9nEeu?u zG=X9$=z3k$n>9I3w@2vRfTG&)$PBP6h+PlIU339<*?k?hx;^Ml4TeoWb5WHQ#C{9t z1_=~X6U$(B_~PWoL_E&a#nwKDlla*F%b(+WU$_|+&x0OC#YRksdF=8dob;NL<=;wH z4PoSA_3lehsnVG)da9s{g)u$o;~0ikl@9&fv(6V1q^cF2JGmmVU(g(knA0)+*fZhP zW{3+S)skMDbMb~S{&T;DA+?2H-|~4F?j}q#G9)o0VabLk`A&jp(e3V7+Sz>1BQNM1 z%hZQ^R#srDpg$|i`oeipd&XX;57egLOtKPhWb6c~y$zxsa-)RYX`)-5hh;B29}kZ8 zFi zT;&XX5&~5OVlA=T@3!%=i!VeS39OX4*-8=Ck|hY#GDcCw?8A;ndG=yNy%sj!@l7;> zaX4NFhLXc&$8B5C(h#AdU{p78>cy9$wqP+_#X=V*wvBeMW%Cm7`84%xrlRG7Na0QSy91omuaSn z54`nqSbh(x-+_}wVx*GN8{_&s`; z@4xTf*lXRMSiEqN6!``x4Zn8>D31N7hx#do z@UZv;WQvY3V*jp!)0>d~EZif|ERVAwBS1^cBp5F;W)GKAcZ%IH#M>j=}e&yfYh_OtE%h<}2g4=Gt zU*t*b(3UM*g6Xq#c)ob2fN80838mLOixc}QcIZ6D!fn3)V5ctRxx%LPsh^NZ-A_vs zQXS`~WZXwFDyGF%#Qi`1A#VTDKf?6du!1fcDmD8I4kt)}0gh>S znH05<`~vE-b-Mb|Wi5qG_Ip&ArP$Rff;2-rieOZV5sorA9T&greTWPTscs-r6oe|T zDKggerzC?CU)$d_J@YFhn-SW*5@{enLB55SqM66I;p!`4bSA`|&){d_#-i`~*X?!j zy0cD~u}Zbn6lvM=6$?=+(~j4WwUcWvV**%i{c1%Q$Zyd!*BonUtxA1hm-pSIz!U}K9mGW>Lbm1YbG|_ zeeX_stC%|M=XnLD3i|VW0(OSCSDWiTIeXUpb7DH82@9AdBkmUy18#XH<{tezY(ovE zWlJ$;_{R2;G1!$VoUDg=x{v?-=bPcAJ=8KEN*;;F8E=M(<)E!OSabBT*!LB$fZH!{ z>|VvNMP|R5bgjSn?)P!mSHFZhBIsEt9dj;htPG)DM^_uf(i2aW%=W(a>%y%@W`G~+KVE{?v=(V0;@+kELA~`@~b4k!#}q(aQJd#4*`-pu($E6mAw-31wq?n2MAe2Ps6wzj02S z_eojuJo$T}yW`AxQa?__MBYy~`w42P6go#d_n9|NR7H__Gz-6Nn(%ub%1#Bjs$sO_ zLaWvg8YU7&!y7KW4E4G5(9U$k{9V03G!liAef6pM+P*noAN44Z5IOeAC|d*Y{1Af` z3yBXnN`fX5eCWdSp|(a*7r7s=S#A=^bs)oD!Lctrj#NQ7W>wtoR;*lva>Wq%UW%~5 z^Er~FS11>96OLWOMQ^-RK0l>pF5%s?(3g(y&q{xIa283n;*RnqYvGS8tpPe4~AyS zYtXOEH)?RTA*^`uDd;*(=L#`wq)=O1vFYwRp|!_gr76_Jg{{X3BNsZ+fiOj++3?K@ z-u~h1Fq&uxYyi`i!+pQP*RH)*ba1tD1!k7PZ@18J zGY9`(3P}UQx-Q$%vfY4 zN|Z)1+z3$5E|M%ChNg&|j76;ERHV>ThQ%4UA1AQu4S3X-2O(a4>02;o`6{&26alG` z?1WW1uT5@+inXJ*yg)AcBMupY_lQp(2HW;k5;8aTmQ#JI0zwmc9{h<&wuJNzuhUx zI&dD$wPhh8d5zeuGpkrbD@lCk#;~z)9eTM{j)RZDuo<*MLA_*graV1eG z;U<%?c~Q(KHy!+M(Xr=~-TE^+Umo&Pg_QmS5wpTon3g4libS_Wj#XF{0_#h$=h&H> zF}^OHZcGzymdd}MeE!8)vd3C+duyjE5{@cZfcuf|To^e7A+HteaT!O*MyS{nsltjc z9>rQ0kcVu9!W1@gG|5-kvH_od=hdjEF-%6?68a{hL2CJRdEEs{FI0C8am3ox+e1O(1742@ap z2|BGYlyev5iH+EC?_IDv+fXN5Z|G=uyRa1vrlDZ_$mU0~@q3p(&)@Q?x8KQEV5*?+ z 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 literal 0 HcmV?d00001 diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png new file mode 100644 index 0000000000000000000000000000000000000000..47868d7b326d698a9955aba23eaed41477344845 GIT binary patch literal 143370 zcma%h1zeQR^YC-W9o^j=9ZCt(a0f^u2neFmp_H^V+|i&QARs6RC?e7g(nq66cXxL; z_u}^-Z~g!8``gdwo|)a9ncbP4nP>N&jeMk~LWEC`4*&p>n(6~x0D#=05C9kJ7O1vN zmH+@2@JREq()9Wv4TlCD*CSf4hZqcIa{ZWwOM7zTaAxCZdh>{yONWN%kzoghic6D$ zTkGj>3@wi)Bd<0cx7M>x%;d&C4Tlyjw>llSF2+ax8iS$YR;TAu7p}gcTycMWL`~8fq_?kZVY-0Uza%-QG zSBoTxIL#zma^`w=Cl*3D~a-mgWGo6S1*iDV{mVxfMjqX#9T+0lA=aE@ z4Cdx;1qS1zOq#Y!62CFMzDJ(0P4ae?KJ(~l$2B>JCi$nG^|M71;xpyWVhxWwg zA%yq&*PmUdwnAKmCxUet=E6(bv||>2y_u~8qxLJRltcW8`AY{8s+2wc(lZ-m4ynf& zf$szf-%~73J^r#AikJ8rOC~6ip)Ji9=&sC@r8ZN@bV5*_9Y&*7)kH~LC`dq z--7Y$`E$3_3k;@dbgOfGb7u22rKpAN$KKq|C9da4TG=m#uiH%n%jEtu9n%Mp`>!xJ zmv;)Tw{I{k?kxvacUairG~o-lul^va&mXm19o=AhW)Il}H5mN|C?Cf#B} z603)>r7Sb090??hPHi7^G2X*w)u4W!_BN$l7-1s+bz*4gluzPuU~JC!>VBd0S=sz; zwm0K~-!=~Y9%9S+o_HvDhJK#eJ!Oho)i(9DX**5)R?8pODW~zGrh8F>iElH=Xmaz0 zQShmXzKe5s;VYl`mw#@a*#rsumeM3H=+qwd?B3YaZfO>-EbpBX$-C^`p;aap>&H7%O0*u%t(UCuZvzf+cQmKGxvHh&P)GJJ!`{p0jkg#KN@Zm2uzUrNF*a%<*Q8HwS5g@hU~dMcdUe5d4Vg zGTKsM?ULm-9|EG{tX5jFeR_+kNKRxB675DTbTemCct{cU@l?f=EYv(%a&|P8j#!aL zl*0N`wCz;QOzN+?NxIK7b(0L;DuX(j?ORvRKELbV42Yg_v?N~6Tz>J|f5Jb_W3vC- zf?dTQO@%Hjgf|9y8@K?nEAS>uvmqZVL)1PEjQE0c+h+C{Q1JmOR{YDAN&Z%sJwT~z zAklTuZ3S{h3&`F=R0cRh6{vG3jb*;Ap)E|IKQSr5(Jc-H4PcReoWwe_30y0wMP&}& z-Po^VZu>&Zf8~u#7oYY%d_`qYL{&FC+2OFi9%`C`{SOlu#RLC~000QR#PAmeVIy8m zVXe__fa*Tuai@_2Kg*gyFvD#{%(%mO{VdknUn0aD5G#v%*JSy(0?gGiqxOSQ|DfW4 z-N$ui1phZd!>X5lm3#0Y*xDU0lX*Y)%kk#tBZoo+vBtm$XiILnrN7+l5x|85`l}!; z78v@=|8E6m14Nl(cmAy)5TMSeSz6q`xIDxx_b&t>)PT=lC=Z2kH_~7OC1Ge4=yqaI zx@^fGhuTm4ecq#RFxsZ6mZ&+`5&c!Y%DeJT$iEc-N+D$gsAWlU zai^e7TX-TLD$=%lUOobx?Hc}91N^5T13ExtoBBVi{}V~YLEj;Y^hI$}4QTc{42}6EO5&Xq^sQ zK0d$xQGZSZRK=&@{Ui8actA*iOoAD^`RB%8C==lF_n&oofN=S6zvF=acZCoC#uxDC zp9K{9$C~J?e<*G{?%<%<7DBOK(2zD#CO_{Q-t6k#3Tv~GpJakry*~GT4KV%-6Zwba zj0NzA^_8*~OFKu5-3hVgH_et)ZQmw}nCxmPAc6evQ~G}i-Ma{K^|_g%FE05(W#@s2 zelik2O%hvzj~bpWy1-NV>h_Oi|6daRvu}XcyMIOIt%7=YdISLpn;gOpWmxC)Xr1N0B1*Hc&@&|WDC4{G&?PDr9?9KuP@)8hN8&x z2(6}tA=iUVJR@pZp_lsd|0JsaV$}b;DOud#`ja8r@)Ya-6yH$>P`m~bf04AA>p=T| z)BblYK|Ttkd8A{;I&bCXgJ;cfCk~Cq<52${_5aykEDEGD&ygmCy?kvJK1>n&vm!Nq z5D{I=Kwh@#ufr|z??Hje07#iuEB1H;Zqo`Hw5J8+Qr*7a!T}x}oZlLuU8lZEZjw5e zp$gx}qqqjw-7zq(n-5Xu;bam|v7dF#>c65sBK_vA?sk-zRGk>P)1SWo4-QDFV_ zOH&%uZKGw>yNn;Lkl7zAp2A2;J1d9xci=Sax!GOMgj9oy&iHjTga*;WY#$ihF*8Xr4` zwe&A)C};LNd$|qP)=0wxd~POo%H3J*J~sdK-1CWmeS`ok;NpBR{xd~qpHB=7@#_YB z2dhMQXk{gb{X1-pNDOt#@nsJ(J!aRZrDIb5^uYB=w|$mwj^W*hx_*&o`^4` zvM9Y>;xcxfi;LguLy5JW+)w#?B05v!&R+fxt+)ja)U1$5(<{LurZ=XYUDMOkkz@-e z=YF2f(lt^IHdU>K@_BSrB!)TPpn|r)Z_}vRc$c{!r8e#(yhxViFAY|OPieA{NS7G~ zS@%QN6cavcrSa^_$`dOwrJJ+GE*cjyyhbY|{CxyB+q|mPMOXf(C;ImVIZ2JNwwEr< zy^asR>J>iOEcosRJMSy&z`l)JbP(vrLm=XpYyR8v3GF8>lbagj^`p(T>2jPSqoX5L zE#pHg8V!C=O=qX3Cf}|dr!gVJIVsti1NWA)on@FKBao5&`bV(x9kZcJZx>U+QKgq= zt`2*u4ef~ARitiYT-tPBH_}t%9?XtzSUdDgD+&wNs74mq?zgm5RJ{IZJDjbX^8{#@ zMRj+I`oQO=kGHp9xvZ*Mq!#e^Gd=3SA*!)RnydCVb z-z?oSjaQGH#`4Z6{zf&t^J@2}u|U;(m1uW?zXwEs01Bf|EE=przF8O*<*%UY{a)&O zV4-c0D}d4mhHQ@}W+zjOa_!j2w5+sGbf0c()&|u16b`rTAlPsD5mT=;50-Bf%05!~ zPDN|xUDO{A`L5-*ef^-%j)~nTLv^NhzVlx){fZTv97!Bh&RS@f^;L{^`ucqbG~V`{ zuhmwnAk~16YUHCd;P5AM9Apg?e*!`jLKO-h2_glnikNZc2Rt~)Vz^iI8*726OPt(i zQqo-{%B$OU6?`7pT9(4MIxM(z<-~YO7w_1Jb{FSO=JHF!h7%5;?3v@nTt4hH^ zeW3b`%BP>@r&`X#s(*WvzMQ$FbFaJhEeIt+V_mbd+?nE9aqTABxmp%?W*Tn486F<# z)oepD_gWAs2jEcvodO6BMzt;E&6Q+6Rj&Y!xORnIH5F6372;3jZL&r($X7vChc4u!T()%oh1X-xZsKF~kR(!DW1 zE6^)Ei5X}d{4Ia?Cld&O^}GfK)HsbvhmmqMH*nw8!vW=+c9(qGM*0NJ2t0}O7Z1YD zu~#yxB@aVO2ZJH^Vy~c_A;Z>K`w79-su7f~V-Mr9NY{9Ef3X%@Jjv@~R!=H@X7f!H zazG>tby}JGf<&Ksr}b(E{3xiLJlG!bejgQYMXt7adF&1EUHz!7>1MEpJ@5c{H0S&3 z${JW>Io|ds1``(1%*I>J*8qFYK;Q0{_h<)EdJxN=C^bQ040iC(KM_~}4xnWuEzp$~ zys2@nQZ$pcW%qq0o3|m-_A!Ia?uRb&*<(h8>7SFY;&?VqC;)M?ppJtIY>dtg3lieM zB|B{J^4k+iKkfHXki!9pfz&e_0Oh`N_VJpX-9F|(kmOzYkB`)0vI-D#K!4e_yv9Ee zz_P8Y%0ou&??2*62=3Iq``pDY{2F}^$sA)uWFFGuNGRZ2if@9E1o~u9v!Ct*US5N! zz)mZUn&XA0LBochv`0{tf|z+=jtiJepE*0sA$aX4cYX+dRw7X)pbzjS9N8v_u+Pe%AFM%p)#JZcb&v8lgjZeLcT#Pb#5^%0buqIdJ|Y z4* zCIvEjKjpRP>FF)HIv%?pyGz#kO@OCIrC)Zx$Q-v36=VJFU!wM8%t34~tlweh^XJ!6 zh+w-s$S&|5#xc5797`=jzKah5R6pF{faXTO&x0Wm9WKx2x`Qp)#A=9<-%i+CiEp>L z62MPt=t|PaVab)CVC7DVVw~vk<^>bL36Smxg?PHU4s7b{4{u|$b$%Hhwx&OnKlkkp z%krM$pyC{-v4&Bpdjh6FSHrHu}EEcHd<^r@NN#L$ruY^E{RnNi3rsD zh7h1sXpy%#wWk3ghyjHmwoe>yUbthHUddI;`Y7Vg0ca38$lOW(|Ay`0(5*uoh{ zY_{(8py_2oo+O54?7$4YqVhh1kdh1TXNwpZaN&pIdP6{P^Hk(36XsZAfAzw{WvS7V z>DJ)Rz+PZ-y6M5IqE{RTaK;p!N8|wFf)sdM4zPCin=c6_@NM^)WXcb$W=hhC?9dwx z^8^H&3S+@&`dF6mC>e;HW8+FAP>yr(=#{704-J5yEzijWz=$$jGksi95j$q4t2(lj zj|`V9N#yQ-?HvC;z*n3$)%2N0-NmM=NT}+BU`8DW9;mq3nHPBMgy`9}(lEP_zgjwz zFYwW=Qub>5eWBK`EZzzFwzhBe(ju`pIO&ih=J4W z>y;01@@eGty$_zZgWcM8Ss&iOK!}|r#*C@7?;*|#51Ah~e@wH^0y0}~c#h?c!?OwJ ze>@rPxAgL&(YE!4vhHs5hOruhveQ-7H%G;w%H*p>k&!$~2-X6Y!nt+(KGWox6`4w9NC;3uvY+@(`#Itv&Z_Qk0CQvy*Ybr)V44+aN)6)9XxG= z8riOVzFRkA{;YnHy89-_Zo6k|sZXJftLw`!6Dhpv+FE^~Bt!*EW@BV7_j@#mUDA@n zPYNMSTZCiw5sh#Wk$ShU5J*HqK#SeWaj`=3ds#Wtd#o^_be!qQ14;;HtMvg3)`NZz zVDKbZXS*_g#nDwuwP9UKwjQ-~g3a5-iqG@^|ef^}1koSl5p9SVmN${PUYtU@ocrhxe5kyO{XO z?W&-#PyrB#e?!*C=+<7GOR^?GP3VFG3y^=m-np4&3;m5W21j9hSvN8FTB6!%y)kJ% zvz%^o=F2MWyThej9O0(pZil8*0PXN?3{p%H;<)DbF2nomo4#RBs7%J|xJ{EoUjYCy z$e||E&5l6(Ia@ol;r~tfj<^2)&d;C!gVz_#{1 z9Toh&dD!Y%d$pMl957o8X9V5@p>Bb6_)lu|DLU6^05qK$3(6<_j&ta1Q0#&bkCP)9 zOFh&`fH92=Y%^Fd(3FNn%892774f+;6V|!=juq_pH@Qu@15vM>1Q*swR^abX2F}GP z_&%rDL$$$EV@HCcj=OiO_&(Qvzke_(E7K0WW`KGBxguf`wU-QVhw}JC=h2kLtJdA5Cj+w z2KBGKoi$w^rB>bm-z6|NKA^e1WPG<#LTrUV>Nfp2 z$AgN?MMXpoc`$RGLs#qT<%rdZM;aIa2+QVPs2Fzm0_|Z*xk`u%dQ!TdL%GO)35B43 z%a6c3cQ(lYLeU~D&^-=&<)UVqmbLT;z@9~*{K3nUyNCf-Fg5h{Q1(?W(*!H7nIT+y zxH*S0?L;+65-=f%hzO#b_PG{{OK;&+6!-@EyFh-M0Ah2qrEC#_v5&0k=pP3@1tekt zGJw{x+xg;D`e^j(DP?Z>(0csRLut_`BtDuYl(jzsBBH=Sqw&VU8HR60_m|7HpZwmI z-zslf{7CFeSb%!JC5d7N+Xx8Xmv|0za6iSKp;?9Ic1fS^{9v9z4{ego;h|;)QPQz& zD>qK|CHJ4f`#@+o3ov?(NT*+gqhC6O{9J&qS;E72jf@VqI{&nv5OQEhv@6*Z5bc!d zlSrv7lAYXKm4_q`hwT>EjVkx@XIe{GLL~cIoSve(fY_nhbC3u2LJop=NYWE0VJvg! zqs5n2DOwT8J?I{rD|MxIMmldk-JUvII`>?C%heV*-~#<(an!?Jl7<@kuvL&RUS@TK zaBdy-f*F(6YpIQvBoXq#TC72uvS7VO`onGwXAAs+`ZqO9assrq&!JS2MF;^v(0?)D>>G8&n%VIF>#DC zmG}#1#7{ow`sYhWq9w3|ln|smVZw%%sv92@{BevGs)on-9xT)o!shZ#WV@%`dVTtE zjzR>FN3l3W4pxjUG9@a52w;ETY|fU9z{<_pwcPobVD0w~c8HzPWj&e>eny7o^!HHT zmSwyW;i)P$KcYXYhN@=uumh0v=B^lJ8UTSMgP+iC4m@$aa@SAZW~H^p(^LV36#FmV zzVZ5nt`(|{j3o~(H9y5Y==hDX-1RX@Q6F{QeDs=7oh>VLg&mJG75C}T-9!E*Mn2X) zg_>U;xH!718HEi>lS;h&p^lH2T&shGoLga*^KUW>dWe$Dtsc>wxRy|=yXa31x7}SK z`MG=8Fsn8o0pGE%yID)^{UX{esm6FIGkaLqC>P!S#mtk2tO*B=bPWlI{IVk>;E+Th zOyAlUu7%(oK4lyXWcZu=@A6_A!M;Q#?u#QZn?u0Z`8e zyRW%sYFtq7H$YE07C+LB%p`)U(lQih({q+>x%a8J=8pVDkv*Vr7GK5v7+l%D zce8WWhpAbQq=UKJCqPA|Ltr4pyQ@5bHA(M?8c;)MC<;$a-av4C%%Um7_L3k35s@o8 z5VNEY!`dknb%s5Gxq>sWv6WzlDmO|&9LS&IyyJq>0I_>7v4(TC!ONrcJ5?< z(EhCcrjZ>?zCJ>Ej>7)}XG28*?@GYQ$!j{m$KN9)%Lhg$x^ii~o(ng!_A7Zf1eZ9p z8`gett77~l;4@C>9Q$}tH07lbHuR>UOGnNb5U>`tuid^ApneKYwL~()tmNly3+XPX zpk`21G~QX|57n8^hd2+vqq>7r2%tyM=Pan}*)UMav$lhYx0{LTc;vwO?Hq31WeX1_ zc6Ec{3ftndHsI@Jj^R+QDjTnRc=fW314>?^V-ein?{L!Gyw%<_<0!pXkx@x!ut(hX6bj+o3b`ghfO(gEZUq?GBvZPzQ9byKr>JGN6<}D z^k+-LRKHeF7`_MHR}QZg6qwak+8g&{_}s}#&y53h6Y3}99p;!*`ar9aij%XaSz`M~ za(wI}#~0ZrmuEQ?ZR;49TQAeWMbVn^mOb|UkSz5}Sr@v-m}ddyDV^Ud&WJp*SJ?cN^N1&pYr3Pz1rV z%4-1%=U~=9Sa*nc0#i@8<+&Iwi;c`pp7>DtcKi_m z*{}jFI+7W>tWhsT{Eo(6)3*H}H3-{8MPLrY84N!vJ@GYwH}jsJp)|Pe6ZXw5NkK z2X5x)=P#lsOqsR0$+<6ZVesFSLZ7V9cCp(RC2D4w_T-aA-n5|xRQ8K%snIV9P%swx z;`!$7a2@~exGIY;iMqLFd6-GeQu{y>?W#r@_8=(;K!DzAqLa%qQWIudn>#fG>QWxC zN=b!UCYTAngHELy3)EQx5x|>eixNY}`te-ZHs^%DK_H<8fqA1YkA0Rm@+>6%cUq+( zXnyqMWS2OJ*yJ|anxFFWVyW?oD7?~Y%|rnoAL+B^EpJ*OcC z#OaCJtZ&jnGdTfEqXxX8enbeV9*VF7Q^N)bgN)2i59|`VzvYx=m|-yZ;u_(E)UvuEaZnkEML>rKMyoG#tAue3}zg?3gAG1_|~GRGZrzG7EI zt?Nv@r!BE8mlhTvnaDpaf8z6ER{$TKbdhRc9ZfuAGI{=z3LBK zBXF{mA$#NGXqIb&>lwJ=8O?b)#vBJ*i{vx2L}E>PZW13H{)w3v>7x7Ycx~wBbLZ+W z!c_0#fYaS=*xaD>b}UO;xAh&5aqwBx>+t2uFAwRBJ+%}C1*CH44J6&eRasYjEjq>L zupcR``5G~9k@rPw;5sJv%}CcdSxUhiO^S6TnRll-QHBo67MMWU zy3yUrWwo){v4sHU6$*TZ1L&zE9J9i*jht~q+2vs~S_ft)bTy}MVU+-vSTyj(1=y%{ zz}zZT6-iv|=x}Z#XqZpz)i4FUsKA@B{H;AD{RnwLnD#vK!vK~rp=gV&r6Iu_E1LmK#GxlYvw-Ps{7e&kt+j^n4`< zqo#o;tF}E*8$1?7IRi6bVIKX4S;gXlxXZ&@8k>t-(DWoXmFH27-E5bR!0M^wK*T+T zi*_Z0pv+JsM>Ae|jZ{P^D})PRi#LkDS#l>f?bmhKfu_uE30i3f0XA*Ecp0f$+VyAW z1~1Mm?9TK?Zzq47X;UE1)@D3sy@r@8HmtAj0U9R!TF(5@0&fl9s9A6wB+Xeg;Lrl!$uuI}1Gj?y%sk=fC7a&5&GqlcRI=Og|JR-b>tl zX1}<-#hiR$Naf zC(8!JmCMhPLE@-tzo(%aFssP0p_L53(O1P9b1TfF);M3hWQyA#3+X`LzO{4Ft$q8Z zylt|!SM4cG&?XwSil1RTn)S-G_Gm0mtW}t2H0}Vaz-z%QIBn3#2;nQofJ$BY_@K@Z z?@Sq|X{YCu;P54R2*%B&_9r}+mQ^S1;imxr73+U8EEr15f7p?ojcJ)V zu5Wkw+;6Dt%Kzn_{ayZil@Fv{J#4bc{=EJEq@J|q5Z74r6iz*^a3_JM*`+&+$Lg26 zV&MA6`FPMc1#^g!7Y*U-%+_u4H;OqF5$chKrO5-2%(F>!VMzIP&j#tuTrO6}$hQz_ z`|-|^8`?jTr>VQ#xO3%T+O*`(#WTzcan_?=uQ(+eDbsju=ZMW%;ZCJ9xi_9?(&WHB z$#Gr-T#*N?Hca;l-*(DsL(~ZNw`7n*yFVlP&( zfrA2vu~sMUK!E2$l2vEF1V@k_+1h|VSfUr=Y-x8M+?%AVlAZ0Qf(QlqM%4<*X#|?dHw)91CNfQF(ANuGcFar)LF+xNbyni1lyK4-4f`;=FyMvcU?6e+8RyY=6(RQJ5raHh3SsY)sl|~ z6wqC$yX>G7J$6X#D7)bqb)-R^#B1^LS}z^`t2UIJOj2V3^&Lua%`!hjRbR~ zQU2%%FGCwxO$5F>79uZxt!z#7tsOa7A5ntLAVN%--iCD!gnovic1>J_LV==v_p8q7 z(T0jO)|IPF_A%;d-@^4BMk}v)Re?puArIy)ZpPlSeQ&MNw+fw^Xrs+BMNJ%4Ur&cx zL}0y47xvPgzJum_ndv)EG_-O&#!dn^ME6#Ir)5{Mx1ijz9C{F$@yWpibuJNQ3D8R! z5?h2JO7%?=H3|aYF$+@eo^@-TuKhGgeVsbquLnVl9>uH{^2=3A-&cYNd1c;e5e1ld zqT4;GF6u6tFq3V#FWG>zL0`Si-FJe=-!P;_=b=!Z#Dnu_zvy?E>2KqEEtE`hSHd5y zrAo3?xVdT^>(Q&v=!uEHw^a))`M4SiSgV5O=KrkZSzD2l`NktSf&%j=UKN_6-_u0M zTdl=)?s;9RkaR<#4Uubf6CBex&zF7b(*&=-4-*G}RCPnhmtK+VD;$+Pc7E=yj&DVv z22$n6y30_C2e>l)z>2M8mTy}kmnc&0eH53FrY(r@07Mkm-a=Gd&+`2qtoZP0x0r=DBrgE zHJF55ii?#y`6DTn8;;P}X;BK0Zhx}ASv)4MG=Aa@khr7A%_GkjWgmchbQ~02zL%fB zf!r`@42Qq8KC!eQV!vmKOo$rNoEhC6y0Z}}4=5kL<@&YLQeUv>lVc*dtP;b*CBGE- zsl4PKL{ov*0DW&htxs_63s_h1hxD2-{&farr8jD3cKB%TBRB+q4r^@$28obm3&EbY zYSb_MJ{ac%L0^&z8g<^iIbZFX3_h*GxrFp_qYfVcv{#q=*S%?iL{SA%(Z{%Glc(-C zBOdRh)SAheA_DK6V0BuzVigZ@E7&r{Oz+X%{gyh2&r*rY0U&@U87YyV(s9iO_4gKV zVJ>G&bJG!Wzz>9fz31D6>j)+nR3Z3T@=h3v!u>ieJH`8CGfTH5Ej_(Z)<#^Sh}%0* zXaO}rWnu>S{o;VBLHnpebth`j?zk?2I3%&oefWN4?E~Cx2ug&flDN`Bm|l>dn@Czp zIf-X3j^s-ZtEJy}AdjBiHB~q3mnk1hN(d|&+>g<4nX8k@#Cf}O z9nUIWlaoo(nP2q2?@7UDDs}DQ`G{6)QAk|pi$0@E>j!S9#dQX*USTj{6tw%NUhb2s z6IF-}&#N>XHX3RpB`MS7ppnp{YkWqHCC?`z$Si83hpU+RvS-3@1?;wa2>(qEVIo3o z6$yFOMGXiq?sg!}ycH&L;n0|jq!a2x7T1t-pd)1y!u6E&aI?!0;_ep6p-Az7ku=Id z7u9gQHYgV7oo~=?F!222QdZ|toA~KuZSZ80p#NrAmK^8^Z|*KRdZ<3zxN)=lcCMNVJ?5@MKpiO+-&tDc{%7KV$$qmN0;p23nR^GTaVLREdftH z?{*C@H(H*vt>WpBjif2=yLkQ_twg8iG+qF1yGJoE0HV#bSpM{FI-p#?>*c*N_7J3r z=3_w?&M5z+w*&)hvnYTuBnZ6)Iwg1jf;fJmrhc4@;Z$u~<^aYr(A8iT_`?_S1nGW? zXv(As<`8LNsWa!iw4zit;5&dbsf0ZCx?Wk%^9Qd{puJ&$^o5h%dHUNbzQJnmuRt?~ z7fj3ZzEb(9m-!F^#v>&CIxv16Kzo*76LQIlaei9}T%d@cfc(T>_GUK29E4 zK}wN0`%l~P1_cYLT&Sq}s^3#n@yjtqrpCtG6o_=7rvYpRHbmA{i>5O@dyWM%BoMkS zVlqbgj!fI!-#R6UUUbWTzBW;11W4vGzY$je_ln6SAx0cc0+dkQdF(~$sS}fGTyW=8 zoH_E#62I=u+O=!?331y$GkcVMF;d*p6&Djc6sUMU z-~iD6^)Tbtqo2xy9s8p2x3JyXk|D>kamT8Z?w~xU^h%(WwrZX>N?3|ZAcFH3qz30} zHH>sma$%O*3|k?5DWtD^$vn^K&wFg=dmouW=tV07J`H4bt<29FpGD9IT0x-;I z8~v;btY~Iz{M;$aa$}}@nLDM*AdGuu6%@dO3Jjdza`$j}wcxdC+mTUf`u;N7Y(FuZ zEuF%W{zEb@T91b)%vgCf3PXHt69EC)f}~T!AqX;iG1v8kYqix;8T<78d(ZjU$cs}% z=|5?iN&SrLQt?mYdeQVAcr^&NVL4Hf|7eBzp|3O*bM|d1!Sx*xIO+q-7CtRxa!~Qb zgZ1PucdZq9`*#|=_S&ilT}ekJQdoR4F?FmloBTcO2X_nLK?D z9M=f?L|XmwL7icb?^r38-noN@J84>RB^&t!U~ZmI1}7KpDT7YFgRT@oH?ME6^UFD< z0|8!O!+Iag&SCd@99c7_6`T3U9wwdI`!akp1Y@ii05^LZ6GYqEA?z3IFKiy{Os?b; z_DBrN`z}^`N(lQrPX27l*S;;8T!n*}!-#E{X1L(YN-ivKUZdi3izCB}z&n8o=Hl3N z536s27a$v`5GC53}O3xU})GWr&_OF>V|EK~oENp0WyQ z@;_^;R&+XTY9Nu9fPlZshc>0sfpaOq;;|LbOV+PToGmYgyno=4@p70cAI7eH!8A4O z8a;IpZ?B;eD_iIJux0pRw|?aGx?%V9^0V7B7~SlLfh1Dwi8x36h8A|v-0C;%Kb*dw z?-vU~X=s{04q|(~K!Vm?14!v$HYLooU7X|uWH)Lc6sGy{tDTfPNsffwCw~)qNAm3_ z55dOclfLZRKfyn@q)5odYaxf2`fkQGU*z~y4SqVQ`+>Wo&*8%kkjp{i zGq<~%CaIVgzS|qo`&Iw!DJ&lXFN+-DH69m71AipvW*;DD$nkz+5;vOSD+TZNoU;<} zsLt*P#al!DD#J;;j6U)$)au6Zkh&SCtc%Q3NuF1pzYVsNP1{|7&(u$^b7FyJrq=0B zAmz_Pe9i0y+sfIVP=W)6xWv~9b$PeNohtGm$6$}yqT{+`MicN8A@O*iDw2~y9U5SX zD2>-8h`KQB(WBc7wwRqf2eA*EQh>Bj;8o|pzHxS@y@_+1^D%fJ#;T>Bq8IS` zdfx(S_OrotRS=;ip`U1RX-&hRUBBiNO>l8gEcabv^AbYU&!#2DWQo{!doB7B#P(Z! zRc)-@W*(Ayr1{5T7@#Qag>NVsikIx{?BAoF$gY)dTm0rV(4|tg2{9Y}=mGBNr~QIW z5VF-$@bWMmA6V=4+&$dS+K?ycmr@0tJlYxBmdX+(*1BKSFllpFP>_U}m@A&4D7{k8 z(d>|GOn(kIP&z}RC)|ne49?D#IvF-&q39jr@vJtn%2A|B+Pr6MoMW_p&h^Y)%XaMH zk?4|=lK%ewKpi3U#O30yb0jJz^Hd=nxPUm!;ttW z#yuprBQ6R*O>Az4+5u(xVKdT$=qT8B`}bEdd+&>y;Tdm(ZtbJJn2xwO36^t$4;!A7 zVn!i%6JBXIO%v!0;7&X{pHDY&;&KuY2*Uc{qyR2|c++bIfDXV44I#Y{)1Z6Q1TXd%VBgCmRE0(Tkk5$f74fPE29_cuBV+`XhyD}A;E zuOn#RZy2!dx#O?%mPH?!raIQdM)RO}f&{HkayKtO7dfn7PLUwQAI|hXgua_Bp9f!2 z?)7e8yu(G|m8v8WS{xta@H)qkD+OluDXsbZccXRbR6#6t>~>Dw~-qZY>3@cUG7w*wAq zbY$rHw6=ipwdFqwYT}u#{TACx@A*CmX9El{tR2riN`8e3kuHO6%z}~hz(X7d8l|=f z2QI)^jle7rip#bDHvdV8mKZtDs3eo@6nUGzT!_8U{ptF5=(PFGAvxkzx~` z37irW_=(2~oO2zujQDd+%LDAPM=B?*zoD%IMPqm{RK-S)t6OQpqk>=1&M zIX!HuhbH&p*T{jM9gb);y1993hueNi&da7w8^9QY&vIJ#^hnOqjr)+N5R3?tM^Q~t zvCQ6dw@1(_EG9}X8#}CQeLivXa=R~1VPKD8ReRmEBxEy@j3&_K`}vqfhvcF9rz1%@ zDC!L&7B8N8^#a4SH;I$CPV)0Z!0ld9-11~(EiP!>=8sp15x*W%i6JA~Kjpf5!eegj zGb$d_TwK=J@IG3}815B7Oe=k#Tp#!op`Fj@uE6+EUaIEX7S|ududk7K^1v@8a`eIh z40p~OXCWva>(hV+UI#zvbi~=i)P(P4{dc{j9CL8P+=QlnJbI+7$z$VYP6@>>K$X7E zb347r(=4EVyS$(mA1}kLMMD3!clRdX)k*vKc$I)@!&PI+a7W{b+;z3YMR?I9H!A%$ zO}`wiWa-@|;wElN-Vg|Bbk<|Tgw0LQ&SLtBlxfcrA? zV}S{s9S5>tvsl^Je6yCo)w_#&EmbQTUGzO@eKEcQ-yKm&gb?ChwM4RmgUswb4Xg!7 zlPj@y$4Zp^6Pj0p6d{8;oH*I~JOp?QilhH=x!X&**KQ2-k zNsby12w%UqW(l;me2z^d-+W)Miv@5oO7B zc5kJWA(f1$;$`lJ*O~FE ze=1(usZau@WDXXkII_@3(@vAT+q5?bW}1r6QKTVeI@|5Rz!ST5HlUew}am@799ZVwy|xnxKI^tZiV&7O!!x3z?QL1lnilD zNirx*Z~V_rt0H(!m4lej-}&&*(rKDK1saL&kLT!^gzP0u7F}rh`EjVk_Qqquz!@(u z*8d1jrz6OT%|`b*-z6B*eHY&WKZrDPdmD`#EN=t3o6B3O+Ros@+8v!V7>^yXliCAs z%xlbEKDQ^>e14}Q8SoNCHlVR$ei=pzY#Sqrdp>Kld{{U?g!wF7!?U8ep4^Ua|ww-=V41#Z^=%!^=i z+LciMjc8YZ^Spn9heh+M$3zek^oUuR(&jczIVU_H(S-?rP3$(ky`#Gz`!pY8vEpNE zYwIaC=_I82 zO!@PVn)Kumf|e~Y@AniI5AOlR??o9{sNYf#Bjq3S3>}&lejmk`v4;Q$(Cp)$_1MN> zBXBov;`hpk4}X&1UA(ghz-+yiyzeYCTf_6i<}rTLP`0X!|KiDdRx>*`>}pk7Bm`4& z9Qy2g4raerR5+MQZnu?hh9u+sUI4H}LCc6PAxM4y?jT zGD=ZfQ4E*By!YCxBT8=2Z;qFKF`cc}yG7%)ysQ-fXf7{j=uX+FMm1UfYpyNsy~%!; zIdtVKMI`+4a?4Y_)@|*b=<9OK+R+^s3-K zvmWqKQ!LoJ>FAT2PeJR9&AOfFlj9CB@@~Fqt-BP?SoV7~T&{my?S+N^t5>f$xqvWa z%_AOTu8{~4Pv$z=`N#1ACBY@t1zX`A7@S9({+=~XaB&|P4gDg7tCezPlG z1MM&V_#0c|9LPTP{{Ly%z|^3prl62x0RYYF6{lW_D;e9vp}cXM$m6ON4(XG2Z2wYOPm;xb;Q*k9r=F17;4LTd$&@02BTgI!g*1l!Yv?p3t^ zp5S|RhsX-#gFSGj#==}rIULFvY@!swsUt{RMmejW#oTS2620=(Cp5X~N#LTbfRp;D zd=;vEfhy#;ry@7UzTjPrSa|Gr127AFsM3%4h$;>pKkk8b*z!acOKDnA5Y7lB6x!AFy95l+o`;wM*C(VnlsUbPaWRw^DT`S6?EF_ng&mN-y63y-v*V97miU7spTIwLi#{tNV-`f3Ygn4= zzw+-mag&M%f!D3}tQX$_M z^X?8dy%UU^5GG%HVg+8?pr1KS$@G`@ti;QB2=sciq}Kc@gCjj>bp=MJrxu72<^q&i645>{Ks7YuE^48AJF=X(mz@mxH47= z*8VG3KjJh=GBilsj_e6Ys8?63VyoZcWpL{~9~(1z`a?GAI))ZlM*nyg_yz$&-9hEh zVsNmZotYY&{tyNcI(yC|0v6guo!@>mmAM{eEz{P7-ES8E?PiM zg-LOsf(Ak7Gt&njVt+B=A7EyILY{}13ENm-@g8EistP=U>m2@VM*2OxZ$NIs7e}*j z-e2DKY3^I!YV}3=$%CiR!tcs}ut98EjkKp*fxa8(*Pt8KW?UuWkyes!n*eBW|&K^JdPScCO_;R$gHDgLaEE4fyvaQ`8KSfoDU?B(6 zgI*fW$*PE$|7_?bM^2hnGzfCMPl31L(bQv1UpIVT5cFhK7uvc!%8r`YBO^wxzEph+ za)K5q=`VIR^Rxd?3+KJH=VIh9wxZE2#Jd1cy~nU1Wa7v5>x1G!S)&6uPgQ3%HU=s*7V6A7fVOi+i=SmLV6z* zUhh#{Z|_LzgI$Xn1_(V_hg%34fXbaV^2b;o%OVaD+50b zy^mu)d^Mgg@_gw`?fDPx(aB|pKdyQnSCY*o9HG56y z5VQ5=QO&zmK2Ezi?W8HcUuMt&PpZBO9!b=NrM$m6Jv?!Q4I5oQhb*sdJO6soTaLhu z)8!hBYYp!&g@hj}{%Z{+2jRD>w?QZT@8JW-EJpAu$NkNVOy5lohSR0P=$y5ym+-m= z`~|yxn#j#iax#=ekwNSHt@Ozk`=Q*xfP{uh`5Oz*V!O<%GemM-@P|C7S5qX8%<@jx z&m?{}4QjazvFs*34SmVfR0Gs&0 zv}<<18JfulSrLs5g)^8#$}J@mjunng>Z&hd9%cL_o5K~)$J*7vt;ME|tV3}3bOs)U zo^(HJ_FGL;Lr{Iar zQ|b^))NGNc)c<62ATpSQ*&J#*J4)7vjnGtdNQFZW7}$Q_@=(W3|H+YkgnK~yE4G%;WKcjecX3!!+!RR?QR$8H zd5w6TQJpST;#1BpSY4=DPRGa&B?P%9B<}DsSr%U; zTK9lG`1RbpmoQxZCZY5*A-#;p@4p{y-dlV%&An^iFJv8CEsZ#E^%)@a2LEt2EoPRH zD)aOv-^=gZm@;k9`E*xauB{AQ0UcdbH-iq#gURqBA>XtyQP35 zTdE})HrH?4jJnx`*J>7HP;&GHsiPvPqYtZjvUfz1{U5U&dRP;8S^m8Km#S!PYikR~ z4Gq=HdGfNppdA;!X{gT*%3&@{CcLSa2xGD<+N9QxE{UuvKq+GsibN7N>tw#$ z4NO;_Y=3|!;1X?%D){}cmVusza<&w4-X<8_STESGdDqt1Zg&Jnp|`CKktE11tZgNXv(2_$B`DWP*x3?m^R4NO<2HtQUqgE-iodOLaq+^vO3v%;~ObRE| zu#Vm7W<)Sfj^RNmJsgIx$!%+nNfgVndv1GawHna;m_o*cGGs=5d_^Gz+YUW(Vn!WP ztE29>XJiGmy!n&!$2y`wyAbX$?w7TD4C41L*SqFCp-5(dCQ)X0`Q%KgZ)e!(H?Qo< zS0b&2R_|8`7L%@f2z`Hq8SC-jg>+2ZhWIq@{TqsLUWdxt{F6BUyP}Jnj=WB0=74qW zTg#sgrA&SJKkPoVYi{7}jzu!6p@nJC4+Xs*_OC?Er~k>4ZX`v9PaTc?d};;dAnk~6 z*WO2g_?ti1OHbBvSdOJ*y2!>Z_cRLyq$BWHDB1*9vQvOw66EQ?fJX^4pV{CCwg;lR z3P2gYSvVo$cR=uP{X~t${DE?Md2!Dhd8acyCQKB$fm9(eG;%r_?kz*=uD)lYP~GzK zyE@e)m5;9n5SX&5jDY0IV!b_pC>cd>GMK4%h~|R0FXh;pQB$;HsUd6)XH(R73nU`T zBm)fLMjA!@k_q8mhr`zv=7mmU7|Z|P+`feR*A>8QW&$xcTDQt24EI-5AN%y?vl7i$xdhow*WoBk#7^<-8t9gn!mG8AxMo@Jy zB!msZ7`bo?N>txceF5(&QSX5@hOQK*K4VgrK5fYH{zrMghG~o@$Uh%90XJulA_1Wg zNvKjGju593eu>aukVuj8Y*d#Bx*B8LqoFcbW!|In_WRk_8^>G85+0gcO&glakTwai zD(484G25NInnjhDR^Hr>QbiP$;&?9Ut%YD6Trkuxv0l@VFV@)I*)tG3*YSM|1Qj zMij-CwiofOg81Ld-1TCDABr#(q(z~z1fvGO9ki`;Hp3{t@~SJtdSI^(I_BoGCva?t zZ=S`CECw{{gfarsXDIW0SmX>=ZmEmy0Hn#mzz)Uf%Q&7wx5RP$_IQ6u6R;q9*7F`& z2U-P`1SL>%*tJjQi3$o}Yf`k6ctA@taPtO?BPdX_;Ly@PLj;93Y2Rms9*kTV%1H+8 zruz-Swb)SEeg%o3N#fwQsgac6=VEO&^aH#Zx6Ao`O+p5W$9z@S(E^tof%os+gv;31 z&k;V5muQb3m{Rn&;p#`$%n81kYwS&3YPce{%wL>bct@jv8r)?%C97r8C@7B-eykRE zvQ=+D3}MVDV2I4y&rewAP)B7(XnfgZvOp;nLg@JmO*R=E_>xfP97n-z6VDC2}jIWcFRRvYyNb+z9rU`fL_Ahyc zivUM{P7qP>=Vj6Mn4VnKQmPOn1>4L9Ynf3J%-nhwyR|c-9uT24J|NHq+d-6jio4t? z-1pz|$7}|X{!|xAXK}QO*)F`ecTy8R0GPOv1}mM)%E}ygp1fQYd}GilP{UCpN_~Mzg9OZ(Dc-s7Z%_!$I$_`6= zV2)wb>Pu z&<89uexAiCf4&%`AV`zO3YU|`{+^0C9lAx&0zVq@v8zlrLlb%48x^fRG=<6+!q^mHo-a$!URg+ z5&Yy(X|IqNAwUFEE0XH|*_(P()vcU(rQtOlVYC@>fKUM(Faf+NXcsR!CyZgB#!q)SQ-IXMIeQM87=|3* zhA|=-ajNEO<`j8Qk{yaAuV{*IuUdsrsu*0U3g39n-OU#e&GwYYDx1Hvliyy-K!iiz z+$@m~ia4U-kS?-^>J1kLX_grTt?x+HZiAZbEoabNKx^IY`s*vm{SJgZcXu(VAg={! z8rwawAT_ppgz{y=BzTcu$R+sP(9dUekaz6(yp%SN$)gE9hVXi_|L43ChNJ`Zbf8Ik zczirQKe!P+wE+Q@x}{%gg=O44F*a<*Iti7;ryB4_+^abL;PV0I(;{;YjC+qQhsSVDt3;xq6EB~8Su=mPREZH$HXa-f69Z5|Hd@O zp=|4bd`G%CI_;Hn{`o#5J%D4?@7)I_x=`_G$hG!negbMq)+jcC@-sj_eOe{&m%oX9 zkK|k3#C#P5Ou}J#*#;R86OaUE*$TKFyFgd(G5>P)+>CK>$IfN;WYvl?M+DMHd4_D} z23x;Snl>@FN}ePCQ}xl8f22=5PaR&BR6H*D^w^o8MG+i~ADSXiQb6XKnwr{&~;i6-P_ z%DR4hKU4qZV|+|gr2Q+a{_r$^KE6N7Ql@gb_zBNJo*-HC@x;bwD*jt048us@iLp4O z7V5_%%)_P0mFG~3)7|-<%4ujQAB9U?2!?GK@x`91CbNR(7s-v8XZaS9pfiKzgDrOnr!>=mK1C6 zSAvl-H-91qIm_2+bd7h1+dNu83ORa1X_S?TbB5~q3OXv5@G%cAjmVZK!nNwMVE&%} zZXOjE-Fb$PsJ7{nufGvr?rr`-Kf5H)6ZCCX94;IJhtnH*w88PTcrDc2-X2xlt?H#0vj6a?a->aYHf&Tyo>S?aO z_k)MALzw6%I9Bsr&(%e3*JJptKT0(|19{S6)Mmx4Suo`3Qgf98lo6w8OAro4o#Jgn zPR~D&beR31BzlR7GWY;VK%BQ?nwO+L!7`TObXeu{eLpLAv(y~Mgt5@f^^r494ORE( z7?QnmG00t?0pUaYA0y(W!wFUEh#VsStf>}=52k~QX9MoV&6~#;|O0ROZ$VPVGBv;!k`H& zD(c_;%GRoplO*+4p7tTU+{V8#A3c6Xg9+R1*WpmDb0vcDHk|T~(Y78A*>tnS zw?MgV2rP?W`LZx>5-u6>&;>DXNemWpE@LXw5FKGI1C@3%vRm>Z@%VxChebt2lO%24 zxqGfpipNKj?5)n_I|-{>eB5lEAT8myBvmXlV< z|LPbD;aNi6YbwU^w#-yPI$b{{a5-apiXv#X658FRdVnHgk*~n(ScA&GCnRg(>6oEA z@H=EE%i$9V+}Y9baUhw>B_Rn8%*)#p&xb>1=jf0{#)9|~hUBG2b z8~^h06E@Z|9UJw5Z0*R{Vjw;BroGb}w#3#qdn?_KiEz#yStVq*Y4QC@wbCbxh(=*w zwJQoE8DqCNt28;1$V0ST630<(dWNB|wSK>r+)|^8TDQ>Y!?VC8Pauv|)0ku!r=bs% zho)IPjC)NmMxff+xlV$j|M!RUAzc9`sgS9$l$h&9Rpn0)Q$+m;|3r$>T2Y|ZMBU*+FrW<9+%3kG?Z zkg5=){@M)+4fcLa?DwlU^eU?msUj&7cN7A^7E zuC;KFkw{)$h^=C!u2(`b$yOf>EI;aCdNe_nK(Nk?>c=K2$&Xhv(P(k^ur778Z^8lj zT1rYw`vj+Qx88K%FF3%Oi7#k%HodS~uE7N(BXNtea!}NvC50y2V@n}Nv%t7Vn{06O z-7`^$5_1799V;q><)~7LA`yn57$0DV)=ibg+8|0?P#EF}Z-$4An5g)88>8pJBr;NDpPloAf^Q#khx_HtZxD#8 z7hR{mf{7k7IQ}JgVpY3gYE1QxMKL6*6{>^i{E2evvE86ZuAyETFLwSGDN?4-vU)xI zjrhH!5XC&rkb&R*FRfv?aOkmN81t{-gz;#qG^+#QMT`Bgf=AhtSPBBsTWig?{iRw7UT{G&~08C|aNOKV5xi`W>+(Q}xN*zKC#fWWz3gj3+H(EBW5r z2a3IQ#MtufxBRCSd?NANv0~(}qr!(DX4KQLnhbG&_+h&b_v&+Vd~46UsDkyrlkh1g z*>NHT?li^OAYqz7jd@4TNg@lyzs*JCvu3nd z0~1DL7uNMJiV9sjc#9UfcjvNF;f8;tmn4@e6aCqzK0s+Bwvgum@zC12iJXv7+SIuV z=ZR}7*!jgGA-Ub!xBUbcuA)g!HpoM5UbW;RT;XsYiZv|oQtBOU@Exxrssl!&jhq05 zz!C~1F0O0&%NN?+9%lvpL=WBh_1BEsczBV@A$(Dgb+PAhPI!pL?>_$fgHf3?jIgHd zghTI9*eVC?6wZNx6=^_TST`x>VA?$%1^<(hPwf(?U11c9U)_0T=~yP}vYEr6!~sN* zv>nM3#9{kcz5F2s`_7^-3+w*X6e`ZP6gkgFXiXsQsemg;z zzr|~faKf#@GEP-5*Mi@cjc&PS_Vg+4*v#6()(-8MT{ls%?j(0A&+TuvyEc(! z+u54lexSG2!npvG=Ti4hC|5u1N0*M46`h&b{Pc9gGd8GfCrd|#$pr$7k=iV*6~t9D zHahl^@!(b@&=}+MqeAZ%F4?tm)c+g0B6|4Dt7>?>?_72uQvlSI~De%0=EB> z+)jof=w4wImK~a$yCyts>2MM8{}SgK)r~$teTy)EdTS)SJojP$>s&9Zso*Qh?pu6X z`F?DqM30cPOd$?d9DxdV>JS^B+l#zElT~%{Kz_p1sM+0h^;4V)&k53O?_geav@R$e zR25)=gdjR1TS!ND_UX#~Ab+Rr(GUStxlvJ_=f0mX1^-BGk!-0<)Rpe3=}JXI*`-a- z6FZ0%4%R1mocOL01dP@n20h<=wu-?PF3#iE{M+gRo92ehL$X;P=@xEy{0%tjM!|!VeSD`fWML zT{-sNztY)=6zoU_GM*hKE!ueQoO|K|LvXN}kOJC;C+n>?8XRLYI&Y=TW5VBVRE&QA zea9;@95R$9uRp+?ex;_76-+w~ui_kE!FwzK9io|M8(c*Mwfmg$Mc6Dg={~c|lUP+Z7Wq^K{}%|MV4Y?8&#NR2P5?$vas6a%P&Dp!k}S?+AH6_*rpE^zQ z7wv~D8@WN=maa@fH!!~AnvYxs=43Jk>d729p*lp5Or#pIK{GX|GJp+7Uwr%trlBh+ z=^TGUI}Uk;Rdw?Il@np`B2%gS{oHV}=BY0C#LvInruz=Td{ zuNtzHvdmruv&xVQk4cc$W`VO`>!#jaYCtd^3(Nj@%~8D@k3o~Gs}4ZbX1qe1nDo2J z`}#HCyyFGuV|PF>NLPBmQa3r@_1Qh!9SJFa-}7*J+CUz<$Jsf&S6{E#M)^k(({JV| z4MnD0Y+JPCXcHP6*Fs?~sRT5kO;p2g(&UM{eg|`G+SODwHMv+ZQ8CPr_kl6$njWWx zr=7NBC#vcx@0&!vH`bTe)|Nv+qn_Sf1@}Y8w+a!w0T~8AsC6d7jPl@Z^>tz$yy!vh z?IXKO{DXm+!5+$c3ZaOXsszXDn24`#3(_z6abK!;*lVzN9qIOPniZn?Jdj*)b$m)2?m+FI99^ChJMc=YBh2lQP;Rww$ z4V9Gq&A6WR%y?$-dwa{_1dE;!37Bo_=_;#`9K}X=o}p*((Vus=wh6FaK{}MMP~Sf$ z_{%VFwa52uNhw1Feh=!%*yCTfBq_J!q1`inhM1fS_$(|KC#&y03GX0k?#$j6*&VLCRhGrkEE-mWgG02L)lBO!n<;(={^kZ2*Vh|&UzNKFesi|-vn4gR!o^XKH) zbnl2WA=j#25>dK1-w?b4An&W6A1QG6v}V~5WRQ^*{ryVNuL!EQwjUNC+%|TMN!Kzq z%TN%Q2l;5h0o4iqU`k7Q5NE>#FWcXu95oew-H{SChYr`jhSQG-kOKkK7ymr`jWtXRYXvH^c~#0|kX6s1aYV=wP}?A85!=8SgEXLB5O zwQsv^iCHD|jpR9bpI{6BLhenFc{3C@U1DD@R+4bIhA=}C!eRBvnN=haBK#r`4rDHa zFsK1#iE_P;R&?MtLk8@0=h*cmT zy}u!)FFoyN5BklQ=Uffg2w`fU%2HJnCb9F}XBqmQ7iHJsu&v^sABPBWF&_79Q4zqR zxXe$!g-0o6XG6il(OXHExS=3wJQPvKMXvqKUxn-At)pxs9UMSV^;z5$y-8<(xqt^~ znvi(<25mu=qfp1M-jZS9~wnDg1xcbBp za0r}}#xcRFuEOAvY6qfTQ3^cAeii$M@+@YyN?|V#$ehEiDw9rM-jb*d3sxTvAfDvOZzWut(<$pgGwXrpuF*#wGo44X;9k*Yzi1Gw{_9iN|P50S1 zDzMTfD3NVpjHQt>z+6;gts<}z5y(|LUdv$ai2eSM0~&21*G0d4`;6%AE#jU`a?!uH zLf;d>c=q6NEEXtSmA!Y)vL6e3k@K!x7zGoezn(6ak2?{|O@U192L02ZfrBwTMA^6G z!v*6fe7gjtxx;4Ws|!Dh?0V|M7hH>9UgQ%#8!K!_e7ElUen}qNVXK!L((MJYaV?8c zp_s13B>P1dGxW_XgXg8$5)3TU;Yf<6CPD7#Br%p_t0R=`>Cw!)n>7TF%F7BSs>oAH zHF6)a$yg#`-{<@3i2M3kVU~~)L?;%*|on}6S&)y6lFFk0^Y7OfD75`RPuTl$^ zAl<;0R*@a3*53JfrJy8i@X<_QBjjeK}{dZ+O3ug2mdZxX5)kkrsVo$eT7t^ zR2y@b=0*!I#(Hy#KL^V#KL)LW;)j0`ZueFBbLUBXf)qH^ERZgJuG-2q5)yC%v$qmS zh2yHQq|hXokjo=u5hiTR_ems4kVcuaK|w`S#gFqR9wdOa=@D}tHh2v5XQ(@-yDJ0l z_&hm#qF9NSGriw5Ne}IC(pLTu5BK9w&?MBpu%@JQ%E*YpvSF;%c2^#2Z{Qg9xE-76 z?I?7Or{PGNs1**$l$!{}!wiA5in~;knhhBO@2c4sHoy=^Yk`k%b$D<8&M_ODgn6wh zNDLVO?sb)zmrq}VUM>tZJXlPS??EDw*%KJ7qhmbh@ROJI5K92Z9gJs7g`v zKGnOdfN&~GYy4_1C9)D_+t&o2+rx{_ye(}a@ve^3$+Hd8G@~bIXspRehK@zlBifVU zG!2ky2PWq?1k%3$G{~C@J+hl*f?k!wCH_sMdw@-SUib%g4&prGtX?d5M^$^vSL&Rhg(GYu}=&_O;Noc}HhRwu=g}jePbubd!@Z@}1k?k$p zVcCEXoiM)z2~Ls!FNUbK0^Fgeab$qm%zfE80t>YLgrhdFfaytY;Irl1_96LpHiC}s zh%18IW_j5xrv>%uSN?WiVkdGhuPrzH#&2%+M)d)q=W&t+w0Z7~foP>9~S!zEiR!zmvS2W&{B z4;*Fb>ZZkwaSsTFNqv(sIoPK1E{A(nva!|n$757QFaUvP+~b=TW1J=q4kyVFec5V& zP0o;Crd~-9FTZ;8R{s4kv8aH<2N;h#v$a*+pt|*U;c<2c%7w&XBn9LsJQ1z+xOp&= zNT^VH7*k7=<=D3-JA>RV%bin0``1_k%bN~Nu(?C)Ky`k;ywWx!-iGxU@oJcbgJ#d)(-Td!AO15agq@s@F=v)}FI06lm<$!}BmvKd?Is=jEI8 znDUAt0yI?_{<_Yey%RZtsToQ(Fk(C;)EZ*DEoTi8sD`OoNfOBB+*+3cPU0FFAr&_B z<=f2wUBJffBbNUC1kJO-DydFLX}~?{BZn$rfmbP@u!IsR$eO=II+4)g7!G==aZ98f z$=pNuDrZdXb~2y-Q>FMbitknNrQCh*;vb^@YH!Hs9T0Ma1I#9vyuia#FP zET11caPHUIr9WYrKqrg_nU3|>xIbX10L;Lcu_kFsvCk+&*|yV>Q+YX+DgP1T1OD`m zK$MBJV&3(~5b#wYl{97JR_YWEkURW876R0jyGb8G{8kj9Kn->`;T3V{zh2nTvO!Ea zOvtenwozZTP|Hb?6im$nKS+mDvOjbU&VsDk{4RBv1*+1IgrsRsrRHhGn)mQKQBJ-oD1#=aJ1 z7k&G&lB!!u44K0;;%E3fA8;Oyjb;NfULfP#!qnHjyFm&NA>PojC2$70$7?)Z#(3@B z%!7&TqWlCZf>&is0>zjS**%aZc>as@8M4OIPBkx|a$*y!%m9m3o9m_f=~W<;Rz)No zI~!;cnZDrwwh`#S-G3Osou#|IRHJi$tF28~zU?((Xz+28sY4=o)@17}056j~v!@SU z_lpBJ^KV5^m?+&wrx0pvGC@H4u2Ttvf{J1Hd#LOa$$qDI`T0Uomb9C8rP#VCt-^N<9h5L@5#rzJ3_^qABp#y^xB;;SJS= zqd2ela*;lCaYJXYSI|GZrD--G0x=5!%l=C}5imHQncQ-Jw+Fj@VKZ~^rBwCp;nvbyEb_$ub5 zBf0~VnX@ohdQxqbjBJ{whx5-6!S#3B0Cg!Z(UB|$+c!Bm=^i|@nw-bQN3kINK0tFi zK-zQjJWEH%s$l94>hptd;nM;23i-Ueyq`u_+{C|qlh%~#n0X5!YA&(C!twPt%mB9Q z7Qg#z-EtROeb9eK#?W#`I+C0UXHzG{}bx9KSZ(hPZ;evn6 z61dcX_^BCwUqRwSr=7Gv8^!&4QhL;OkkGB*rJP@_+r3f$EsYtHjtd65YtG(wCoy%v z7|t?#q)ixS@B2@0Kq&1od&}d$_Xrw@hSP*i?)6X-3FmOOw`&>Pap3hS4|zU7&)$*iw#4F2|c0` zX}B^nTLaIg%f**u;xJ17xsHy$p>oG0(WH)@C5{aa`#^je`m={dqO@cMJ6H@zlx2o1 zqqJUha8`hFDil5ptP!{HrG!veAHa*n*Cm8K2NUPj=@Jn#UzwBfZIVAiwQLn25;g)Yw-A_hFW%V zIv&>82CGhyjxuX|ice9jjG3MitC_n?9lBd-!^_eC%YUaOZ%=HO+wtG90{(yk2oSrm z^Y?9_r8b^zBg8fnmTKCJ8$Lq)_Hs{wJ@C*BI3te5*q2ha5Je=Pz~jty8Y+bx4EuM0*PxP#9cE&KX| z=&x&W!0rbbBCf;-pLjq-Acr-bLeyX~agaNcSU)XfqZ4-;=u z(o8Xsphn%3Px{7(7Z$&h-RK8j*FJ6Bdv4Jer>4w7W{r)F*Db(%86DVx1?#?p_yZ#O z4>8f^(C06k+X`z0WGEJL5Tb$R(!uIw6!Jz zI&x80Kcih=BvaXR(V{~A8Bb*^C2aBEIA&QYd*ky?HX~;;)@-|_g>qe^4r^Z^*+`2^ zRr)6fw5<1P*KOVWg6<(G_pT0(pR`FzqCW1XY`%be#dIy-~u%0fR0X;)G0%TxpVqi!ZJJ&E6Wn^E(WbrxxK zt=Es%sD1O23lB(y+jVO6#4}+oukcx}=@n7CKdIi1YQTDCszfzl6RF-5o{Z!|sEOED zwCVx42kGC*UzY*8p4)g&)~nCs)c4t_nyGXdZi! z~`FZdQkj}jhAJjawgWATYf>wT;B@?Y zI}4Sty$j&aLF0jC=z8uOj7YK?y2k~!5LWQBy1q%xKeuhsT7p(=m?Iqg_mb2j8Z7IJ z0wiHa1Ru(RAden)kQ(o-d>!+I0Agncs{dXt5wM$SX>-V&udliSWEC9r_w~6|esB9I z`*|}>CAgiDgUMiXLNq%-NCl_0?X?H&E&>GY?rZj1c;g!_t>P-qSRYy?`bM;vIOJfk zIL6L;qd`1V_irEh^kw!>A7SA_o#CEXJv<8aNhW;5{H%PHuKO1ngL~Fu6#lUSd0%?J ziunK4a~5x1n3@783Vg71iL-r|&6-s~D|Pd6iC*HFClrSSc*7Rg7{$nD6*XPm47F4Q4kp`kLhW&9l?#h4eLWQMTIkdQ@jqL7Gr!o zwjgo&4>YAy_pw4)!hGT31qG&F9SB=f>EyjY7mt_Y1G)I?_TkLWvtk{x#=m0lpqI>F$*u|z zL@GST<<$jvVvPPGXSTSb@~l8D1!C4gNI{hP6we^$DPA5azKb%iQ+R>){jyT??~lR@ z?D}*B@e7GT?5;B}3}_!9iGi;Hg$ut1V%ie$Y@Xe-IO#$gViJ8Wg4DleIu_$0JpcPy z8n~FbGo?Sq&#F>+AZuEOp-!>+rhq%1w@6}D;-7dUCCp46wRODi`K#BViiOA~gb#}# z2!R26z@nT@+Pg!ijNhb5-w-J<@2b6yG{%DzFo&>@@RkI_>Q}|Ad#A zlk;oVcu3i0+!Cc&vo;RT*MfK|Ca>B?cN3TW;dG*&{fVYu)d(6at-?{5L^XPM3sDm3 zyAo^SKKR=+K4r6J`W%37$X_dsa#8_D>x9(cKooR*7rWMLMYlH)R+H-BboT_$vfbv4nK47ndEp8*DjNdk z$9bY&XwDCWU}dOmv|k1&i9UNeWIKH^Jk{UW{O+vavMqtUcC+ca_e9^>*@}5qPaI!= zm*JKYoLj`~V2?v0%=PoNBJUw$rMADp-GzR1B210t_E-TH99n|0=e$Rc9tp>V?%Ha3 z3zF%;D^$lZR0y$|nz^9tg*EWdP53e1_^oOCyC+PGh+3D+@#C0y#>HTk(j3K=4sDiZ zhwt>dkG{>scq>g?efvOJqMIT?%p1OOkHs(}Mk{Qrj5jvHgJGMG3r>Pz%kax>mr!2* zc!*)*vRQm8#`mUnFCBe~as(Zu)2YI_Ii;AW+uPfs^`jKb`x?boQ7eHau}YH~{`mDW znHzbYk4+!rxNo4_B=3M;?c_iva10gnTKGwRXlG%q&#!7;soOG+5HO@7r1kifd&9$G zsdOVsN9<`R7*_AfB$jKLSn}&PL)71Lc)+Kbg~<46@lZveO-!e)U7sR#?tJI?sEgZ){lK#5 zDM&E6n(E+Ft7#D`d^F6P^QAxm*YchN35DvEfTEH))(d^zNrQcjqh%hLCA+VSKoO}F z;fNGvG`5BnQ{65XnWX2G;!KRmM*feGSBz}oEG)l>u9I^c1DEIw(tf8T*QKQWU5zE> zW|{VGa~nHz8>0&8k{VI)G~H`QDK$u=(P%3T>12`Eo!L%{E626?oV^J&BL zT;Ywiy8cZ8XM3T$el%Zdf#+R_m<%tHX;u5$^ta@XI>GnYHt&*6?BJ+O_MkG`191{% zLG2f`Ut`Hc!^x(YLgn#JrAGU@$a{jYLYG2O6Kkfwn$A5*K^8c$rb9LC2O?TK{z5v= z_I!O=w?7182zM$<3k;IEP&6J2$n?k1vb!Y`F6(~%a%|y-S>mv(P*Kj}SJLi^@c5yO zX-2e81TYOn{zCD!-c(U;eqdAc{5?JWQUeCUw8_RbAQ>BamN;7NXEO}YQhrcpX-e4D zu9dMJjiD$fLgLf>+=KL#@5OPtmn72PXP0#ya1fB3B3zlPKC|PKq1Ld*VlcG=OQTEU z8W!Q6KMHhMksAx78_%jQV zHGMyoRgmjP+Damopd#lIc;e{$fgYgBf4er0yp;g5(K-^ZER)FKl=4v|x^%_JZ~{UFN+ zEBY(H4dWnn=^sn0O=%D%xVLw6m_mV;SdgFbdATfcjgT|$4*!upmOeC$1iN8Ot80Gt z!M~5@))dQFX^)8*X^+oNK2jVJqz?`-^AmM_mLdfyR$ZiORv#)xo>J8?XRR!MMjk#> zSIKozgX;D`c;Jf0XLHRJMmPwT=(!j|ahqg(pye~xKq^(rzj71dH&ToRTu=_2IwFD+ zn>@civJ-=G4l+3yJT~{%ZpZI2xVyd6cz`*+U*|RU{!=i}fR1>;&%=|`dl5uC-(AaN z3+r+Fe>7bMTU1@wJ`;3zr*wD6&>$e8lynJ7cf-&qN=i2i(N`Jc1Di}I0v z#^at+Si{v&0+<@jN@S)oe;}RIpGW$IZmR$u)R6N#YEk$PixfsJ80wk*baVro=ZBB~$D7x-^LOIwjOl*4b_ZL?U4WhR{yS+ai4i$-k z&|9&3d5GuPL*v@Jzzmdr<&-v{j}tmgcMgHVSUeYS5-JgZD;$*XUiK{Wq@m~K+{X~T z?eq~7Ffa+u#1G8$!pDvVrlx5H-oV){KrZmyO+Nkyj<7++111j#z_K8A$YrV7ezw@L z98Bt@)_WO@+kgkUtA@WtO%WttE8IHUtPiI~GLMEQ2Tau3F#ysqNmkfb`n77B)rnEK zksHEhi3phP`8Vd0q%=iC16l3FE-4~$OyBg2OvsJ=8;?6>zDr*798(CM@aD&BiGdC# zo5#N`=;uu^I z@Yd?+)iei9!0;gO=*#XnC!6w$w%2N)mc zES@$k8I6(9sf|HT3xyguu_TPnacWmkC?z^# z@#tyoHnLBOB5=_4K)yz3cV9t;%NKaEDDjdk$y@T`9!`1(T=p<$vM8&__9=^R38SnV zienA}J~Wz%zPJ_%72i7%Mrvf_2_f`kY~wd3+XfocW1#1MwkJZlldDa?q8!3Kc0Su* z_9oilSP3>}%mLEmdH2uK45Si*s&ls2*U?F5ukc@CCgrsmMhXUgOwwAnchlivgg6X! zM^yU|j~R(90@h~_Oui|!B*zfiU`|v0T{+_cXbOYOOUW=lrcI#T^YH%0%2|hf<)!QReU(}6`(&A`R;>nY7kJVq{*!5*KKQ6o886G{2T$173Xk+PI#A!Bp zquR_nXHLclTzrO}1g<-uP}yhY{+w-_X zWKY%~ReCs*9_)~6}rB zDPI^*;2B;`QsuO2l=?wGRwACcJijSs|1l5o^J6o`hn>dswg2s*tvtRk$OoTtT7F{$ z=oRRx2ae8O?`2$-DnrAN(_?YX7#uJ0f_iYMEQS1vTS#q77iBd5C2Uh3iwLJY0D#${ z?hsVjHDr$OK>2-)oo&R5F#aR++m+bY;n*YV3cxNHK$sx{LwE}N7$T}VC9pqll6{uM za5g>tvy+T_Qg35WB7ppc9vXeCz?*`D-v^-YCnSJ~nsMKKhzGW`wiAC`iwhwAhQW0i z`Qv}yIy8QsX1_u)$wN;<`%5yK;PAq&x@I*mDxvV zqvbpVkWYh##c|l^>`hwgDJ>u{0)PVe8AS<`&icBt6Nn>WWj(XU{h_mU&)zJcF> z^4Xa)h1oWSd@v>VWk%a>R;3zep*@4<+6#c3NkFiGcS*;WbX`I{D(a0bd+CLR##413 z>)amT+~dIg)vF&}D<)y7SnJ=E7_OXlo;S;#PEDZ}vScE==oBQZhJoU2YYey$@{6a* z;-`_GaG#-(ySY{l)Y`EFDBm*L!hd}^I@Q0QLk?qldiwh>Mn=sF#ax=Zl1QF%+sab- zo61*)_{AbhU-)9A{fGOIqI19dfZYB>K*uI{5BVz9L)SAY#K|}K*Ws&8k4qy@7&KeK zX>^I%l~D);V3dwE1sx?z&QSowpuhWFzmCJbZtVyFaUgVz1Kz54&$8(Sz;P^!7Wexw z(=vdi%f-9=h*n`xO&i7kTmTwIR!Gi7g0t13&8-{+>{p zT`EArXSDhy@e^l<$?gry7&|z@E9G&>_%q5+ll!>3mO<0@fwURX2YG$ z^*g%1`~`;)2{Bg)@b6b-u*U;ZukOAE6HW_s7eG!{S2z_y77K!K;Dkj(bBeO!aTLg1 zpfvsM{^AJ!g9ons`0%So!?7Hck01#NI%xyWd%U<{2?~(9$-?$|6PpMtoNI=~*&d_L zEuIwT#gccaK9`P#Ucl*LUOfozadv7am)OUo1_+u15BoA8Ya@sRXnkPI7{Fc(3h*l# zYXHOFJZx?nB}6jA!v)w@IyzZQj5VxtajdWBLO-QE_46ARQYA5rqz6iqx`DUY&gE{} zxh1hF*dK^~iSe^IWgRInuw$*iT5|#5gL^I2^Pj#~4`kGrm$8=3SsU&~fK)J85_2@V z6(z*;x_AC%%ELY7F}#>ULvL%3T|L;^{tE*=a1e%Cs1Ix{mU%D^&_t(fJ9K!A0(CF+l&l9-~YPBTtR=l**46ufo6H2A;521(E3>z!y1amnLa(7GZN9`Oe& z(9*uo-{U%hj6Majwi5a~KtnX=!73RG%abf8qBz1|W!dm0=A=I{^os*LNiFX(LDQ}2^%&MoVp@x-51VGLht&SRU+tXf+`YDS ziJkrK8o>Y{B#-@CLqZ!TjOQ6(%JW7BVt<{7}iPlfrXuW zy`2q$t|N5yUn#p;Cu@WWOo15R*oy#3@SZQMs2_xv?&Gsn{zmp-xWGG#6p3s~2^%H~i}eC<*|q^}N&$T1b_ zkg*qCHgr&~#Ml<`F8urm=PpE4QteUIw{nq8!8y}y0>iQIBkbUFRv9Du^ z0T3L%>rNdRvN%ls2{(NUOp~ZE0RMWLyX4Sj>HyLz3BJ1MhVA7W@mw09?Q1~!0iIWF zbYKc1=J3i^vLuYK!q^I>9^s$_X8_z!qz(g`P}ya)siVyY)NVKT8LQAW+d~ai|W(O zEbDT}t^xd?eS8_O&qd-KRSEgh{~n|j2<&`4!-ZcUIB*m=!Au(lG@~4H-;lHJoBT=9 z>F#;L5{lg%X&NJ_oyz>;_l+xWjEJg%p`n5O-lYgJqCTSn)h6o~V!`6H3G~ z$L=@O9!Ix{?BmS_r&U&GQ)u(byb24dd0@av|1OXI^d@*RUkTqc{vcftBtPb#VTb?QR&IzUQ{eI|T^Qw-&j`ECT6@z$eo@U;+tK0G!-XUjk;EdP4?#$P8a&q4 zordzb82t~J0BpH-gV7a-9fw*HBcYA`L{f%VTNKPoCXHl8+CS|pWBUM*3))E2oTTRT z^{2b3nLjO;_s8e0cukREpq2r7z&tI5acAxn^TJaXPZZYq)hfX8pH%>dMAON^jD;qFjFkT%Xj(J#=JG33#+3Ni$5&f_!ch-F7Ene*}8w- z*E|ro@Dh4(;CsNdR^&n~#jSLJbsvWfQ-Gn(5i%a)zga%)nROU_VhKbklKQ4v03>9} zyEjh_*a=EeSlV;qx+Do{gR>7Aeusu0P{d@*@6jJRtNP^2vn4!G7Gqi4O2 z)EFZ!p(xkz2#26l1h~ATWK_&OFPeC2uRHb@%o7IIL(<^AGYd@d>+c=vl*efuc^J1lvw+><~UP)PX`EOUR#C>tjE& zHu+bCU)&-g_wP^73;t-)h+g610W^O}P|T1J%uJzd03@S1Mc?Z{FnfDBD<Q z&2_n{ssJ4G@rPyXcMh0R7k3X+*noxlUuiE~5`k)ky3G6ac; z0cHc6zE#J`dlfnrNyj(Qy%g9O3B1Eqa^rJ|jq4G%W#mk`;{Hyk;y^2@3V;EY!dm|s zDmn*;p<1P%E6X(Ozs80W8DA5be2I|7g2lacNApavrx+ZuVeno6LC;(%^=eyZ{rg5z z(%_GG#PDz{IR(!E&y!ILud^MRQdA8Y^Bdd`FnQ@-0JhGR8&jkTmYbt^fo%D#ps?2n z5JR!|@z-b14%n(yo{pqRLsJ-Z zr5@$)8DnTDqw)k?$Zu6Z@hX5rFTKKjMbF@aS3bVyh3m$15nr*Nrc66Q@Xu#Ys{@A5 z_&aXe-z!mjV%;DH^2i~km{IU&xDUXDO9wEaXA6XypZHq(Y!Az-0zs zY)uUyJ_JK+SYSfoTus}kXud~}U`P_6@)Ia6=Jxi8(2955VeKD10O$W(xKHwMrLTme z=E?eG`k78!Q*lB?V#L=ji^()QOIlWFAUO~xWGbKCs36mEKi`F6qJa_1_nHS(!He~7 zFxaW0LR*o~DhOq4wX~`@c?9sWICJCGqN|_FR2rGA3Oj3<)QpjWBvqJ;}H*YE@NcOi#%4@ zP?|%JCRdfYg>S?3U!$Tym3ZE?aAKI>M;cb3rj^8_y1MMHtkd8;{qY^9CJ+l!7s%q7 zSPi=N^!vxezJ@r>pRx+7rA}}G-4C&Y%}(+tkUJ(GN#b$)blB^(f7@moy2G{S_v-_6 zYA2^ife)ohNRNpfbAvJ(k8f?8VKWaS+xdhj6aW2V*H9=b98o^f zP>A6kD&)6&&di_TIXzivhY7*JQ2NgKW{$~6@WkZjSxAU4w$no%dLFeqJL~pbTRXzK zqwCDei$xoXyR7zfRgWA$J=>R+>Xw(-Y;rPGVyU%RhCI1m*Y~LXWX^sk#QG>~;mJ(| z2!4Efh2C^?081hN{x<3*e~y;~!D>7{cv78uCD<%_H@EcVpYxS!iqA|Uu+!k zA12?S>o{bbk?4%C>K)u$B(-1viZKhH#R8@?E(|pOfDE{dQ1c!M0S9%zvU;?j2%%Z& zF+DnIMaMthGcSsSY%V7Y>Jn^AD6?fjHN}F=9SS^rw{GY0r;_z}p{;LY>4t438^)P! zbybY?hzO~kk_aKaLHcZeeae*jg+|Q~aLBsDG}laWd(QDL7X;>}+kg$&ttEfCMDx>NGVI`0o?fY$S{u8t_Sk8~9S zPi#6Grrlg541_wCz?QymA4$6DZ9{sWe45MW`nkIw1O<{py$h3|YpUs?3wYF@1ND7bBSM89eSU{&9# zZ>NA`)|oo4$kpN^;<39mC3tZi<~WoMr{q7tC{0@2%t|cxW{tDd+@mTyh<7{dxI5|+ z1_RYjY^YwavQ$=LA!y(llV=ClHZ?8yXn_Lr2ltDIn;GB9_=yT1>2LAcg9Sz9f}!eD z+%lh`m=Az)yXdYk3yckbi-5O=f~BUWVcWCQO%?X9PWNw?v8U*vb*f=oWUO!qFNK;N z;`c249fxhAXqqbc(zV1oSQLQeDJ~Sz(+KL|2QV2zR}653BjHIlV^!)~+$xwgKRD|0 zca<%5n%Z&Xar5x1EmB65bJ3VLQe?Qlsa5jFc$&cQmYoe-bC>W%OUMg< zI~kf}H!yrto=QRbs_XoJftp)vaTEM{vXT}4a3}v0ihW+uSy20sg?cYObDVsuFf{^8 zHB^~OO<962JQuu)IIewCjN~4?!zb$3|TlaU;&Xsg9smoYZs%!c`_vMOTpyDJHrwW&uG`kq=igLxt{x@X2flU z_8SLay&v?R{1y%K==>tf_{yzoK3J-VNIe>cQzJc$A{j0&N1U`HhnT5{{rt?r*HMog zBU6^|suQ0wY_tR(KF4k-Fvc$uuEXQe`C6^X>OgKeH`wzu-Hqz9p6AVD+xHx7IwVfu zT-~XC7oY(LJy3bG%#`@1xIj+JMB&j3)jqO$ap5EjW;QipjPk^FDYZpeYwp$EomYqx z86Y(HsFsRX8jcId{X$y*hLsyM=Py|}%)NM`MJT4f-CIX%J41aS{RZ912$sl%^ znfVMWO>@s3wHH?Vu}mu=(*=zW?GGP8aywtA#urNU->21m`s{*3Ye6RstHhP@j{0h1 z>rjk7U-P=UEbg~jENE6`a$2o;HXI(ms;H=9wwYjFzGaP{W28z z{9Bw0|1lveg(s_a+>?_E)?X~RG~y)=BE%#M*pL*{*tqPtCe02g@0bdeq#w%d7cL;h z&C@b=yE)9U6jh5;Rh^yCx2vpdr#h727-MqV|78m(mE?lNsgx8JK3a_MI~vI1{a@Y! z5J_DjdSRYy;{f6`;@{+EZ2+FAS%O}QZCNdO-ps?&1@ZW9y4~X(0PzIJn>=?WOEumqE}I~jyzDDRJtD_(jD?YN#T90ek!3;VE%elVyKc zO@fO9y{V4hKzp3eYmd(-4>+v;{pkCm-_gV7V*ITx=^PCaMuyp$4@RR(IH|ir!?dR{ zzxnoRNUf#Zq@#}Q?^>&>0bECk5Cpyf5k7Vc2DUy8DZ&8|8x%Nr8Au5HB_3h3P9}2X z6c7}gyeOZ~*Qdt>)Fz$1%ZDT3I>~jcyd8W7^n%=VL;$Cry9j^@bX_M2K>gqCb>ym3 z37ujqTie9xOy>_4QfhwQvat6I0`Gs%E%WMw2a2uw5FaAMG4&zv2ud(95E^ypiAqh@2(K7+TiJ89!gm04EAjA9D=Q6d7H8pK= z!Z(PTVa9(N?GBZG2y$V6#Rme%Oqg@<75^l4#Z^)wmU*O0Ku~`kp5hS302G(l79U~V9XZTUpntJ?5_Q@KEka9` zce+f;JdtIDj4A3bt)(ac=U-OKB`{!}Jn>9cnL)k;PhM%!s4};3!)-I+^X`0G@h#am z@m-$=U+Fv@R-z@cq|2DRVS3nAO7e; zb)8ERN=U{J-$}&Fuwq$tiN1fZYaB~?-6fm*;R88AK?F56aZ3`2%FKt{%sH3eMcMcu z#`ry{NPG26sQ?|Wd~w$z`%nct7w+&&K1d4jR+~OginlW2#$F|}tY@s*)lUh1yz#gC zcIdQKZ#Ke5Ks$F6d!aQcQ$yG7IE2$4H)cvqkSeRSv%5$;^987z8en}CZwB+s%p47i zq=n^KU*g4pe%yx1ApXE66)_K$V1TuHP5s9-d^wqEeJVAuk0?EM?2narb$81g^L!AA z@@Y@yV`@s$r8?z?ipuq;9i?Q_r77#%KfY5-L@3cGOkLi&d^9uo!29^E5<7z3SuLH# znDDVC29K%Kn?pp(b6w8ht{fuuBepVz3z8%b5(s^lFLD@(690DZ(G@`eNPuhtu8xxl zQXWh|#R{^G_bw%WpGPHxyjhY%qZ4Ftt1X?Y?5lHYmqglw)k;gfE{)My5O_Y3F2sx?VUTq8wg zG^m)fW9PtQjTYSu69C<0z>|U>T|IY~jc66P4b2aY?Fu1Z#CDfn$3$8x*7bEoye#*s^TqckQUi-M#-lj>T)owdw#z+Akt~}oR0e(i6kPo=wv%XK zVS({}?@0OOg3nulO4GLR2_}_jfhmlVyZ9?`+n#_^sJmTd9WL-LwR|$UP=I0_`O+u& zhuFq3e^c0dlwHtVE~d!(#d#a!bFi84QZlrP40Qj#+3%pyJnBhz%Y;<%C`RE^uzL7R zlmzi2Jub&tlBR5~F@h*itl^9U8*)GU`#yHED^(Lx0p^ioeuld2zAZVZb@3716mk@? zp`XmDBFDgHq)zjCX>`Q_fw>GIIn7)3Y6{EPOyq2!+D zcQv176js$hs$iOk@1(QCLDCm*riWl;`dORhPIYhdS%APi!t&HB-^n|1_{$|U4HAwt zztHVQI559p($Xpu86HzZJa7bcSb_6nWlrNqDbEFLk)zCzD~;aT}i1$5#2$wQCoT`}M#)$Dn@ ziVIKLf}h(Vk1Gjp!gKl}%*$Xf$~P+hf-{o-QAaMeQ;DHlLfii4U5PZ5<``o%-?kxzYL53;}Y9Rm+UlKAB9E+s9 zJ>!k!|4Pfe3vuwDZlVD0Nf^VgTiWkzU={SPgaXZR)J&ft7hh&Vn4v8Fr>z(%%UKYP z8B!f=h^O%a2y{+G=<8k$hLHj=RE>U!|JL)DKQmz^Y&sAM5R?mRxQgw4Owv)6*;}kK z?7*|F5#w%nGD-&>DjtSX%MN|fVD(`&kc4`^l5hf=_yC8AzGzVs$dLW&c~~Ftdn3@B zIiG$PJ3a(+a`h-YV#(vO>c7-w6*yUNcQe!@`-2<@qoVB5Z1`TnSmHm-cH4`oH$=8SHO{>h+diC8?dskE~-wSayzV+Lhp*&@aXTVl|zNZEI zp9K0Vh;O_4k4Rcgk@k4HSEtCYC0Dli;YD&`j71JtjW3@-4XI(6MXjbZ1PY)3Ei2y->?7ykf#>_z;$5LQ2lNB-i+4}P2%EC z_!R|xLR{1y!huw(q2lFn^8Syh{O&IYj!+NW(rhiwmlO4vRk)T^_@-lM9fu*RA&xNV zhD54H&BiU;k?fwArj_I}4sSH6Q?7;*J#k~Xa)jt1+LW=W3S!qvEecPNMc9BafNsAe zCz)szzEoqP*WrQE0R5hVVgvu^3Y;3CedNTUP+;Ik)fG-$dS$-w4EMs#+=nQrP;%c^ z$MMUes95|4OyWf&)-|i|5l7GzM$3}rhtBJ>uODQsoks8I+IaX$f6Iq_d}P*v2`h<|<@E@qex-*$hP0P32*^6;o{F2)?*d z-dL93&V^`8Ei`d{677#gq3TUz?Mi`I^{ zz8{TB3}Qy#lctNkQ;J@jx;no>qAkh%07Mu7c=lNeFNG-j7?32~4mlKflJ6T(G=)=( zDT78=)Pv7PycLR2WOaCWWBYm{?ThS&$c6~{OBHTSQ$z*+Usha@nU>?U$A0($i*a-n zN9F6~vFLj7)I&1xg^4la(XnFC-gvB}HgHk|AW(lH*G_S$Vr%scg#6tTm4&y3=Ts33_Uv=ZpANSA`&A`mb0+<&ZU?ftaqQgfFvehp z(`a8}r-s-p`sFi8nz32e1!7plznW+@`)}X_e;CaBZ}aNHHsp&MAmw$w@t2w=*xGsw zK|O+ayur$#nFJW*$Gy)Jun_PI;?E00Chv-_$SMS2x;%OfGJ zCdT>Fl-jL3&F#JwC3{gBR(;9{?5~RZube*e{kf_C7 zBi(qtf2|MO1#3+0jXh9+9SB^OzB;-q$|^YfZY;Zr;N=k_{5i{)5yOsq_YclRlUDnu z>0Fz43@<3=Mq@8+O0Y(7!f8(0+gY*5+ImaW{i&CSNk82NuZPgWE3Y6CEkgWnHL7pH z^WuCXE&QVJ9Oc9I<EuOiCpCNb-Z+_3>YX~N%UT4ncwSR<*WPp_AzoR(bHHAW&^M6XnMMJ`N78i=~E*MOL7#PqCI*@*x8yMr&i z9D}I51dy0f$n8q_ir0}Q4)7Q_nf2KmU*yzlBV^ zNgV4Y{-nMyqlEa~H>%1Z*(mpT+u9Uo)hOFP`MvSYZlASJY4MUPZkz64vNUxzW#wEa zXVX;(9j`^5GDX*2Mjnxg*@|M10ye>07BHIPL-6_Y_Y#hTfCE*ArsK_5`Rq_(GNHc> z5l&@U$>O(nHu<8W)pSYwPWg(Ho8+-7_XBj?$>iC1;VMv35RRO*9(6-H!MHFn0F~N9 z%=uMRFs-$tbw(S)Mn_-0dTze2cvzrI4H`4%Ggxp_5$Ag+Cq)f>0yp^>WgI>kqZG%( zQ4}uk=le{+t4YDb`X-Vsm@A$_Hx=L4VYr-BRV$TwuEKeANqW}(3uoP$nhNn_kY|;A zVT<6@SHF$v)h>2yE)YVS8X+4Y737Hpv~|cOmN(sLst@tl4kgd5=O=4Vgqxz;#M zZ3pLf?3mGN=1{61|W=RfquRW1Oe1w-^GYv>2x^nRr;Dq zBO70Fc7y-#i_EuLESYt*X-v^sY~VQRG{{%aX`)l2#(HefFE(-^aMN33{3ud>F0*h~ zI>U#H%tL>XQ}s)aiHQfsXJCmdGTdhc_xBHF>;O72V|qjNbrLoTFZ@6tOu@A-#&5J9 zbI)`wsq8j>%4hzLR%_t#vm3XxYMGab3Pst4JKDZa-6;i0OF&m%+ke!3&c3J@ani)R z+w!XGT@9S!8V3iSYHLywGD>t<;1`QY;ea4~My*9h`+xN?m!V#pk7mTvWV z`roF#o00)1;jZsLswhCA{Jyo&qwgB;V~ZEos3FGBzuv9|ZW{n2u>E@_6@-W-qtT>@ zj)?&$2bye%p>-UHQlPs>0?!qVs=;50wYJie`G1>On@d`x#G)2Ds}-b=-3tmdqrvZAA@lG(hQMfcC3oqaJ0WXd9~Yx$jGN z)~p6eUVnH4z!&?MKRP419VDnny|y>s`H0zYA^M`{=6vXgjxGM>^RHk6_(b*W05#*S zYt>WEA^y0J)}tNh1=<Fc2dIm&DU7wPvO#90v8_>Nw)= zUhqe0;sUWy%sm_oIrN&d&q_ECE@GS zd3TDt*B!MHQ~dYGjLWCN^^$-1 z_I+ouT+sFNE0wa2*%S58ztmZisLu`5t8Z1#x0n2e8hP$icV(`*hqL?`N)4S2 z|7pf?%cR!Sya#f0vDz>B38mj zfrdb+&~-dfixD3H=zvR~PY{ez*4!1mY(gQrnL_?jW`B=jXl*Fm6s6+KsQuGt#7gP1 zDBJrG=78$)sIkBX#CN)L3TS(ws|l0f7t;rMESM=X-+Bm?z7sXB+Xyk&&G%`CM-YP6 zA5zKkb6Jn|w%3|JZ|#MIFhM}ceA^#;E=#Adh^NVfqOEjJUJ_sLgki@XEj|79o|PsZ+wB(maxR~#}e4WAw*$a;cu_oD&OAHOtZ73_6* zNwjEmU~2dnEa0JIT{KQ}K_mX7(;{o9KJEtk>x?K4Uecs8O?-hA@G&mTA6S`umay(isO@d;+pCw>8N z6frgj#)T#QzTA&(bc1ft@SQVyD}r)6x!)QmyATX8CgTqX!FrtZSY%Ju8&Q^D-y&=a*Mo2w0N4S)fL59*O~3IFJA+GxYql!i0+ee7E}% zO}GCJj|1xPCLn+-H22^raWFe$S7th^=>GP|i~4ky6M&tDSTap#G_RAML~lG@3zvwy zjEGL?@g(c>a*H^-bq-!vXEUTJ!~z6s0%_arA4j-nC+Yi@)T!s<=^R zbV;>b0DuTPW=eqxC}DBo1@|oqw7?{kxkCVai}fp{g=D@`XxQIwPxq(`VX;6%JjqQN z6ao(lELPcfYN29E!#;Et+1VuxKiHXjS>~uz!T65S!^gtOCx`|^Mk>a0LecVF99%m5 z!D8Z3{9sNyWL0~6o=LGKA4b~VVWo!a32Op4>C7nu==L5;njS62Z7AJL@2yqoq2+;~F$0X! zidLP$CtZ^mfRN|zy_qA<5l^SKy3c0EfS%J0rFI@g-~H;%JPJemI90Av);sh?UoyLn zq-~q9BPa9*RlOITiXLjSL#Kg;Z8qA-=KUEcF_Th~pcNex8e!Vip~ndfqvpgJ<1w8# zXxxyuUUYF#IQG-#W>s_q^2mOKB7x~Lvc%XWPG{=dyD0^Bz_|uL9uG*VsrebVh2+) zD%bANfyk>$K)}U9K`?5z<*(3kk2-w8!Ou_E9)0t5`r97)Mku;O7^N+z^_I;fUxlcG z`Q}de<~CIKb5fh6BBLtqfSe#Hzokbqh!_!ea%hYr$+LdG5jDvy3-vsmWrxmuWj;4u0M2g*F?TS;Kh(NV3Ly{5z)PRvpAM5cK6+#dp zup03%Qu_Ld-71^fa`4vv#_8+zA-W%8kYUOeG`0X6P})%PfAzQQP7_&BWlrn*lb=Eu zt!qK)E2#hlw6)vyl!04IAfU+Fd9U(ifoMcOpq+L{^e!NMap3E0v zs*LJauQIU!q12}rL2q%so!>tE+!{#S^yMx%?W?_>=o8hYvJiZtH}`FCrM1GVZp*Dm z?!6Fc)CzLTd@u5k1A}dC9tr(>kR%xpDtc$+|A8NJIu(dHukh@C=DxZjN{AAkWGxRZ z?gVc#LkP?sLg_~1_d(z^4jpr&xX=ZgUG z-_X0wlG;2gzW-9@wqg$+(6F~jB!h$Yn}ZQ}j-(BjoTuNYzGa{O6xH_LY*$j31oV-p z`ko-5jsHEJk^eK*l0)sd+5>?#kI9?xNk#Z;|6oIZ$-AUu?eeovU;qC7#zVZ)=%$2k zb1+^KN$`)N77qkxg;!M>zXiW@7@0q&?=^Gx@f8;H zo!%uAfH)}yrsvL&m2B$x+m;SuM%90nJ?LXl^h`yB9ba@^Jy#Z=pWgsLX|)>*f8T7u zZu_&Rav(rMf6r29M@rkQs73k!bU^LnYmVL{tQb{-C?Z0ft}Ew6i>nZTP8J7KFmJBP*lTH#hC8ABo%?=N&%FjOPh^oM79{~rr* z1!SlF?BR?e3@>O0xzu-V-Ek9p;t0P#GfctdfT{7Oy5Lxo{`2ti%|H76^lB&Gg$qdV z2AF*P8ON-7z%UZP9|Ak>4pY4IJZqlR1-LkY%9pu;_u6ZIG8RK5w1<1s59ONQw|mJC zDui^I_7yCt5*6(Uz4gBC(=|oV(w2I0Az??O-!J?HiK*;_Ye)WtZMDfLeDSn@QT*wC zeRXybw2pR5{@C;I2!wcv4}9SqIY{5#QCga6jq2|C+4(}3=i>lu03aMOn9vu_s`P6FItdX@@69(Ys=Z8f|PfgeUC2j1V<^&7; zSBcvZ`k_#>->hij+q3+f9WXxdpV00DrQFyN)b1J`JgZqbuRfU}d{)MJ>#VKe=4 zJf7SPU;kdj0-hV^9qR9oFAN`h0wC~1{Ybx#Z^7H!FesJeq}ARbL(%x$>2EcbyD7be zP98!g2f#UT7VhZy?$LQ7UaL>=bVY*|U1nz8M*|Yy*y_}aR^2SVpl6<+^q9wzGl6e( zr6$a+0x79};xYfn(^1`|?%0zJ$U{Mh=Y~;o7MIsLW8Q=VQYY`nbyV=mrO`~s>sa&` z**%r96FXVG5%)NTF$PC6h%z4TJW%AvAOBt8j3W-qqP~x4RI=;vg2mbzZ^~>Vh3M*C za){j8^Tbu~HJ<}~?&7UwdPZ{$N@y{k`a%jc@X z=L}#UTk(o9v#D*Yz`u9bD|U!CJtky*0&sL^ zu>c&)Jl)vKDUnbz3GQ^{#UXdhX|1X3fdrPaaf~^;7eN7@&J2p2Gt@ zf^ysCF#x!sE-!npW618^c0c>`7V56Mzmet{-SvdKd_F_4W>coST>j75S6JMP;-9ad z=)ymdj)G5AQxS@brh!l#pj%NhGMQ$YO;6`w50~2ATZ8~7C%T*}NdOS*g3mFYB9CWt z9m&=dHrUJ*q>&CC;jG_o3w?J`q+F&0f|N$qSBr)Fi{X%OsutM(x!;d^xOx(jlZQK2 z4s2Id{}WIk>*4tm{@AanV|0*y+5a$Tgv; zqBui+XrqSz53x6|{2)9yHp`wDauf&xK=N8CdANbUj;xbTyYDzD?s3WIjTxSpkxz7P zVv{TEOdo_e8Ur0ZxiNrveJ?Dg1FgXQUl1+8fg{d51l-Z0l5oC+sN`3U_s2R4H;wTy zS@xJM>oT%@aU9?ha}7#X3NnNdXpVdJNz`nq&h&fgjz+jp4qB+L)->wqK9dWI!Ch+m z&*@8D-SK)3 zSmKhQ4Dj$67HiqLkmsgLPJRfgF21<`YiXYRtLB5986>~ktA>JphfY%^U9oo-qo|t`p^YDs1 z=feY@Pj{Y=3T-Izk|U`4qAK!w6#Dsx6U4+_V5qB{p2NIb^eAWGJM}Q%B_I@INGR_z zTUz6{9fCf`0ZUVsd|rl6RUb+5W>q|juw_>GYFxKaTU7s*2}W#nPJBwtFhK8X8&Lz`8PLM;kA< z<+mS2rnA#D%o8It%S4&jKZw|D*YDdP#93U6I92a;a)JG!*>x3Mk5Fnw3QB7M?qofs zmnJFe1kzKK8dM!){e=1&UrR})+m)X=#m2YDxuuSuVMRvt&1slYCj&GD7F#B+v=$ro zf;tYcBGcH@_M2WH2MArzyCY?B@hN_vMH;3DNS3Oq>EK%>NqoGSQum%rSGqL4XV-aV zL}z|SFD5sY&ZllBVsH9SSka2^uNNc%7Dz=>2QlqG6a8}Lxh!-tPa8-8ngX(vD`j^B zj$-B<-A&Ja;gJ`9Tas8-ZkC+%s#?UTPAGd2yP~piOGDSypFcBrKYJTH;+%lJ1yDEJ z9*a z0*!T}UIOf3^5@UBwWrn9;J^U=2q`#cs|^`EpjwXEJ>?XG z%yclbid~bdSpBYs55Qd6kQRaa*IOZhUi|%&?WXAp!dTl?ze2PP#`qAUd3h9`xEHqX z;y8CP`GHHvLV*9Nr;m@f*W{FHV7uT1MCr6@&o#)_HfXW8^CWz*hhD@@xlhy+tw}ha zn)J@*$8mZ3_s(`Eao7^Yc90sep9qI^?@59l-x2{qDQiFZ5+@3wVd8iN6m&yBPR{m*wceilWCur?ttvD#CxcL4oAWy?yUgCrP zhj*R%&gL+__^_Y{D{`LuxBo#izes+tDN%lRKLA&;B2ur6F}3p1&1wiw4gKQHu@Q|> zzGyL_NQB)T!X!4n5u*{!yq7$a@AarjN2m%2NK*3YP(RoF4Q^q(7EZz+rsh2!w|7Wx z)>2xXPN~UKXMWksnYi`#(W`f%KX!9injb_R$FGSSvJt~tUqxJVbFOK;JjQhw4&~F# z;FAn;bz0r+*2U(VIvL=!I2+Y>IV0?Y${ur1Wq&BeO~U zVU*Om%;H(j-qp{zdKXq?R!_Y?LB0ma!e82&y>!+z!`NS_RW~Hu)>Qf9?Je2=U{K9vfhcAbwJ0KDZIw|Mso8F z#jPPP2_eM4NO5t0th+xWMs1|d8$&d*;XZnm7uhd~C{$U-_Rhq=rhR}-qFR)4J8!f+KI&@AJEt}NXNp<<_>&3| zQtH=pdjv}bIX)$Q4cx7h^URF7jqWAfvY!=qf;$>OJ(s=DFXz3At8Fw4o8EOlV-5Xe zm5?^MV$uKQ3oG2@KeY{W$=m7+E>BE0es#Zv^y(ICYa|1!xO zpz70$Ls8HYbyWwgt|-2Mf|2!GthV1mHUs^|7)kM_;D~Xn9lOh!oSqNgAt8P*!vXoJ zVdk+<`k^L7xpP6e6yB*&fplTgn`t-7 z&dm+dv#GIsdY!gse%{liYo~e?&txvsW^lJc_emU?cfFv59@qj+bJKHuyu7wU?`y5p z$A4-J>($IevoVmrGY=jP=XCw7rkXuwdAvQo0HBAp0-ARD*H zW&Vw%uL#DDQZWgw-XpBXJzK_U8j$Ndllpe zYjbmJLucn2a;_QUZfiCqC#pHGTMi>P__U`f@rSE^RpQJ0TA!aCdvk%f?~jHt?#3K4rJI%$$3*YAOr}I4=u0zTgkWJ9iV{PfN>9#twH_t;Y6;kSuUs=)g38UO- zZ_l%i{X~IW#P-Zvoj<ly?Npi4h}EUDADp24}zLj_FT}NtCiQ4&Xer=J{vT8Preh} z0un>J*s~o4N>sFhPM&}^>{1OA+jeak3}td%e#t$-cpIPMZ)b?zjy>rGHEO24{W3WV zIM^#WeLwzly)vc&Nm{5oC0u43iN${|CKJqv?HQ(BS%g#hHb=VVeRZOJrVwjr{AxA4 z`irrl?^v3#He37#=`?gI3E~A_7$+J#y05#P8u#kT-TSk)_*w3-i|tA z3Pp5`bjGzg37%ga7P~@&n+TSC7h7%6(h#vWYIzB4d~I2?Y{S^bgI%)YG51^dCrYG$ zy3XaXm-Sf2W%1l2_g@*oegEsjL&GxL9xs8$RVrPSq@0WPvd>iak5o3PJYYZT?vda^ zMy3FV9P>Z{Oze(2sNg5a1=*3E>q0^fA)hWigz_2gA*DeS4&*6jHTC@Qz!mm-DhqFM z?lK%7d4SzNc2AmYFB|XQIihC>?s}x-yggTiBCS(U1{gLJW`QO3LZXa*Y1Gqy*J(+i zIM%Jx`4`Z%0VN^_`1rnLzP~gJ2NQ#w4~;h zmEYJ{BE?1tJGGgr6zv0#e1_3dViakF0rw!^f;k7W8KK+}8ja`jzp8eVrtq%pZhbZr z4DT5dlng%K9r2aUFG&gR)a9Bel@pJ;Vt?5wwKMPrwc=C)KR2;AP(2~JxQ7!W4mr5% zeh}#I*?NhW4aajBmb<*#S$0_n&KODpN{l%Fo-%|U7T|WdBlG)F@SX^Kx{ed$+ z+x^mR)>7aHO(6b;p`b*5#!GMv$~`$d@(zYzdp>@oxE{EJHJ-dM&29Eve(oM3l5+eZ z$Zq_p0oviyuZg1FKVu&F*znZg{KtlWEq=apgsa8)MjTNmI?ukHdLQ{I7kSnT@f|$O z{5;~4Ou(0)v-ivd(SG;*?z^W5MbYY2Nne+q!lUwzXR#0hApai6xKSj};pl)2YUg4% zeHNKs0oT2QjDuIN`7Q=fDQ}%uxe$L;Fs962oh)1%FMB1n9+>xGMW&|Gtt^g>(HtN4 zOr87;x8kV{Q5@rPr#=}uF~TJ_P-V&pY<%{_!Ukclzc$re`9$ah5fQS|J|Mhl{D6}E z9iw~WA|fJ6B;fO~SpH;)ZaI=bK>{pWl<4co!NGo(we*CnWz?u6f_%NveIn2#Hi7Lc_PIREoz&`C8Va%5e4q82I)BH| zsQG2FqnravGue>xF+ntx}gA-XzT$*f3XV6Jy-@omiY#VMJ@akedcVCXAvj*zur}*Wbf!e4x{$G(_TP| zpi6jvFy{};^dL!H>86zDxS?u7mA0d#oO14wl^kdZe$`qyISjUO9-Xyo{KTSF;g++Y zYV=oY2?+|+Tpv#CmE20RJG7?^C*^`Rgz*%fNE)S`6ZWsM z;)@QAsJ}nF74ov`+3G|@~+YlbdFW(6U9J5*X~8KzkBd&i2y^|+dWF9y_xE^8Z=xY3Ow+6 zj>0WWqycK7?sglbORS@-EeKr79-Wlozj-<6SF1W!I|-^Wu0yD3pBi%Arpy}@=M0qD z_uG{Q4JZe$g5cYEmu2fL`t#)psP^>J80jAP(xW$?efNHOuYZhnyUYe9PpfYN?RfM1 z^vR)UO-) zJlJ3Z)cF`9CQm58r~A-iEfBb7T}*f(?Wj0XkCpz+P1kFq1}ronOOxMA@F3t2Yj}6a zqNXBP{CH<~v9~vlteGET#|GO#(-I@Kuz_tUEavZJMH`Hy%9QWRY}qfuNqeOZeJG`d zJ|#ZuRSfC{51ijsTX5Cw5G^s&vtLTxSctN|O{(Riei6zk!@6oqKl+`C(2{+gp{oyN zUPq;H?>pAIubXw%vio}hs;?~5w8wO7{MhnLGYB1VX)RF>ba9Y3r0W3NMKePg8h7uJ&?*LB}OJq7jo)0vd#=ftt(fW|v>x0H#IV%eXv zZ-;|M82I`#F77M(!gOFYYj(EB5*G|gC?r8qzdp;ggdDdhSR%#*DWTlg2fw7c>#=mh zXzQn_h+uncYN7JWZFGCkp?&#< z)s8)X2*oA(EoQYx^&{=j;LoSQiI3RfrF$&}hXrHCD`y|_x8KM=Kz=A5VO%DeCOKy6 z=a7G}pw`De|IVIve3B7uKAjLU^F#D2g!lW%wkVq8~x2|C#$vNl>S zyY{n|8-be1f?!jH{py8`u+EpSM?9~$92-Z@@Dfs^Z4cU1HOXZ zw`7_)vJ0Lz%26d6u!WP(()W5=NMfgS;2=^pfhrtxtbj zk`$4v9-UwM6_>cARdcJ9Ia1!TOArRB1xZXuIk9i}_l9vt_m3e+In|4vaH?_g;wjO0 zv?53BI>aICVJY5;M+Kz0cgg{SXVyyo^3l2g41BtSOuez2CBF_=#R^)kKLx%<;Czs` zqorjRf^6&Sy4Nu;HvC(0_}>G`dRpYS4(os3uQ&Iizf9DTj?u^JWuue$E>_plQ#~8{ zyCJHtXP`6YFRVX`WbQTGIhrIyc(}OS3-P$vziUPs?{eERH``^t8W)&0IC21L>6qTD za3~VF4yBela4-!1q@}i=Y|ZqGC0hWIS>;`ebB@9bDh4BB)5odN&)kEnG0PMV7j7&4 z`@IZaK7_~${u{4e8}!i;eqaP<{A?zks23CkCPxoNOqKHOqLWgx%wC`CBO+} zD;T+E*r@#gsq-zjW=lDt8{s)v8#EoS0H}5sbSSJxzyOj@2+qt;7bQ>i=X2Ptl$Dhi zf2?Sv_?Gim*sWPR=&NmvU;v2EvRNC-S;~S~geQJ_Xvd{iOOF2a>#oCOnCd`z22C}q zZY~Y*o(}m8H!}Sqr*JXbk+-M`pT0Tam0E002i4&a!!J9n3fU}1TD!eRU7?z`HAaK` zZ-mAmZ-W1-!{r$u72#kA1YHYdVYL{6telvz+w*wYLu1{@SO882<-Z zlHQlEw4n(p3Ubj!As1ZhLq7*^*yAlCIP(eF%=8lO^nKC@=d;Wf0=#FL%I%JLoOd;B z{v3Tr;ZDgZ5euo8l28WQ{C++0!>}MpNyU+vJ2*k@lXx}%18piOXW~Vi7h&Rn&Wa1Xl>FC5uI+d_Wb8*ka;Wt_K)VJL9gK8D~{<^Yo3Dhbf^9U zy%v-2X;yFT$lmDG(sL@^R(04j*2C4AL9^Vc)dyy{``JQsPcs>QPKobzdBs5{ex+3| zwl(`Lwgue-X068dJ6~^#!t^^RxKNl6_B~~3z!1*iX!Kw{zi@qj4WSCxTpy=(#90c> z3V{P{K;m%}V)NrKCg~Xh(sude>;^abl>|B0KC{NLn3r`EM*irr6ooaG`@t#5*_J3Y z<6%>u@E4-AYtIyqgEE3R7rNo#6qqF4YIBsoA?q2#4wQ@?5$-~%TtPPXvk4)4q6^i> zLJCizM(@K z(-`kXO#NiVCE#@(%<9Bta2KB#;1jM(!A3mSvv=_*Az>e1T}g;3F0x1mYiCl^A&9VrWx~?%UYfCld`cr}-LX@cQW`)IBRTpFaHKdKvqiW<0afVB zNTEM5)%^GD*%}SWV>uHG`NC*z_zZ4wJvuod@nOqf|LLOOzgJ8M^8CRxwp*HDq}pw@ zJ3$-mDSjw>IpXecr_ZF_pZUce!r?ViuYQ~0{i{;raZr5ybuDPe0l2ybO5q?Eyuki5;rRo>w=|_EeoC(X2s_o1uu3G1 z=`??-j@)%Qh!i5v39^}Q-=>#$s6h5&ysyr>qm#&*{WSVauFs$#H!F|DywR-eTblLX zA;m2YyE_%foJVbYdP9+zqzyGrjyP1fJM9F#dy2BVJa+g0-vX?geJ*s*Xw+~ml9 zUar;5u5dl&ppM&6S&PM!Q=gCnwf|bM>IG_7tO`KPn*%$!VEbR`@$+Z2r2OA~1;RCJ z#~#&MUA@LfH6!&*m`4-qeQ^#M9k?J+Bc`Ph^@-(&Y`$R%q75kx@$~%W{P%!kRn9ux zN`?X)wEG>MAMONVEtZt`n85i!czpX8pRYAq=C-~C{8_97EF5oezbA;PRrZRl5t5C_ zo`;jJzlM)VuN^-RG>!JVa4R+o#7qyg#)t~MOTcGJjZ({ORX2~w>8>}5*yzuWU@;$e zp64`hp>e{FkP^hju<)7`*J=mkdE|7OhEVJ%&ji-2CUn+B?OlBvKFU1{B6Y(W#T*Ae z3n6@LK{plvEuh!U!rD`z(8^^-Jg1!}!_0F5o;c_be}erIagZ^#)=n^pUF5>qZ(t@z zCPI(@@mR4ZXP2c38bp;Pvw!T6*dX<(4p#lmaBp5OUKzDyLfvmBYzfcD)3~ROSi)yh zBS_R>mC9GbMe@?#Ee=*9$OB!c6o(H$5ieitlWigaoPRjt3!M+sSq|_wtuu@&1YtiF z--$AKgx~i1PWtWqV&0P1a1j?~R03KRqyjtw{&Qjh%hU~x?c6*5rYHdu++&O2J;5oW8YQ-H5fC&95K#z;{Hht^hBz~q0S({O#qdlwgnVM^r$#S1R;#JMr${2%2j4W&#C?PNw&-=V6bn1#b(VLr z-tBu&|J`vcG83G9R2(y`MXRE$1*OAjQKs#3T+zpsFcV;UG0w2zh7kBu**Kq&i-3`I z(23BUeDil|rU%k8WX?Hu5Y#$DC}#F20;|qybd41dhXx%~s0RD^`;oY=yRdKe{fnZS zQnxfUNG{x4EM}_ka_m{PJ3hLvEVRv?<1PMse=5~_8{}C&}E;yqS4~pMIRvfB=LazYA2F~+c)?FYm^sK-;pSn z)sb%e3$%=jkR`aV2N>c41?0uvdHBJXYS5;@%v@zgkFt5LloiCdE#)hjls%X}oWuES z?|rb9o(UPe*e6e=V{*?`0L=8;WqZd%E6+GrZ`8ZHY|N(J>}pGEOEYoae_ z28+4V3q32%DktrUh%RK!4?P3QyorB8<)d$CNUpde9ia$-cmF#=x(GVr@Tr<^_3`QY zIppu7M$+3wys~?9bDr)UxK3v?i6C-MA>`jI84O_mk-Wl~uX_;z1ALIhe6`>Z`K!wE zA8h(}dhR!a$EW$HWoziL2e7&)O!XDv!mOg-T!<-us$TH~CHuMUUbr#a4ko4GZ@w@i zgMDXtGyb(rC@=h+Oc=<#*xMQi9AU(=fk7KjLnkV*aTi-KO%>(WtZavTig)TP~}0(0^NLS=-;aBo=@<>N#Vjkx?stS)R+r2)IkW<6d2{txj2Z!&`J++c4=Qcr);73V?Y{P7<1uq*T zH$7iN^@^GcCUxP%& zLL++R|DFyoLUfmS^BJo5pJ^muMn0gqFDdW=VVzJE$#?vXsX5?6jaPQ0w zvAU;As4AHOv+BIheau7`^PB|tC-N-=3KEE0N^Zo3 zEGTj(q4ngUC^$ZSv0jx~Z`<*e2l2`>3(8;uJ)VtFrToI;yu?_I)CQa1qtd8kMv76~ zzKUYfbhh$x4nu2PW77NjPZzMy!-eXedfZ5)u(+UJB`A z%;Pm}tbg>*5*~3%{P*3aKi@-$BZUt!ndzGwSfS~9#Q36L2@b$cf$DQ#0+8Es*}eQz zCLQE|6qNBGd3yIHuf~Dd)#V1ylEBVI!I$<(Z!ykG?q*0eX&h69Ffm=%OB!y?fX4zp zs`Fz%Y(!q0-+;-joSrifAeUF4oePR08C+~BnFe|mSTO%q4Ii&C5E5Z$E-i_GC!B(X z8v%c#$tv!x>I11H2f=~=w$UoIBh|QS`1_d4g$FtvyF~1)$)?Bt;G}rJ13=1W!S!^lmm#`g<#m9-fyd19Dyg?fd2X@Ko`~A1) zc6Kek#%w%v>g?P`Zl0Hfsk072s{sfdh&J4yhrTi~x;5eP!!6ucNk*WNRV;D({@~T$ zM?^je-c{yb%fEwD&s>bWFbKq8c|o<&VDL1ZGX&u)s|8%cfz|puc$7`-)BZ!V1ow8pS2+=n))NM&3Bxa*4e-24uiFsdXb-vhM}AL- znn-Qc0@ZF>^^zZlZ$Q(}EKSDJi-e=`$s1VS`oB(^>wK42Nr8(S+e1$PJ+36utjSjo zkJ{K+Qr9a!xwa7>n=BsQON1l9oTSjV)jT>>Y)8Z1L;q9fg*kw%uOA3$_K?%R>^;V@ z35JF$eE>+d@2;xV)O4t@0Ksg(z#HxJxv1^OZm+Gy$pZ;NF>zSt<;C~VJGcnKeE!Tl z-p98ao{fhtHDox$^)#9`Ekwq{Xuxub%uIWFZ?b&6tIzCi z4wD*Zy$U<5!q6vtmcYl;^I3)db}Dt|pkW|kKMpK!!tK$pp0GB% zP$(bbylP%f{;Ji2h60f<4H5qtj&#?#Ex3J);CteR&&h8?N5T1CEpwW|bW-erjYYwL zdE&crJ3s$6w`giM3p1LI*6-4J-3EqhHq!>t_#&>EP_4I1pAZkFAAjMDVUIo5x&eiQC{L?UW{%V{{2^{;pL-dt=a1bl5e=lt3&eEudWv70$p36k{-5h{#1a+ zmmG)|9zZ>@>U58YTBqR zuB|G;s^b*@!+{42ro z+FH}7+m!}BcwZ#5S}ff9x~!Qjc3VYj^r^+sYgSJoSywjUmU{6lwW1Dru?7ygF0r;! zo^2V$vjm%AojRYXlt1fh290J;^RWYX1O4v@`Yl|w4IqJJB;Vn{P#YpD zbBV3=gRgTaPF(ldL0NgzM{sSDm+?p_@x+6o1}#Ysw=79}PdD`4ZJyUQ%qBP~b^TtQ zd5MwaBx3$KIL?@gEM}nYc0*Y4-CSSNL5Pmu6*p_cQsQl|}I= z%0b_)?g^a3Yju)m`>)&%(gEt}?_13oZ$Ci6Kcy1e*G0(8Z!Ia`b!5IA_e_)yR=jlw zx*}c1*(&x`D~pML@yqsOUz;|J_ArbkG) zx?&Y?8gK5BkI>51l`#hq$TiJ6j3005;ufIc0rq|6lJ`vU&ywe+TzSW7K4spv4@ktR z?z1FQ^Zw|V_0Fk)d^!<^s|34iTt?BJl+h?XF&kvHw^b?E)ujUv!y~=l`0$`21vq`m z$ME7OTbXvApLlLFS+pi^>(e}XNKD@Pn&*4wCzd3WN5t<=VtdCkG*4Rj&O1XppJ%lI zBowrD;z-ut6th})B0wjVUWE4I%b?)r!|1Bx2$>5%4iqRY&oDD(sw}tfVqW-FGr4Ay z@$GTS$4wgyof%unxci~Kj#gjyE!~?z;4wPu=epl{h$!&haW6#5<@Pt5v5$EC8B=X< zddQ5xJG**(%^)*K6DoQqkrOO3J{0)TF^s)-1REYD;3yO^B))X8r8>`?AW;F?$mx1R zQNz#c%zCKZAVVby*hkLYwRcR@o+TbI80d;~9?q>HN!nS}XINyC=1-=-so9FcyM5TD=E*^BAhZs%#SGvV~g_mtKy{svzbOkk^t#EjnMl~%A;P2z5yBK zqgjY_S?^!wzKQfQs;42O9F3x6@7V7yijaK$CqGC)<(X2j%Xi=cK^?E;h{kE}x-fv- z3?V^B&wE#;-kp8hT4*}y6Wn~gce8}GIur7NV_LEgQ6-M5BVS-vp^KNtzZRzw3{YPT z1z1l0lA;a2EvQB}I?X;rYMP*09g_}JdWR(_h$c4Pr8zyS-Auou4tWXJG!;=JtwzX7 ziAYT>tbXn(3U3V<$OFjtxm}$Ncis|O*G62wR=4cX9@CL&X%}ktKf^Tl0(C=#Lb#q zw|f{D?4o)8@K^Evq=nv8sXxmS&xM_@Izuy7&pHlNMTBs-5(;GVSC=iB6`bb}>OR{0 zGI{}9JX+kV%bqGLF5Sf+sVwOMbIw(?E@qJ9_)gfL(b@VwI*ltgL5B=%czp1s`G*S2 zZU*{RYW&;ce(MmZlB`!v`rzW$z4@($9++FpjU9?DrqQgp7>>*x84OI6hslSgklo>& ziu^Ft9adg5I6rEr^%jPjczgq)b)@IuWEE3(&fpPYE}QIfNg@p+a+PI({!v6h0U?+! z1o(g?qvH&R794@Tf1~TFB|4n=|1zZ6jE*&)oHV7@a9e&~?j3vZKKfksI12g9$mYj{ zW@)#I08&8}P=d3)95=Av&pQhl3B;Vi_}R_2^u|@}+rnvPS;IE#1r{HMIOJgN?PWlDD%>l? zA$cSMO$&Xk@I>f-R(9z5MxeV*Y3mh=LI=HdqGEa@jSA%l`&W;&eaPab?QWyixy&gz z4VA4QLIJYv)#sR(FUO@z!elt;qX8$DzB|k+v6Rjx(55=U(ES}T49rPgEy6%HA6Vjr zpbXI@7(5zOQXIKeP|#>WZl+(!fnPiEl`Zw?AM3Jk)=UNV*SfHrB2XxkojC(Xo;-Np zlk+s^&wlhC*_$9oo=KV3G8~k`{NEgAleVB>!&~5msgAYvQKn47%(pc^+Wf}b%8J=< zSadGCq}{_2H3AEq8&Sv(mXQ75s~$Qe$X`sO>qDm}GW3W}fU6jK5T4hN(t(*bR>;4y z=ZUMyt9_A7ix6RPlB3+)|56N`F*nYI^>LqA%kOL>1383+H_tIAC(gk!^|^#o2keHJ zluHNF6mq+jZ!L5dIRE1GlU)HVaF1;psG0oz_>%YhSq6SS+mfx~c`Wo-(ul`ht2lfy zB;7ARa#!2OqZT6AnVFe?CQy{+Z<+S#l3m{TSmMyztIz`SJ5Q#m=QYxN-4_yd6M0nH z=S-O+(jxPmv&}|y-$%waN}SzEyGk~@{h;!l?AN56aO4Bo=P71Hs)?0J^h#_-Wf2Au z#?9VPVZs+qVbdme#XpY?o%!S}*`QcGw_>X}wmaW(qy)&X2Mq09`<U5y&(m_2{UiyxH z=H;qc&kccF!?wFtV?MM8loCbOPQ4^kPtajQX#ht~0#^q<6U~-!8q!Lsqfv$6Fz*n3 zj5RLG*%%!;ikOBg2}2&fIsc4QzA_|3zE>dajU~1m!r5Ta49Ze7n|Z=O%m-zqi$AlF zVNom`9=C3PP@E9o)F%07J-aQMf@VXJ9r?pMouFIR9h=-2&pWANO$JXB&LYizYyGp4 z#>l?c?rm54^1Qn#TVNnU{bIJ6XQL!XGHu(rszTP)%AqCXUctusb-`3t%)5||5MZ_1 zS20yspa&SS8v_ARC&J{&YdIx|ANSG}ObM4&?wph`)by0LA$01SB>1%(cEJ_H0MWC< z-DSzXY`m~)goA?kgIASVpP31RJ%>*k31%gXd4&sT%0jMl8#FJSv#%XJq6`&>qA(tK4!9?rv$gN8T^I*Z2~_E0+H~PBk&uf*`ecK_XTBy#y=+ zIa(qg@BT`&9So&L_$PM`T8q?!9Fr_&57TDo12t`jY|rW(`mNFm1O!D1R+z|M&c083 zjfrEJ{bNl47-lNv5zUVC(kx)>k0vs}h@3AA**pT?N`jk9TV0$S^PC2m$GH2$1qG9^ z{1@0#Ppk->^#r?v;JGhx7I|jxAQ7Ifj=a`GEm!1x&&!gxmZ1}L6?87a4Yuf zKMTIk0h^gmT(Mh~+%!4>u(7_CgV|!eFSgl%&4snB-`njxqm`uQ5k_|6anH+Ik32r& zf%1y*3(fB@q&sg(V>Ba27dsJNxLh<#LdQ_kH@M1TjoL6ln>0v1BlIR%IB}}W$~oak z=!pL8choD#JQtMoC9dLGx@%J?J6+V^nMdAStJF+n<|R?WA&%A-n_Pc1MfN72lMdrn z+>}!)4#MF&KAo=wI2vz5j5e_n&UW@Qu6CI5QYA=*X3T{s1mmt_0EL2_u0m87EHp`5 ziqaSU5vj~yK?X4~xm=)w13dyia#nN*Rv-KOuU06)+3(^*Qs!izis`R6=c=6QRLL2x zm!5ZR=KUQTFsK8GN@{tMA$-4Q<6!4wkAn5Bt&0owUv8yF(k?#Qx!V7E2dBEEUYw3; zKvMDDm;j{x7a&@{+C77sO)3MBWQAEYQZyyH*QvM1L~$CV0slS?X^ zaqq4>8K&9w=ZYWh&S}gz<{LJo3kwx5V^>(&78=wOsL6;GuMX>i(4H z2ANQ`6SBJ{da&rG?-P4xH+pc%tiim7rp<$lk;I}Y*&;aiDHo^vM)0F`>g?qC`ohC1 z)5qnHAk|W=5OUoV*8uVBecF)s?2&DIM;td$`1-Tl+>v9W!ACuBRPO$KOk7G>cR{Vq zG>|=DD-sUPY~equiO=Uo2{HOxPm8|&1N0g-bD`H8lPX|41^5;d-+*0m z%Ygfjhm~Y1C7f7}qzLAlUR>`oKI?A6QtOY-q z3WIMozbuQ@L6k+7Q@|W#-v&qM%1giOQ|YHS=noVP^y{GL%0+p5kNH3p!V&CLtsqcM zl!{uMuQHYc_Vy7b4;NF}5Kz-lJW3ckEG!KDB>kQM4+O;=GtBko#rr>deaGQkEojeY zoA}sygV1Z%&5)9wmm=1!qq_MfkEzQpVlg42^5r5aP71!8?*IO72+2{l^Qc2BU%j>H zZ5`J7oC-$%u{G%w#{Q15r+-c3%Jdb-HEa}GZ<|ORhAZS)8>r|__Qm#Pr&Zdr&;lZa zBPmxq0`@fFrrGZ83YxX#h2;|?ZF#|?nYJ(`kIUzLh3*`$3)&gEH0j(v|Kr3ZJRMaE zwf1!COzGm{RLfj?&K|-Rqq1Z=s<#JY&COPL;2nR2<>CQN>`~8=iSGH8Gk6HeMiODb z7{q0xP#cVo(|lcTbqviH+s_HWHn;b`LSUZfv&BI4VAG`U6B&NEaZ5^MWXPB@;B!9$ z_z^38gKyVNECxFenU z0;7))dP-_eh}2(9dJ#{tw*SzNNJDt$`@JWxB{KIeknALw8Wk$njwf6eXducQXY%4k zzjEv>7ZeGh9I>>VKfI(0!cs!S9UFrk_$u^&-4l|8B6jLmZRM{X#-khK#F5Vh_oEb8 z?cj79+k8-~okzf`)Wz4HRi(c@#Fh2VR;XTBbf=4gbDBD;i#%UoBH%KR*B(83A@02U z=KpR1bPW1~9!=WJDyX6rNHvHu9cZPM3ijh;2x|kVfxaPDGbZ$MCahrlcP#Acp{goc zpnQ2aLBIDq5Fwtvt@D?N=K@Rj5Q)!_6mGv&a_3F46sNr7=Sa8RQ=uPkaSoPertc%I z2~-W4OxIp}(xu;FguBG_Y1SDcteJMZIzNS@!d@{yG zTBbvEaS`{xuPT7Rfv|pXnA7DTowqKscZwGg{e)(n9A0+R45flT$fjsVcj|ieh7>~k zU;)o-B~05J@r#&bLROi`Ds$&*&x{GJ_7>_(N`A{&dhr%CyVhXp>y2|^Z{OweRG`m*cF>$vkfRbC?m zGz55xUx&&T-wC&h(|t@lIcC6r{)&if9M@%h`T|qABS}o>VDf6rF&v@9S?C&)cO=(- zB)5)Vc0Bj`*+1+3zf0Fy&zM@J%PX_&&^^72&oR4p52H@?0n#{D+XAQ+k9%ILU$(sO zpVTlsjl=vke_4M!2|?{Xd$%GA^p_3wNJk=jLEz9Oh|(b--CarzCA@-!NJxXAG$oPG9Ld+ldE zftO1G<9RH=Xhte}ht_@n7fjirtiy^M86BAkv3chNAM6Pcw^pTLZ>U-u$vOl}>V(Y4i3I zNukLbbGQK#2#Az^b=ilz6$$*?`!B_x>hTZVv)PcXuews4QnHHmptzPlzXwL#7A4@X zqf6cJN@8@-=HkJOUeE2@hY`tig%A$JU#>ZM3RmzY*KmjT z$lg~JfxI;e-^4xLy?o&@o7pSU?(@7?N$Yr%Exv-(9 z&wmAezt4V2ZFJzu6oevE;@ZTg*&wKGX-kW71u$R0!RN|=NqUSdkRpNO<5l|O&H7#U zSuxAKr;MyV-=Qld8dPdN89WPl=5|5lq){WtuK^eRK`tyn1rDX9txp1UmISQ7Ut+#D z*55UR1fQTaHPjKp`tp+>*QNO{XgNgl*W`roB!`(_0K#j>-#acIdm{qFuB4k-Y@lk4R?Xe*|>{CA%UOiPtf zxz)nxJng%aE84Griboit`G*y{IsGgiKRIZg0vP}Gd3$ffB(xud+c?1c}S zC0DT4)$eB4fJYa)RID_qd)a@8&F=@5FaB6_M`j8To-`TGn06m=NaOwTd9;IRV}cpF z-bkk+lB7vK6)k43S>?oEt@g?|Lfu_Ggk5C_OhVoynFb&^7UaFKnSA58*%BFwuQ!(J zd(n)G^qD0FX+5|NSW#d=Ih-BpCY!k?NqBg;lrwa7l`!roD#}s+LssFvf`Y!9llN7F z758%*77y2(h-VKUUVoTVpaM8PCmt$ZkN&SEW?q>j8WEn0ajrS+b^-L|L`#ISpX6g+ zF3|(u_Q(aMSGE89vYf#FT;oA&Q>!$vyqkO%u=~4TXzZ0+hiMs*cP$kBreq9pkjl7uDC`D|6`axr1@Je>>;&ThpQP zBujDAWm7)hKsAH48k z^>-NxI#flqT=Z=+=S-xpawDd%`77qK=6a)y>qvqw=JpyQg7>?Z?pFb+yRasW`vsc` z;1jgVJ;!$;Zrxq5=)egZlB9~oWK*$mRksYV1nud|Vj9oqlb!}R+TBd?oPIW$#Sz)w zKEL7J8c;eNTHh>i-Y-LWWloU_iEW=vL_@{x<1B=B4U;cJr~ij-Z_h5>L-1 zmkw-NJT&~g75q9b;mW?QK%;Q~ znvegyx@sQTNhDLkZv5ofd`O56$BeMG$?A~;f4e^ocR$Rnk~*r~h_^;1>i8Xs0gEL5 zm3{HFkkV!Y?vPAA5}Cs_YktF_k>b$zY50%Q@e*R@TkFr0?GYd#Fe3IfQW^mw1f+NgM9rEIVrWL60fuSjE{CYIs!m?fY^%&*4S6 zm4eH98H6Jta8Gv+z0!8~R`nO84Rj}7C$pUl^k23-y)dRDRK%*qO9V{p{hLDMjw8_3 zUlV0t3A$Md<=!rO^MMOf!<%J)u5Hc5kPutYsCPZ-=i!^k6%7*!fy-0@yeNmshFm@F zDuz^>ot?O+;PBAbe=+A`yoqrOl;GvwgUFt6l>C8C@Ukb`?utLSxt{7+M+BjUZT;5W z=Xo^spjUQ0S^F)e-}rfQStXe{5|uTnL*bM7!7to3hZpaAZ3tj_Vz5s9&wo}1xXWDd zwIE9lp`^X5ibBhaW>4nPBevOKp?++XkiVmK$P`E*2Ec^zs#I=?3_xo+@ikI8ltKn3 zH_2tOpB(wa5QQybw-{+zSVQtzb8(H7pmfl-~HL$v@ehB5A=0Hz4`V4QQuDzYsC=i zpJ#~+`Dh-_m4O!VD2zzf;}fxzzrONH^kAd0b3y&k3OOxgRvCxV)q(!;h*TG4wm@K)^oW;2K- zm9}~I6yS0NxBpUPrL`CugXiEou$&#Sz_u;!xRz&OC+$Yoht%~`?xo$;%@)6>i;7sX z0!5D9QG)*+${P%D>TBjaT+&WmRaa4+Kr6nK;nACB3=;?98z1GG&(3I~1H z53@ySNa)b;QlI!jfE6ye@KQ$6KpkR05OyP&;|3W-Kn!oc62*!)fFubo^>QPxAe#V~ zkMFVvIe26u$Tuvu-pit@zAsVAG*Yt_n<@0*GL^q!kco)6rX@*1aPm^EMjwktl= z#MmP|8*sI!q3u1>_uj+G%FD&MJtj=P^EdWYIh3Z@2c-qF3$L}u#t&tW7#LSN?`KBZ zV*hbt+dc6$@53vrv0m{6LKcB(L43V1X;(qc%7-7}6JVs#@=&)sPcP!SZEt9ak|qlN zp4&|sr9n2OrjO;YQpbzAN&6biuB zDaF1E3Cw}FcoWRb3dO%V`fbnlOZ)*lxbt#aL~t63Sz4*jX+PT{ zb35ZB;BGzGq5zm9PH95;k0fyCd|ua(u;fCIjO&^romPZjusS1Dh;9-a_w?2*#Tsy< z@80G6`J5$!K_~qAnaNo_4?k|qFPt;e8Wdz^2H_GyY?a}B^Qp@ptRR5>Ys9TE#|6pZ==3#eT@|xt22z{`cTM zC17D^(U(;^^<_P%p#tg%`ISpW69Uf&T>5I*@uo8(XO{qYBZgkxmd$9VZMYME>u!GP z9@$zXcxsz|Q2H<(U*n3f&=O74IPvknliF-{+W6?i%Y$&XSt?32>MoXnIB~hqP(c9&w zg-py>(2Hl`&D~xj;ZSR$UuaZye4fPG;Q0=|K13h^=y5~hHoI_3b_ z6wBn)XIqe|-A_}PYcDh8&0*@!G$4RTsKU~bgltkcg= z*df71H2mr6D(*+0*>2tIK2w%2LklXax76zsW(dDdjKL@e&W~z9qt1ET z<@1%=a~`of9*ID&p85LnJQj>z4h%p?LSWFUr&r?$}9k+-ajHQ^z_2cg^zokR~7-4yNsJc82uyz!%v zl+LB9_t^CljHe>B+F#f~xWMkN#Ph{s?NL;0uvTe3yb(fDpNJ@=rhj z{5@}>DYGD2*#SgIA~4?y;nWe$Psx;_HtGZpk{qV?i@46oq}f!!S(s#V>bCH{sHJ673n!$M_n)&jb^@K!L6jYEPw4%i4~|4 z0(c8oHlUi+AbL7Xb_(PD%$_cK5U?qXgnxvGy?I;9$4%c6$%JM>W9C}#8Nm2@{z_JY zRMgzmRne^PxHZ0n7f#!eiUCWLUM{gZY|KA4NklN{f3vr>nD*wl>-b=D6_1?!mBN0$ znmq;OemV%`bAjDRdTN(M1JCEbQ+ATp>}c>(#>MHr*0D7NKH=Eivc9)liSC)KyVI-v z<$R$|3E3Opw{MC52qJ)?zg`MD%r?B?HM2kulqa&;5r}AP&P&Zc}{_5$5Ts{gM%)U|*A^~J9l_`H& zL6tX~Fv!gXO<<|_jTo?8nb2E-3f9&^Q1VyA#tUD!Y?UTKb&i)lz2Zx zxQt()Gne;UX|k&FLv|3~$L@C6b7gT0Z$Eq>4+5)^n8!WyLaz|b@~fg&LRnV`CmbUzquK;B4_wp;H*3?k?(+tv1L zH+$0x}}sXPWtx<_#GiFe?1ztYNuUq?bP{$ zkF89<+saW4eTt8NuPQVec85C5EhVF4NVLZ-CGBUy*Qcg$lfKVgwv^6n+_bv}zYdt# zHA{W+oM$IWGp1RZe_3(kVWQ1rir+UIO8)S}DL%cMp9wMw z&xD3sL%Z8Vj3Jn~r8`Vk2iSM1pGD^D=Y+6;($Mkg;m0}pKo!8_L5+nwmCZPU7(e`& z!g9nvHT4>sDVSy<28qt)F`5z8$k4JP>mPALdk+aU@GQ-$korDp)?MyQesgFfmwub9+&-3K2ehYtw1Ho()SOv z&6z{-I$)Xz92+1(gq3vNRTvjxV^~rGZCw$P>Vl8%u8Mmu=QW;tOc(FE?^l!ew`bFs zC@MUBsBl#K``V||y~mK@?(BdA()`14g-jGeRhxKJf8f{c)nRuh>Q#El-# z`9tUdD_NtdxmqY=;+F2H{;?9(OM1@ho~J+w)`p9`Z1`ptME+L?{zCirNh2VaOt8+( z+VO((LxDbF(!)6JVw@cy7Zoyu;$hziu8|haBxQ6t)$Sw|CN=rU*2h%6PxcV5Xu`vzsM;Q`soCAk z-rM7KbSfYIP9azEaq@)H+Hvo6Ztr%dSI6(?lJGOa=8c7qm*)HT>NR@)=PNPt{`{gE zYm&ERSjCnw0n#P;BcQ)i*FFcae#p28c%f@_k$=6W_4iDR8ID{_PX_J?Wj#1QAN#jd zQt^)#P+=c+!#&cs{uDd3NwCL#X&tvff^y(}K%b%n_NDo(&CQRi<4w1R5x6jk0mk*6 z%#pYYkTy$0n8`D-aorEY8cH!Deuph>24^k3PI`{ooe8{! zNpwV(<+Y~Qs46g;bi#O;LcLm=+$x{1y^G~V&_QAMk7ep|`oV^6pC9AzUWVQf>7o;> zRl7r|KF|aBIxa^#+1(gG&5dc+xIp{|C{2*9sHU}lfw^!<9v5jn?v~fdcYME{*)ctJ zK2T=W-pS)mHqPR+IiEj5kf-yRb>H8FGZlOQ5=R|NiL;%R6P4*F{;C}CW08`HiOJzR z#0z?v+EO|cAUXAi{?gkA(Q_(#N+J;NKi1RVy1s&eU%rvB(2#BmSa>I^nXgD#Us}{B?ZunqL z?!^;_Ovkxd_#VyyyNQ7we%0RjTVM5@lReHoHC;b;4u+X$XiF7xgjD%GxtHE{_UP2eu=Pu zq;FL2a(EU-6C_co)1B`>3$tp8`sboaPtldhW*&OZJyQ75e-Z%lBM^R;HY2CQK&6!g0V~}$@51^@}=P)7mSJ@ zR2mKT!}nT6eeM4ZHUi)G2pJ*>O|vAW0fe4Hn#^D#hUGhynvz0OyhIwp1)M^n3z4&A zRBzmh5K)<0wPFo;r;4*Q-j()kI&L);0R7XU{kUY}T_$!a1vOYzeP(5CEFSZb>tOH> zqc`D#_wU#$ap3ydM!0(V2$YScBreI!rnK%1$I z1fIJH7a;QR=9uDIQBRONlykHy37`ii9D3A16~BfAm<86C`@gpD8UcFw(;e+Esua1S z@=&8c`!Z>~G&%<~5kDeey$tp%PEMLUTTZ$d+lY|+$&a2HoKJ{@?)DeOBE>+m6|i9+vOn!HHT)$~7y?Mc z29YOd0Nsq1ltp+n>-ckXbh$|qn0_YmA!znN5hD*89$l_an2222)b<2bjnC?YgA$Qz#E<_QJ-%zXc1NT&U0X%2 zh%cm)6Gv&G+#2Y1q9TqU&#E_2ar*yU0Dh0Ry46TmIz(>7-hu@Ztq-*Un9}Eco2hXL zc>NipSB6!7%ViN2#g9z5c&(!1$HSdKU|C;7ExJzS`SSLO_3;A&y*K(MI1gF8*AJKZ znw_b?Tr&Wz&p(NB$df&iWT|Kp5yi!sPMZ8zgczgF!UtxI5gAL1*>JGUf6kl|kq6c&I zWiHA7`BNUnrejKSxrG~u+!A1fXNj$wt;qvUuU}cN8*uX^oanxf)@u?~I($Hq(5*+s z39l@!y$krWQ4}Uul=ku4f|J21G4R&EkUQnrWiOv^&qny-1Cm(q`%S>XfZYWD;1^rK zboqI6(Bc6NK(PL|OCsC#q?QF0zrLsfk}@*$?B(R-{AGVxQBL1RW9v=c)*+S$Be4Hz4ssg!T3$ET=#bEuXpYfDKvPNN7P=2GybcSJh zWq|u9jsqq{xa>bkD^ZxyXDBbpZdcfZhrpj;3ASMio$G$=W>SlQH@`HE+_`#=d%-GD z+s3skgVz*+em1>?tt|cX^k;2|68-P_UwL$_;!Je)#nGJx6tJ>l7J>$`?ikhp-{ey5 zjJDyE-G>;w&``6C{a(WCsjDRsEn=(5%QVPds_CwwLgSgfetqMha&QzJ90EQHn@WyNpjm2>==UMoP5kEW@=Ijiky5BWmE$tz+2FaZ+shX=lnv6)h|X%sl@+m< z9sj+Dv6NtXvCoVI(qqWV+R`ojQNNHGL&W75(r3sr>uyj-gMOG`t;p?x(=0qa^LxL! zbM=_EW_-o>(3XK76IzXjiIohlK#Xqq>}9w6S06=a5WxQxG~E_<(WR;<)EdaYt2M;D z-QLTn%W(^T|JONW)V$5B`(luN8E^jm`&-YRN5})ux5;{ zRMyL)ql<}TODdF?p`$s>BqbB~uan}2xBAOWuiUl!jRM){q7b7Ku045}y?Yf$bx2Hx z4rw8uFfok81f$|1eFDp^E)!;VS_r((^lf_ zi{$D$qUvWrhUcmGbwb!P@M2$(*xmPYrDVFHjN8xz;${G5CzLk!cjxwdH}z{TS^A}& z#`9UEZJd;79;J4{OR?Wx5X6Qn=|~Z;D_IOZ)q5;*?fi3n{aT4tTA$Io|EM2qBvY+z zl4M9--}QE;pH<3LtDM>P8Jtn{ot^J)nqho#W6*Q9L+*>hh?}@Zg9a%N!@mK&21kjc z^@H@igWtyiDy)4z9=Yd#eu)Bx8j2;FkDW^2wNO<1u-G2`F5hK(GbNPkSnF4n4Cxtx zYP_ZT2hl>=@_^~R&Xs>G)0)ir^}hy0%Gt`4@@l z{u|XRcAWwbF7UqF^<(KTC4hO*U2j}f7^x^&1q^ZNbI+fh$%F47!b`-764YShW?@4< zMD8PepbnG9rv1LvLdCjwxK8w%bZ)6u%YER>XQ@0nBBQI^MkG>tGkdOB)95z2-Jf+t`YGus#2Iwi~VM-!N*gi zVIHxHD%g6VO~sT--NO7=`k2tx+IpgV40EQWj5#9%! z@u6Wk^tBfw5h`sPvHZuT!X#g4_}qm>2juYtm#+E-mmv-a{O~ESwT%E|ZnWUCj}Euc z8IzO$^&i`DF6o&%2vGxu@^{DtCDd)^Fq$RUn*6npv=%vBMzn#i0)aohu?MbYVu1hn zqI@=bo$;46Sb89iNA-Q{+TqzJ5>*HVHn${`{D0026v?9ZA_!kxPuCX2$&?5^GnE;K=}NIpVrc;Q_-@q!?~Id zEs7?$260kDU;ST?7UgW2rCUl3lgwNN5V22o_m`|cOialwaB)VxtNq$epkaf|Ma z8f~=7t7W?yX8(gzfEq-|%ISlGapkwtZCu>^Zx?das!J<-Xz$y0Knv~wE*-g*gJZej z9d`I_Z1jAIB*B&adx`_%jFI6%jH@T17_Z5vTlt30Hw$-mj<3adSDOc8%B9NTXYWl2UKjr+A6?XPZD2Y+@=!U1(}aKuE~ z{ag2uRd4bM(B(^i@^i31XR%iXk|}20W`ck<8YVd53^wW-Tb}h|52!aXP8WsT`A5GO z7yhF8glIP=R8cl`qGn~bs+_AKC@x=Fzh$0${{ zgIUQ&KEYqfMnXZt78St>(-%1ulh zRj>p>mi5eJr(j^1Wb%8i;`GeA@O>t`vgHz}qgNc`w)IBr;=f_c^Euk6-_Q_GY!E$A z`z(%(`pjS*ePaHCE#u`cTr8mFW>vOXRM!y!ShCb#X^ZTBXxZ`Q%MDPHYtbOPkj( zZ)WggT385X1s$%PAJPy40qB_x34(Wwwi5WmVX{KeoGsW;8WcJBPJkjpE(ANI!Txb9 zSYI(CXTK)9t(g^qwwKUSWYfe$GiqQfih%!{_#Mm}B{T7S3PFSMBOU}_Brv~Y;o;HC0&cnw@ho%0C{JytnMD&b z<6=Z^HRnmdNW3`sLXrzW#J?k9Le7hNkQ*1Ao~%m!ASguvdZ{RcAY2L_@ViYd4kU^R z@KL0>i2IMzC@4$*P?F@T%AMO1N1lEnVT8^VOG|4jue>T3Qz$`?GYJfGo&RdQSOiI;AcMDA0Yzsc1>V1|Sw=B~{5`(ui zURyRG`Qn-?JpjAN@0(W*l7uq;|KjUSGa*>R%&{?pz|M%5wNBd=OJsl7!vS|_Q&78J zvf|1>LfVzV9qo;I!W*T_UoR0eWc1-2$N@WW4FUXj*iW6?yj zxJx^{purl_1E$_*Uk6ER9k?>$@6!_|oPKYs{m^7IkF3B^7a`o}rQ^8|w=+pXI$>75 zUIPgn^J!)#rfc7u#PyKtc3RwQ2Kvg2Vs2uc>uQ@7Vfy#eeHj(2wXkTMz&SQk@sL$Omo#m3Sx{bEwGu&iq3K;}o9YbMW{f zr&xUS_t%}JcSvD^^X>X#rnx>kxXaSaPj)ltYPtyIF8<5ylmP(J8o4}wU#1puzuDP1 z_Mz8Cf6(RYVubjuuC1SHwX`RLZwI6x98)wW(z-e>;Dh=PsfWPaVETGet|>#C7L4Zhbl_*u8EF+z zxKfmYEU&Z2_I>+v63am%%=h3?%!BK$`4di+ypOaiO@9PWg(H^rNzn&}kMkj&DJ(?wd_|vtKA?>x8r;neBd9 zjhtS(S~twR94j3B#YcRvpmcrj;5_?4?zjKF(SH_Pffe#8mo!>Nn{x!7=ywPxJylg3 z+Q!H9#BV05G6P#WOe;7c0yW$F;u;_qvF|40V?~Vmb4l&UiVER|pNT4=;Oht~vLoxI zE#hX4L^>c+|AWSSQIqLd5N`WksVE<|^VO2ojpR>kE?{q`1Ugj6UMZ=3-jv7qP7L%j8)6fa zukBo8U)GpHzJ)pGWG%jE>qsi5y3G|%lQMN>zLWhrV(U+YykKP@NoD*y5b=>^a6! zn>CHI3ZK??-;^^`iwx!ddRO#ky}gyxVz0-e5EcZeblceFcsafGPFUq`V(|Gs#NzWGt?;Yea0vhVCb!;r9d|C5jbHM;i4>o|1H7$qKD^M{LN z>Sbu?Y=JqE7f{M8-9Q5r+ot&{!!4OfiB1+zNf-UD<&?#UT$LmEbH=YZg#ZyEkk1r?a$?nM8 z9{Y=k4mnGfAx3H3Kw^-mzS5o|F<+ClYzmVkvR6Rgm4{bH{#Ey2P{#Vf#_je8YR8>} zEF$Z=xIci)r63kd8=9R;9|-g5zKxFiJMf)neU{@& zGU!Co?Myp~Hu%@SiZJg% zDyF=*@Kn4BV8;Do23z6RPK2&aI5uYd8gty!mq8gRpvy ztlFZgLSzh|alJ^it84Em#J{c3Q*@(! zlPaYMy3&wEWN0b(Bf#|Z2h!v1KQaFh^dKwyYvYZ4=MwQ6hIsxjiS1xuBW$W(Aq7$Y zNe^20bk1{=YFa>UkSjvyltK3(AE{Cxe~1@`>DG}e9@|6Y5?I4>M|G@Aoftjs0s&L` z%dI~}4CT#*XoVsmBeG_`F4(l_))r87rt*JxahjyxRV8P(a{&y3-lNBKXzujuU=@3fk-b?{!bz z$B76~AwDZygiTVn-PwWv-mGL*&lIIH=%`zp#WfeyT*dAG7;9Br`U&m5lgmJi>91eg z7n@JtVSC0Gm2b(5O`;d+iB>%`oSC%N#)qYamF{P8~(;{cew#qMJO3Q|z1OzaM~ZS0H75neaXAcXqk~Oy z@u|eb6*hj!JjAv>?t79sKR$hVE$V*-Sp-~EyBzjFHFjM-;8E_QMw;vt$QGBn}m z?zp)-i5S~+eb5e7zBHl&I^oY=B8Ewv?58VS47B>+ouw$e#5sp7Uha=8M#y@_Tk-V3 zi^I$-e$T{-OC~m1%P+0)4}*LyaV%J+Vw|-w{7sMF-n%dU{yjQC_JB_8nWEj&sPl>+@mxnf6rdeCJ?}NbyKsGhg$GF+xtieEw>wlf};;krKDdY1n8w$ zszZ=euQ#X$v@+@AkI*5O&tflSHh!0R4z61&NbZ=Va|3#4qyvxG-Ma%t2rLghDEn(S zrr?7#1W{rj=BkmlP+0@4hADj*i&WqdHhYKLn0*au{npQnm-~Zrc=3UP+3(BMM4aG2 zn+tb{F%jh=Pv)S#d>tQ(6ef4?EsMZ6zk~29@tiO(Z*Xy7C@RlVylw>hWsR>ILY5*T zc1-7*Kkeu3mzuL6Gyx#2CbgGif8>37`cV+4+1|YBSvU=ENVfOL;(kx|Rp&dCjTqze zglj~rUF~kvoRp;Lc9}5!nV2NgqlP~k%IuG6LzGL>gmlEez12>78{u>r*$`Vkl*Yn8 znD#9EYeOkLv!m5kRgFvJ-69`$pBE_#zmHp`gILW>nv|>#VuLIMC;p#p2(iebDj-wk zqJ}aqn0! zKL&nuETGWE2e`uRd*@{7bC`0uy$uPi-HlV*uy zAO<*$=9wh~Nf62KASATkJF3f^J-X-`k$zw?)z$g4#K zZ+rbYd}NNnH1!f6bFtTNaV-FtRlrf*nO}OvLet-X>|ul$+XFB?#W8fGNdo_D!E{#< zf%jEjHpTM9C_&(1EtZUM$`SQH5yu?d`&^Vn1iI8-vM-&M=LDs73tl|10xd_u)#k5> zK(eOexJ<{qY&Sr?1^DiAZvpYByWKxc2|tgf>Pl7Jp(mo|h}G_{OP6N0t!wq?)|&pSV{NwnSb78mxQAQfTt41zS6Er_bDdS^r=K8-kQA|Dr^jNPV`Xq_o#GFy90b z9(rk?>c@{)+yH>jiUn4|rlbkkw5oG(tGXt^Tqhjcque5eQ4}5$_4m>y2CT#V7J+kzR`Z2gmH&I| z?tgF3e*YdkE}{u?$#jQCDPEd_oLPiTO;?cPhqpm2b9=sPBP5$Mt|j+wc+NuztPu?dB@ zAi#Xe7KqyN`bz_t&d1H7eTB2z*tA@Nsbn$=*9OK8v&sn$HvWD^{(mk2HY{F(G?#3T zZFOSDsgH6BS_D zUKEvY;n=5xET7J1{Y>q;%EVlWHW~pv2ov25I;&b4urp(#Gm0YzB;och0*x(19znfq zA7Z=Qg>!SXkzO{>NB75vE^9Og>%L!<+lsSaWSSeVbn6rlQMJO&p^rP`9_Zs+E|Gb9 zxpTVhrThw;pMRN;$VH+LnrB;saqH5<-xq_bP;heIde&ij56*EkTk3l-0I6Kd3%IX^ zyJT#rglto`#@&M6MQe>k5ad8-`4N~_vAyFQzNbOI@o^u+#biR~pkk*4^C&Jmqh6|Ya%GVk7~-G`LQGIA@-P5j zbsmFJW6Pui1*b{Xl}Em*w>nj^l!Pi6E@lGWQNFK~d8k$FE5D&4cfEtDEzz2pP#1sO^Ii7a{7?ES6J^m6pZ$H(d9fjkX-PX zSn}pzoly$Utq^X|mX`<>Ed<=9x`-V+$jCW@M=(~7#wa*5vMa5 zk<9_YY(|n1pO=nJ$F=1Io;CcYjGgsgBLhpbUD;jP6Nes%f@*)??1it^s=*H%>#|Of ze0AO$$);)V`lequI2DNRpO1YZC4rx+i)AwesO;e2`kdz# zpU#|x8E&{OGiO_^)?rpdPX`!dmrifer+Eg2hGSL*sd8?QX5wmtf>tazY;R|*_=+Rn zSV&4C4;Uh(uQ3HM9fyuOTCQ6gT&ag0LgbtX z%(1XtO2~?^6%s89VDD4fYKs!AGCph!;$()^41pkB$76Tgfs!a&|AzrTH~)%bK~^r{ z4hn5U?^rRe4uHjrdTwfP7emR|bya*H0^}BVn7<}NOMmpm-@blZjxiArHig&Adh}y? zWNbI3Sqnod-H3PX4|XPQwH$7%;FOYN6nA6^z=>4*WH)YEq}T=^K~oaW3n*U&unz^kyTbDZd628h-6hHD|@e_NVXCw>mVV7 zWUt@-`92=M|KOa*eZAlJeZ8*N^?dEIq=(6IAmHpSrkra~kuG0I#NtRxN>TkA`e zsUFlE36V$ZO5LTKQqDI!mUb59SNHY4iD8yTO}_m4@0N{1*ezSe63Mh4yCM zk91-{Wg79|&-qV?jzS_{2>pKUuEB5MtMciRTm#FA*v62paF>{Hi=#j`>vPR!WM8{| zJtx!S-Zr7{rVKYZkucq{K~MS~z-Fo+C9q!7qKss|YawEApy$lcGh86Xt-ClnU)-G1m&I{ zMf0ipRT;<-o((gDUUN*jgbC%L+8-)_I(+b$IE-w24NLrJivomI4sSZLg#_X73Y)wA z`NaF`{up+RQL5~;s5==ah}?abH|`{=rHbmJ_{F*1B8zjzH?L`?h#BIUX@f5{Tc8{z zY*4(l47cP{nNStKdQ^Ton^CB7yM9vn;%?&+E&IgsV|V%k|FpBBUc+`eo-19IlcPoy z{&$m&(I14Uuh#J_N;R!)HZO2%$X*=J>aVOSkv^UG(%o}wujOQ+NfTS&cz3+03`Ly1 z^;~DO;fccY%La+>S7bAXsp;7heu{|}Sx=}TA9$oUWzC2GKG1OiH$cjw%-y8j1y>_^ zzhP=sevJGMyx^~C{WG=`CMHN@hB)v_@J(E&4s5L2S<@{Af4;f>(Fg?fuFAUYzWdaAiZ*=6(Y+rdOz3M{-^dOrsPd-*ms?sx^OEtQ*P@)Ka z=(^Bw#3c+s9Wxb2d`cWjZj+%i+%^TdN+kov2=r5F@9*!C)N)fb7rg?5eAGk1A8p}N z-^IVLmi}E*_Co>UcUnN2YZM{<{=K-#;u%W~;(BN09z6SElczC|Gk@vHRc)fJd6}%@ zcJX`jiC>J?34%xh775Dyv*cb6Xh5?C}Q13$rHIID$%)GE$}z3rq@0!p{LVir(Q?{CwGA+B{!5%<(tPM-_(6|Jy2 z;JmU0sLv#XXbJ=VLQy&zuVXG znzxDzmEDB5`@yas`o7n`AS!pEKomaNLe*ixF$*}X+_U%x{oq!HBTAl)>LO|HE|^&3 z2SgoNGiHFjX2VX;&V*X`wm{%-%ta|JHCX&C^7b{la);8(5os1}?_STv%d2;O*K+%+ zbX#eg9GXJ9BmlR&&#Zh5c|sWz+pW8-k8~fLvaGwM>D218%HR(!{pff1`4?^{IFRrm zD9Ag*AKh2_!2L;XV)2i8!KNo8r(X3W`Rts|?Y=lI_a}V5ql~EtbJN z?XeWc9m%|1_wz->lgKa5*a$}Z*AGcX4CB0S;*M>-b<~-Y1quv1H+QR|f z-fVuo)j`(t6!3K71qwP8J`eBY#CJvPG7OYC`>v-XjC(0I9Y`N#uh0seLM3p6$TM1P z8jzfmIhf?#?C9zH`EZHqHk;$_Kj&H#>CWR%*4koLqTj1*-_&@VM2P?l@%3Hs#6%

{Y`U&|1dtu&H%J*T&+V-m0w8Jd)&!7N;v4EBwe|uAR z9inCg(G{xadfltvm;s!}#xP2D_ zE2Zz(3{?299LP_hzyyaCg6W5tU%0^egldXUifi3I~R&!LrF zbHOhDL7zKonj%HpM+%yTeENJ!Z1*2+b+2dQ$u~?Qk?S=Vf`ibxg-c`WjnX$|a@E;95>Y|&faYPD+Qd(#ekKhJW+NjkKeN5AeVuNlg8*E5c+QIl$j}<> z6Y9SPie3OjIey6d{RrJoTZ+-5GBa?o^yRbo5!ZE7)pTUPLJ6l%2)1wh7v<9$c9gbu zEcFqJw}MJc*zW^izjt4!~L?6+VLr5i&J!<1kJ93 zsAY{PDZuei4A>ns80z(gd9ylo@&0*p+j|E~g)_rh3!GATG*}~t-^3<= zv&5pe(a~!c&ahoU1H@XpokM+IW(R}fPYf?UL$T8z3i@L2X(O5uH7aFWCv(P(j1c>= z&QDW$?mEZGu+9vt2zXY_kj%HkM~e(F#(0`rm}50lIKA67%atgAFNG)^N?iVyC%gEG z87Ok(WUkZ^HxnUJdi^oUu+6lrj1ez&@_BJW(advI67_{B#2HlW*q;Mwd|uS&-W|AAnRH@v{|EY8iN zVx9u%4NPm~f;ZXIYv0tkS1$wKLWSp}(UEqDR94?0N*~S4I+tn1<8KI*y=9Ciun_-g zh!gDE0#e&$5&yBA*y#@o(qzyJwZW2oYOhCu_X9NRX_p6LLgbR40E`?e$B>Bj&T@A3 zD{ws#ft;@htAuv_n16;3ylsdAi{>j}=*P`=DRe&g0js!^$aBfKsLM@$1Oz zYC~iM)os4ASK_!815U>3H^*Mf!c{dZHd{$H!aa!!!Upm5w(Qme&?74`cq)-$Ks2Bg1X&!H$T8C&AF{Y*O zhG{Lq*d$$8S|o~$pVk9E97TuB)^gLY#8?Q43cllq=t#@tH#9vxGO=Ka3uts52lKrK zf8ELvOF)Lvf*DHaDT*%PJa$D(5XYfe$1W!V5n{YjACKI7yupNp!@WDMr8q*M=i`zG zaFbVU*ke?NLRo!2_CB0VvpGXmE2Rg~Lhw(cNbKTf)r+RVSLe>zA%!;eb{;(;mOxpI z;%z(Bu09EKLeql~zlT;Re13Tt8L<0{ur23Htp6PuidQj$`APbbR{%W6VWfz!M}ERY~ zy%tl$AjJ!`^PduebZT>PU_Q?3J+G$JXWyJtm}Uvq={H9~O^HWy@u-tE0HmzZEkWrt z;|^ngFCv{Evy?GhxKqr>w^87If9JY>M90*<6ukf%@b?r1f3i;4F}m{Y;Sitnu`WoI zIVF7aHU%&{CD&-RkJTd|ctN4i+9ZGi4|F{XyoB~7INOienbGPq;%8%TTN<2t2tJeZ zVuP!$&eH&UZZK`nqh3)x3gDG)UOOWc5TuTp_T}DBG17P}SE%wF13xbatMym()KP2uBC|?G{WFJqCvtn=!T)4<{Zyzvlt`l9HEjI1 z?3yZ!OK-A3!z_9%WXzPY<8o&=XI$)DPtvg9@PU5C7$pin-tb?m@N&42LNP^SV*FKI z`EggR6FHD`_)eFGJ`2YBa`X=NN3WmKraTKm{svM4-)KNs|Fn%81a1_wPcMwXl~}-z z-R+INuzN%U4#T1K!yl_{Wx;k^jKJ=q2Tf}Kg#I6`;1?~Tr?X*oVloa3yJ7UIzTc#2 zn{$9O$nkUZu->~s@F7I(f+G_B2v zO0le7Pgnj+=Bp#al8Of3+f?uyl=wercB6blD@imS?)UOGd8DJr#~YKdGW|<@yf`Xf zk`scr3!MC-0ZSXy1qP8c7aH)nQPpVtfZ35th#dCijt}kqnoveZN#|VQzS0?nY-(vs z%IvpgQUM+EVWaOM+rf04X^jSshr?S)z}3euCXN%dPk@>CaL8{i2PXt$@}&1q7&rPY z8m2|Vk7tosOSQ-jyn5XQsHwzj^1Q_Z-``s%3t#&V=HExU zCj#qMJ*dN(ocB}?&Jyt&FyKRRSFi8Xi4QdxQzo6J)$ z{mK#>1>V2tGUaXMw3QixGJDilSM_#Z>P;b*{=SDSa%2Q0ggB(?q6yTCx##DB%#Xqb z7i`9;Hna|KyZ;n_yZKOo+^x3rjJdm`bg$nJ3V=JDAXHXtDD{sB&o7bDe5MSht42w9 z;6f|O8e28J8Zc0DpZ&!yds7OAI0rjdyd2q)_kMlZFCHbs@SwNvS5W-W7F}VO=2lRh z9&qOc4&UEpcyY!7m>-3o7WqT=)(6WTrtZit{-p7`Hl7;B34!dH9^-$6bW7PQHD}M& zAmfBV8zd6*HprB!-wVUUF@r;F3;iIU!sLteBrD8WrN|OtJy&^C3wn1~!9ol<3v?+@ zrRpreBw=Jsc`rW@B*rMyCr*qHdU7Ki1hGID`P*9Z`l()=48QVz<`%m0>b3@+ycZxJZ5T{v5#u547qm&L|6P9^avg=CTf>TW%IiGIp5q6I*$@qd`e!_^> zGXzxeV`=?^9pHLm)a7-_BPZZ00_ym7y&sUx9G^6gcQt|cl&%1z-Ter&(XYmJJjZ-c zzbJKyv*uh0-owvkf-3C(=l?x+9PD}V;^Ypky0b9A!X_8zpYEuvqZo(S9w;Zq=*4oV zfC#DE$Zp8Y{#v8SpX-|sr~zf5-1DVwp`^o92=K7FqW6YB8Ba0Ca_@Nb`d$S%`bDFl z{G~5`iYY-g9D5$=+wy=om}RoFTobwXIAOTX`>G_pS~6$i2=#g)+JDnY?YYx)Ke~Gp ztTv6M%4m=_;6hn`)&D4M=*aX<&5vrU=513n(bPKr{roLFEl_8qXU4y5Wm()ib2?GNx$f=sFdUAjuLJ{Ez!9Ua#y1V&)a~Nlgp8Fvn1l>({ z6AORGB?NK>=^5}?B*>B+7#x7S^LY0&rf+c+pmF`@%}3or3nI1lsnf3*DSDj z;5>x@e!e__jTPR4e+V-?_^TfQHCHDdX z;&EjF{YRr=bo^`to*xiZfC}98aFFad3Lj=#st4B^)<#kk9epkIPNJYhJEB@+ch)`OJ!GhmWV8$G>% zQG42_z8WrvKb>Sid_d0&JYI@Ru`6WstF4_U~CpL(t;^8+GTmyx8fys;SiUgzp9 zFt*D404ldUR{4-`KEWlaK;~!z8C2jKBMxD)Te44T)CrOTpI#HMx_fy&yy0E;OvMe;_PU0CH=w{WpXE z;$kuc{zhpa%~l%(v|6&Rjqh5431-`YG7>uxKTTwBqs8>`slyxZgRfd*0S^{Dy=rW~ z4hb65mAGTmqk)<7jnj=yT>tfeX@FQa8L8FM3S9z7;kt^w=tGQMyvAZ=K3W)%$_;9} zgZ%ZQLbz?%98~lGxI+zK(MC(D=Z5)m8r}9IQpuqIfd=!VY@qdAkw8K8yW;-gA;hnB zgh6bhZ(R6g?nS3Nn?(aZ`x)&JH@#Khd)hm)GT!xB_P*b*=k;bO>w33*m9qB^ zGU?FW*Ryr%WHW!~GoQV%^P)TFUG3bB%W&md%R5lQl4~b7_kb%ktTsgs>c>H}OHok~ zbFl8SV%_%YP)fi}U!O2iWj~L%4h&*BQlaM$-~s+j;9w1CMCGEY$9zADVTIholP>AN z^bT1N@C^@7_^3UD#_XTl2-d2(t4#8Ws;I3Gqo$Yn8VlHwHMwds!T!nyiI2y$Vxi{g z#)3~f4lS%Z0sLeV80cFSQsE=u{HPAJ3=6T-N(oE|A;zzJdabbLbMs!DL?rNKKu~x6 zN6BMtG{~L5b`mmJHn4W@&&gkZj!_?FFNH4FDH$3kXZ>|=9UsbU9BFmkq{Ydm% zt1RNwMK{bbc~Pghza6AT5j+JN8DACksP!ZArA!=l6s`N3YN9qq6K>=w&3je&RgE!% z`vxu35)ABkt;hhX=%S# zTI+(;T0xQhuCm+uR;ZHYF% z2SVU6lmw_3$LSenBvzzEf#kvQ*`_xYo4_+ZKQ$4 z!TJ%MQ$xAd1A0>LqVPFwDG2EMd`mF640xKg9?#%sad|UhXO?>_wBL-# zsr!inq$z>y(=x4oXfaa`KGH{>GZuPLnEj3Bq&looWBbJDgAWA0Wzd5mCDYyh*PY)) zAR-z6XRRL$_-ml-nAIENSN!F>&?6V`_UHurg)-OsneEV3R)C8C{5`Di_q z6ca(u&`8hkQXUg15aWlxZ~aZ2>hsqvh>$w|G{Ge){gRfJyK7DQDU%kEvOe~XiF3~s zz?ZgzLliX`qutZ@pi;93P`_QPSDG<4q=URmRtA`C@Ph8JsKrbTUzQ;xp!i!dWwXnW)YK z(&GkOLPCOm{ed;8h*;u97UJreO(di1PtbFD@vQa5Ey{U~`@%`Ng?K}w-!;a_UKibm z^D!^}4lW*Z{ko!HM-B+`zD^e)1<%TN(WK_0re%-P31-rCg}*XR4Aw(8!*i-^lU6Lg zXyzl&*C?l`2Se|ufI`?|rqWrJxL&j}o~S3^E7!&qr)IC+=&I(T_^p);9NLTHhPDry zAcUW}B=B@st*bAG?e%drvuwacz<#4%xOQziTPhTt7IIg z8Q<6B^fKT#8lw%tI_hsxb25*A-Ghu?7WS-rySsmO9n^pun)DGYK7W29$V;c3>4h?W zFw<~Bi1^Ps5eSBHfY#R#UMmi@zZ``Fimnj+I2T2#$YyVi7V}Xcd)a<>@d057+&u55 zkOasb9Y!E1=2+c>?5KkDapDMlbcNq@{+^Lc{4}eJl1&nLN==>@fkPM*! ziT@l7r?)c<+dRc#GUpSUC+GT3U}`EE+Ch^ZTgg$X?Sx0Bp?mKrU;;U?*Vb3glC&D6 zT_%+fTj)~S8Tf|3T>`(R{kCXm7EaZI#L$*{gG``GpXcHVWXRTiS7&vvm7Ifi$BZwt zRGhxT$V8TS2{r5+2nau?V)veh`|v&aP;qe*ON;|DLz^3ie0m4y)8KBq6OF3i`}j1SoUv}1SEHy%atLe zA`}J;+`Ly&yATOrCM;r8(vNoMv_DSYL3d3j zIB|Sqez{>iRR%sy-~Typ65EiUFPzDX2Sf$OIcMVKS?mz9@_}g%4z9rRIsFSn5yKn4 zVno4H%^5?6@-CxMqLQlY&fj4*Q!KA~{dF`cj1E58ZV+{w!V*iz5#M^YRNY4u0GTGW z^@XRcN0S-1bA+GXlJ^RHPbVd70pK`|8t3#;A2Yz*Pt7u_(~bi5d1c}B+VfMHE0NJGrsSK$h5L}4DtNCXy(oo2_7jJKwslehy!SJYt=I}_;2ku9v_Th^?Ma8_;6lHoW#n&oYM=I3oF8It z_~Z`=Vp-q6f9PwmC3CUI0y@BdN#rd_-{4ip8UH&4-BNoIR+Rb94QL2t0cnum}4f?J&=Bk802$xy99C}Py5AR~}-iu*&c)@}b=fp$vu zMx7p7H&gi>GRT7OWiw1>|IvEqp)&Y|pCXnK@#oyf=h1#b5zqOOjlyV8&nvO%n8m6} zp!w4N9LvX8GDpjx%9TVGlrNnL#`T8N@lkhHxZmBIU<}mD0@q(BcjMmITrJGmOm=D zBV&hx>{`eO&B1%25851J0TJa`!JjB4nLxrBn(s}oo-JQj66Tie=l!~RT~|HNCuK9W zHSyNNd%`>FZPUhb6|BX7TE4$iJikl}7|8~ZFi(?Kv?6LE$7?>fHER6-eCQ;WKgJ?OWdVK@l-TVQ6Tx zv+wXHpW_o6e-wT!^=k);pP$M{RN4*ji-uYO5c2IE`lAcc9RO{yvPx}!Si+i^lxXdn zLgRSELkSe{5@F3zet4BMfKty=RbxWntV3kLv2FbZfjBi4Hg(YU#1SD`^P7~|EVtdY z`JpvcsHCBoGsuhF_zEc00tzOGthn=Tpb#_cttAkwRhkcez4_L@-g~_WJTr}EDwp;9 z2H>drw^s#X8S#?8t}#R&U72vC2N!mXK{}{EJST7fJ#%&awZK;riK*A^&v8Xh=os2j z%;O;;Mgai#PHQr7?_7QnbMFU2&jp@{SRqWX>bvfFixCqlQ}AB+bbUH48Ek_8EmON? zYM6T4gG@8>ov!lKJIc|kzDTc~iNC+5d_5qr&UKN45?&{~AujW9v#;xMJX_d&C@FTx z^6;Io=J5U?IULzR{X$JR``W#%mSB6SXSS0a^!%l0`@>~5B=I^b+?f^dukSw`H@ra} zbfp1F!i|WzGxpG4r|8W?smLQVXne-EHK70zP^90WUS)Ilv+3M^JfMW7PCHn(lNppe z&=IKVsA}B0O+pd-^IUUkv5_8>L>vf{9)&(Rld;38D=I|z2cBl=B`C?0lS!3Qu#^dl zfvqhH>d2gMKyP^mfNk2pV{to%0DiU|=oh?K5Lme7Grp>+5XSkpKpux+J7;4^c4pzP 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 literal 0 HcmV?d00001 diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 3ff4e7f9..a61a3b9e 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -1,5 +1,5 @@ "use client"; -import { createTheme } from '@mui/material/styles'; +import {createTheme} from '@mui/material/styles'; const loginTheme = createTheme({ palette: { From a81b8176608872d03dc456281c7ffa59b141d6ab Mon Sep 17 00:00:00 2001 From: gilles-arnout Date: Tue, 12 Mar 2024 17:26:50 +0100 Subject: [PATCH 051/138] small error fix in theme and layout --- frontend/src/app/components/LoginCard.tsx | 74 +++++++++++------------ frontend/src/app/layout.tsx | 2 +- frontend/src/styles/theme.ts | 57 ++++++++++------- 3 files changed, 71 insertions(+), 62 deletions(-) diff --git a/frontend/src/app/components/LoginCard.tsx b/frontend/src/app/components/LoginCard.tsx index 60bf9618..98fd6511 100644 --- a/frontend/src/app/components/LoginCard.tsx +++ b/frontend/src/app/components/LoginCard.tsx @@ -1,48 +1,46 @@ import React from 'react'; import {Box, Container, CssBaseline, Typography} from '@mui/material'; -import {ThemeProvider} from '@mui/material/styles'; import CASButton from "@/app/components/CASButton"; import LoginForm from "@/app/components/LoginForm"; -import loginTheme from '../../styles/theme'; const LoginCard: React.FC = () => { return ( - - - - - Pigeonhole - -

- - - - OR - - - - - - + + + + + Pigeonhole + +
+ + + + OR + + + +
+
+
); }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index c72bb9ed..caf37ade 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,7 +8,7 @@ export const metadata = { description: 'Generated by Next.js', } -export default function RootLayout(props) { +export default function RootLayout(props: React.PropsWithChildren<{}>) { const {children} = props; return ( diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 40eb9d24..b607229d 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -1,24 +1,35 @@ "use client"; -import {createTheme} from '@mui/material/styles'; +import {createTheme, PaletteOptions} from '@mui/material/styles'; +import {Palette} from '@mui/material/styles/createPalette'; + +declare module '@mui/material/styles/createPalette' { + interface Palette { + failure?: Palette['primary']; + } + + interface PaletteOptions { + failure?: PaletteOptions['primary']; + } +} const loginTheme = createTheme({ palette: { background: { default: '#f4f5fd' }, - primary:{ - main:'#1E64C8', - contrastText:'#FFFFFF' + primary: { + main: '#1E64C8', + contrastText: '#FFFFFF' }, - secondary:{ - main:'#D0E4FF', - contrastText:'#001D36' + secondary: { + main: '#D0E4FF', + contrastText: '#001D36' }, - failure:{ - main:'#E15E5E' + failure: { + main: '#E15E5E' }, - success:{ - main:'#7DB47C' + success: { + main: '#7DB47C' } }, typography: { @@ -50,21 +61,21 @@ const loginTheme = createTheme({ }); export const theme = createTheme({ - palette:{ - primary:{ - main:'#1E64C8', - contrastText:'#FFFFFF' + palette: { + primary: { + main: '#1E64C8', + contrastText: '#FFFFFF' }, - secondary:{ - main:'#D0E4FF', - contrastText:'#001D36' + secondary: { + main: '#D0E4FF', + contrastText: '#001D36' }, - background:{ - default:'#f4f5fd', + background: { + default: '#f4f5fd', }, - text:{ - primary:'#001D36', - secondary:'#FFFFFF' + text: { + primary: '#001D36', + secondary: '#FFFFFF' }, }, }); From 3908805a7fc00db82914479750761ec893997f81 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Tue, 12 Mar 2024 17:51:31 +0100 Subject: [PATCH 052/138] user api and permissions --- .../pigeonhole/apps/courses/permissions.py | 2 +- backend/pigeonhole/apps/users/permissions.py | 18 ++++++++++ backend/pigeonhole/apps/users/views.py | 35 ++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 backend/pigeonhole/apps/users/permissions.py diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index f7939197..2bcebaaf 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -17,7 +17,7 @@ def has_permission(self, request, view): return True return - if request.user.is_student or request.user.is_teacher: + if request.user.is_student: return view.action in ['list', 'retrieve'] return False diff --git a/backend/pigeonhole/apps/users/permissions.py b/backend/pigeonhole/apps/users/permissions.py new file mode 100644 index 00000000..bc2d912d --- /dev/null +++ b/backend/pigeonhole/apps/users/permissions.py @@ -0,0 +1,18 @@ +from rest_framework import permissions + +from backend.pigeonhole.apps.users.models import User + + +class UserPermissions(permissions.BasePermission): + def has_permission(self, request, view): + if request.user.is_admin or request.user.is_superuser: + return True # TODO can admins destroy each other? + + if request.user.is_teacher or request.user.is_student: + if view.action in ['list', 'retrieve']: # TODO: can teachers create and destroy users? + return True + elif view.action in ['update', 'partial_update', 'destroy'] and User.objects.filter( + id=request.user.id).exists(): + return True + + return False diff --git a/backend/pigeonhole/apps/users/views.py b/backend/pigeonhole/apps/users/views.py index 5fec4879..81dc7938 100644 --- a/backend/pigeonhole/apps/users/views.py +++ b/backend/pigeonhole/apps/users/views.py @@ -3,12 +3,13 @@ from rest_framework.response import Response from backend.pigeonhole.apps.users.models import User, UserSerializer +from .permissions import UserPermissions class UserViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, UserPermissions] def list(self, request, *args, **kwargs): serializer = UserSerializer(self.queryset, many=True) @@ -24,3 +25,35 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + user_id = kwargs.get('pk') + user = User.objects.get(pk=user_id) + serializer = UserSerializer(user, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, *args, **kwargs): + user_id = kwargs.get('pk') + user = User.objects.get(pk=user_id) + serializer = UserSerializer(user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + user_id = kwargs.get('pk') + user = User.objects.get(pk=user_id) + user.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def retrieve(self, request, *args, **kwargs): + user_id = kwargs.get('pk') + user = User.objects.get(pk=user_id) + serializer = UserSerializer(user, many=False) + return Response(serializer.data, status=status.HTTP_200_OK) From 2f06aeead20a56bcf08ce2d0d6345ed7ba13249a Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 18:57:40 +0100 Subject: [PATCH 053/138] model test fixes --- backend/pigeonhole/tests/test_models/test_groups.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 38d4fc96..c7c86b8b 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -12,6 +12,7 @@ class GroupTestCase(TestCase): def setUp(self): # Create teacher user teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", @@ -21,6 +22,7 @@ def setUp(self): # Create student user student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", @@ -30,6 +32,7 @@ def setUp(self): # Create a second student user student2 = User.objects.create( + id=3, username="student_username2", email="student2@gmail.com", first_name="Fozzie", @@ -67,14 +70,14 @@ def test_group_project_relation(self): def test_group_student_relation(self): group = Group.objects.get(group_nr=1) - student = User.objects.get(id=1) - student2 = User.objects.get(id=2) + student = User.objects.get(id=2) + student2 = User.objects.get(id=3) self.assertIn(student, group.user.all()) self.assertIn(student2, group.user.all()) def test_group_final_score(self): group = Group.objects.get(group_nr=1) - self.assertEqual(group.final_score, 0) + self.assertEqual(group.final_score, None) def test_group_group_nr(self): group = Group.objects.get(group_nr=1) From cba6b28974fa4bc868517b1d15b5485c2bad973e Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 19:19:02 +0100 Subject: [PATCH 054/138] fix dubbele max_score --- backend/pigeonhole/apps/projects/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index 79638120..ff37a28e 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -23,7 +23,7 @@ class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project fields = ["project_id", "course_id", "name", "description", "deadline", "visible", "number_of_groups", - "group_size", "max_score", "file_structure", "max_score"] + "group_size", "max_score", "file_structure"] class Test(models.Model): From 495eadca9a24acbd1b75e27d50f29ac419fedacf Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:17:15 +0100 Subject: [PATCH 055/138] add auth urls --- backend/pigeonhole/urls.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 0c0a9680..2a5503e2 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,10 +1,11 @@ -from django.contrib import admin -from django.urls import include, path from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import routers, permissions +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from backend.pigeonhole.apps.courses.views import CourseViewSet from backend.pigeonhole.apps.groups.views import GroupViewSet @@ -35,11 +36,13 @@ # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ - path('', include(router.urls)), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path("admin/", admin.site.urls), -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path("admin/", admin.site.urls), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += router.urls From 88a52a10c8066e0c4105309767cb14efd9fc139f Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 20:18:49 +0100 Subject: [PATCH 056/138] layout fix --- backend/pigeonhole/apps/groups/models.py | 2 +- backend/pigeonhole/apps/groups/views.py | 1 + backend/pigeonhole/apps/projects/models.py | 2 +- backend/pigeonhole/apps/projects/views.py | 7 ++++--- backend/pigeonhole/apps/submissions/models.py | 2 +- .../pigeonhole/tests/test_models/test_course.py | 2 +- .../pigeonhole/tests/test_models/test_groups.py | 2 +- backend/pigeonhole/tests/test_models/test_user.py | 14 +++++--------- .../tests/test_views/test_complete/admin.py | 4 +--- .../tests/test_views/test_project/test_admin.py | 3 +-- .../tests/test_views/test_project/test_teacher.py | 4 ++-- .../test_project/test_unauthenticated.py | 2 +- 12 files changed, 20 insertions(+), 25 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 6024f144..f8fa92e1 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -1,6 +1,6 @@ +from django.core.exceptions import ValidationError from django.db import models from rest_framework import serializers -from django.core.exceptions import ValidationError from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 2ec62913..36dbd19f 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -7,6 +7,7 @@ from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.projects.models import Project + # TODO tests for score/max_score diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index ff37a28e..b533214e 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -33,4 +33,4 @@ class Test(models.Model): str(project_id) + '/' + str(test_nr), null=True, blank=False, max_length=255) - objects = models.Manager() \ No newline at end of file + objects = models.Manager() diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 0b9cec90..81dfca5d 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -4,10 +4,11 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .models import Project, ProjectSerializer, Course from backend.pigeonhole.apps.groups.models import Group +from .models import Project, ProjectSerializer, Course from .permissions import CanAccessProject + # TODO hier nog zorgen als een project niet visible is, dat de students het niet kunnen zien. # TODO tests for visibility and deadline @@ -34,10 +35,10 @@ def create(self, request, *args, **kwargs): serializer = ProjectSerializer(data=request.data) if serializer.is_valid(): - project = serializer.save() # Save the project and get the instance + project = serializer.save() # Save the project and get the instance # make NUMBER OF GROUP groups for i in range(serializer.validated_data['number_of_groups']): - group = Group.objects.create(group_nr=i+1, project_id=project) # Assign the Project instance + group = Group.objects.create(group_nr=i + 1, project_id=project) # Assign the Project instance group.save() return Response(serializer.data, status=status.HTTP_201_CREATED) else: diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 7d9f035a..2749e122 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,6 +1,5 @@ from django.db import models from rest_framework import serializers -from django.core.exceptions import ValidationError from backend.pigeonhole.apps.groups.models import Group @@ -31,6 +30,7 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields self.submission_nr = max_submission_nr + 1 super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + class SubmissionsSerializer(serializers.ModelSerializer): class Meta: model = Submissions diff --git a/backend/pigeonhole/tests/test_models/test_course.py b/backend/pigeonhole/tests/test_models/test_course.py index d0904273..499d1b12 100644 --- a/backend/pigeonhole/tests/test_models/test_course.py +++ b/backend/pigeonhole/tests/test_models/test_course.py @@ -27,7 +27,7 @@ def setUp(self): last_name="Piggy", role=3 ) - + # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index c7c86b8b..145a116d 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -19,7 +19,7 @@ def setUp(self): last_name="The Frog", role=2 ) - + # Create student user student = User.objects.create( id=2, diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 6c7966a3..174802b0 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -15,7 +15,7 @@ def setUp(self): last_name="The Frog", role=2 ) - + # Create student user User.objects.create( id=2, @@ -33,7 +33,7 @@ def test_student_fields(self): self.assertEqual(student[0].first_name, "Kermit") self.assertEqual(student[0].last_name, "The Frog") self.assertEqual(student[0].role, 2) - + def test_teacher_fields(self): teacher = User.objects.get(id=2), self.assertEqual(teacher[0].username, "student_username") @@ -41,7 +41,7 @@ def test_teacher_fields(self): self.assertEqual(teacher[0].first_name, "Miss") self.assertEqual(teacher[0].last_name, "Piggy") self.assertEqual(teacher[0].role, 3) - + def test_user_name_length_validation(self): with self.assertRaises(Exception): User.objects.create( @@ -51,7 +51,7 @@ def test_user_name_length_validation(self): last_name="Piggy", role=3 ) - + # TODO def test_user_correct_email(self): with self.assertRaises(Exception): @@ -62,7 +62,7 @@ def test_user_correct_email(self): last_name="Piggy", role=3 ) - + def test_user_role_validation(self): with self.assertRaises(Exception): User.objects.create( @@ -72,7 +72,3 @@ def test_user_role_validation(self): last_name="Piggy", role=4 ) - - - - \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_complete/admin.py b/backend/pigeonhole/tests/test_views/test_complete/admin.py index da4d7afd..684bddd9 100644 --- a/backend/pigeonhole/tests/test_views/test_complete/admin.py +++ b/backend/pigeonhole/tests/test_views/test_complete/admin.py @@ -22,7 +22,7 @@ def setUp(self): last_name="LastName", role=2 # Teacher role ) - + # Create a student user self.student = User.objects.create( username="student_username", @@ -86,5 +86,3 @@ def test_create_submission(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Submission.objects.count(), 1) self.assertEqual(Submission.objects.get(submission_id=1).submission, "Test Submission") - - \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 1f51ebf5..12844ff9 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -58,7 +58,7 @@ def test_create_project(self): # Check that the number of projects has increased by 1 # This assumes that there was already one project before this test self.assertEqual(Project.objects.count(), 2) - + # test whether 4 group objects are created self.assertEqual(Group.objects.count(), 4) @@ -71,7 +71,6 @@ def test_create_project(self): # Check that the name of the newly created project is correct self.assertEqual(new_project.name, "Test Project 2") - def test_retrieve_project(self): response = self.client.get( API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 1f7c9b88..66f2d98b 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -162,7 +162,7 @@ def test_retrieve_invalid_project(self): API_ENDPOINT + f'{self.course.course_id}/projects/100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + def test_update_project_invalid_project(self): response = self.client.patch( API_ENDPOINT + f'{self.course.course_id}/projects/100/', @@ -174,7 +174,7 @@ def test_update_project_invalid_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + def test_delete_project_invalid_project(self): response = self.client.delete( API_ENDPOINT + f'{self.course.course_id}/projects/100/' diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index 4d54ae51..044cf907 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -86,7 +86,7 @@ def test_partial_update_project_unauthenticated(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # tests with an invalid course - + def test_create_project_invalid_course_unauthenticated(self): response = self.client.post( API_ENDPOINT + f'100/projects/', From dcfc5deadec979ebd4495bacaf2d7a26992b6784 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:24:27 +0100 Subject: [PATCH 057/138] add auth agent --- frontend/src/auth/auth-agent.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/src/auth/auth-agent.js diff --git a/frontend/src/auth/auth-agent.js b/frontend/src/auth/auth-agent.js new file mode 100644 index 00000000..279b33a2 --- /dev/null +++ b/frontend/src/auth/auth-agent.js @@ -0,0 +1,26 @@ +import axios from "axios"; + +class AuthAgent { + + login(username, password) { + return axios + .post("http://127.0.0.1:8000/auth/login/", { + username, + password + }) + .then(response => { + console.log(response) + if (response.data.access) { + localStorage.setItem("user", JSON.stringify(response.data.user)); + } + return response.data; + }); + } + + getCurrentUser() { + return JSON.parse(localStorage.getItem('user')); + } + +} + +export default new AuthAgent(); \ No newline at end of file From fefb8c32f8791c7f03cfbb830b0f6050ef3a8c8e Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:24:34 +0100 Subject: [PATCH 058/138] add auth header --- frontend/src/auth/auth-header.js | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 frontend/src/auth/auth-header.js diff --git a/frontend/src/auth/auth-header.js b/frontend/src/auth/auth-header.js new file mode 100644 index 00000000..bee5b57b --- /dev/null +++ b/frontend/src/auth/auth-header.js @@ -0,0 +1,10 @@ +function authHeader() { + const user = JSON.parse(localStorage.getItem('user')); + if (user && user.accessToken) { + return {Authorization: 'Bearer ' + user.accessToken}; + } else { + return {}; + } +} + +export default authHeader; \ No newline at end of file From ea9f0fad1948fce4674f37024c47f27ddf913ae2 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:26:15 +0100 Subject: [PATCH 059/138] add basic frontend login logic --- frontend/src/app/components/LoginForm.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/LoginForm.tsx b/frontend/src/app/components/LoginForm.tsx index abb5f37c..d3d1d685 100644 --- a/frontend/src/app/components/LoginForm.tsx +++ b/frontend/src/app/components/LoginForm.tsx @@ -3,15 +3,18 @@ import React, {useState} from 'react'; import {Button, IconButton, InputAdornment, TextField} from "@mui/material"; import Visibility from "@mui/icons-material/Visibility"; import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import AuthAgent from "../../auth/auth-agent"; const LoginForm = () => { - const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const handleLogin = (): void => { - // Implement your login logic here - console.log('Login with:', email, password); + AuthAgent.login(username, password).then((data) => { + console.log("Logged in") + console.log(data) + }) }; const handleClickShowPassword = () => { @@ -27,8 +30,8 @@ const LoginForm = () => { setEmail(e.target.value)} + value={username} + onChange={(e) => setUsername(e.target.value)} fullWidth /> Date: Tue, 12 Mar 2024 20:26:25 +0100 Subject: [PATCH 060/138] add axios dependency --- frontend/package-lock.json | 91 ++++++++++++++++++++++++++++++++++++++ frontend/package.json | 1 + 2 files changed, 92 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d03e7e24..b7efaa1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@mui/material": "^5.15.12", "@mui/material-nextjs": "^5.15.11", "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.344.0", @@ -1596,6 +1597,11 @@ "has-symbols": "^1.0.3" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/autoprefixer": { "version": "10.4.17", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", @@ -1657,6 +1663,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -1909,6 +1925,17 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2043,6 +2070,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2826,6 +2861,25 @@ "integrity": "sha512-noqGuLw158+DuD9UPRKHpJ2hGxpFyDlYYrfM0mWt4XhT4n0lwzTLh70Tkdyy4kyTmyTT9Bv7bWAJqw7cgkEXDg==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -2850,6 +2904,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -3816,6 +3883,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4444,6 +4530,11 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d0d115b8..3ed1fbde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@mui/material": "^5.15.12", "@mui/material-nextjs": "^5.15.11", "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.344.0", From 59723ce5ca34e420ee515915f839b273f042b720 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:26:48 +0100 Subject: [PATCH 061/138] change user manager --- backend/pigeonhole/apps/users/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index 285e6b7b..fe3b228c 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, UserManager from django.db import models from rest_framework import serializers @@ -19,7 +19,7 @@ class User(AbstractUser): course = models.ManyToManyField(Course) role = models.IntegerField(choices=Roles.choices, default=Roles.ADMIN) - objects = models.Manager() + objects = UserManager() class Meta(AbstractUser.Meta): db_table = "auth_user" From 8e13b0a3c1b0f32f1b1d28ebebfeda01fa404469 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 20:50:51 +0100 Subject: [PATCH 062/138] fix migrations authentication --- .../migrations/0006_alter_user_managers.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py diff --git a/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py b/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py new file mode 100644 index 00000000..1c3b6146 --- /dev/null +++ b/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.3 on 2024-03-12 19:50 + +import django.contrib.auth.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_user_role'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] From 5b453f382fe3942055cf345c5faf6d3f8c71f295 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 20:51:34 +0100 Subject: [PATCH 063/138] fix lint --- backend/pigeonhole/apps/courses/views.py | 2 +- ...emove_conditions_submission_id_and_more.py | 6 +- backend/pigeonhole/apps/submissions/models.py | 2 +- .../tests/test_models/test_tests.txt | 95 +++++++++++++++++++ .../pigeonhole/tests/test_models/test_user.py | 2 +- 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 backend/pigeonhole/tests/test_models/test_tests.txt diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index a576fa6c..ff565f65 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -71,4 +71,4 @@ def join_course(self, request, *args, **kwargs): course = Course.objects.get(pk=course_id) user = User.objects.get(id=request.user.id) user.course.add(course) - return Response(status=status.HTTP_200_OK) \ No newline at end of file + return Response(status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py index 5663b76a..a72afcf7 100644 --- a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py +++ b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py @@ -14,9 +14,11 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Test', fields=[ - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='projects.project')), + ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + primary_key=True, serialize=False, to='projects.project')), ('test_nr', models.IntegerField()), - ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), + ('test_file_type', models.FileField(max_length=255, null=True, upload_to= + 'uploads/projects//')), ], ), migrations.RemoveField( diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 2749e122..6302f759 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -20,7 +20,7 @@ class Submissions(models.Model): objects = models.Manager() - # submission_nr is automatically assigned and unique per group, and + # submission_nr is automatically assigned and unique per group, and # increments def save(self, force_insert=False, force_update=False, using=None, update_fields=None): if not self.submission_id: diff --git a/backend/pigeonhole/tests/test_models/test_tests.txt b/backend/pigeonhole/tests/test_models/test_tests.txt new file mode 100644 index 00000000..c6054165 --- /dev/null +++ b/backend/pigeonhole/tests/test_models/test_tests.txt @@ -0,0 +1,95 @@ +from django.test import TestCase +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project, Test + + +class ConditionsTestCase(TestCase): + def setUp(self): + # Create teacher user + teacher = User.objects.create( + id=1, + username="teacher_username", + email="teacher@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + # Create student user + student = User.objects.create( + id=2, + username="student_username", + email="student@gmail.com", + first_name="Miss", + last_name="Piggy", + role=3 + ) + + # Create course + course = Course.objects.create(name="Math", description="Mathematics") + teacher.course.add(course) + student.course.add(course) + + # Create project + project = Project.objects.create( + name="Project", + course_id=course, + description="Project Description" + ) + + # Create conditions + self.conditions = Test.objects.create( + submission_id=project, + condition="Condition 1", + deadline="2021-12-12 12:12:12", + test_file_location="path/to/test", + test_file_type="txt" + ) + + + def test_conditions_submission_relation(self): + self.assertEqual(self.conditions.submission_id, Project.objects.get(name="Project")) + + def test_conditions_forbidden_extensions(self): + self.assertEqual(len(self.conditions.get_forbidden_extensions), 1) + + def test_conditions_allowed_extensions(self): + self.assertEqual(len(self.conditions.get_allowed_extensions), 1) + + def test_create_conditions_without_submission(self): + with self.assertRaises(Exception): + Test.objects.create( + condition="Condition 2", + deadline="2021-12-12 12:12:12", + test_file_location="path/to/test", + test_file_type="txt" + ) + + def test_allowed_extension(self): + allowed_extension = AllowedExtension.objects.get(extension=123) + self.assertEqual(allowed_extension.project_id, Project.objects.get(name="Project")) + + def test_forbidden_extension(self): + forbidden_extension = ForbiddenExtension.objects.get(extension=456) + self.assertEqual(forbidden_extension.project_id, Project.objects.get(name="Project")) + + def test_update_and_delete_conditions(self): + self.conditions.condition = "Condition 2" + self.conditions.save() + updated_conditions = Conditions.objects.get(condition="Condition 2") + self.assertEqual(updated_conditions.condition, "Condition 2") + + # Delete associated extensions explicitly + AllowedExtension.objects.filter(project_id=self.conditions.submission_id).delete() + ForbiddenExtension.objects.filter(project_id=self.conditions.submission_id).delete() + + self.conditions.delete() + with self.assertRaises(Conditions.DoesNotExist): + Conditions.objects.get(condition="Condition 2") + + # Check if associated extensions are deleted as well + with self.assertRaises(AllowedExtension.DoesNotExist): + AllowedExtension.objects.get(extension=123) + + with self.assertRaises(ForbiddenExtension.DoesNotExist): + ForbiddenExtension.objects.get(extension=456) \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 174802b0..740da8f0 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -52,7 +52,7 @@ def test_user_name_length_validation(self): role=3 ) - # TODO + # TODO def test_user_correct_email(self): with self.assertRaises(Exception): User.objects.create( From 3fa501e16b23bba6ef50f4f65434796df7af10d5 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:52:02 +0100 Subject: [PATCH 064/138] fix username label --- frontend/src/app/components/LoginForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/LoginForm.tsx b/frontend/src/app/components/LoginForm.tsx index d3d1d685..8e652e3d 100644 --- a/frontend/src/app/components/LoginForm.tsx +++ b/frontend/src/app/components/LoginForm.tsx @@ -28,8 +28,8 @@ const LoginForm = () => { return (
setUsername(e.target.value)} fullWidth From a4268fd7f57f9f2e55ab36d94dcd7c988882df83 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 20:56:01 +0100 Subject: [PATCH 065/138] add superuser and reset make commands --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index f625d393..e6e04f4a 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,13 @@ lint: docker exec pigeonhole-backend flake8 . docker exec pigeonhole-frontend npm run lint +superuser: + docker exec -it pigeonhole-backend python manage.py createsuperuser + +reset: + docker image prune -af + docker system prune + backendtest: docker exec -it pigeonhole-backend sh /usr/src/app/backend/runtests.sh From 31fd002f78e4fe06aa652b5e263e36608ad5e342 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 20:59:18 +0100 Subject: [PATCH 066/138] fix lint --- .../0005_test_remove_conditions_submission_id_and_more.py | 8 +++++--- .../tests/test_views/test_project/test_admin.py | 4 ++-- .../tests/test_views/test_project/test_student.py | 6 +++--- .../tests/test_views/test_project/test_teacher.py | 4 ++-- .../tests/test_views/test_project/test_unauthenticated.py | 6 +++--- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py index a72afcf7..42064650 100644 --- a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py +++ b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ('projects', '0004_alter_project_deadline'), ] @@ -17,8 +16,11 @@ class Migration(migrations.Migration): ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='projects.project')), ('test_nr', models.IntegerField()), - ('test_file_type', models.FileField(max_length=255, null=True, upload_to= - 'uploads/projects//')), + ('test_file_type', + models.FileField(max_length=255, + null=True, + upload_to='uploads/projects/' + '/')), ], ), migrations.RemoveField( diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 12844ff9..283ab93c 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -120,7 +120,7 @@ def test_partial_update_project(self): def test_create_project_invalid_course(self): response = self.client.post( - API_ENDPOINT + f'100/projects/', + API_ENDPOINT + '100/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -170,7 +170,7 @@ def test_retrieve_project_invalid_course(self): def test_list_projects_invalid_course(self): response = self.client.get( - API_ENDPOINT + f'100/projects/' + API_ENDPOINT + '100/projects/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 438f9ed5..a1321063 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -40,7 +40,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + API_ENDPOINT + '{self.course.course_id}/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -100,7 +100,7 @@ def test_partial_update_project(self): def test_create_project_invalid_course(self): response = self.client.post( - API_ENDPOINT + f'100/projects/', + API_ENDPOINT + '100/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -119,7 +119,7 @@ def test_retrieve_project_invalid_course(self): def test_list_projects_invalid_course(self): response = self.client.get( - API_ENDPOINT + f'100/projects/' + API_ENDPOINT + '100/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 66f2d98b..6e64e177 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -105,7 +105,7 @@ def test_partial_update_project(self): def test_create_project_invalid_course(self): response = self.client.post( - API_ENDPOINT + f'100/projects/', + API_ENDPOINT + '100/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -124,7 +124,7 @@ def test_retrieve_project_invalid_course(self): def test_list_projects_invalid_course(self): response = self.client.get( - API_ENDPOINT + f'100/projects/' + API_ENDPOINT + '100/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index 044cf907..1585b6a8 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -89,7 +89,7 @@ def test_partial_update_project_unauthenticated(self): def test_create_project_invalid_course_unauthenticated(self): response = self.client.post( - API_ENDPOINT + f'100/projects/', + API_ENDPOINT + '100/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -102,13 +102,13 @@ def test_create_project_invalid_course_unauthenticated(self): def test_retrieve_project_invalid_course_unauthenticated(self): response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' + API_ENDPOINT + '100/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_projects_invalid_course_unauthenticated(self): response = self.client.get( - API_ENDPOINT + f'100/projects/' + API_ENDPOINT + '100/projects/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 7ae361eb0ed1376db29fe3ea29253b0b0a2f2335 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Tue, 12 Mar 2024 21:05:34 +0100 Subject: [PATCH 067/138] add user admin view --- backend/pigeonhole/apps/users/admin.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/pigeonhole/apps/users/admin.py diff --git a/backend/pigeonhole/apps/users/admin.py b/backend/pigeonhole/apps/users/admin.py new file mode 100644 index 00000000..ec1d1884 --- /dev/null +++ b/backend/pigeonhole/apps/users/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from backend.pigeonhole.apps.users.models import User + + +class UserAdmin(BaseUserAdmin): + list_display = ('username', 'id', 'email', 'first_name', 'last_name',) + search_fields = ('username', 'id', 'email', 'first_name', 'last_name',) + ordering = ('username',) + filter_horizontal = () + fieldsets = ( + (None, {'fields': ( + 'username', + 'email', + 'password', + 'first_name', + 'last_name', + )}), + ) + + +admin.site.register(User, UserAdmin) From 8bd7150d4a1c16c504e09b847ef26f832f281249 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Tue, 12 Mar 2024 21:18:22 +0100 Subject: [PATCH 068/138] fix tests --- .../test_views/test_project/test_admin.py | 18 ------------------ .../test_views/test_project/test_student.py | 2 +- .../test_project/test_unauthenticated.py | 2 +- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 283ab93c..a4d1386e 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -37,10 +37,6 @@ def setUp(self): self.client.force_authenticate(self.admin) def test_create_project(self): - # Assuming API_ENDPOINT is defined elsewhere in your test setup - # and self.course.course_id is correctly set up to point to an existing course - - # Make a POST request to create a new project response = self.client.post( API_ENDPOINT + f'{self.course.course_id}/projects/', { @@ -51,24 +47,10 @@ def test_create_project(self): }, format='json' ) - - # Check that the response status code is 201 CREATED self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Check that the number of projects has increased by 1 - # This assumes that there was already one project before this test self.assertEqual(Project.objects.count(), 2) - - # test whether 4 group objects are created self.assertEqual(Group.objects.count(), 4) - - # Retrieve the newly created project - # Since we're creating a new project, it should be the last one in the list - # However, it's safer to filter by name or another unique field - # For demonstration, I'll use the name "Test Project 2" new_project = Project.objects.get(name="Test Project 2") - - # Check that the name of the newly created project is correct self.assertEqual(new_project.name, "Test Project 2") def test_retrieve_project(self): diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index a1321063..3d27f9c8 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -40,7 +40,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - API_ENDPOINT + '{self.course.course_id}/projects/', + API_ENDPOINT + f'{self.course.course_id}/projects/', { "name": "Test Project 2", "description": "Test Project 2 Description", diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index 1585b6a8..c769f24e 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -102,7 +102,7 @@ def test_create_project_invalid_course_unauthenticated(self): def test_retrieve_project_invalid_course_unauthenticated(self): response = self.client.get( - API_ENDPOINT + '100/projects/{self.project.project_id}/' + API_ENDPOINT + f'100/projects/{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 735091116da314b0efe06c31f462347bd5a95eff Mon Sep 17 00:00:00 2001 From: avoyen Date: Tue, 12 Mar 2024 22:46:44 +0100 Subject: [PATCH 069/138] some migrations, changes to group model, added functionality for groups endpoint --- .../groups/migrations/0008_group_visible.py | 18 ++++++ .../migrations/0009_alter_group_visible.py | 18 ++++++ backend/pigeonhole/apps/groups/models.py | 23 +++++++- backend/pigeonhole/apps/groups/views.py | 58 ++++++++++++------- 4 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/migrations/0008_group_visible.py create mode 100644 backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py diff --git a/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py b/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py new file mode 100644 index 00000000..a64ed9c8 --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-03-12 18:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0007_remove_group_max_score'), + ] + + operations = [ + migrations.AddField( + model_name='group', + name='visible', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py b/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py new file mode 100644 index 00000000..73d30b4d --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-12 21:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0008_group_visible'), + ] + + operations = [ + migrations.AlterField( + model_name='group', + name='visible', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index f8fa92e1..f13fb412 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -13,6 +13,7 @@ class Group(models.Model): user = models.ManyToManyField(User) feedback = models.TextField(null=True) final_score = models.IntegerField(null=True, blank=True) + visible = models.BooleanField(null=False, default=True) objects = models.Manager() @@ -23,6 +24,7 @@ def clean(self): project_id=self.project_id, student=student).exclude( group_id=self.group_id) if existing_groups.exists(): + print("fout") raise ValidationError(f"Student {student} is already part of " "another group in this project.") @@ -41,4 +43,23 @@ def save(self, *args, **kwargs): class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ["group_id", "group_nr", "final_score", "project_id", "user", "feedback"] + fields = ["group_id", "group_nr", "final_score", "project_id", "user", "feedback", "visible"] + + def get_visible_data(self): + # remove certain fields if visible is false. + data = self.data.copy() + if not self.instance.visible: + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data + + def get_other_group(self): + # remove certain fields if visible is false. + data = self.data.copy() + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 36dbd19f..f6d83099 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -9,31 +9,15 @@ # TODO tests for score/max_score - +# TODO bij de update/partial update zorgen dat de user bestaat, anders errors class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated] - def create(self, request, *args, **kwargs): - # TODO zorg dat er geen 2 groepen met hetzelfde nummer kunnen zijn. - course_id = kwargs.get('course_id') - - if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - serializer = GroupSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response({"message": "You are not allowed to create a new group."}, - status=status.HTTP_400_BAD_REQUEST) - def list(self, request, *args, **kwargs): + serializer = GroupSerializer(self.queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -44,11 +28,43 @@ def retrieve(self, request, *args, **kwargs): # Check whether the course exists get_object_or_404(Course, course_id=course_id) - # Check whether the project exists get_object_or_404(Project, pk=project_id) group = get_object_or_404(Group, group_id=group_id) - + user = request.user serializer = GroupSerializer(instance=group, many=False) + if user.is_superuser or user.is_staff or user.is_teacher: + serializer = GroupSerializer(instance=group, many=False) + return Response(serializer.data, status=status.HTTP_200_OK) + elif user.is_student: + return Response(serializer.get_visible_data(), status=status.HTTP_200_OK) - return Response(serializer.data, status=status.HTTP_200_OK) + def update(self, request, *args, **kwargs): + course_id = kwargs.get('course_id') + project_id = kwargs.get('project_id') + group_id = kwargs.get('pk') + get_object_or_404(Course, course_id=course_id) + get_object_or_404(Project, project_id=project_id) + group = get_object_or_404(Group, group_id=group_id) + serializer = GroupSerializer(instance=group, data=request.data, partial=False) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + course_id = kwargs.get('course_id') + project_id = kwargs.get('project_id') + group_id = kwargs.get('pk') + get_object_or_404(Course, course_id=course_id) + get_object_or_404(Project, project_id=project_id) + group = get_object_or_404(Group, group_id=group_id) + serializer = GroupSerializer(instance=group, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, *args, **kwargs): + return Response({"message": "You can't creat groups"}, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + return Response({"message": "You can't creat groups"}, status=status.HTTP_400_BAD_REQUEST) From 71d4da035a930d27ef83f0d8559881273a04f946 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Tue, 12 Mar 2024 22:49:18 +0100 Subject: [PATCH 070/138] Submissions correct API --- .../apps/courses/migrations/0001_initial.py | 2 +- .../apps/groups/migrations/0001_initial.py | 6 +- .../apps/groups/migrations/0002_initial.py | 9 +-- .../migrations/0003_alter_group_group_nr.py | 18 ------ .../0004_alter_group_final_score.py | 18 ------ .../0005_remove_group_student_group_user.py | 24 ------- .../groups/migrations/0006_group_max_score.py | 18 ------ .../migrations/0007_remove_group_max_score.py | 17 ----- .../apps/projects/migrations/0001_initial.py | 35 ++++------ .../migrations/0002_project_deadline.py | 18 ------ .../migrations/0003_alter_project_deadline.py | 18 ------ .../migrations/0004_alter_project_deadline.py | 18 ------ ...emove_conditions_submission_id_and_more.py | 63 ------------------ .../submissions/migrations/0001_initial.py | 10 ++- ...ions_file_alter_submissions_output_test.py | 26 -------- ...002_alter_submissions_group_id_and_more.py | 30 +++++++++ ...ions_file_alter_submissions_output_test.py | 30 --------- ...ions_file_alter_submissions_output_test.py | 32 ---------- backend/pigeonhole/apps/submissions/models.py | 17 +++-- backend/pigeonhole/apps/submissions/views.py | 9 ++- .../apps/users/migrations/0001_initial.py | 63 ++++-------------- ...ename_is_assistent_teacher_is_assistant.py | 18 ------ ...acher_course_remove_teacher_id_and_more.py | 64 ------------------- .../users/migrations/0004_alter_user_role.py | 18 ------ .../users/migrations/0005_alter_user_role.py | 18 ------ .../migrations/0006_alter_user_managers.py | 20 ------ backend/pigeonhole/urls.py | 3 + 27 files changed, 91 insertions(+), 531 deletions(-) delete mode 100644 backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py delete mode 100644 backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py delete mode 100644 backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py delete mode 100644 backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py delete mode 100644 backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py delete mode 100644 backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py delete mode 100644 backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py delete mode 100644 backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py delete mode 100644 backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py delete mode 100644 backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py create mode 100644 backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py delete mode 100644 backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py delete mode 100644 backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py delete mode 100644 backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py delete mode 100644 backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py delete mode 100644 backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py delete mode 100644 backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py delete mode 100644 backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py diff --git a/backend/pigeonhole/apps/courses/migrations/0001_initial.py b/backend/pigeonhole/apps/courses/migrations/0001_initial.py index 48a29d53..c4d73149 100644 --- a/backend/pigeonhole/apps/courses/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/courses/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 from django.db import migrations, models diff --git a/backend/pigeonhole/apps/groups/migrations/0001_initial.py b/backend/pigeonhole/apps/groups/migrations/0001_initial.py index 1102b309..56cdc00a 100644 --- a/backend/pigeonhole/apps/groups/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 import django.db.models.deletion from django.db import migrations, models @@ -17,9 +17,9 @@ class Migration(migrations.Migration): name='Group', fields=[ ('group_id', models.BigAutoField(primary_key=True, serialize=False)), - ('group_nr', models.IntegerField()), + ('group_nr', models.IntegerField(blank=True, null=True)), ('feedback', models.TextField(null=True)), - ('final_score', models.IntegerField()), + ('final_score', models.IntegerField(blank=True, null=True)), ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), diff --git a/backend/pigeonhole/apps/groups/migrations/0002_initial.py b/backend/pigeonhole/apps/groups/migrations/0002_initial.py index 649b27b5..e5477516 100644 --- a/backend/pigeonhole/apps/groups/migrations/0002_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0002_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 +from django.conf import settings from django.db import migrations, models @@ -9,13 +10,13 @@ class Migration(migrations.Migration): dependencies = [ ('groups', '0001_initial'), - ('users', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( model_name='group', - name='student', - field=models.ManyToManyField(to='users.student'), + name='user', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), ), ] diff --git a/backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py b/backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py deleted file mode 100644 index 51d6180a..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0002_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='group', - name='group_nr', - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py deleted file mode 100644 index 1a72c57e..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0003_alter_group_group_nr'), - ] - - operations = [ - migrations.AlterField( - model_name='group', - name='final_score', - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py b/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py deleted file mode 100644 index 34f5581e..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0005_remove_group_student_group_user.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-11 13:17 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0004_alter_group_final_score'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name='group', - name='student', - ), - migrations.AddField( - model_name='group', - name='user', - field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py b/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py deleted file mode 100644 index ab42a7c8..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0006_group_max_score.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 15:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0005_remove_group_student_group_user'), - ] - - operations = [ - migrations.AddField( - model_name='group', - name='max_score', - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py b/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py deleted file mode 100644 index bd3e2c46..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0007_remove_group_max_score.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 15:51 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0006_group_max_score'), - ] - - operations = [ - migrations.RemoveField( - model_name='group', - name='max_score', - ), - ] diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index d849fb34..4127dc45 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -18,35 +19,21 @@ class Migration(migrations.Migration): ('project_id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=256)), ('description', models.TextField()), + ('deadline', models.DateTimeField(blank=True, null=True)), ('visible', models.BooleanField(default=False)), + ('number_of_groups', models.IntegerField(default=5)), + ('group_size', models.IntegerField(default=1)), + ('file_structure', models.CharField(max_length=1024, null=True)), + ('max_score', models.IntegerField(blank=True, null=True)), ('course_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='courses.course')), ], ), migrations.CreateModel( - name='ForbiddenExtension', - fields=[ - ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.CharField(max_length=512)), - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), - ], - ), - migrations.CreateModel( - name='Conditions', - fields=[ - ('condition_id', models.BigAutoField(primary_key=True, serialize=False)), - ('condition', models.TextField(max_length=256)), - ('test_file_location', models.CharField(max_length=512, null=True)), - ('test_file_type', models.CharField(max_length=256, null=True)), - ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, - to='projects.project')), - ], - ), - migrations.CreateModel( - name='AllowedExtension', + name='Test', fields=[ - ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.CharField(max_length=512)), - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), + ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='projects.project')), + ('test_nr', models.IntegerField()), + ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), ], ), ] diff --git a/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py deleted file mode 100644 index 330da245..00000000 --- a/backend/pigeonhole/apps/projects/migrations/0002_project_deadline.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='deadline', - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py deleted file mode 100644 index 639d1bc8..00000000 --- a/backend/pigeonhole/apps/projects/migrations/0003_alter_project_deadline.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0002_project_deadline'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='deadline', - field=models.DateTimeField(null=True), - ), - ] diff --git a/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py b/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py deleted file mode 100644 index f9b12a86..00000000 --- a/backend/pigeonhole/apps/projects/migrations/0004_alter_project_deadline.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0003_alter_project_deadline'), - ] - - operations = [ - migrations.AlterField( - model_name='project', - name='deadline', - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py b/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py deleted file mode 100644 index 42064650..00000000 --- a/backend/pigeonhole/apps/projects/migrations/0005_test_remove_conditions_submission_id_and_more.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 15:15 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('projects', '0004_alter_project_deadline'), - ] - - operations = [ - migrations.CreateModel( - name='Test', - fields=[ - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, - primary_key=True, serialize=False, to='projects.project')), - ('test_nr', models.IntegerField()), - ('test_file_type', - models.FileField(max_length=255, - null=True, - upload_to='uploads/projects/' - '/')), - ], - ), - migrations.RemoveField( - model_name='conditions', - name='submission_id', - ), - migrations.RemoveField( - model_name='forbiddenextension', - name='project_id', - ), - migrations.AddField( - model_name='project', - name='file_structure', - field=models.CharField(max_length=1024, null=True), - ), - migrations.AddField( - model_name='project', - name='group_size', - field=models.IntegerField(default=1), - ), - migrations.AddField( - model_name='project', - name='max_score', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='project', - name='number_of_groups', - field=models.IntegerField(default=5), - ), - migrations.DeleteModel( - name='AllowedExtension', - ), - migrations.DeleteModel( - name='Conditions', - ), - migrations.DeleteModel( - name='ForbiddenExtension', - ), - ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index 715328f3..1e392fe4 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -17,12 +18,9 @@ class Migration(migrations.Migration): fields=[ ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), ('submission_nr', models.IntegerField()), - ('file', models.FileField(null=True, upload_to='uploads//' - '/')), + ('file', models.FileField(max_length=255, null=True, upload_to='uploads/submissions/files///')), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField(null=True, upload_to='uploads///output_test/')), + ('output_test', models.FileField(max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/')), ('group_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='groups.group')), ], ), diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index 56752ac0..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('submissions', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField(null=True, - upload_to='uploads///'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(null=True, - upload_to='uploads///output_test/'), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py new file mode 100644 index 00000000..ae798ac6 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.3 on 2024-03-12 21:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0002_initial'), + ('submissions', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='submissions', + name='group_id', + field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='groups.group'), + ), + migrations.AlterField( + model_name='submissions', + name='output_test', + field=models.FileField(blank=True, max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/'), + ), + migrations.AlterField( + model_name='submissions', + name='submission_nr', + field=models.IntegerField(blank=True), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index 48eb7b6e..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-05 11:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('submissions', - '0002_alter_submissions_file_alter_submissions_output_test'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField(max_length=255, null=True, - upload_to='uploads///',), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(max_length=255, null=True, - upload_to='uploads///output_test/',), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index c2aa722a..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('submissions', - '0003_alter_submissions_file_alter_submissions_output_test'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField( - max_length=255, - null=True, - upload_to='uploads/submissions/files///'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField( - max_length=255, - null=True, - upload_to='uploads/submissions/outputs//' - '/output_test/'), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 6302f759..0354f6ff 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -7,15 +7,15 @@ # Create your models here. class Submissions(models.Model): submission_id = models.BigAutoField(primary_key=True) - group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=False) - submission_nr = models.IntegerField() + group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=True) + submission_nr = models.IntegerField(blank=True) file = models.FileField(upload_to='uploads/submissions/files/' + str(group_id) + '/' + str(submission_nr) + '/', null=True, blank=False, max_length=255) - timestamp = models.DateTimeField(auto_now_add=True) + timestamp = models.DateTimeField(auto_now_add=True, blank=True) output_test = models.FileField(upload_to='uploads/submissions/outputs/' + str(group_id) + '/' + str(submission_nr) + - '/output_test/', null=True, blank=False, + '/output_test/', null=True, blank=True, max_length=255) objects = models.Manager() @@ -32,6 +32,15 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields class SubmissionsSerializer(serializers.ModelSerializer): + submission_nr = serializers.IntegerField(read_only=True) + output_test = serializers.FileField(read_only=True) + class Meta: model = Submissions fields = ['submission_id', 'group_id', 'file', 'timestamp', 'submission_nr', 'output_test'] + read_only_fields = ['group_id'] + + def save(self, **kwargs): + group_id = self.context['view'].kwargs.get('group_id') + self.validated_data['group_id'] = Group.objects.get(pk=group_id) + return super().save(**kwargs) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 67a42403..d0df6cf1 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -2,8 +2,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer + # TODO test timestamp, file, output_test @@ -16,11 +18,14 @@ def perform_create(self, serializer): serializer.save(user=self.request.user) def list(self, request, *args, **kwargs): - serializer = SubmissionsSerializer(self.queryset, many=True) + group_id = kwargs.get("group_id") + group_ids = Group.objects.filter(group_id=group_id).values_list('group_id', flat=True) + queryset = Submissions.objects.filter(group_id__in=group_ids) + serializer = SubmissionsSerializer(queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): - serializer = SubmissionsSerializer(data=request.data) + serializer = self.get_serializer(data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index 36858182..d748fc54 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -1,14 +1,13 @@ -# Generated by Django 5.0.3 on 2024-03-10 16:00 +# Generated by Django 5.0.3 on 2024-03-12 20:43 import django.contrib.auth.models import django.contrib.auth.validators -import django.db.models.deletion import django.utils.timezone -from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -20,36 +19,21 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has ' - 'all permissions without explicitly ' - 'assigning them.', - verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, - help_text='Required. 150 characters or fewer. Letters, digits and ' - '@/./+/-/_ only.', max_length=150, unique=True, - validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], - verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into ' - 'this admin site.', - verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be ' - 'treated as active. Unselect this instead ' - 'of deleting accounts.', - verbose_name='active')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will ' - 'get all permissions granted to each of their ' - 'groups.', related_name='user_set', - related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', - related_name='user_set', related_query_name='user', - to='auth.permission', verbose_name='user permissions')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('first_name', models.CharField(max_length=30)), + ('last_name', models.CharField(max_length=150)), + ('role', models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=1)), + ('course', models.ManyToManyField(to='courses.course')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], options={ 'verbose_name': 'user', @@ -61,23 +45,4 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name='Student', - fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), - ('number', models.IntegerField()), - ('course', models.ManyToManyField(to='courses.course')), - ], - ), - migrations.CreateModel( - name='Teacher', - fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), - ('is_admin', models.BooleanField(default=False)), - ('is_assistent', models.BooleanField(default=False)), - ('course', models.ManyToManyField(to='courses.course')), - ], - ), ] diff --git a/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py b/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py deleted file mode 100644 index a9cf87be..00000000 --- a/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 18:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='teacher', - old_name='is_assistent', - new_name='is_assistant', - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py b/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py deleted file mode 100644 index 67dd6291..00000000 --- a/backend/pigeonhole/apps/users/migrations/0003_remove_teacher_course_remove_teacher_id_and_more.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-11 13:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('courses', '0001_initial'), - ('groups', '0005_remove_group_student_group_user'), - ('users', '0002_rename_is_assistent_teacher_is_assistant'), - ] - - operations = [ - migrations.RemoveField( - model_name='teacher', - name='course', - ), - migrations.RemoveField( - model_name='teacher', - name='id', - ), - migrations.AlterModelManagers( - name='user', - managers=[ - ], - ), - migrations.AddField( - model_name='user', - name='course', - field=models.ManyToManyField(to='courses.course'), - ), - migrations.AddField( - model_name='user', - name='role', - field=models.IntegerField(choices=[(1, 'Superuser'), (2, 'Teacher'), (3, 'Student')], default=3), - ), - migrations.AlterField( - model_name='user', - name='email', - field=models.EmailField(max_length=254, unique=True), - ), - migrations.AlterField( - model_name='user', - name='first_name', - field=models.CharField(max_length=30), - ), - migrations.AlterField( - model_name='user', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='user', - name='last_name', - field=models.CharField(max_length=150), - ), - migrations.DeleteModel( - name='Student', - ), - migrations.DeleteModel( - name='Teacher', - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py b/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py deleted file mode 100644 index e3d92cdf..00000000 --- a/backend/pigeonhole/apps/users/migrations/0004_alter_user_role.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 15:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0003_remove_teacher_course_remove_teacher_id_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='role', - field=models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=3), - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py b/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py deleted file mode 100644 index 9ec9bc9d..00000000 --- a/backend/pigeonhole/apps/users/migrations/0005_alter_user_role.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 15:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0004_alter_user_role'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='role', - field=models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=1), - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py b/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py deleted file mode 100644 index 1c3b6146..00000000 --- a/backend/pigeonhole/apps/users/migrations/0006_alter_user_managers.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 19:50 - -import django.contrib.auth.models -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0005_alter_user_role'), - ] - - operations = [ - migrations.AlterModelManagers( - name='user', - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 2a5503e2..5c4a083b 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -32,6 +32,9 @@ router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) +router.register(r'courses/(?P[^/.]+)/' + r'projects/(?P[^/.]+)/' + r'groups/(?P[^/.]+)/submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From 9c53512374f8f393f665629bc92d66c9ee9c1fa7 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Tue, 12 Mar 2024 23:19:34 +0100 Subject: [PATCH 071/138] Submissions get correct file location --- backend/pigeonhole/apps/groups/models.py | 3 +++ backend/pigeonhole/apps/submissions/models.py | 10 ++++++++-- backend/pigeonhole/apps/submissions/views.py | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index f8fa92e1..8d27c5db 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -16,6 +16,9 @@ class Group(models.Model): objects = models.Manager() + def __str__(self): + return str(self.id) + # a student can only be in one group per project def clean(self): for student in self.user.all(): diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 0354f6ff..2fb02066 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,16 +1,22 @@ +import re + from django.db import models from rest_framework import serializers from backend.pigeonhole.apps.groups.models import Group +def get_upload_to(self, filename): + match = re.search(r'\.(\w+)$', filename) + return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input.' + match.group(1) + + # Create your models here. class Submissions(models.Model): submission_id = models.BigAutoField(primary_key=True) group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=True) submission_nr = models.IntegerField(blank=True) - file = models.FileField(upload_to='uploads/submissions/files/' + - str(group_id) + '/' + str(submission_nr) + '/', + file = models.FileField(upload_to=get_upload_to, null=True, blank=False, max_length=255) timestamp = models.DateTimeField(auto_now_add=True, blank=True) output_test = models.FileField(upload_to='uploads/submissions/outputs/' + diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index d0df6cf1..753fd7f8 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -14,6 +14,10 @@ class SubmissionsViewset(viewsets.ModelViewSet): serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated] + @property + def allowed_methods(self): + return ['GET', 'POST'] + def perform_create(self, serializer): serializer.save(user=self.request.user) @@ -31,3 +35,4 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + From d9c07850527b98951879ca50df53757741587d4d Mon Sep 17 00:00:00 2001 From: avoyen Date: Tue, 12 Mar 2024 23:29:46 +0100 Subject: [PATCH 072/138] group nr url --- backend/pigeonhole/apps/groups/models.py | 1 - backend/pigeonhole/apps/groups/permission.py | 2 +- backend/pigeonhole/apps/groups/views.py | 29 +++++++++++++------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index f13fb412..6df9c2c0 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -24,7 +24,6 @@ def clean(self): project_id=self.project_id, student=student).exclude( group_id=self.group_id) if existing_groups.exists(): - print("fout") raise ValidationError(f"Student {student} is already part of " "another group in this project.") diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 5672d2c3..6becedca 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -1,7 +1,7 @@ from rest_framework import permissions -class CanAccessProject(permissions.BasePermission): +class CanAccessGroup(permissions.BasePermission): # Custom user class to check if the user can join a group. def has_permission(self, request, view): user = request.user diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index f6d83099..fe456d53 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -6,6 +6,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.projects.models import Project +from .permission import CanAccessGroup # TODO tests for score/max_score @@ -14,23 +15,31 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated and CanAccessGroup] + lookup_field = 'group_nr' def list(self, request, *args, **kwargs): + course_id = kwargs.get('course_id') + project_id = kwargs.get('project_id') + # Check whether the course exists + get_object_or_404(Course, course_id=course_id) + # Check whether the project exists + get_object_or_404(Project, pk=project_id) - serializer = GroupSerializer(self.queryset, many=True) + queryset = self.queryset.filter(project_id=project_id) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('project_id') - group_id = kwargs.get('pk') + group_nr = kwargs.get('group_nr') # Check whether the course exists get_object_or_404(Course, course_id=course_id) # Check whether the project exists get_object_or_404(Project, pk=project_id) - group = get_object_or_404(Group, group_id=group_id) + group = get_object_or_404(Group, project_id=project_id, group_nr=group_nr) user = request.user serializer = GroupSerializer(instance=group, many=False) if user.is_superuser or user.is_staff or user.is_teacher: @@ -42,10 +51,10 @@ def retrieve(self, request, *args, **kwargs): def update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('project_id') - group_id = kwargs.get('pk') + group_nr = kwargs.get('group_nr') get_object_or_404(Course, course_id=course_id) get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_id=group_id) + group = get_object_or_404(Group, group_nr=group_nr) serializer = GroupSerializer(instance=group, data=request.data, partial=False) if serializer.is_valid(): serializer.save() @@ -54,17 +63,17 @@ def update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('project_id') - group_id = kwargs.get('pk') + group_nr = kwargs.get('group_nr') get_object_or_404(Course, course_id=course_id) get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_id=group_id) + group = get_object_or_404(Group, group_nr=group_nr) serializer = GroupSerializer(instance=group, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): - return Response({"message": "You can't creat groups"}, status=status.HTTP_400_BAD_REQUEST) + return Response({"message": "You can't create groups"}, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): - return Response({"message": "You can't creat groups"}, status=status.HTTP_400_BAD_REQUEST) + return Response({"message": "You can't delete groups"}, status=status.HTTP_400_BAD_REQUEST) From d6566ef1a0c799360d15876f336fdfbda234aced Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Tue, 12 Mar 2024 23:58:45 +0100 Subject: [PATCH 073/138] Merge with latest changes --- .../apps/courses/migrations/0001_initial.py | 2 +- .../apps/groups/migrations/0001_initial.py | 3 +- .../apps/groups/migrations/0002_initial.py | 2 +- .../groups/migrations/0008_group_visible.py | 18 ----------- .../migrations/0009_alter_group_visible.py | 18 ----------- .../apps/projects/migrations/0001_initial.py | 2 +- .../submissions/migrations/0001_initial.py | 11 +++---- ...002_alter_submissions_group_id_and_more.py | 30 ------------------- .../apps/users/migrations/0001_initial.py | 2 +- backend/pigeonhole/urls.py | 1 - 10 files changed, 12 insertions(+), 77 deletions(-) delete mode 100644 backend/pigeonhole/apps/groups/migrations/0008_group_visible.py delete mode 100644 backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py delete mode 100644 backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py diff --git a/backend/pigeonhole/apps/courses/migrations/0001_initial.py b/backend/pigeonhole/apps/courses/migrations/0001_initial.py index c4d73149..a62b1faf 100644 --- a/backend/pigeonhole/apps/courses/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/courses/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 from django.db import migrations, models diff --git a/backend/pigeonhole/apps/groups/migrations/0001_initial.py b/backend/pigeonhole/apps/groups/migrations/0001_initial.py index 56cdc00a..f7a3b64b 100644 --- a/backend/pigeonhole/apps/groups/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.db.models.deletion from django.db import migrations, models @@ -20,6 +20,7 @@ class Migration(migrations.Migration): ('group_nr', models.IntegerField(blank=True, null=True)), ('feedback', models.TextField(null=True)), ('final_score', models.IntegerField(blank=True, null=True)), + ('visible', models.BooleanField(default=True)), ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), diff --git a/backend/pigeonhole/apps/groups/migrations/0002_initial.py b/backend/pigeonhole/apps/groups/migrations/0002_initial.py index e5477516..c90a35bb 100644 --- a/backend/pigeonhole/apps/groups/migrations/0002_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 from django.conf import settings from django.db import migrations, models diff --git a/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py b/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py deleted file mode 100644 index a64ed9c8..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0008_group_visible.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2024-03-12 18:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0007_remove_group_max_score'), - ] - - operations = [ - migrations.AddField( - model_name='group', - name='visible', - field=models.BooleanField(default=False), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py b/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py deleted file mode 100644 index 73d30b4d..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0009_alter_group_visible.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 21:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0008_group_visible'), - ] - - operations = [ - migrations.AlterField( - model_name='group', - name='visible', - field=models.BooleanField(default=True), - ), - ] diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index 4127dc45..ac2c58a0 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.db.models.deletion from django.db import migrations, models diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index 1e392fe4..ea9510d5 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 +import backend.pigeonhole.apps.submissions.models import django.db.models.deletion from django.db import migrations, models @@ -17,11 +18,11 @@ class Migration(migrations.Migration): name='Submissions', fields=[ ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), - ('submission_nr', models.IntegerField()), - ('file', models.FileField(max_length=255, null=True, upload_to='uploads/submissions/files///')), + ('submission_nr', models.IntegerField(blank=True)), + ('file', models.FileField(max_length=255, null=True, upload_to=backend.pigeonhole.apps.submissions.models.get_upload_to)), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField(max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/')), - ('group_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='groups.group')), + ('output_test', models.FileField(blank=True, max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/')), + ('group_id', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='groups.group')), ], ), ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py deleted file mode 100644 index ae798ac6..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_group_id_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-12 21:43 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0002_initial'), - ('submissions', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='group_id', - field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='groups.group'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(blank=True, max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/'), - ), - migrations.AlterField( - model_name='submissions', - name='submission_nr', - field=models.IntegerField(blank=True), - ), - ] diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index d748fc54..ac5f2cc8 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-03-12 20:43 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 5c4a083b..35b6a8c9 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -29,7 +29,6 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) -router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) router.register(r'courses/(?P[^/.]+)/' From ad3cb7e195ba55f1145471a81a4a72325f9511e0 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 00:04:31 +0100 Subject: [PATCH 074/138] small todo to remember --- backend/pigeonhole/apps/submissions/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 2fb02066..9189e8c9 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -11,6 +11,9 @@ def get_upload_to(self, filename): return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input.' + match.group(1) +def get_upload_to_test(self, filename): + return None # TODO + # Create your models here. class Submissions(models.Model): submission_id = models.BigAutoField(primary_key=True) From ecbcb33db3ab5837db7bcf3c174acf9419b43f1a Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 00:05:20 +0100 Subject: [PATCH 075/138] small todo to remember --- backend/pigeonhole/apps/submissions/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 9189e8c9..7e1648ad 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -12,7 +12,7 @@ def get_upload_to(self, filename): def get_upload_to_test(self, filename): - return None # TODO + return None # TODO implement this # Create your models here. class Submissions(models.Model): From 5ad57757ae5d7a372ed3e71a54f8db6fe9ac6eda Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 00:09:24 +0100 Subject: [PATCH 076/138] list en join groups implementaties --- backend/pigeonhole/apps/groups/models.py | 2 +- backend/pigeonhole/apps/groups/views.py | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 6df9c2c0..5fb1a208 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -13,7 +13,7 @@ class Group(models.Model): user = models.ManyToManyField(User) feedback = models.TextField(null=True) final_score = models.IntegerField(null=True, blank=True) - visible = models.BooleanField(null=False, default=True) + visible = models.BooleanField(null=False, default=False) objects = models.Manager() diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index fe456d53..11cdcf1e 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -1,5 +1,6 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -28,6 +29,12 @@ def list(self, request, *args, **kwargs): queryset = self.queryset.filter(project_id=project_id) serializer = self.get_serializer(queryset, many=True) + if request.user.is_student: + for group in serializer.data: + print(request.user.id) + if request.user.id not in group["user"]: + del group["final_score"] + del group["feedback"] return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, *args, **kwargs): @@ -54,7 +61,7 @@ def update(self, request, *args, **kwargs): group_nr = kwargs.get('group_nr') get_object_or_404(Course, course_id=course_id) get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_nr=group_nr) + group = get_object_or_404(Group, group_nr=group_nr, project_id=project_id) serializer = GroupSerializer(instance=group, data=request.data, partial=False) if serializer.is_valid(): serializer.save() @@ -66,7 +73,7 @@ def partial_update(self, request, *args, **kwargs): group_nr = kwargs.get('group_nr') get_object_or_404(Course, course_id=course_id) get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_nr=group_nr) + group = get_object_or_404(Group, group_nr=group_nr, project_id=project_id) serializer = GroupSerializer(instance=group, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -77,3 +84,16 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): return Response({"message": "You can't delete groups"}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post']) + def join_group(self, request, *args, **kwargs): + # TODO hier nog checks toevoegen, maar je kan een group joinen + course_id = kwargs.get('course_id') + project_id = kwargs.get('project_id') + group_nr = kwargs.get('group_nr') + get_object_or_404(Course, course_id=course_id) + get_object_or_404(Project, project_id=project_id) + group = get_object_or_404(Group, project_id=project_id, group_nr=group_nr) + + group.user.add(request.user) + return Response(status=status.HTTP_200_OK) From 3008d3b16979db7e4ddf7b9c808c3349bf3f09b1 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 12:04:29 +0100 Subject: [PATCH 077/138] migretions Alexander --- .../migrations/0003_alter_group_visible.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py diff --git a/backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py b/backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py new file mode 100644 index 00000000..a0ac7211 --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-13 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='group', + name='visible', + field=models.BooleanField(default=False), + ), + ] From 727a8c6b71f6cda4e20356e0e1c9215071c5fd8d Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 12:09:14 +0100 Subject: [PATCH 078/138] fix courses --- backend/pigeonhole/apps/courses/views.py | 77 ++++++------------------ 1 file changed, 19 insertions(+), 58 deletions(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index ff565f65..3f09e5ef 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -3,72 +3,33 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from backend.pigeonhole.apps.users.models import User -from .models import Course, CourseSerializer +from .models import Course from .permissions import CourseUserPermissions +from backend.pigeonhole.apps.courses.models import CourseSerializer class CourseViewSet(viewsets.ModelViewSet): - queryset = Course.objects.all() + queryset = Course.objects.all() # Add this line to specify the queryset serializer_class = CourseSerializer permission_classes = [IsAuthenticated, CourseUserPermissions] - def create(self, request, *args, **kwargs): - serializer = CourseSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def update(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - course.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def list(self, request, *args, **kwargs): - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def my_list(self, request, *args, **kwargs): - if request.user.is_admin or request.user.is_superuser: - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - if request.user.is_teacher or request.user.is_student: - courses = User.objects.get(id=request.user.id).course.all() - serializer = CourseSerializer(courses, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, many=False) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @action(detail=True, methods=['post']) def join_course(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - user = User.objects.get(id=request.user.id) + course = self.get_object() + user = request.user user.course.add(course) return Response(status=status.HTTP_200_OK) + + @action(detail=False, methods=['GET']) + def get_selected_courses(self, request, *args, **kwargs): + user = request.user + courses = user.course.all() + serializer = CourseSerializer(courses, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def get_queryset(self): + if self.action == 'list': + return Course.objects.all() + elif self.action == 'get_selected_courses': + return self.request.user.course.all() + return Course.objects.none() From 46abe051c2037550b26cfcfc8a5b03987a57a56c Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 12:10:30 +0100 Subject: [PATCH 079/138] Revert "fix courses" This reverts commit 727a8c6b71f6cda4e20356e0e1c9215071c5fd8d. --- backend/pigeonhole/apps/courses/views.py | 77 ++++++++++++++++++------ 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 3f09e5ef..ff565f65 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -3,33 +3,72 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .models import Course +from backend.pigeonhole.apps.users.models import User +from .models import Course, CourseSerializer from .permissions import CourseUserPermissions -from backend.pigeonhole.apps.courses.models import CourseSerializer class CourseViewSet(viewsets.ModelViewSet): - queryset = Course.objects.all() # Add this line to specify the queryset + queryset = Course.objects.all() serializer_class = CourseSerializer permission_classes = [IsAuthenticated, CourseUserPermissions] + def create(self, request, *args, **kwargs): + serializer = CourseSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + course.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def list(self, request, *args, **kwargs): + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def my_list(self, request, *args, **kwargs): + if request.user.is_admin or request.user.is_superuser: + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + if request.user.is_teacher or request.user.is_student: + courses = User.objects.get(id=request.user.id).course.all() + serializer = CourseSerializer(courses, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + serializer = CourseSerializer(self.queryset, many=True) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, many=False) + return Response(serializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + serializer = CourseSerializer(course, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @action(detail=True, methods=['post']) def join_course(self, request, *args, **kwargs): - course = self.get_object() - user = request.user + course_id = kwargs.get('pk') + course = Course.objects.get(pk=course_id) + user = User.objects.get(id=request.user.id) user.course.add(course) return Response(status=status.HTTP_200_OK) - - @action(detail=False, methods=['GET']) - def get_selected_courses(self, request, *args, **kwargs): - user = request.user - courses = user.course.all() - serializer = CourseSerializer(courses, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def get_queryset(self): - if self.action == 'list': - return Course.objects.all() - elif self.action == 'get_selected_courses': - return self.request.user.course.all() - return Course.objects.none() From 50c4462004b1880c0a5d8507d1f9c2e692da86ad Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 13:37:33 +0100 Subject: [PATCH 080/138] permissions added to submissions --- .../apps/submissions/permissions.py | 18 +++++++++++++ backend/pigeonhole/apps/submissions/views.py | 26 +++---------------- backend/pigeonhole/urls.py | 4 +-- 3 files changed, 22 insertions(+), 26 deletions(-) create mode 100644 backend/pigeonhole/apps/submissions/permissions.py diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py new file mode 100644 index 00000000..c7c6971f --- /dev/null +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -0,0 +1,18 @@ +from rest_framework import permissions + + +class CanAccessSubmission(permissions.BasePermission): + # Custom permission class to determine if the currect user has access + # to the submission data. + def has_permission(self, request, view): + user = request.user + submission_id = view.kwargs.get('submission_id') + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + if user.submission.filter(submission_id=submission_id).exists(): + return True + elif user.is_student: + if user.submission.filter(submission_id=submission_id).exists(): + return view.action in ['list', 'retrieve'] + return False diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 753fd7f8..45de3817 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,9 +1,8 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer +from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission # TODO test timestamp, file, output_test @@ -12,27 +11,8 @@ class SubmissionsViewset(viewsets.ModelViewSet): queryset = Submissions.objects.all() serializer_class = SubmissionsSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated & CanAccessSubmission] @property def allowed_methods(self): return ['GET', 'POST'] - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def list(self, request, *args, **kwargs): - group_id = kwargs.get("group_id") - group_ids = Group.objects.filter(group_id=group_id).values_list('group_id', flat=True) - queryset = Submissions.objects.filter(group_id__in=group_ids) - serializer = SubmissionsSerializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 35b6a8c9..2a5503e2 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -29,11 +29,9 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) +router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) -router.register(r'courses/(?P[^/.]+)/' - r'projects/(?P[^/.]+)/' - r'groups/(?P[^/.]+)/submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From 7935a89355656ab0e2f55b003a031a11eed41c17 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 13:46:31 +0100 Subject: [PATCH 081/138] course tests and view fixed (user test not finiched) --- backend/pigeonhole/apps/courses/views.py | 69 ++----- backend/pigeonhole/apps/users/views.py | 50 +---- .../test_views/test_course/test_admin.py | 5 +- .../test_views/test_course/test_teacher.py | 5 +- .../tests/test_views/test_user/__init__.py | 0 .../tests/test_views/test_user/test_admin.py | 130 +++++++++++++ .../test_views/test_user/test_teacher.py | 182 ++++++++++++++++++ 7 files changed, 333 insertions(+), 108 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/test_user/__init__.py create mode 100644 backend/pigeonhole/tests/test_views/test_user/test_admin.py create mode 100644 backend/pigeonhole/tests/test_views/test_user/test_teacher.py diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index ff565f65..1f8ce181 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -3,8 +3,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from backend.pigeonhole.apps.users.models import User -from .models import Course, CourseSerializer +from backend.pigeonhole.apps.courses.models import CourseSerializer +from .models import Course from .permissions import CourseUserPermissions @@ -13,62 +13,17 @@ class CourseViewSet(viewsets.ModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAuthenticated, CourseUserPermissions] - def create(self, request, *args, **kwargs): - serializer = CourseSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def update(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - course.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def list(self, request, *args, **kwargs): - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def my_list(self, request, *args, **kwargs): - if request.user.is_admin or request.user.is_superuser: - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - if request.user.is_teacher or request.user.is_student: - courses = User.objects.get(id=request.user.id).course.all() - serializer = CourseSerializer(courses, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - serializer = CourseSerializer(self.queryset, many=True) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, many=False) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - serializer = CourseSerializer(course, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @action(detail=True, methods=['post']) def join_course(self, request, *args, **kwargs): - course_id = kwargs.get('pk') - course = Course.objects.get(pk=course_id) - user = User.objects.get(id=request.user.id) + course = self.get_object() + user = request.user + user.course.add(course) return Response(status=status.HTTP_200_OK) + + @action(detail=False, methods=['GET']) + def get_selected_courses(self, request, *args, **kwargs): + user = request.user + courses = user.course.all() + serializer = CourseSerializer(courses, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/users/views.py b/backend/pigeonhole/apps/users/views.py index 81dc7938..264e3e80 100644 --- a/backend/pigeonhole/apps/users/views.py +++ b/backend/pigeonhole/apps/users/views.py @@ -1,6 +1,5 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from backend.pigeonhole.apps.users.models import User, UserSerializer from .permissions import UserPermissions @@ -10,50 +9,3 @@ class UserViewSet(viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsAuthenticated, UserPermissions] - - def list(self, request, *args, **kwargs): - serializer = UserSerializer(self.queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def create(self, request, *args, **kwargs): - serializer = UserSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def update(self, request, *args, **kwargs): - user_id = kwargs.get('pk') - user = User.objects.get(pk=user_id) - serializer = UserSerializer(user, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, *args, **kwargs): - user_id = kwargs.get('pk') - user = User.objects.get(pk=user_id) - serializer = UserSerializer(user, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - user_id = kwargs.get('pk') - user = User.objects.get(pk=user_id) - user.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def retrieve(self, request, *args, **kwargs): - user_id = kwargs.get('pk') - user = User.objects.get(pk=user_id) - serializer = UserSerializer(user, many=False) - return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index 53e66a39..3f369d32 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -1,3 +1,5 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -74,4 +76,5 @@ def test_delete_course_not_of_teacher(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), Course.objects.count()) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py index dd99f6a3..28fea62f 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -1,3 +1,5 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -73,7 +75,8 @@ def test_delete_course_not_of_teacher(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), Course.objects.count()) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) def test_retrieve_course(self): response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') diff --git a/backend/pigeonhole/tests/test_views/test_user/__init__.py b/backend/pigeonhole/tests/test_views/test_user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_user/test_admin.py b/backend/pigeonhole/tests/test_views/test_user/test_admin.py new file mode 100644 index 00000000..b94dbac9 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_user/test_admin.py @@ -0,0 +1,130 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.groups.models import Group + + +class ProjectTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="User1", + last_name="User1", + role=1 + ) + + self.client.force_authenticate(self.admin) + + def test_create_user(self): + response = self.client.post( + '/users/', + { + "id": 1, + "username": "admin_username", + "email": "test2@gmail.com", + "first_name": "User2", + "last_name": "User2", + "role": 3 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 2) + new_user = User.objects.get(name="User2") + self.assertEqual(new_user.name, "User2") + + def test_retrieve_user(self): + response = self.client.get( + f'/users/{self.admin.id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('username'), "User1") + + def test_list_users(self): + response = self.client.get( + '/users/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + def test_update_user(self): + response = self.client.put( + f'/users/2/', + { + "username": "admin_username", + "email": "test2@gmail.com", + "first_name": "User2", + "last_name": "User2", + "role": 2 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(User.objects.get(id=2).role, 2) + + def test_delete_user(self): + response = self.client.delete( + '/users/2/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(User.objects.count(), 1) + + + # def test_partial_update_user(self): + # + # + # def test_create_project_invalid_user(self): + # + # + # def test_update_project_invalid_user(self): + # + # + # def test_delete_project_invalid_user(self): + # + # + # def test_partial_update_project_invalid_user(self): + # + # + # def test_retrieve_project_invalid_user(self): + # + # + # def test_list_projects_invalid_user(self): + # + # + # def test_retrieve_invalid_user(self): + # + # + # def test_update_invalid_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/', + # { + # "name": "Updated Test Project", + # "description": "Updated Test Project Description", + # "course_id": self.course.course_id + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_partial_update_invalid_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/', + # { + # "name": "Updated Test Project" + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_delete_invalid_project(self): + # response = self.client.delete( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_user/test_teacher.py b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py new file mode 100644 index 00000000..ce634773 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py @@ -0,0 +1,182 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class ProjectTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.teacher) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id, + "number_of_groups": 4, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Updated assertion to use the correct project_id from the response + created_project_id = response.data.get('project_id') + self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") + self.assertEqual(Project.objects.count(), 2) + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + '100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + '100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # tests with a course not of the teacher + + def test_create_project_course_not_of_teacher(self): + response = self.client.post( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_teacher.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_project_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_project_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From fd17261d526c3d56297c4f524a9779a16ae5bd0c Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 17:31:05 +0100 Subject: [PATCH 082/138] fix courses --- backend/pigeonhole/apps/courses/views.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 1f8ce181..a99f93f3 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -4,10 +4,11 @@ from rest_framework.response import Response from backend.pigeonhole.apps.courses.models import CourseSerializer +from backend.pigeonhole.apps.projects.models import ProjectSerializer +from backend.pigeonhole.apps.projects.models import Project from .models import Course from .permissions import CourseUserPermissions - class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer @@ -27,3 +28,11 @@ def get_selected_courses(self, request, *args, **kwargs): courses = user.course.all() serializer = CourseSerializer(courses, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + # give a list of all projects in the course + @action(detail=True, methods=['GET']) + def get_projects(self, request, *args, **kwargs): + course = self.get_object() + projects = Project.objects.filter(course_id=course) + return Response(ProjectSerializer(projects, many=True).data, status=status.HTTP_200_OK) + From 0b55b17bf764a83239045fba92a7b61627db57b7 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 17:31:38 +0100 Subject: [PATCH 083/138] fix lint --- backend/pigeonhole/apps/courses/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index a99f93f3..214c8a82 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -9,6 +9,7 @@ from .models import Course from .permissions import CourseUserPermissions + class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer @@ -28,11 +29,10 @@ def get_selected_courses(self, request, *args, **kwargs): courses = user.course.all() serializer = CourseSerializer(courses, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - + # give a list of all projects in the course @action(detail=True, methods=['GET']) def get_projects(self, request, *args, **kwargs): course = self.get_object() projects = Project.objects.filter(course_id=course) return Response(ProjectSerializer(projects, many=True).data, status=status.HTTP_200_OK) - From c0a2490c56192fae7210cf38eb7d09a1fe69a822 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 17:50:17 +0100 Subject: [PATCH 084/138] join course when you create it --- backend/pigeonhole/apps/courses/views.py | 6 +++++- .../pigeonhole/tests/test_views/test_course/test_admin.py | 1 + .../pigeonhole/tests/test_views/test_course/test_teacher.py | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index 214c8a82..e8c4fb9b 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -15,6 +15,11 @@ class CourseViewSet(viewsets.ModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAuthenticated, CourseUserPermissions] + def perform_create(self, serializer): + course = serializer.save() + user = self.request.user + user.course.add(course) + @action(detail=True, methods=['post']) def join_course(self, request, *args, **kwargs): course = self.get_object() @@ -30,7 +35,6 @@ def get_selected_courses(self, request, *args, **kwargs): serializer = CourseSerializer(courses, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - # give a list of all projects in the course @action(detail=True, methods=['GET']) def get_projects(self, request, *args, **kwargs): course = self.get_object() diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index 3f369d32..a514b8e8 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -39,6 +39,7 @@ def test_create_course(self): response = self.client.post(API_ENDPOINT, self.course_data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Course.objects.count(), 3) + self.assertEqual(self.teacher.course.count(), 2) def test_update_course(self): updated_data = { diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py index 28fea62f..9e556b71 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -42,6 +42,7 @@ def test_create_course(self): response = self.client.post(API_ENDPOINT, self.course_data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Course.objects.count(), 3) + self.assertEqual(self.teacher.course.count(), 2) def test_update_course(self): updated_data = { From 5d26614dd8042ab30c32882a16705e68590d2d82 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 17:57:47 +0100 Subject: [PATCH 085/138] pause to implement nested submissions --- .../apps/submissions/permissions.py | 13 +- backend/pigeonhole/apps/submissions/views.py | 10 +- .../test_views/test_submission/test_admin.py | 207 ++++++++++++++++++ backend/pigeonhole/urls.py | 4 +- 4 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/test_submission/test_admin.py diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index c7c6971f..9f3b4b8c 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -6,13 +6,18 @@ class CanAccessSubmission(permissions.BasePermission): # to the submission data. def has_permission(self, request, view): user = request.user - submission_id = view.kwargs.get('submission_id') + group_id = view.kwargs.get('group_id') + project_id = view.kwargs.get("project_id") + course_id = view.kwargs.get("course_id") if user.is_admin or user.is_superuser: return True elif user.is_teacher: - if user.submission.filter(submission_id=submission_id).exists(): + course = Course.objects.get(course_id=course_id) + project = Project.objects.get(project_id=project_id) + if course.teachers.filter(user=user).exists() and project.course_id == course: return True elif user.is_student: - if user.submission.filter(submission_id=submission_id).exists(): - return view.action in ['list', 'retrieve'] + group = Group.objects.get(group_id=group_id) + if group.users.filter(user=user).exists(): + return view.action in ['list', 'retrieve', 'create'] return False diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 45de3817..1c9494f2 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -15,4 +15,12 @@ class SubmissionsViewset(viewsets.ModelViewSet): @property def allowed_methods(self): - return ['GET', 'POST'] + if self.request.user.is_student: + return ['GET', 'POST'] + else: + return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + + def list(self, request, *args, **kwargs): + if request.user.is_student: + self.queryset = Submissions.objects.filter(group_id__members=request.user) + return super().list(request, *args, **kwargs) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py new file mode 100644 index 00000000..ed74863c --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -0,0 +1,207 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class SubmissionTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group.user.set(self.admin) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.client.force_authenticate(self.admin) + + def test_submit_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/' + f'{self.project.project_id}/groups/' + f'{self.group.group_id}/submissions/', + { + "file": test_file, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submissions.objects.count(), 2) + + def test_retrieve_submission(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + f'{self.project.project_id}/groups/' + f'{self.group.group_id}/submissions/' + f'{self.submission.submission_id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('file'), SimpleUploadedFile("test_file.txt", b"file_content")) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + # tests with an invalid course + + def test_create_project_invalid_course(self): + response = self.client.post( + API_ENDPOINT + '100/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.count(), 1) + + def test_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": 100 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_delete_project_invalid_course(self): + response = self.client.delete( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.count(), 1) + + def test_partial_update_project_invalid_course(self): + response = self.client.patch( + API_ENDPOINT + f'100/projects/{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + + def test_retrieve_project_invalid_course(self): + response = self.client.get( + API_ENDPOINT + f'100/projects/{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_projects_invalid_course(self): + response = self.client.get( + API_ENDPOINT + '100/projects/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_partial_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.course.course_id}/projects/100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.course.course_id}/projects/100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 2a5503e2..35b6a8c9 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -29,9 +29,11 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) -router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) +router.register(r'courses/(?P[^/.]+)/' + r'projects/(?P[^/.]+)/' + r'groups/(?P[^/.]+)/submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From f660e2143638e42fbfd3ad6092ebdb353cd0c622 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 18:22:27 +0100 Subject: [PATCH 086/138] fix groups --- backend/pigeonhole/apps/groups/views.py | 69 +---------------------- backend/pigeonhole/apps/projects/views.py | 67 ---------------------- backend/pigeonhole/urls.py | 2 +- 3 files changed, 4 insertions(+), 134 deletions(-) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 11cdcf1e..b5446b32 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -1,17 +1,15 @@ -from django.shortcuts import get_object_or_404 from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from .permission import CanAccessGroup +from django.shortcuts import get_object_or_404 -# TODO tests for score/max_score -# TODO bij de update/partial update zorgen dat de user bestaat, anders errors class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() @@ -35,65 +33,4 @@ def list(self, request, *args, **kwargs): if request.user.id not in group["user"]: del group["final_score"] del group["feedback"] - return Response(serializer.data, status=status.HTTP_200_OK) - - def retrieve(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('project_id') - group_nr = kwargs.get('group_nr') - - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - # Check whether the project exists - get_object_or_404(Project, pk=project_id) - group = get_object_or_404(Group, project_id=project_id, group_nr=group_nr) - user = request.user - serializer = GroupSerializer(instance=group, many=False) - if user.is_superuser or user.is_staff or user.is_teacher: - serializer = GroupSerializer(instance=group, many=False) - return Response(serializer.data, status=status.HTTP_200_OK) - elif user.is_student: - return Response(serializer.get_visible_data(), status=status.HTTP_200_OK) - - def update(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('project_id') - group_nr = kwargs.get('group_nr') - get_object_or_404(Course, course_id=course_id) - get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_nr=group_nr, project_id=project_id) - serializer = GroupSerializer(instance=group, data=request.data, partial=False) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('project_id') - group_nr = kwargs.get('group_nr') - get_object_or_404(Course, course_id=course_id) - get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, group_nr=group_nr, project_id=project_id) - serializer = GroupSerializer(instance=group, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - - def create(self, request, *args, **kwargs): - return Response({"message": "You can't create groups"}, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - return Response({"message": "You can't delete groups"}, status=status.HTTP_400_BAD_REQUEST) - - @action(detail=True, methods=['post']) - def join_group(self, request, *args, **kwargs): - # TODO hier nog checks toevoegen, maar je kan een group joinen - course_id = kwargs.get('course_id') - project_id = kwargs.get('project_id') - group_nr = kwargs.get('group_nr') - get_object_or_404(Course, course_id=course_id) - get_object_or_404(Project, project_id=project_id) - group = get_object_or_404(Group, project_id=project_id, group_nr=group_nr) - - group.user.add(request.user) - return Response(status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 81dfca5d..5db22b88 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -17,15 +17,6 @@ class ProjectViewSet(viewsets.ModelViewSet): serializer_class = ProjectSerializer permission_classes = [IsAuthenticated & CanAccessProject] - def list(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - serializer = ProjectSerializer(Project.objects.filter(course_id=course_id), many=True) - - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, *args, **kwargs): course_id = kwargs.get('course_id') @@ -46,64 +37,6 @@ def create(self, request, *args, **kwargs): return Response({"message": "You are not allowed to create a new project."}, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - - if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - # Check whether the project exists - project = get_object_or_404(Project, pk=project_id) - project.delete() - return Response({"message": "Project has been deleted successfully."}, - status=status.HTTP_204_NO_CONTENT) - return Response({"message": "You are not allowed to delete this project."}, - status=status.HTTP_403_FORBIDDEN) - - def retrieve(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - # Check whether the project exists - project = get_object_or_404(Project, pk=project_id) - serializer = ProjectSerializer(instance=project, many=False) - return Response(serializer.data, status=status.HTTP_200_OK) - def update(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - - if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: - get_object_or_404(Course, course_id=course_id) - - project = get_object_or_404(Project, pk=project_id) - serializer = ProjectSerializer(project, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response({"message": "You are not allowed to update this project."}, - status=status.HTTP_403_FORBIDDEN) - - def partial_update(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('pk') - if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - # Check whether the project exists - project = get_object_or_404(Project, pk=project_id) - - serializer = ProjectSerializer(project, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response({"message": "You are not allowed to update this project."}, - status=status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 35b6a8c9..2f1439d5 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -30,7 +30,7 @@ router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) -router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) +router.register(r'groups', GroupViewSet) router.register(r'courses/(?P[^/.]+)/' r'projects/(?P[^/.]+)/' r'groups/(?P[^/.]+)/submissions', SubmissionsViewset) From 4fb0c7ade01adfe161615ea3bb412a6d128df98c Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 18:53:40 +0100 Subject: [PATCH 087/138] user tests fixed --- backend/pigeonhole/apps/courses/views.py | 2 +- backend/pigeonhole/apps/users/models.py | 2 +- .../tests/test_views/test_user/test_admin.py | 152 ++++++------------ 3 files changed, 54 insertions(+), 102 deletions(-) diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index e8c4fb9b..ba7f84be 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -4,8 +4,8 @@ from rest_framework.response import Response from backend.pigeonhole.apps.courses.models import CourseSerializer -from backend.pigeonhole.apps.projects.models import ProjectSerializer from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.projects.models import ProjectSerializer from .models import Course from .permissions import CourseUserPermissions diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index fe3b228c..6994d287 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -16,7 +16,7 @@ class User(AbstractUser): email = models.EmailField(unique=True) first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=150) - course = models.ManyToManyField(Course) + course = models.ManyToManyField(Course) # TODO: Add blank=True role = models.IntegerField(choices=Roles.choices, default=Roles.ADMIN) objects = UserManager() diff --git a/backend/pigeonhole/tests/test_views/test_user/test_admin.py b/backend/pigeonhole/tests/test_views/test_user/test_admin.py index b94dbac9..feb70c34 100644 --- a/backend/pigeonhole/tests/test_views/test_user/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_user/test_admin.py @@ -1,130 +1,82 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -from backend.pigeonhole.apps.groups.models import Group + +API_ENDPOINT = '/users/' -class ProjectTestAdminTeacher(TestCase): +class UserTestAdmin(TestCase): def setUp(self): self.client = APIClient() self.admin = User.objects.create( - username="admin_username", - email="test@gmail.com", - first_name="User1", - last_name="User1", - role=1 + username="user1", + email="user1@gmail.com", + first_name="user1", + last_name="user1", + role=1, + ) + + self.student = User.objects.create( + username="user2", + email="user2@gmail.com", + first_name="user2", + last_name="user2", + role=3, ) - self.client.force_authenticate(self.admin) + self.course = Course.objects.create(name="Test Course", description="This is a test course.") + + self.client.force_authenticate(user=self.admin) def test_create_user(self): response = self.client.post( - '/users/', + API_ENDPOINT, { - "id": 1, - "username": "admin_username", - "email": "test2@gmail.com", - "first_name": "User2", - "last_name": "User2", - "role": 3 + "username": "user5", + "email": "user5@gmail.com", + "first_name": "user5", + "last_name": "user5", + "role": 2, + "course": [self.course.course_id] }, format='json' ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(User.objects.count(), 2) - new_user = User.objects.get(name="User2") - self.assertEqual(new_user.name, "User2") - - def test_retrieve_user(self): - response = self.client.get( - f'/users/{self.admin.id}/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('username'), "User1") - - def test_list_users(self): - response = self.client.get( - '/users/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 2) + self.assertEqual(User.objects.count(), 3) def test_update_user(self): - response = self.client.put( - f'/users/2/', - { - "username": "admin_username", - "email": "test2@gmail.com", - "first_name": "User2", - "last_name": "User2", - "role": 2 - }, - format='json' - ) + updated_data = { + 'username': 'user2', + 'first_name': 'user6', + 'last_name': 'user2', + 'email': 'user2@gmail.com', + 'role': 3, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.student.id}/', updated_data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(User.objects.get(id=2).role, 2) + self.student.refresh_from_db() + self.assertEqual(self.student.first_name, updated_data['first_name']) - def test_delete_user(self): - response = self.client.delete( - '/users/2/' - ) + def test_delete_course(self): + user_id = User.objects.get(username="user2").id + response = self.client.delete(f'{API_ENDPOINT}{user_id}/') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(User.objects.count(), 1) + def test_list_users(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) - # def test_partial_update_user(self): - # - # - # def test_create_project_invalid_user(self): - # - # - # def test_update_project_invalid_user(self): - # - # - # def test_delete_project_invalid_user(self): - # - # - # def test_partial_update_project_invalid_user(self): - # - # - # def test_retrieve_project_invalid_user(self): - # - # - # def test_list_projects_invalid_user(self): - # - # - # def test_retrieve_invalid_user(self): - # - # - # def test_update_invalid_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/', - # { - # "name": "Updated Test Project", - # "description": "Updated Test Project Description", - # "course_id": self.course.course_id - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_partial_update_invalid_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/', - # { - # "name": "Updated Test Project" - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_delete_invalid_project(self): - # response = self.client.delete( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_retrieve_user(self): + response = self.client.get(f'{API_ENDPOINT}{self.admin.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.admin.first_name) From acab72f32f820c3ee7b18ad5e28289d207b5fbf9 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 19:43:42 +0100 Subject: [PATCH 088/138] fix groups - projects --- .../migrations/0004_alter_group_user.py | 20 +++++++ backend/pigeonhole/apps/groups/models.py | 19 ++++--- backend/pigeonhole/apps/groups/views.py | 17 ++++-- backend/pigeonhole/apps/projects/views.py | 56 ++++++++++--------- .../test_views/test_project/test_student.py | 4 +- backend/pigeonhole/urls.py | 2 +- 6 files changed, 75 insertions(+), 43 deletions(-) create mode 100644 backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py diff --git a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py new file mode 100644 index 00000000..fadc38cb --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.3 on 2024-03-13 18:34 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0003_alter_group_visible'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='group', + name='user', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index d8488f9c..6e0029b4 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -10,7 +10,7 @@ class Group(models.Model): group_id = models.BigAutoField(primary_key=True) group_nr = models.IntegerField(blank=True, null=True) project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - user = models.ManyToManyField(User) + user = models.ManyToManyField(User, blank=True) feedback = models.TextField(null=True) final_score = models.IntegerField(null=True, blank=True) visible = models.BooleanField(null=False, default=False) @@ -22,13 +22,14 @@ def __str__(self): # a student can only be in one group per project def clean(self): - for student in self.user.all(): - existing_groups = Group.objects.filter( - project_id=self.project_id, student=student).exclude( - group_id=self.group_id) - if existing_groups.exists(): - raise ValidationError(f"Student {student} is already part of " - "another group in this project.") + if self.user.exists(): # Only validate if there are users + for student in self.user.all(): + existing_groups = Group.objects.filter( + project_id=self.project_id, user=student).exclude( + group_id=self.group_id) + if existing_groups.exists(): + raise ValidationError(f"Student {student} is already part of " + "another group in this project.") # a student can only be in one group per project, group_nr is # automatically assigned and unique per project @@ -64,4 +65,4 @@ def get_other_group(self): del data['final_score'] if 'feedback' in data: del data['feedback'] - return data + return data \ No newline at end of file diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index b5446b32..9282cd49 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -15,22 +15,29 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated and CanAccessGroup] - lookup_field = 'group_nr' + """ def list(self, request, *args, **kwargs): course_id = kwargs.get('course_id') project_id = kwargs.get('project_id') + # Check whether the course exists get_object_or_404(Course, course_id=course_id) # Check whether the project exists - get_object_or_404(Project, pk=project_id) + project = get_object_or_404(Project, project=project_id) + + # Ensure the project is associated with the course + if project.course_id != int(course_id): + return Response({'detail': 'Project not found in the specified course.'}, status=status.HTTP_404_NOT_FOUND) queryset = self.queryset.filter(project_id=project_id) serializer = self.get_serializer(queryset, many=True) + if request.user.is_student: for group in serializer.data: - print(request.user.id) - if request.user.id not in group["user"]: + if request.user.id not in group["users"]: del group["final_score"] del group["feedback"] - return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file + + return Response(serializer.data, status=status.HTTP_200_OK) + """ \ No newline at end of file diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 5db22b88..1a59cbe8 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -1,42 +1,44 @@ -from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - +from django.db import transaction from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.groups.models import GroupSerializer from .models import Project, ProjectSerializer, Course from .permissions import CanAccessProject -# TODO hier nog zorgen als een project niet visible is, dat de students het niet kunnen zien. -# TODO tests for visibility and deadline - class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated & CanAccessProject] + @transaction.atomic def create(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - - if request.user.is_teacher or request.user.is_admin or request.user.is_superuser: - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - - serializer = ProjectSerializer(data=request.data) - if serializer.is_valid(): - project = serializer.save() # Save the project and get the instance - # make NUMBER OF GROUP groups - for i in range(serializer.validated_data['number_of_groups']): - group = Group.objects.create(group_nr=i + 1, project_id=project) # Assign the Project instance - group.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response({"message": "You are not allowed to create a new project."}, - status=status.HTTP_400_BAD_REQUEST) - - - - + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + number_of_groups = serializer.validated_data.get('number_of_groups', 0) + project = serializer.save() + + groups = [] + for i in range(number_of_groups): + group_data = { + 'project_id': project.project_id, + 'user': [], # You may add users here if needed + 'feedback': None, + 'final_score': None, + 'visible': False, # Adjust visibility as needed + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + + # You may return the newly created groups if needed + groups_data = GroupSerializer(groups, many=True).data + response_data = serializer.data + response_data['groups'] = groups_data + + headers = self.get_success_headers(serializer.data) + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 3d27f9c8..696a2856 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -1,6 +1,8 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +# reverse +from django.urls import reverse from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project @@ -40,7 +42,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + reverse('project-list', kwargs={'course_id': self.course.course_id}), { "name": "Test Project 2", "description": "Test Project 2 Description", diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 2f1439d5..f7e77466 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -29,7 +29,7 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) -router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) +router.register(r'projects', ProjectViewSet) router.register(r'groups', GroupViewSet) router.register(r'courses/(?P[^/.]+)/' r'projects/(?P[^/.]+)/' From b176700fb3be76e37b2d9162700dd41b241ddd62 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 20:15:53 +0100 Subject: [PATCH 089/138] user test done --- backend/pigeonhole/apps/users/permissions.py | 6 +- .../tests/test_views/test_user/test_admin.py | 2 +- .../test_views/test_user/test_teacher.py | 217 ++++++------------ 3 files changed, 69 insertions(+), 156 deletions(-) diff --git a/backend/pigeonhole/apps/users/permissions.py b/backend/pigeonhole/apps/users/permissions.py index bc2d912d..a04b0c5c 100644 --- a/backend/pigeonhole/apps/users/permissions.py +++ b/backend/pigeonhole/apps/users/permissions.py @@ -1,7 +1,5 @@ from rest_framework import permissions -from backend.pigeonhole.apps.users.models import User - class UserPermissions(permissions.BasePermission): def has_permission(self, request, view): @@ -11,8 +9,8 @@ def has_permission(self, request, view): if request.user.is_teacher or request.user.is_student: if view.action in ['list', 'retrieve']: # TODO: can teachers create and destroy users? return True - elif view.action in ['update', 'partial_update', 'destroy'] and User.objects.filter( - id=request.user.id).exists(): + # user can only update their own user + elif view.action in ['update', 'partial_update'] and request.user.pk == int(view.kwargs['pk']): return True return False diff --git a/backend/pigeonhole/tests/test_views/test_user/test_admin.py b/backend/pigeonhole/tests/test_views/test_user/test_admin.py index feb70c34..5b482ff2 100644 --- a/backend/pigeonhole/tests/test_views/test_user/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_user/test_admin.py @@ -64,7 +64,7 @@ def test_update_user(self): self.student.refresh_from_db() self.assertEqual(self.student.first_name, updated_data['first_name']) - def test_delete_course(self): + def test_delete_user(self): user_id = User.objects.get(username="user2").id response = self.client.delete(f'{API_ENDPOINT}{user_id}/') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) diff --git a/backend/pigeonhole/tests/test_views/test_user/test_teacher.py b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py index ce634773..a6239158 100644 --- a/backend/pigeonhole/tests/test_views/test_user/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py @@ -1,182 +1,97 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/courses/' +API_ENDPOINT = '/users/' -class ProjectTestTeacher(TestCase): +class UserTestTeacher(TestCase): def setUp(self): self.client = APIClient() - self.teacher = User.objects.create( - username="teacher_username", - email="test@gmail.com", - first_name="Kermit", - last_name="The Frog", - role=2 - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.course_not_of_teacher = Course.objects.create( - name="Test Course 2", - ) - - self.teacher.course.set([self.course]) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - self.client.force_authenticate(self.teacher) - - def test_create_project(self): - response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id, - "number_of_groups": 4, - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Updated assertion to use the correct project_id from the response - created_project_id = response.data.get('project_id') - self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") - self.assertEqual(Project.objects.count(), 2) - - def test_retrieve_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) - def test_list_projects(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' + self.teacher = User.objects.create( + username="user1", + email="user1@gmail.com", + first_name="user1", + last_name="user1", + role=2, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - def test_delete_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + self.student = User.objects.create( + username="user2", + email="user2@gmail.com", + first_name="user2", + last_name="user2", + role=3, ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Project.objects.count(), 0) - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + self.course = Course.objects.create(name="Test Course", description="This is a test course.") - # tests with an invalid course + self.client.force_authenticate(user=self.teacher) - def test_create_project_invalid_course(self): + def test_create_user(self): response = self.client.post( - API_ENDPOINT + '100/projects/', + API_ENDPOINT, { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 + "username": "user5", + "email": "user5@gmail.com", + "first_name": "user5", + "last_name": "user5", + "role": 2, + "course": [self.course.course_id] }, format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with a course not of the teacher + self.assertEqual(User.objects.count(), 2) + + def test_update_self(self): + updated_data = { + 'username': 'user1', + 'first_name': 'user6', + 'last_name': 'user1', + 'email': 'user1@gmail.com', + 'role': 2, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.teacher.id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], updated_data['first_name']) - def test_create_project_course_not_of_teacher(self): - response = self.client.post( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course_not_of_teacher.course_id - }, - format='json' - ) + def test_delete_self(self): + response = self.client.delete(f'{API_ENDPOINT}{self.teacher.id}/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' - ) + self.assertEqual(User.objects.count(), 2) + + def test_update_other(self): + updated_data = { + 'username': 'user2', + 'first_name': 'user6', + 'last_name': 'user2', + 'email': 'user2@gmail.com', + 'role': 3, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.student.id}/', updated_data, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_list_projects_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' - ) + def test_delete_other(self): + response = self.client.delete(f'{API_ENDPOINT}{self.student.id}/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(User.objects.count(), 2) - # test with invalid project - - def test_retrieve_invalid_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_project_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_list_users(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) - def test_delete_project_invalid_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_retrieve_user(self): + response = self.client.get(f'{API_ENDPOINT}{self.teacher.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.teacher.first_name) From 5a2e0a491c696e00ab7eb80f58c944c695f1bdaa Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 20:32:57 +0100 Subject: [PATCH 090/138] cleanup groups --- backend/pigeonhole/apps/groups/models.py | 14 +++++++++++++ backend/pigeonhole/apps/groups/views.py | 26 ------------------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 6e0029b4..2f9d71ac 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -65,4 +65,18 @@ def get_other_group(self): del data['final_score'] if 'feedback' in data: del data['feedback'] + return data + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get('request') + + # Check if the user is a student and the group is not visible + if request and request.user.is_student and not instance.visible: + # Hide sensitive information for students + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data \ No newline at end of file diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 9282cd49..de01f3c3 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -15,29 +15,3 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated and CanAccessGroup] - - """ - def list(self, request, *args, **kwargs): - course_id = kwargs.get('course_id') - project_id = kwargs.get('project_id') - - # Check whether the course exists - get_object_or_404(Course, course_id=course_id) - # Check whether the project exists - project = get_object_or_404(Project, project=project_id) - - # Ensure the project is associated with the course - if project.course_id != int(course_id): - return Response({'detail': 'Project not found in the specified course.'}, status=status.HTTP_404_NOT_FOUND) - - queryset = self.queryset.filter(project_id=project_id) - serializer = self.get_serializer(queryset, many=True) - - if request.user.is_student: - for group in serializer.data: - if request.user.id not in group["users"]: - del group["final_score"] - del group["feedback"] - - return Response(serializer.data, status=status.HTTP_200_OK) - """ \ No newline at end of file From 3580f9fb712b996d233d954e952933ce06c18cdc Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 20:38:30 +0100 Subject: [PATCH 091/138] course tests done --- .../test_views/test_course/test_student.py | 70 +++++++++++++++++++ .../test_course/test_unauthorized.py | 44 ++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 backend/pigeonhole/tests/test_views/test_course/test_student.py create mode 100644 backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py new file mode 100644 index 00000000..140ec262 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -0,0 +1,70 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/courses/' + + +class CourseTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a regular user (teacher) + self.teacher = User.objects.create_user( + username="teacher", + email="teacher@gmail.com", + first_name="teacher", + last_name="teacher" + ) + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + # Provide a value for the "number" field when creating the Student instance + self.student = User.objects.create( + username="student", + email="student@gmail.com", + first_name="student", + last_name="student", + role=3 + ) + self.student.course.set([self.course]) + + # Authenticate the test client with the regular user + self.client.force_authenticate(user=self.student) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 1) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py new file mode 100644 index 00000000..ebf84f0a --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py @@ -0,0 +1,44 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course + +API_ENDPOINT = '/courses/' + + +class CourseTestUnauthorized(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 331aaef0a1bad2df7c197ce85306a953678615be Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 20:40:24 +0100 Subject: [PATCH 092/138] semi fixed tests projects --- .../test_views/test_project/test_admin.py | 82 +++---------------- .../test_views/test_project/test_student.py | 58 ++++--------- .../test_views/test_project/test_teacher.py | 81 ++++-------------- .../test_project/test_unauthenticated.py | 77 +++-------------- 4 files changed, 50 insertions(+), 248 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index a4d1386e..4b14e4b7 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -7,7 +7,7 @@ from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.groups.models import Group -API_ENDPOINT = '/courses/' +API_ENDPOINT = '/projects/' class ProjectTestAdminTeacher(TestCase): @@ -38,7 +38,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + API_ENDPOINT, { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -55,21 +55,21 @@ def test_create_project(self): def test_retrieve_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('name'), self.project.name) def test_list_projects(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' + API_ENDPOINT ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) def test_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -82,14 +82,14 @@ def test_update_project(self): def test_delete_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Project.objects.count(), 0) def test_partial_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project" }, @@ -98,75 +98,15 @@ def test_partial_update_project(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + '100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - def test_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - def test_delete_project_invalid_course(self): - response = self.client.delete( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - def test_partial_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - # test with invalid project - def test_retrieve_invalid_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + API_ENDPOINT + '100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_update_invalid_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + API_ENDPOINT + '100/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -178,7 +118,7 @@ def test_update_invalid_project(self): def test_partial_update_invalid_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + API_ENDPOINT + '100/', { "name": "Updated Test Project" }, @@ -188,6 +128,6 @@ def test_partial_update_invalid_project(self): def test_delete_invalid_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + API_ENDPOINT + '100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 696a2856..7f68e5b1 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -1,15 +1,12 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -# reverse from django.urls import reverse from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/courses/' - class ProjectTestStudent(TestCase): def setUp(self): @@ -42,7 +39,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - reverse('project-list', kwargs={'course_id': self.course.course_id}), + reverse('project-list'), { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -55,21 +52,21 @@ def test_create_project(self): def test_retrieve_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + reverse('project-detail', args=[self.project.project_id]) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('name'), self.project.name) def test_list_projects(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' + reverse('project-list') ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) def test_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + reverse('project-detail', args=[self.project.project_id]), { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -82,14 +79,14 @@ def test_update_project(self): def test_delete_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + reverse('project-detail', args=[self.project.project_id]) ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 1) def test_partial_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + reverse('project-detail', args=[self.project.project_id]), { "name": "Updated Test Project" }, @@ -98,38 +95,11 @@ def test_partial_update_project(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + '100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # tests with a course not of the student def test_create_project_course_not_of_student(self): response = self.client.post( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/', + reverse('project-list'), { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -142,19 +112,19 @@ def test_create_project_course_not_of_student(self): def test_retrieve_project_course_not_of_student(self): response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/' + reverse('project-detail', args=[self.project.project_id]) ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_projects_course_not_of_student(self): response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/' + reverse('project-list') ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_project_course_not_of_student(self): response = self.client.patch( - API_ENDPOINT + f'{self.course_not_of_student.course_id}/projects/{self.project.project_id}/', + reverse('project-detail', args=[self.project.project_id]), { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -169,13 +139,13 @@ def test_update_project_course_not_of_student(self): def test_retrieve_invalid_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + reverse('project-detail', args=[100]) ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_invalid_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + reverse('project-detail', args=[100]), { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -187,6 +157,6 @@ def test_update_invalid_project(self): def test_delete_invalid_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + reverse('project-detail', args=[100]) ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 6e64e177..59d3fbf0 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -1,12 +1,13 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +from django.urls import reverse from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/courses/' +API_ENDPOINT = '/projects/' class ProjectTestTeacher(TestCase): @@ -40,7 +41,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + reverse('project-list'), { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -58,21 +59,21 @@ def test_create_project(self): def test_retrieve_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + reverse('project-detail', args=[self.project.project_id]) ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('name'), self.project.name) def test_list_projects(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' + reverse('project-list') ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) def test_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + reverse('project-detail', args=[self.project.project_id]), { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -85,14 +86,14 @@ def test_update_project(self): def test_delete_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + reverse('project-detail', args=[self.project.project_id]) ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Project.objects.count(), 0) def test_partial_update_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + reverse('project-detail', args=[self.project.project_id]), { "name": "Updated Test Project" }, @@ -101,71 +102,17 @@ def test_partial_update_project(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + '100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with a course not of the teacher - - def test_create_project_course_not_of_teacher(self): - response = self.client.post( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course_not_of_teacher.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_course_not_of_teacher(self): - response = self.client.get( - API_ENDPOINT + f'{self.course_not_of_teacher.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # test with invalid project def test_retrieve_invalid_project(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + reverse('project-detail', args=[100]) ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_project_invalid_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + reverse('project-detail', args=[100]), { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -173,10 +120,10 @@ def test_update_project_invalid_project(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_project_invalid_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + reverse('project-detail', args=[100]) ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index c769f24e..b263fd21 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -6,7 +6,7 @@ from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/courses/' +API_ENDPOINT = '/projects/' class ProjectTestUnauthenticated(TestCase): @@ -34,7 +34,7 @@ def setUp(self): def test_create_project_unauthenticated(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + API_ENDPOINT, { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -47,19 +47,19 @@ def test_create_project_unauthenticated(self): def test_retrieve_project_unauthenticated(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_projects_unauthenticated(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' + API_ENDPOINT ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_project_unauthenticated(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -71,68 +71,13 @@ def test_update_project_unauthenticated(self): def test_delete_project_unauthenticated(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_partial_update_project_unauthenticated(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # tests with an invalid course - - def test_create_project_invalid_course_unauthenticated(self): - response = self.client.post( - API_ENDPOINT + '100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) - - def test_retrieve_project_invalid_course_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_list_projects_invalid_course_unauthenticated(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_update_project_invalid_course_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_project_invalid_course_unauthenticated(self): - response = self.client.delete( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_project_invalid_course_unauthenticated(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project" }, @@ -144,13 +89,13 @@ def test_partial_update_project_invalid_course_unauthenticated(self): def test_retrieve_invalid_project_unauthenticated(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + API_ENDPOINT + '100/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_invalid_project_unauthenticated(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + API_ENDPOINT + '100/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -162,13 +107,13 @@ def test_update_invalid_project_unauthenticated(self): def test_delete_invalid_project_unauthenticated(self): response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' + API_ENDPOINT + '100/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_partial_update_invalid_project_unauthenticated(self): response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', + API_ENDPOINT + '100/', { "name": "Updated Test Project" }, From 3109a2efd5a2b9a296afd5b43cc011cabf16a05c Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 20:58:49 +0100 Subject: [PATCH 093/138] project admin test fixed --- .../pigeonhole/tests/test_views/test_project/test_admin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 4b14e4b7..605c1902 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -1,11 +1,13 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -from backend.pigeonhole.apps.groups.models import Group API_ENDPOINT = '/projects/' @@ -65,7 +67,8 @@ def test_list_projects(self): API_ENDPOINT ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 1) def test_update_project(self): response = self.client.patch( From 63d711d0c8092da8b58fe96968007938e2f6ad6b Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 20:59:50 +0100 Subject: [PATCH 094/138] begin tests, project en groups api changes --- backend/pigeonhole/apps/groups/views.py | 22 ++++++- .../pigeonhole/apps/projects/permissions.py | 2 +- backend/pigeonhole/apps/projects/views.py | 15 ++++- .../tests/test_views/test_group/test_admin.py | 57 +++++++++++++++++++ .../test_views/test_group/test_student.py | 0 .../test_views/test_group/test_teacher.py | 0 .../test_group/test_unauthenticated.py | 0 7 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/test_group/test_admin.py create mode 100644 backend/pigeonhole/tests/test_views/test_group/test_student.py create mode 100644 backend/pigeonhole/tests/test_views/test_group/test_teacher.py create mode 100644 backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 9282cd49..db908b5c 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -40,4 +40,24 @@ def list(self, request, *args, **kwargs): del group["feedback"] return Response(serializer.data, status=status.HTTP_200_OK) - """ \ No newline at end of file + """ + + @action(detail=True, methods=['post']) + def join(self, request, pk=None): + group = self.get_object() + user = request.user + if group.members.count() < group.project.max_group_size: + group.members.add(user) + group.save() + return Response({'message': 'User joined group'}, status=status.HTTP_200_OK) + else: + return Response({'message': 'Group is full'}, status=status.HTTP_400_BAD_REQUEST) + + # leave a group + @action(detail=True, methods=['post']) + def leave(self, request, pk=None): + group = self.get_object() + user = request.user + group.members.remove(user) + group.save() + return Response({'message': 'User left group'}, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index c350e5e5..b4d9cb0f 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -14,5 +14,5 @@ def has_permission(self, request, view): return True elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['list', 'retrieve'] + return view.action in ['list', 'retrieve', 'get_my_groups', 'get_groups'] return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 1a59cbe8..d5f9516a 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -1,5 +1,6 @@ from rest_framework import status from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.db import transaction @@ -41,4 +42,16 @@ def create(self, request, *args, **kwargs): response_data['groups'] = groups_data headers = self.get_success_headers(serializer.data) - return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + + @action(detail=True, methods=['get']) + def get_groups(self, request, *args, **kwargs): + project = self.get_object() + groups = Group.objects.filter(project_id=project) + return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) + + @action(detail=True, methods=['get']) + def get_my_groups(self, request, *args, **kwargs): + project = self.get_object() + groups = Group.objects.filter(project_id=project, user=request.user) + return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py new file mode 100644 index 00000000..b604d8f6 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -0,0 +1,57 @@ +from unittest import TestCase + +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + +class GroupTestAdminTeacher(TestCase): + + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="Test1", + last_name="Test2", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.client.force_authenticate(self.admin) + + def test_admin_create_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.course.course_id}/projects/', + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id, + "number_of_groups": 4, + }, + format='json' + ) + # self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # self.assertEqual(Group.objects.count(), 4) + # new_group = Group.objects.get(name="Test Project 2") + # self.assertEqual(new_project.name, "Test Project 2") + + + def test_retreive_group(self): + response = self.client.get( + # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/groups/{}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py new file mode 100644 index 00000000..e69de29b From a4379a45afb828224fb72a398f748ee84c1252e5 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 21:13:23 +0100 Subject: [PATCH 095/138] course test done (really done this time) --- .../tests/test_views/test_course/course.txt | 91 ------------------- .../test_views/test_course/test_admin.py | 22 +++++ .../test_views/test_course/test_student.py | 11 +++ .../test_views/test_course/test_teacher.py | 6 +- .../test_course/test_unauthorized.py | 4 + 5 files changed, 41 insertions(+), 93 deletions(-) delete mode 100644 backend/pigeonhole/tests/test_views/test_course/course.txt diff --git a/backend/pigeonhole/tests/test_views/test_course/course.txt b/backend/pigeonhole/tests/test_views/test_course/course.txt deleted file mode 100644 index cfcdd187..00000000 --- a/backend/pigeonhole/tests/test_views/test_course/course.txt +++ /dev/null @@ -1,91 +0,0 @@ - -class CourseTestStudent(TestCase): - def setUp(self): - self.client = APIClient() - - # Create a regular user (teacher) - self.user = User.objects.create_user( - username="teacher_username", - email="kermit@gmail.com", - first_name="Kermit", - last_name="The Frog" - ) - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course = Course.objects.create(**self.course_data) - - # Provide a value for the "number" field when creating the Student instance - self.student = Student.objects.create(id=self.user, number=123456) - self.student.course.set([self.course]) - - # Authenticate the test client with the regular user - self.client.force_authenticate(user=self.user) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.course.refresh_from_db() - self.assertNotEqual(self.course.name, updated_data['name']) - self.assertNotEqual(self.course.description, updated_data['description']) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 2) - - -class CourseTestUnauthorized(TestCase): - def setUp(self): - self.client = APIClient() - - self.course_data = { - 'name': 'Test Course', - 'description': 'This is a test course.' - } - - self.course = Course.objects.create(**self.course_data) - - def test_create_course(self): - response = self.client.post(API_ENDPOINT, self.course_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_update_course(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.course.refresh_from_db() - self.assertNotEqual(self.course.name, updated_data['name']) - self.assertNotEqual(self.course.description, updated_data['description']) - - def test_delete_course(self): - response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) - - def test_list_courses(self): - response = self.client.get(API_ENDPOINT) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(len(response.data), Course.objects.count()) -""" \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index a514b8e8..0a55e965 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -74,8 +74,30 @@ def test_delete_course_not_of_teacher(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Course.objects.count(), 1) + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.course.name) + self.assertEqual(response.data['description'], self.course.description) + def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["count"], 2) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_course_not_exist(self): + response = self.client.delete(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py index 140ec262..21bd6303 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -63,8 +63,19 @@ def test_delete_course(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Course.objects.count(), 1) + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], "Test Course") + self.assertEqual(content_json["description"], "This is a test course.") + def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(content_json["count"], 1) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py index 9e556b71..65c65191 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -10,8 +10,6 @@ API_ENDPOINT = '/courses/' -# TODO - class CourseTestTeacher(TestCase): def setUp(self): self.client = APIClient() @@ -84,3 +82,7 @@ def test_retrieve_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.course.name) self.assertEqual(response.data['description'], self.course.description) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py index ebf84f0a..a17263b9 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py @@ -39,6 +39,10 @@ def test_delete_course(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Course.objects.count(), 1) + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From d51497b20ba840c510c089fdddbc00527df43be1 Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 21:47:42 +0100 Subject: [PATCH 096/138] permissions groups en wat andere changes --- .../pigeonhole/apps/courses/permissions.py | 1 - backend/pigeonhole/apps/groups/models.py | 22 +------------------ backend/pigeonhole/apps/groups/permission.py | 3 +-- backend/pigeonhole/apps/groups/views.py | 6 ++--- 4 files changed, 5 insertions(+), 27 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 2bcebaaf..4808e135 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -19,5 +19,4 @@ def has_permission(self, request, view): if request.user.is_student: return view.action in ['list', 'retrieve'] - return False diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 2f9d71ac..4f8a0d5c 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -48,25 +48,6 @@ class Meta: model = Group fields = ["group_id", "group_nr", "final_score", "project_id", "user", "feedback", "visible"] - def get_visible_data(self): - # remove certain fields if visible is false. - data = self.data.copy() - if not self.instance.visible: - if 'final_score' in data: - del data['final_score'] - if 'feedback' in data: - del data['feedback'] - return data - - def get_other_group(self): - # remove certain fields if visible is false. - data = self.data.copy() - if 'final_score' in data: - del data['final_score'] - if 'feedback' in data: - del data['feedback'] - return data - def to_representation(self, instance): data = super().to_representation(instance) request = self.context.get('request') @@ -78,5 +59,4 @@ def to_representation(self, instance): del data['final_score'] if 'feedback' in data: del data['feedback'] - - return data \ No newline at end of file + return data diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 6becedca..10f65384 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -6,7 +6,6 @@ class CanAccessGroup(permissions.BasePermission): def has_permission(self, request, view): user = request.user course_id = view.kwargs.get('course_id') - if user.is_admin or user.is_superuser: return True elif user.is_teacher: @@ -14,5 +13,5 @@ def has_permission(self, request, view): return True elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return True + return view.action in ['list', 'retrieve', 'join', 'leave'] return False diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 0bea7a9a..44b0b345 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -20,8 +20,8 @@ class GroupViewSet(viewsets.ModelViewSet): def join(self, request, pk=None): group = self.get_object() user = request.user - if group.members.count() < group.project.max_group_size: - group.members.add(user) + if group.user.count() < group.project.max_group_size: + group.user.add(user) group.save() return Response({'message': 'User joined group'}, status=status.HTTP_200_OK) else: @@ -32,6 +32,6 @@ def join(self, request, pk=None): def leave(self, request, pk=None): group = self.get_object() user = request.user - group.members.remove(user) + group.user.remove(user) group.save() return Response({'message': 'User left group'}, status=status.HTTP_200_OK) From 9a8080e626427f75b9492e35d8b4e0b2c46ba34c Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 21:53:59 +0100 Subject: [PATCH 097/138] fix group & projects --- backend/pigeonhole/apps/groups/views.py | 7 +++++++ backend/pigeonhole/apps/projects/views.py | 8 +------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 44b0b345..458172fa 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -35,3 +35,10 @@ def leave(self, request, pk=None): group.user.remove(user) group.save() return Response({'message': 'User left group'}, status=status.HTTP_200_OK) + + # get all submissions for a group + @action(detail=True, methods=['get']) + def get_submissions(self, request, pk=None): + group = self.get_object() + submissions = group.submission_set.all() + return Response({'submissions': submissions}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index d5f9516a..8f1da926 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -48,10 +48,4 @@ def create(self, request, *args, **kwargs): def get_groups(self, request, *args, **kwargs): project = self.get_object() groups = Group.objects.filter(project_id=project) - return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) - - @action(detail=True, methods=['get']) - def get_my_groups(self, request, *args, **kwargs): - project = self.get_object() - groups = Group.objects.filter(project_id=project, user=request.user) - return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) + return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) \ No newline at end of file From c9d8bddbb7b0e512f4e37516132135a870dd8ad8 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 21:55:13 +0100 Subject: [PATCH 098/138] action submission --- backend/pigeonhole/apps/groups/models.py | 3 -- backend/pigeonhole/apps/submissions/models.py | 9 ++--- backend/pigeonhole/apps/submissions/views.py | 34 +++++++++++++------ backend/pigeonhole/urls.py | 4 +-- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index d8488f9c..5fb1a208 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -17,9 +17,6 @@ class Group(models.Model): objects = models.Manager() - def __str__(self): - return str(self.id) - # a student can only be in one group per project def clean(self): for student in self.user.all(): diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 7e1648ad..2e454dce 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -43,13 +43,8 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields class SubmissionsSerializer(serializers.ModelSerializer): submission_nr = serializers.IntegerField(read_only=True) output_test = serializers.FileField(read_only=True) + group_id = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all()) class Meta: model = Submissions - fields = ['submission_id', 'group_id', 'file', 'timestamp', 'submission_nr', 'output_test'] - read_only_fields = ['group_id'] - - def save(self, **kwargs): - group_id = self.context['view'].kwargs.get('group_id') - self.validated_data['group_id'] = Group.objects.get(pk=group_id) - return super().save(**kwargs) + fields = ['submission_id', 'file', 'timestamp', 'submission_nr', 'output_test', 'group_id'] diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 1c9494f2..635ce14e 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,6 +1,8 @@ from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission @@ -13,14 +15,24 @@ class SubmissionsViewset(viewsets.ModelViewSet): serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] - @property - def allowed_methods(self): - if self.request.user.is_student: - return ['GET', 'POST'] - else: - return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] - - def list(self, request, *args, **kwargs): - if request.user.is_student: - self.queryset = Submissions.objects.filter(group_id__members=request.user) - return super().list(request, *args, **kwargs) + @action(detail=False, methods=['POST']) + def submit(self, request, *args, **kwargs): + submission = self.get_submission() + serializer = SubmissionsSerializer(submission, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['GET']) + def get_submission(self, request, *args, **kwargs): + submission = self.get_object() + return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) + + @action(detail=False, methods=['GET']) + def get_all_submissions(self, request, *args, **kwargs): + user = request.user + groups = Group.objects.filter(users=user) + submissions = Submissions.objects.filter(group_id__in=groups) + if not submissions: + return Response({"message": "No submissions found"}, status=status.HTTP_404_NOT_FOUND) + return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 35b6a8c9..2a5503e2 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -29,11 +29,9 @@ router = routers.DefaultRouter() router.register(r'users', UserViewSet) router.register(r'courses', CourseViewSet) +router.register(r'submissions', SubmissionsViewset) router.register(r'courses/(?P[^/.]+)/projects', ProjectViewSet) router.register(r'courses/(?P[^/.]+)/projects/(?P[^/.]+)/groups', GroupViewSet) -router.register(r'courses/(?P[^/.]+)/' - r'projects/(?P[^/.]+)/' - r'groups/(?P[^/.]+)/submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. From 3cf45d45d8cfc177eeda48c041fbae1db6c42535 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 21:58:26 +0100 Subject: [PATCH 099/138] fix lint --- .../submissions/migrations/0001_initial.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index ea9510d5..42638ec9 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -17,12 +16,26 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Submissions', fields=[ - ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), + ('submission_id', + models.BigAutoField(primary_key=True, serialize=False)), ('submission_nr', models.IntegerField(blank=True)), - ('file', models.FileField(max_length=255, null=True, upload_to=backend.pigeonhole.apps.submissions.models.get_upload_to)), + ('file', + models.FileField(max_length=255, null=True, + upload_to=backend.pigeonhole.apps. + submissions.models.get_upload_to)), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField(blank=True, max_length=255, null=True, upload_to='uploads/submissions/outputs///output_test/')), - ('group_id', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='groups.group')), + ('output_test', + models.FileField(blank=True, + max_length=255, + null=True, + upload_to='uploads/submissions/outputs/' + '//output_test/')), + ('group_id', + models.ForeignKey(blank=True, + on_delete=django.db.models.deletion.CASCADE, + to='groups.group')), ], ), ] From 6af32f22e5ecbc24dbd42acc64a7ff28eff21856 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 22:01:31 +0100 Subject: [PATCH 100/138] fix lint bis --- backend/pigeonhole/apps/groups/models.py | 2 +- backend/pigeonhole/apps/groups/views.py | 2 +- backend/pigeonhole/apps/projects/views.py | 2 +- backend/pigeonhole/apps/submissions/models.py | 3 ++- backend/pigeonhole/apps/submissions/views.py | 1 - backend/pigeonhole/tests/test_views/test_group/test_admin.py | 4 +--- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 4f8a0d5c..92d3731f 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -22,7 +22,7 @@ def __str__(self): # a student can only be in one group per project def clean(self): - if self.user.exists(): # Only validate if there are users + if self.user.exists(): # Only validate if there are users for student in self.user.all(): existing_groups = Group.objects.filter( project_id=self.project_id, user=student).exclude( diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 458172fa..a7019d95 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -41,4 +41,4 @@ def leave(self, request, pk=None): def get_submissions(self, request, pk=None): group = self.get_object() submissions = group.submission_set.all() - return Response({'submissions': submissions}, status=status.HTTP_200_OK) \ No newline at end of file + return Response({'submissions': submissions}, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 8f1da926..81c10416 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -48,4 +48,4 @@ def create(self, request, *args, **kwargs): def get_groups(self, request, *args, **kwargs): project = self.get_object() groups = Group.objects.filter(project_id=project) - return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 7e1648ad..eb5c82b9 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -12,7 +12,8 @@ def get_upload_to(self, filename): def get_upload_to_test(self, filename): - return None # TODO implement this + return None # TODO implement this + # Create your models here. class Submissions(models.Model): diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 753fd7f8..a834bf42 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -35,4 +35,3 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index b604d8f6..d9ae41a3 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -4,12 +4,11 @@ from rest_framework.test import APIClient from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.groups.models import Group -from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User API_ENDPOINT = '/groups/' + class GroupTestAdminTeacher(TestCase): def setUp(self): @@ -48,7 +47,6 @@ def test_admin_create_group(self): # new_group = Group.objects.get(name="Test Project 2") # self.assertEqual(new_project.name, "Test Project 2") - def test_retreive_group(self): response = self.client.get( # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/groups/{}/' From d1dbe56f171227169f15662f6eac32eb18319c40 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 22:07:17 +0100 Subject: [PATCH 101/138] fix groups permissions --- backend/pigeonhole/apps/groups/permission.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 10f65384..664d021e 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -10,8 +10,9 @@ def has_permission(self, request, view): return True elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): - return True + return view.action in ['retrieve', 'join', + 'leave', 'get_submissions'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['list', 'retrieve', 'join', 'leave'] + return view.action in ['retrieve', 'get_submissions'] return False From 5d35bc77216d585eb00140b22bf672fcf8a83db6 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 22:07:53 +0100 Subject: [PATCH 102/138] fix group permissions bis --- backend/pigeonhole/apps/groups/permission.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 664d021e..57515f24 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -10,9 +10,9 @@ def has_permission(self, request, view): return True elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', - 'leave', 'get_submissions'] + return view.action in ['retrieve', 'get_submissions'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'get_submissions'] + return view.action in ['retrieve', 'join', + 'leave', 'get_submissions'] return False From 349f5b17143bdfad68c0748e6e59703cb15c9e02 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 22:37:33 +0100 Subject: [PATCH 103/138] project permissions and teacher test --- .../pigeonhole/apps/projects/permissions.py | 40 ++++++++++++++----- .../test_views/test_project/test_teacher.py | 21 +++++----- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index b4d9cb0f..6549030c 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -1,4 +1,8 @@ from rest_framework import permissions +from rest_framework import status +from rest_framework.response import Response + +from .models import Project class CanAccessProject(permissions.BasePermission): @@ -6,13 +10,31 @@ class CanAccessProject(permissions.BasePermission): # to the project data. def has_permission(self, request, view): user = request.user - course_id = view.kwargs.get('course_id') - if user.is_admin or user.is_superuser: - return True - elif user.is_teacher: - if user.course.filter(course_id=course_id).exists(): + if view.action in ['create']: + course_id = request.data.get('course_id') + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + return False + elif view.action in ['list']: + if user.is_admin or user.is_superuser: + return True + return False + else: + project_id = int(view.kwargs.get('pk')) + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + course_id = Project.objects.get(project_id=project_id).course_id.course_id + if user.is_admin or user.is_superuser: return True - elif user.is_student: - if user.course.filter(course_id=course_id).exists(): - return view.action in ['list', 'retrieve', 'get_my_groups', 'get_groups'] - return False + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + elif user.is_student: + if user.course.filter(course_id=course_id).exists(): + return view.action in ['retrieve', 'get_my_groups', 'get_groups'] + return False diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 59d3fbf0..166bd0a4 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -1,7 +1,6 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -from django.urls import reverse from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project @@ -41,7 +40,7 @@ def setUp(self): def test_create_project(self): response = self.client.post( - reverse('project-list'), + API_ENDPOINT, { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -59,21 +58,21 @@ def test_create_project(self): def test_retrieve_project(self): response = self.client.get( - reverse('project-detail', args=[self.project.project_id]) + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('name'), self.project.name) def test_list_projects(self): response = self.client.get( - reverse('project-list') + API_ENDPOINT ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(len(response.data), 1) def test_update_project(self): response = self.client.patch( - reverse('project-detail', args=[self.project.project_id]), + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -86,14 +85,14 @@ def test_update_project(self): def test_delete_project(self): response = self.client.delete( - reverse('project-detail', args=[self.project.project_id]) + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Project.objects.count(), 0) def test_partial_update_project(self): response = self.client.patch( - reverse('project-detail', args=[self.project.project_id]), + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project" }, @@ -106,13 +105,13 @@ def test_partial_update_project(self): def test_retrieve_invalid_project(self): response = self.client.get( - reverse('project-detail', args=[100]) + API_ENDPOINT + f'{self.project.project_id}5654168944/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_project_invalid_project(self): response = self.client.patch( - reverse('project-detail', args=[100]), + API_ENDPOINT + f'{self.project.project_id}5615491/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -124,6 +123,6 @@ def test_update_project_invalid_project(self): def test_delete_project_invalid_project(self): response = self.client.delete( - reverse('project-detail', args=[100]) + API_ENDPOINT + f'{self.project.project_id}651689/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 209ecc053c4abd8cedc55c6075f202733d72bd56 Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 22:51:22 +0100 Subject: [PATCH 104/138] group permissions should work? --- backend/pigeonhole/apps/groups/permission.py | 27 ++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 57515f24..d24c6ade 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -1,18 +1,35 @@ -from rest_framework import permissions +from requests import Response +from rest_framework import permissions, status + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project class CanAccessGroup(permissions.BasePermission): # Custom user class to check if the user can join a group. def has_permission(self, request, view): + if view.action in ['create', 'list']: + return False user = request.user - course_id = view.kwargs.get('course_id') + group_id = int(view.kwargs.get('pk')) + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + project_id = Group.objects.get(group_id=group_id).project_id + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: return True elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'get_submissions'] + return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', - 'leave', 'get_submissions'] + return view.action in ['retrieve', 'join', 'leave'] return False From 22b392f3b2d397a7636a1887341842764623bc11 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 22:55:38 +0100 Subject: [PATCH 105/138] fix submissions --- backend/pigeonhole/apps/submissions/views.py | 22 -------------------- 1 file changed, 22 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 635ce14e..19d5d67a 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -14,25 +14,3 @@ class SubmissionsViewset(viewsets.ModelViewSet): queryset = Submissions.objects.all() serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] - - @action(detail=False, methods=['POST']) - def submit(self, request, *args, **kwargs): - submission = self.get_submission() - serializer = SubmissionsSerializer(submission, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['GET']) - def get_submission(self, request, *args, **kwargs): - submission = self.get_object() - return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) - - @action(detail=False, methods=['GET']) - def get_all_submissions(self, request, *args, **kwargs): - user = request.user - groups = Group.objects.filter(users=user) - submissions = Submissions.objects.filter(group_id__in=groups) - if not submissions: - return Response({"message": "No submissions found"}, status=status.HTTP_404_NOT_FOUND) - return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) From d73ed72c3a37d29d1a44858469eddebbe12b0491 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Wed, 13 Mar 2024 23:01:29 +0100 Subject: [PATCH 106/138] Revert "Merge branch 'api-working' of https://github.com/SELab-2/UGent-1 into api-working" This reverts commit 325037e102312c76505e127df3c559db407c0ae6, reversing changes made to 22b392f3b2d397a7636a1887341842764623bc11. --- backend/pigeonhole/apps/groups/permission.py | 27 ++++---------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index d24c6ade..57515f24 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -1,35 +1,18 @@ -from requests import Response -from rest_framework import permissions, status - -from backend.pigeonhole.apps.groups.models import Group -from backend.pigeonhole.apps.projects.models import Project +from rest_framework import permissions class CanAccessGroup(permissions.BasePermission): # Custom user class to check if the user can join a group. def has_permission(self, request, view): - if view.action in ['create', 'list']: - return False user = request.user - group_id = int(view.kwargs.get('pk')) - if not Group.objects.filter(group_id=group_id).exists(): - if user.is_admin or user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) - return False - - project_id = Group.objects.get(group_id=group_id).project_id - if not Project.objects.filter(project_id=project_id).exists(): - if user.is_admin or user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) - return False - - course_id = Project.objects.get(project_id=project_id).course_id.course_id + course_id = view.kwargs.get('course_id') if user.is_admin or user.is_superuser: return True elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] + return view.action in ['retrieve', 'get_submissions'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', 'leave'] + return view.action in ['retrieve', 'join', + 'leave', 'get_submissions'] return False From d4eff5464dac5002426ad278bdb6d327e96da901 Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 23:05:42 +0100 Subject: [PATCH 107/138] project tests done --- .../test_views/test_project/test_student.py | 44 ++++++++++--------- .../test_views/test_project/test_teacher.py | 27 +++++------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 7f68e5b1..bcb59158 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -1,12 +1,13 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -from django.urls import reverse from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User +API_ENDPOINT = '/projects/' + class ProjectTestStudent(TestCase): def setUp(self): @@ -35,11 +36,16 @@ def setUp(self): course_id=self.course ) + self.project_not_of_student = Project.objects.create( + name="Test Project", + course_id=self.course_not_of_student + ) + self.client.force_authenticate(self.student) def test_create_project(self): response = self.client.post( - reverse('project-list'), + API_ENDPOINT, { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -48,25 +54,25 @@ def test_create_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Project.objects.count(), 2) def test_retrieve_project(self): response = self.client.get( - reverse('project-detail', args=[self.project.project_id]) + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('name'), self.project.name) def test_list_projects(self): response = self.client.get( - reverse('project-list') + API_ENDPOINT ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(len(response.data), 1) def test_update_project(self): response = self.client.patch( - reverse('project-detail', args=[self.project.project_id]), + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -79,14 +85,14 @@ def test_update_project(self): def test_delete_project(self): response = self.client.delete( - reverse('project-detail', args=[self.project.project_id]) + API_ENDPOINT + f'{self.project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Project.objects.count(), 2) def test_partial_update_project(self): response = self.client.patch( - reverse('project-detail', args=[self.project.project_id]), + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project" }, @@ -99,7 +105,7 @@ def test_partial_update_project(self): def test_create_project_course_not_of_student(self): response = self.client.post( - reverse('project-list'), + API_ENDPOINT, { "name": "Test Project 2", "description": "Test Project 2 Description", @@ -108,23 +114,23 @@ def test_create_project_course_not_of_student(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Project.objects.count(), 2) def test_retrieve_project_course_not_of_student(self): response = self.client.get( - reverse('project-detail', args=[self.project.project_id]) + API_ENDPOINT + f'{self.project_not_of_student.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_list_projects_course_not_of_student(self): response = self.client.get( - reverse('project-list') + API_ENDPOINT ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_project_course_not_of_student(self): response = self.client.patch( - reverse('project-detail', args=[self.project.project_id]), + API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -135,17 +141,15 @@ def test_update_project_course_not_of_student(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - # test with invalid project - def test_retrieve_invalid_project(self): response = self.client.get( - reverse('project-detail', args=[100]) + API_ENDPOINT + f'{self.project.project_id}6165498/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_invalid_project(self): response = self.client.patch( - reverse('project-detail', args=[100]), + API_ENDPOINT + f'{self.project.project_id}6841684/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -157,6 +161,6 @@ def test_update_invalid_project(self): def test_delete_invalid_project(self): response = self.client.delete( - reverse('project-detail', args=[100]) + API_ENDPOINT + f'{self.project.project_id}681854/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 166bd0a4..fd729ea8 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -9,15 +9,15 @@ API_ENDPOINT = '/projects/' -class ProjectTestTeacher(TestCase): +class ProjectTestStudent(TestCase): def setUp(self): self.client = APIClient() - self.teacher = User.objects.create( + self.student = User.objects.create( username="teacher_username", email="test@gmail.com", first_name="Kermit", last_name="The Frog", - role=2 + role=3 ) self.course = Course.objects.create( @@ -29,14 +29,14 @@ def setUp(self): name="Test Course 2", ) - self.teacher.course.set([self.course]) + self.student.course.set([self.course]) self.project = Project.objects.create( name="Test Project", course_id=self.course ) - self.client.force_authenticate(self.teacher) + self.client.force_authenticate(self.student) def test_create_project(self): response = self.client.post( @@ -49,12 +49,7 @@ def test_create_project(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - # Updated assertion to use the correct project_id from the response - created_project_id = response.data.get('project_id') - self.assertEqual(Project.objects.get(project_id=created_project_id).name, "Test Project 2") - self.assertEqual(Project.objects.count(), 2) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_retrieve_project(self): response = self.client.get( @@ -80,15 +75,14 @@ def test_update_project(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_delete_project(self): response = self.client.delete( API_ENDPOINT + f'{self.project.project_id}/' ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Project.objects.count(), 0) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) def test_partial_update_project(self): response = self.client.patch( @@ -98,8 +92,7 @@ def test_partial_update_project(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # test with invalid project From ff474526273925d07b8410fb2a45eab38713cc27 Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 23:09:03 +0100 Subject: [PATCH 108/138] fixed imports --- backend/pigeonhole/apps/groups/permission.py | 27 +++++++++++--- backend/pigeonhole/apps/groups/views.py | 4 --- .../apps/submissions/permissions.py | 35 +++++++++++++------ backend/pigeonhole/apps/submissions/views.py | 2 -- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 57515f24..5683a5c1 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -1,18 +1,35 @@ -from rest_framework import permissions +from rest_framework.response import Response +from rest_framework import permissions, status + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project class CanAccessGroup(permissions.BasePermission): # Custom user class to check if the user can join a group. def has_permission(self, request, view): + if view.action in ['create', 'list']: + return False user = request.user - course_id = view.kwargs.get('course_id') + group_id = int(view.kwargs.get('pk')) + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + project_id = Group.objects.get(group_id=group_id).project_id + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: return True elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'get_submissions'] + return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', - 'leave', 'get_submissions'] + return view.action in ['retrieve', 'join', 'leave'] return False diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index a7019d95..0163977d 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -3,13 +3,9 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from .permission import CanAccessGroup -from django.shortcuts import get_object_or_404 - class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 9f3b4b8c..4db15b00 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -1,23 +1,36 @@ -from rest_framework import permissions +from rest_framework import permissions, status +from rest_framework.response import Response + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project class CanAccessSubmission(permissions.BasePermission): # Custom permission class to determine if the currect user has access # to the submission data. def has_permission(self, request, view): + if view.action in ['create', 'list']: + return False user = request.user - group_id = view.kwargs.get('group_id') - project_id = view.kwargs.get("project_id") - course_id = view.kwargs.get("course_id") + group_id = int(view.kwargs.get('pk')) + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + project_id = Group.objects.get(group_id=group_id).project_id + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: return True elif user.is_teacher: - course = Course.objects.get(course_id=course_id) - project = Project.objects.get(project_id=project_id) - if course.teachers.filter(user=user).exists() and project.course_id == course: - return True + if user.course.filter(course_id=course_id).exists(): + return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] elif user.is_student: - group = Group.objects.get(group_id=group_id) - if group.users.filter(user=user).exists(): - return view.action in ['list', 'retrieve', 'create'] + if user.course.filter(course_id=course_id).exists(): + return view.action in ['retrieve', 'join', 'leave'] return False diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 19d5d67a..3ca7c2f9 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,8 +1,6 @@ from rest_framework import viewsets -from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission From 7a91ed0676133c026a574b9638d2f84e3dcdc808 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 23:17:28 +0100 Subject: [PATCH 109/138] tests --- .../apps/submissions/permissions.py | 1 + backend/pigeonhole/apps/submissions/views.py | 13 - .../test_views/test_submission/test_admin.py | 274 +++++++++--------- 3 files changed, 136 insertions(+), 152 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 9f3b4b8c..92d60a08 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -21,3 +21,4 @@ def has_permission(self, request, view): if group.users.filter(user=user).exists(): return view.action in ['list', 'retrieve', 'create'] return False + diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 635ce14e..a4827df1 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -15,19 +15,6 @@ class SubmissionsViewset(viewsets.ModelViewSet): serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] - @action(detail=False, methods=['POST']) - def submit(self, request, *args, **kwargs): - submission = self.get_submission() - serializer = SubmissionsSerializer(submission, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['GET']) - def get_submission(self, request, *args, **kwargs): - submission = self.get_object() - return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) - @action(detail=False, methods=['GET']) def get_all_submissions(self, request, *args, **kwargs): user = request.user diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index ed74863c..a55fc863 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -41,7 +41,7 @@ def setUp(self): project_id=self.project ) - self.group.user.set(self.admin) + self.group.user.set([self.admin]) self.submission = Submissions.objects.create( group_id=self.group, @@ -53,11 +53,10 @@ def setUp(self): def test_submit_submission(self): test_file = SimpleUploadedFile("test_file.txt", b"file_content") response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/' - f'{self.project.project_id}/groups/' - f'{self.group.group_id}/submissions/', + API_ENDPOINT + f'submissions', { "file": test_file, + "group_id": self.group.group_id }, format='json' ) @@ -66,142 +65,139 @@ def test_submit_submission(self): def test_retrieve_submission(self): response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - f'{self.project.project_id}/groups/' - f'{self.group.group_id}/submissions/' - f'{self.submission.submission_id}' + API_ENDPOINT + f'submissions/{self.submission.submission_id}' ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('file'), SimpleUploadedFile("test_file.txt", b"file_content")) - def test_list_projects(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - def test_delete_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Project.objects.count(), 0) - - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - - # tests with an invalid course - - def test_create_project_invalid_course(self): - response = self.client.post( - API_ENDPOINT + '100/projects/', - { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - def test_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": 100 - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - def test_delete_project_invalid_course(self): - response = self.client.delete( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.count(), 1) - - def test_partial_update_project_invalid_course(self): - response = self.client.patch( - API_ENDPOINT + f'100/projects/{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - - def test_retrieve_project_invalid_course(self): - response = self.client.get( - API_ENDPOINT + f'100/projects/{self.project.project_id}/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_list_projects_invalid_course(self): - response = self.client.get( - API_ENDPOINT + '100/projects/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - # test with invalid project - - def test_retrieve_invalid_project(self): - response = self.client.get( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_update_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project", - "description": "Updated Test Project Description", - "course_id": self.course.course_id - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_partial_update_invalid_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.course.course_id}/projects/100/', - { - "name": "Updated Test Project" - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_invalid_project(self): - response = self.client.delete( - API_ENDPOINT + f'{self.course.course_id}/projects/100/' - ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # def test_list_projects(self): + # response = self.client.get( + # API_ENDPOINT + f'{self.course.course_id}/projects/' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(len(response.data), 1) + # + # def test_update_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + # { + # "name": "Updated Test Project", + # "description": "Updated Test Project Description", + # "course_id": self.course.course_id + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + # + # def test_delete_project(self): + # response = self.client.delete( + # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' + # ) + # self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + # self.assertEqual(Project.objects.count(), 0) + # + # def test_partial_update_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', + # { + # "name": "Updated Test Project" + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + # + # # tests with an invalid course + # + # def test_create_project_invalid_course(self): + # response = self.client.post( + # API_ENDPOINT + '100/projects/', + # { + # "name": "Test Project 2", + # "description": "Test Project 2 Description", + # "course_id": 100 + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # self.assertEqual(Project.objects.count(), 1) + # + # def test_update_project_invalid_course(self): + # response = self.client.patch( + # API_ENDPOINT + f'100/projects/{self.project.project_id}/', + # { + # "name": "Updated Test Project", + # "description": "Updated Test Project Description", + # "course_id": 100 + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + # + # def test_delete_project_invalid_course(self): + # response = self.client.delete( + # API_ENDPOINT + f'100/projects/{self.project.project_id}/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # self.assertEqual(Project.objects.count(), 1) + # + # def test_partial_update_project_invalid_course(self): + # response = self.client.patch( + # API_ENDPOINT + f'100/projects/{self.project.project_id}/', + # { + # "name": "Updated Test Project" + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + # + # def test_retrieve_project_invalid_course(self): + # response = self.client.get( + # API_ENDPOINT + f'100/projects/{self.project.project_id}/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_list_projects_invalid_course(self): + # response = self.client.get( + # API_ENDPOINT + '100/projects/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # # test with invalid project + # + # def test_retrieve_invalid_project(self): + # response = self.client.get( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_update_invalid_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/', + # { + # "name": "Updated Test Project", + # "description": "Updated Test Project Description", + # "course_id": self.course.course_id + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_partial_update_invalid_project(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/', + # { + # "name": "Updated Test Project" + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # + # def test_delete_invalid_project(self): + # response = self.client.delete( + # API_ENDPOINT + f'{self.course.course_id}/projects/100/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From 9beebc88c03e6778a5b6a574e8a62f0735a0fc55 Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 23:28:48 +0100 Subject: [PATCH 110/138] submission permissions --- .../apps/submissions/permissions.py | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 4db15b00..eeb16958 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -2,35 +2,40 @@ from rest_framework.response import Response from backend.pigeonhole.apps.groups.models import Group -from backend.pigeonhole.apps.projects.models import Project class CanAccessSubmission(permissions.BasePermission): # Custom permission class to determine if the currect user has access # to the submission data. def has_permission(self, request, view): - if view.action in ['create', 'list']: - return False user = request.user - group_id = int(view.kwargs.get('pk')) - if not Group.objects.filter(group_id=group_id).exists(): - if user.is_admin or user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) + if view.action in ['list']: return False - - project_id = Group.objects.get(group_id=group_id).project_id - if not Project.objects.filter(project_id=project_id).exists(): + elif view.action in ['create']: + if user.is_student: + group_id = request.data.get('group_id') + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + group = Group.objects.get(group_id=group_id) + if user in group.user: + return True + else: + return False + else: + group_id = int(view.kwargs.get('pk')) + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + group = Group.objects.get(group_id=group_id) if user.is_admin or user.is_superuser: - return Response(status=status.HTTP_404_NOT_FOUND) + return True + elif user.is_teacher: + if user in group.user: + return view.action in ['retrieve'] + elif user.is_student: + if user in group.user: + return view.action in ['retrieve', 'create'] return False - - course_id = Project.objects.get(project_id=project_id).course_id.course_id - if user.is_admin or user.is_superuser: - return True - elif user.is_teacher: - if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] - elif user.is_student: - if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', 'leave'] - return False From 02f091fdb750000977b26a9eb7bfdf0256d7884e Mon Sep 17 00:00:00 2001 From: Reinhard Date: Wed, 13 Mar 2024 23:29:46 +0100 Subject: [PATCH 111/138] group admin test (unfinished) --- .../tests/test_views/test_group/test_admin.py | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index d9ae41a3..3e5b3ae5 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -4,10 +4,10 @@ from rest_framework.test import APIClient from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/groups/' - class GroupTestAdminTeacher(TestCase): @@ -27,29 +27,63 @@ def setUp(self): description="Test Course Description", ) + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + name="Test Group", + project_id=self.project + ) + self.admin.course.set([self.course]) self.client.force_authenticate(self.admin) def test_admin_create_group(self): response = self.client.post( - API_ENDPOINT + f'{self.course.course_id}/projects/', + f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', { - "name": "Test Project 2", - "description": "Test Project 2 Description", - "course_id": self.course.course_id, + "name": "Test Group 2", + "description": "Test Group 2 Description", + "project_id": self.project.project_id, "number_of_groups": 4, }, format='json' ) - # self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # self.assertEqual(Group.objects.count(), 4) - # new_group = Group.objects.get(name="Test Project 2") - # self.assertEqual(new_project.name, "Test Project 2") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_admin_retrieve_group(self): + response = self.client.get( + f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.group.name) - def test_retreive_group(self): + def test_admin_list_groups(self): response = self.client.get( - # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/groups/{}/' + f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) + self.assertEqual(len(response.data), 1) + + def test_admin_update_group(self): + response = self.client.put( + f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', + { + "name": "Test Group 3", + "description": "Test Group 2 Description", + "project_id": self.project.project_id, + "number_of_groups": 4, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_admin_delete_group(self): + response = self.client.delete( + f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Group.objects.count(), 0) From 74410dfd624f26ed46c72ef39d8abe5daee95061 Mon Sep 17 00:00:00 2001 From: avoyen Date: Wed, 13 Mar 2024 23:34:42 +0100 Subject: [PATCH 112/138] submission permissions fix --- backend/pigeonhole/apps/submissions/permissions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index eeb16958..31cdc85a 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -19,7 +19,7 @@ def has_permission(self, request, view): return Response(status=status.HTTP_404_NOT_FOUND) return False group = Group.objects.get(group_id=group_id) - if user in group.user: + if group.user.filter(id=user.id).exists(): return True else: return False @@ -33,9 +33,9 @@ def has_permission(self, request, view): if user.is_admin or user.is_superuser: return True elif user.is_teacher: - if user in group.user: + if group.user.filter(id=user.id).exists(): return view.action in ['retrieve'] elif user.is_student: - if user in group.user: + if group.user.filter(id=user.id).exists(): return view.action in ['retrieve', 'create'] return False From 2e1a581bc15e0a72615f97c5da9db4b6d4c20bda Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 23:55:00 +0100 Subject: [PATCH 113/138] Fix permissions to view group by id --- backend/pigeonhole/apps/groups/permission.py | 3 +-- backend/pigeonhole/apps/submissions/views.py | 25 ++++++++++++++++++- .../test_views/test_submission/test_admin.py | 7 +++--- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 5683a5c1..5be1241a 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -17,12 +17,11 @@ def has_permission(self, request, view): return Response(status=status.HTTP_404_NOT_FOUND) return False - project_id = Group.objects.get(group_id=group_id).project_id + project_id = Group.objects.get(group_id=group_id).project_id.project_id if not Project.objects.filter(project_id=project_id).exists(): if user.is_admin or user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) return False - course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: return True diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 3ca7c2f9..886e87fe 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,9 +1,10 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission - +from backend.pigeonhole.apps.groups.models import Group # TODO test timestamp, file, output_test @@ -12,3 +13,25 @@ class SubmissionsViewset(viewsets.ModelViewSet): queryset = Submissions.objects.all() serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] + + @action(detail=False, methods=['POST']) + def submit(self, request, *args, **kwargs): + submission = self.get_submission() + serializer = SubmissionsSerializer(submission, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['GET']) + def get_submission(self, request, *args, **kwargs): + submission = self.get_object() + return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) + + @action(detail=False, methods=['GET']) + def get_all_submissions(self, request, *args, **kwargs): + user = request.user + groups = Group.objects.filter(users=user) + submissions = Submissions.objects.filter(group_id__in=groups) + if not submissions: + return Response({"message": "No submissions found"}, status=status.HTTP_404_NOT_FOUND) + return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index a55fc863..f1a6da94 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -17,8 +17,8 @@ def setUp(self): self.client = APIClient() self.admin = User.objects.create( - username="admin_username", - email="test@gmail.com", + username="admin_username1", + email="test1@gmail.com", first_name="Kermit", last_name="The Frog", role=1 @@ -53,7 +53,7 @@ def setUp(self): def test_submit_submission(self): test_file = SimpleUploadedFile("test_file.txt", b"file_content") response = self.client.post( - API_ENDPOINT + f'submissions', + API_ENDPOINT + f'submissions/', { "file": test_file, "group_id": self.group.group_id @@ -62,6 +62,7 @@ def test_submit_submission(self): ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Submissions.objects.count(), 2) + self.assertEqual(1, 2) def test_retrieve_submission(self): response = self.client.get( From 72915b6f2d997db1b5f3016a78f83fe6fd64eb02 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Wed, 13 Mar 2024 23:59:18 +0100 Subject: [PATCH 114/138] get_submissions in group --- backend/pigeonhole/apps/groups/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 0163977d..02d64adb 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from backend.pigeonhole.apps.groups.models import Group, GroupSerializer +from backend.pigeonhole.apps.submissions.models import Submissions from .permission import CanAccessGroup @@ -36,5 +37,5 @@ def leave(self, request, pk=None): @action(detail=True, methods=['get']) def get_submissions(self, request, pk=None): group = self.get_object() - submissions = group.submission_set.all() + submissions = Submissions.objects.filter(group_id=group).all() return Response({'submissions': submissions}, status=status.HTTP_200_OK) From f9bb205ab5518f8b47799c3181b97b06b9732bf9 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 00:20:03 +0100 Subject: [PATCH 115/138] test project fix and extra --- .../pigeonhole/apps/projects/permissions.py | 2 + .../test_views/test_project/test_student.py | 87 ++++++++++++++----- .../test_views/test_project/test_teacher.py | 81 ++++++++++++----- 3 files changed, 128 insertions(+), 42 deletions(-) diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py index 6549030c..e125789f 100644 --- a/backend/pigeonhole/apps/projects/permissions.py +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -35,6 +35,8 @@ def has_permission(self, request, view): if user.course.filter(course_id=course_id).exists(): return True elif user.is_student: + if not Project.objects.get(project_id=project_id).visible: + return False if user.course.filter(course_id=course_id).exists(): return view.action in ['retrieve', 'get_my_groups', 'get_groups'] return False diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index bcb59158..5387b703 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -31,10 +31,16 @@ def setUp(self): self.student.course.set([self.course]) - self.project = Project.objects.create( + self.invisible_project = Project.objects.create( name="Test Project", course_id=self.course ) + + self.visible_project = Project.objects.create( + name="Test Project 2", + course_id=self.course, + visible=True + ) self.project_not_of_student = Project.objects.create( name="Test Project", @@ -54,15 +60,21 @@ def test_create_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 2) + self.assertEqual(Project.objects.count(), 3) - def test_retrieve_project(self): + def test_retrieve_visible_project(self): response = self.client.get( - API_ENDPOINT + f'{self.project.project_id}/' + API_ENDPOINT + f'{self.visible_project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.project.name) - + self.assertEqual(response.data['name'], "Test Project 2") + + def test_retrieve_invisible_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_list_projects(self): response = self.client.get( API_ENDPOINT @@ -70,9 +82,22 @@ def test_list_projects(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(len(response.data), 1) - def test_update_project(self): + def test_update_visible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.visible_project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") + + def test_update_invisible_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.project.project_id}/', + API_ENDPOINT + f'{self.invisible_project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -81,25 +106,43 @@ def test_update_project(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") - def test_delete_project(self): + def test_delete_visible_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.project.project_id}/' + API_ENDPOINT + f'{self.visible_project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 2) - - def test_partial_update_project(self): + self.assertEqual(Project.objects.count(), 3) + + def test_delete_invisible_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 3) + + def test_partial_update_visible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.visible_project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") + + def test_partial_update_invisible_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.project.project_id}/', + API_ENDPOINT + f'{self.invisible_project.project_id}/', { "name": "Updated Test Project" }, format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") # tests with a course not of the student @@ -114,7 +157,7 @@ def test_create_project_course_not_of_student(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 2) + self.assertEqual(Project.objects.count(), 3) def test_retrieve_project_course_not_of_student(self): response = self.client.get( @@ -130,7 +173,7 @@ def test_list_projects_course_not_of_student(self): def test_update_project_course_not_of_student(self): response = self.client.patch( - API_ENDPOINT + f'{self.project.project_id}/', + API_ENDPOINT + f'{self.invisible_project.project_id}/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -139,17 +182,17 @@ def test_update_project_course_not_of_student(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") def test_retrieve_invalid_project(self): response = self.client.get( - API_ENDPOINT + f'{self.project.project_id}6165498/' + API_ENDPOINT + f'{self.invisible_project.project_id}6165498/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_update_invalid_project(self): response = self.client.patch( - API_ENDPOINT + f'{self.project.project_id}6841684/', + API_ENDPOINT + f'{self.invisible_project.project_id}6841684/', { "name": "Updated Test Project", "description": "Updated Test Project Description", @@ -161,6 +204,6 @@ def test_update_invalid_project(self): def test_delete_invalid_project(self): response = self.client.delete( - API_ENDPOINT + f'{self.project.project_id}681854/' + API_ENDPOINT + f'{self.invisible_project.project_id}681854/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index fd729ea8..b3f4ecc5 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -12,12 +12,12 @@ class ProjectTestStudent(TestCase): def setUp(self): self.client = APIClient() - self.student = User.objects.create( + self.teacher = User.objects.create( username="teacher_username", email="test@gmail.com", first_name="Kermit", last_name="The Frog", - role=3 + role=2 ) self.course = Course.objects.create( @@ -29,14 +29,19 @@ def setUp(self): name="Test Course 2", ) - self.student.course.set([self.course]) + self.teacher.course.set([self.course]) self.project = Project.objects.create( name="Test Project", course_id=self.course ) + + self.project_not_of_teacher = Project.objects.create( + name="Test Project", + course_id=self.course_not_of_teacher + ) - self.client.force_authenticate(self.student) + self.client.force_authenticate(self.teacher) def test_create_project(self): response = self.client.post( @@ -44,12 +49,12 @@ def test_create_project(self): { "name": "Test Project 2", "description": "Test Project 2 Description", - "course_id": self.course.course_id, - "number_of_groups": 4, + "course_id": self.course.course_id }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 3) def test_retrieve_project(self): response = self.client.get( @@ -66,7 +71,7 @@ def test_list_projects(self): self.assertEqual(len(response.data), 1) def test_update_project(self): - response = self.client.patch( + response = self.client.put( API_ENDPOINT + f'{self.project.project_id}/', { "name": "Updated Test Project", @@ -75,25 +80,25 @@ def test_update_project(self): }, format='json' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_delete_project(self): response = self.client.delete( API_ENDPOINT + f'{self.project.project_id}/' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Project.objects.count(), 1) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - def test_partial_update_project(self): - response = self.client.patch( - API_ENDPOINT + f'{self.project.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' + def test_retrieve_invisible_project(self): + invisible_project = Project.objects.create( + name="Test Project", + course_id=self.course, + visible=False ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + response = self.client.get( + API_ENDPOINT + f'{invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # test with invalid project def test_retrieve_invalid_project(self): @@ -119,3 +124,39 @@ def test_delete_project_invalid_project(self): API_ENDPOINT + f'{self.project.project_id}651689/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with project not of teacher + + def test_retrieve_project_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_not_of_teacher(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_not_of_teacher(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_not_of_teacher(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) \ No newline at end of file From 9918d5bcaf429ac88f300d35b746d48602158473 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Thu, 14 Mar 2024 00:44:11 +0100 Subject: [PATCH 116/138] fix submission view imports --- backend/pigeonhole/apps/submissions/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 886e87fe..77382a7f 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,10 +1,12 @@ -from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated +from rest_framework import viewsets, status from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission -from backend.pigeonhole.apps.groups.models import Group + # TODO test timestamp, file, output_test From fe52c7bac6991936e15ced629eeea7c0bd5de0ac Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 01:04:52 +0100 Subject: [PATCH 117/138] test project added get_groups --- backend/pigeonhole/apps/submissions/views.py | 16 +---------- .../test_views/test_course/test_admin.py | 8 ++++++ .../test_views/test_project/test_admin.py | 8 ++++++ .../test_views/test_project/test_student.py | 25 +++++++++++------ .../test_views/test_project/test_teacher.py | 27 +++++++++---------- .../test_project/test_unauthenticated.py | 6 +++++ 6 files changed, 53 insertions(+), 37 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 77382a7f..fc5fe0a1 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -22,18 +22,4 @@ def submit(self, request, *args, **kwargs): serializer = SubmissionsSerializer(submission, data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['GET']) - def get_submission(self, request, *args, **kwargs): - submission = self.get_object() - return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) - - @action(detail=False, methods=['GET']) - def get_all_submissions(self, request, *args, **kwargs): - user = request.user - groups = Group.objects.filter(users=user) - submissions = Submissions.objects.filter(group_id__in=groups) - if not submissions: - return Response({"message": "No submissions found"}, status=status.HTTP_404_NOT_FOUND) - return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index 0a55e965..9b868595 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -101,3 +101,11 @@ def test_update_course_not_exist(self): def test_delete_course_not_exist(self): response = self.client.delete(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_projects_of_course(self): + response = self.client.get( + API_ENDPOINT + f'{self.course.course_id}/get_projects/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 605c1902..1aa3a611 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -134,3 +134,11 @@ def test_delete_invalid_project(self): API_ENDPOINT + '100/' ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 5387b703..74894246 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -1,6 +1,7 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +import json from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project @@ -35,7 +36,7 @@ def setUp(self): name="Test Project", course_id=self.course ) - + self.visible_project = Project.objects.create( name="Test Project 2", course_id=self.course, @@ -68,13 +69,13 @@ def test_retrieve_visible_project(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['name'], "Test Project 2") - + def test_retrieve_invisible_project(self): response = self.client.get( API_ENDPOINT + f'{self.invisible_project.project_id}/' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_list_projects(self): response = self.client.get( API_ENDPOINT @@ -94,7 +95,7 @@ def test_update_visible_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") - + def test_update_invisible_project(self): response = self.client.patch( API_ENDPOINT + f'{self.invisible_project.project_id}/', @@ -114,14 +115,14 @@ def test_delete_visible_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 3) - + def test_delete_invisible_project(self): response = self.client.delete( API_ENDPOINT + f'{self.invisible_project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.count(), 3) - + def test_partial_update_visible_project(self): response = self.client.patch( API_ENDPOINT + f'{self.visible_project.project_id}/', @@ -132,7 +133,7 @@ def test_partial_update_visible_project(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") - + def test_partial_update_invisible_project(self): response = self.client.patch( API_ENDPOINT + f'{self.invisible_project.project_id}/', @@ -207,3 +208,11 @@ def test_delete_invalid_project(self): API_ENDPOINT + f'{self.invisible_project.project_id}681854/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index b3f4ecc5..cd3a54e2 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -1,6 +1,7 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +import json from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project @@ -35,7 +36,7 @@ def setUp(self): name="Test Project", course_id=self.course ) - + self.project_not_of_teacher = Project.objects.create( name="Test Project", course_id=self.course_not_of_teacher @@ -98,7 +99,7 @@ def test_retrieve_invisible_project(self): API_ENDPOINT + f'{invisible_project.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - + # test with invalid project def test_retrieve_invalid_project(self): @@ -126,13 +127,13 @@ def test_delete_project_invalid_project(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # test with project not of teacher - + def test_retrieve_project_not_of_teacher(self): response = self.client.get( API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_update_project_not_of_teacher(self): response = self.client.patch( API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/', @@ -144,19 +145,17 @@ def test_update_project_not_of_teacher(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - + def test_delete_project_not_of_teacher(self): response = self.client.delete( API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_partial_update_project_not_of_teacher(self): - response = self.client.patch( - API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/', - { - "name": "Updated Test Project" - }, - format='json' + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) \ No newline at end of file + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py index b263fd21..ebcc1728 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -120,3 +120,9 @@ def test_partial_update_invalid_project_unauthenticated(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_groups_of_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 915fac2a60f56e584e7b1820b6208dc79a5529bb Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 01:25:25 +0100 Subject: [PATCH 118/138] test_admin for submissions --- backend/pigeonhole/apps/submissions/models.py | 6 +- .../apps/submissions/permissions.py | 4 + backend/pigeonhole/apps/submissions/views.py | 29 +-- .../tests/test_views/test_group/test_admin.py | 178 ++++++++--------- .../test_views/test_submission/test_admin.py | 189 +++++------------- 5 files changed, 148 insertions(+), 258 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 6762f8a8..03e5c6e2 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,4 +1,4 @@ -import re +import os from django.db import models from rest_framework import serializers @@ -7,8 +7,8 @@ def get_upload_to(self, filename): - match = re.search(r'\.(\w+)$', filename) - return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input.' + match.group(1) + return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input' + \ + os.path.splitext(filename)[1] def get_upload_to_test(self, filename): diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 31cdc85a..3ebce179 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -23,6 +23,10 @@ def has_permission(self, request, view): return True else: return False + elif user.is_admin or user.is_superuser: + return True + else: + return False else: group_id = int(view.kwargs.get('pk')) if not Group.objects.filter(group_id=group_id).exists(): diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 886e87fe..ce547068 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,10 +1,11 @@ +from rest_framework import status from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import action +from rest_framework.response import Response from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission -from backend.pigeonhole.apps.groups.models import Group + # TODO test timestamp, file, output_test @@ -14,24 +15,8 @@ class SubmissionsViewset(viewsets.ModelViewSet): serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] - @action(detail=False, methods=['POST']) - def submit(self, request, *args, **kwargs): - submission = self.get_submission() - serializer = SubmissionsSerializer(submission, data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['GET']) - def get_submission(self, request, *args, **kwargs): - submission = self.get_object() - return Response(SubmissionsSerializer(submission).data, status=status.HTTP_200_OK) + def update(self, request, *args, **kwargs): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) - @action(detail=False, methods=['GET']) - def get_all_submissions(self, request, *args, **kwargs): - user = request.user - groups = Group.objects.filter(users=user) - submissions = Submissions.objects.filter(group_id__in=groups) - if not submissions: - return Response({"message": "No submissions found"}, status=status.HTTP_404_NOT_FOUND) - return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) + def destroy(self, request, *args, **kwargs): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index 3e5b3ae5..5d88b162 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -1,89 +1,89 @@ -from unittest import TestCase - -from rest_framework import status -from rest_framework.test import APIClient - -from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.groups.models import Group -from backend.pigeonhole.apps.projects.models import Project -from backend.pigeonhole.apps.users.models import User - - -class GroupTestAdminTeacher(TestCase): - - def setUp(self): - self.client = APIClient() - - self.admin = User.objects.create( - username="admin_username", - email="test@gmail.com", - first_name="Test1", - last_name="Test2", - role=1 - ) - - self.course = Course.objects.create( - name="Test Course", - description="Test Course Description", - ) - - self.project = Project.objects.create( - name="Test Project", - course_id=self.course - ) - - self.group = Group.objects.create( - name="Test Group", - project_id=self.project - ) - - self.admin.course.set([self.course]) - - self.client.force_authenticate(self.admin) - - def test_admin_create_group(self): - response = self.client.post( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', - { - "name": "Test Group 2", - "description": "Test Group 2 Description", - "project_id": self.project.project_id, - "number_of_groups": 4, - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_admin_retrieve_group(self): - response = self.client.get( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.group.name) - - def test_admin_list_groups(self): - response = self.client.get( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_admin_update_group(self): - response = self.client.put( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', - { - "name": "Test Group 3", - "description": "Test Group 2 Description", - "project_id": self.project.project_id, - "number_of_groups": 4, - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_admin_delete_group(self): - response = self.client.delete( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Group.objects.count(), 0) +# from unittest import TestCase +# +# from rest_framework import status +# from rest_framework.test import APIClient +# +# from backend.pigeonhole.apps.courses.models import Course +# from backend.pigeonhole.apps.groups.models import Group +# from backend.pigeonhole.apps.projects.models import Project +# from backend.pigeonhole.apps.users.models import User +# +# +# class GroupTestAdminTeacher(TestCase): +# +# def setUp(self): +# self.client = APIClient() +# +# # self.admin = User.objects.create( +# # username="admin_username", +# # email="test@gmail.com", +# # first_name="Test1", +# # last_name="Test2", +# # role=1 +# # ) +# +# self.course = Course.objects.create( +# name="Test Course", +# description="Test Course Description", +# ) +# +# self.project = Project.objects.create( +# name="Test Project", +# course_id=self.course +# ) +# +# self.group = Group.objects.create( +# name="Test Group", +# project_id=self.project +# ) +# +# self.admin.course.set([self.course]) +# +# self.client.force_authenticate(self.admin) +# +# def test_admin_create_group(self): +# response = self.client.post( +# f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', +# { +# "name": "Test Group 2", +# "description": "Test Group 2 Description", +# "project_id": self.project.project_id, +# "number_of_groups": 4, +# }, +# format='json' +# ) +# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +# +# def test_admin_retrieve_group(self): +# response = self.client.get( +# f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', +# ) +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# self.assertEqual(response.data.get('name'), self.group.name) +# +# def test_admin_list_groups(self): +# response = self.client.get( +# f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', +# ) +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# self.assertEqual(len(response.data), 1) +# +# def test_admin_update_group(self): +# response = self.client.put( +# f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', +# { +# "name": "Test Group 3", +# "description": "Test Group 2 Description", +# "project_id": self.project.project_id, +# "number_of_groups": 4, +# }, +# format='json' +# ) +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# +# def test_admin_delete_group(self): +# response = self.client.delete( +# f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', +# ) +# self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) +# self.assertEqual(Group.objects.count(), 0) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index f1a6da94..a0a48b32 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -9,7 +9,7 @@ from backend.pigeonhole.apps.submissions.models import Submissions from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/courses/' +API_ENDPOINT = '/submissions/' class SubmissionTestAdmin(TestCase): @@ -50,155 +50,56 @@ def setUp(self): self.client.force_authenticate(self.admin) + def check_setup(self): + self.assertEqual(User.objects.count(), 1) + self.assertEqual(Course.objects.count(), 1) + self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Group.objects.count(), 1) + self.assertEqual(Submissions.objects.count(), 1) + def test_submit_submission(self): test_file = SimpleUploadedFile("test_file.txt", b"file_content") - response = self.client.post( - API_ENDPOINT + f'submissions/', - { - "file": test_file, - "group_id": self.group.group_id - }, - format='json' - ) + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Submissions.objects.count(), 2) - self.assertEqual(1, 2) def test_retrieve_submission(self): response = self.client.get( - API_ENDPOINT + f'submissions/{self.submission.submission_id}' + API_ENDPOINT + str(self.submission.submission_id) + '/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('file'), SimpleUploadedFile("test_file.txt", b"file_content")) - - # def test_list_projects(self): - # response = self.client.get( - # API_ENDPOINT + f'{self.course.course_id}/projects/' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(len(response.data), 1) - # - # def test_update_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - # { - # "name": "Updated Test Project", - # "description": "Updated Test Project Description", - # "course_id": self.course.course_id - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - # - # def test_delete_project(self): - # response = self.client.delete( - # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/' - # ) - # self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - # self.assertEqual(Project.objects.count(), 0) - # - # def test_partial_update_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/{self.project.project_id}/', - # { - # "name": "Updated Test Project" - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - # - # # tests with an invalid course - # - # def test_create_project_invalid_course(self): - # response = self.client.post( - # API_ENDPOINT + '100/projects/', - # { - # "name": "Test Project 2", - # "description": "Test Project 2 Description", - # "course_id": 100 - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # self.assertEqual(Project.objects.count(), 1) - # - # def test_update_project_invalid_course(self): - # response = self.client.patch( - # API_ENDPOINT + f'100/projects/{self.project.project_id}/', - # { - # "name": "Updated Test Project", - # "description": "Updated Test Project Description", - # "course_id": 100 - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - # - # def test_delete_project_invalid_course(self): - # response = self.client.delete( - # API_ENDPOINT + f'100/projects/{self.project.project_id}/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # self.assertEqual(Project.objects.count(), 1) - # - # def test_partial_update_project_invalid_course(self): - # response = self.client.patch( - # API_ENDPOINT + f'100/projects/{self.project.project_id}/', - # { - # "name": "Updated Test Project" - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Test Project") - # - # def test_retrieve_project_invalid_course(self): - # response = self.client.get( - # API_ENDPOINT + f'100/projects/{self.project.project_id}/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_list_projects_invalid_course(self): - # response = self.client.get( - # API_ENDPOINT + '100/projects/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # # test with invalid project - # - # def test_retrieve_invalid_project(self): - # response = self.client.get( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_update_invalid_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/', - # { - # "name": "Updated Test Project", - # "description": "Updated Test Project Description", - # "course_id": self.course.course_id - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_partial_update_invalid_project(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/', - # { - # "name": "Updated Test Project" - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # - # def test_delete_invalid_project(self): - # response = self.client.delete( - # API_ENDPOINT + f'{self.course.course_id}/projects/100/' - # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + # tests with an invalid submission + + def test_create_submission_invalid_group(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": 95955351, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_not_possible(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_delete_submission_not_possible(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) From d8ef075d74ae4fbf8e846449eec10c4f1898f742 Mon Sep 17 00:00:00 2001 From: avoyen Date: Thu, 14 Mar 2024 01:42:44 +0100 Subject: [PATCH 119/138] group tests ??!?!?!? legit unfixable?? pls help me --- .../tests/test_views/test_group/test_admin.py | 124 ++++++++++++------ 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index 3e5b3ae5..e5888a68 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -1,5 +1,4 @@ -from unittest import TestCase - +from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -8,17 +7,18 @@ from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User +API_ENDPOINT = '/groups/' -class GroupTestAdminTeacher(TestCase): +class GroupTestAdminTeacher(TestCase): def setUp(self): self.client = APIClient() self.admin = User.objects.create( username="admin_username", email="test@gmail.com", - first_name="Test1", - last_name="Test2", + first_name="Kermit", + last_name="The Frog", role=1 ) @@ -27,63 +27,107 @@ def setUp(self): description="Test Course Description", ) + self.admin.course.set([self.course]) + self.project = Project.objects.create( name="Test Project", - course_id=self.course + course_id=self.course, + number_of_groups=3, + group_size=2, ) - self.group = Group.objects.create( - name="Test Group", - project_id=self.project + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, ) - self.admin.course.set([self.course]) + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) self.client.force_authenticate(self.admin) def test_admin_create_group(self): response = self.client.post( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', + API_ENDPOINT, { - "name": "Test Group 2", - "description": "Test Group 2 Description", + "name": "Test Group 1", + "description": "Test Group 1 Description", "project_id": self.project.project_id, - "number_of_groups": 4, }, format='json' ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_admin_retrieve_group(self): + def test_retrieve_group(self): response = self.client.get( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', + API_ENDPOINT + f'{self.group1.group_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('name'), self.group.name) - def test_admin_list_groups(self): + # + def test_list_groups(self): response = self.client.get( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/', + API_ENDPOINT ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - - def test_admin_update_group(self): - response = self.client.put( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', - { - "name": "Test Group 3", - "description": "Test Group 2 Description", - "project_id": self.project.project_id, - "number_of_groups": 4, - }, - format='json' - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_admin_delete_group(self): - response = self.client.delete( - f'/courses/{self.course.course_id}/projects/{self.project.project_id}/groups/{self.group.group_id}/', - ) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Group.objects.count(), 0) + # def test_update_group(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.group1.group_id}/', + # { + # 'feedback': 'Updated Feedback' + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(Group.objects.get(group_id=self.group1.group_id), 'Updated Feedback') + # + # def test_delete_group(self): + # count = 5 + # groups = Group.objects.filter(project_id=self.project.project_id) + # for group in groups: + # response = self.client.delete( + # API_ENDPOINT + f'{group.group_id}/' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(Group.objects.count(), count-1) + # + # def test_group_count(self): + # self.assertEqual(Group.objects.count(), 3) + + # def test_partial_update_group(self): + # response = self.client.patch( + # API_ENDPOINT + f'{self.project.project_id}/', + # { + # "name": "Updated Test Project" + # }, + # format='json' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + # + # def test_retrieve_invalid_group(self): + # response = self.client.get( + # API_ENDPOINT + '9999/' + # ) + # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # From 376be884765c2dbd450ab0d815580cd6290774d1 Mon Sep 17 00:00:00 2001 From: avoyen Date: Thu, 14 Mar 2024 02:30:18 +0100 Subject: [PATCH 120/138] a lot of progress to group tests, just need to fix student tests basically. --- backend/pigeonhole/apps/groups/permission.py | 2 +- backend/pigeonhole/apps/groups/views.py | 4 +- .../tests/test_views/test_group/test_admin.py | 102 +++++++----- .../test_views/test_group/test_student.py | 154 ++++++++++++++++++ .../test_views/test_group/test_teacher.py | 153 +++++++++++++++++ 5 files changed, 373 insertions(+), 42 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 5be1241a..3236b6bd 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -24,7 +24,7 @@ def has_permission(self, request, view): return False course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: - return True + return view.action not in ['join', 'leave'] elif user.is_teacher: if user.course.filter(course_id=course_id).exists(): return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 02d64adb..079b3e62 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -6,6 +6,7 @@ from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.submissions.models import Submissions from .permission import CanAccessGroup +from ..projects.models import Project class GroupViewSet(viewsets.ModelViewSet): @@ -17,7 +18,8 @@ class GroupViewSet(viewsets.ModelViewSet): def join(self, request, pk=None): group = self.get_object() user = request.user - if group.user.count() < group.project.max_group_size: + project = Project.objects.get(project_id=group.project_id) + if group.user.count() < project.max_group_size: group.user.add(user) group.save() return Response({'message': 'User joined group'}, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index e5888a68..c511f92f 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -10,7 +10,7 @@ API_ENDPOINT = '/groups/' -class GroupTestAdminTeacher(TestCase): +class GroupTestAdmin(TestCase): def setUp(self): self.client = APIClient() @@ -90,44 +90,66 @@ def test_list_groups(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # def test_update_group(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.group1.group_id}/', - # { - # 'feedback': 'Updated Feedback' - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(Group.objects.get(group_id=self.group1.group_id), 'Updated Feedback') - # - # def test_delete_group(self): - # count = 5 - # groups = Group.objects.filter(project_id=self.project.project_id) - # for group in groups: - # response = self.client.delete( - # API_ENDPOINT + f'{group.group_id}/' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(Group.objects.count(), count-1) - # - # def test_group_count(self): - # self.assertEqual(Group.objects.count(), 3) - - # def test_partial_update_group(self): - # response = self.client.patch( - # API_ENDPOINT + f'{self.project.project_id}/', - # { - # "name": "Updated Test Project" - # }, - # format='json' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") - # - # def test_retrieve_invalid_group(self): + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Group.objects.count(), 2) + + def test_group_count(self): + self.assertEqual(Group.objects.count(), 3) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/join/' + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # TODO get submissions test fixen geeft een 301? + # def test_get_submissions(self): # response = self.client.get( - # API_ENDPOINT + '9999/' + # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' # ) - # self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # + # self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py index e69de29b..d05e22de 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_student.py @@ -0,0 +1,154 @@ +# from django.test import TestCase +# from rest_framework import status +# from rest_framework.test import APIClient +# +# from backend.pigeonhole.apps.courses.models import Course +# from backend.pigeonhole.apps.groups.models import Group +# from backend.pigeonhole.apps.projects.models import Project +# from backend.pigeonhole.apps.users.models import User +# +# API_ENDPOINT = '/groups/' +# +# +# class GroupTestStudent(TestCase): +# def setUp(self): +# self.client = APIClient() +# +# self.student = User.objects.create( +# username="student_username", +# email="test@gmail.com", +# first_name="Kermit", +# last_name="The Frog", +# role=3 +# ) +# +# self.course = Course.objects.create( +# name="Test Course", +# description="Test Course Description", +# ) +# +# self.student.course.set([self.course]) +# +# self.project = Project.objects.create( +# name="Test Project", +# course_id=self.course, +# number_of_groups=3, +# group_size=2, +# ) +# +# self.group1 = Group.objects.create( +# group_id=0, +# group_nr=1, +# final_score=0, +# project_id=self.project, +# feedback="Test Feedback", +# visible=True, +# ) +# self.group2 = Group.objects.create( +# group_id=1, +# group_nr=2, +# final_score=0, +# project_id=self.project, +# feedback="Test Feedback", +# visible=True, +# ) +# self.group2.user.add(self.student) +# +# self.group_not_visible = Group.objects.create( +# group_id=2, +# group_nr=3, +# final_score=0, +# project_id=self.project, +# feedback="Test Feedback", +# visible=False, +# ) +# +# self.client.force_authenticate(self.student) +# +# def test_student_create_group(self): +# response = self.client.post( +# API_ENDPOINT, +# { +# "name": "Test Group 1", +# "description": "Test Group 1 Description", +# "project_id": self.project.project_id, +# }, +# format='json' +# ) +# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +# +# def test_retrieve_group(self): +# response = self.client.get( +# API_ENDPOINT + f'{self.group1.group_id}/' +# ) +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# +# # +# def test_list_groups(self): +# response = self.client.get( +# API_ENDPOINT +# ) +# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +# +# def test_delete_group(self): +# response = self.client.delete( +# API_ENDPOINT + f'{self.group1.group_id}/' +# ) +# self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) +# self.assertEqual(Group.objects.count(), 2) +# +# def test_group_count(self): +# self.assertEqual(Group.objects.count(), 3) +# +# def test_partial_update_group(self): +# response = self.client.patch( +# API_ENDPOINT + f'{self.group1.group_id}/', +# { +# 'feedback': 'Updated Feedback' +# }, +# format='json' +# ) +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') +# +# def test_retrieve_invalid_group(self): +# response = self.client.get( +# API_ENDPOINT + '9999/' +# ) +# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +# +# def test_partial_update_invalid_group(self): +# response = self.client.patch( +# API_ENDPOINT + '999/', +# { +# 'feedback': 'Updated Feedback' +# }, +# format='json' +# ) +# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +# +# def test_delete_invalid_group(self): +# response = self.client.delete( +# API_ENDPOINT + '999/' +# ) +# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +# +# def test_join_group(self): +# response = self.client.post( +# API_ENDPOINT + f'{self.group1.group_id}/join/' +# ) +# +# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +# +# def test_leave_group(self): +# response = self.client.post( +# API_ENDPOINT + f'{self.group1.group_id}/leave/' +# ) +# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) +# +# # TODO get submissions test fixen geeft een 301? +# # def test_get_submissions(self): +# # response = self.client.get( +# # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' +# # ) +# # self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py index e69de29b..97364347 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py @@ -0,0 +1,153 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + self.client.force_authenticate(self.teacher) + + def test_student_create_group(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Group 1", + "description": "Test Group 1 Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # + def test_list_groups(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_group_count(self): + self.assertEqual(Group.objects.count(), 3) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/join/' + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # TODO get submissions test fixen geeft een 301? + # def test_get_submissions(self): + # response = self.client.get( + # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' + # ) + # self.assertEqual(response.status_code, status.HTTP_200_OK) From 0b33a33b68d99422f29650622eee6c3cc0de3109 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 02:41:29 +0100 Subject: [PATCH 121/138] test_teacher for submissions --- .../apps/submissions/permissions.py | 16 +- .../test_views/test_submission/test_admin.py | 51 +++++- .../test_submission/test_teacher.py | 159 ++++++++++++++++++ 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 backend/pigeonhole/tests/test_views/test_submission/test_teacher.py diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 3ebce179..939d7cce 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -2,6 +2,9 @@ from rest_framework.response import Response from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.projects.models import Project class CanAccessSubmission(permissions.BasePermission): @@ -28,17 +31,24 @@ def has_permission(self, request, view): else: return False else: - group_id = int(view.kwargs.get('pk')) + submission = Submissions.objects.get(submission_id=view.kwargs['pk']) + group_id = submission.group_id.group_id if not Group.objects.filter(group_id=group_id).exists(): if user.is_admin or user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) + elif user.is_teacher: + if teacher_courses.filter(course_id=course).exists(): + return True return False group = Group.objects.get(group_id=group_id) if user.is_admin or user.is_superuser: return True elif user.is_teacher: - if group.user.filter(id=user.id).exists(): - return view.action in ['retrieve'] + group = Group.objects.get(group_id=group_id) + project = Project.objects.get(project_id=group.project_id.project_id) + course = Course.objects.get(course_id=project.course_id.course_id) + if user.course.filter(course_id=course.course_id).exists(): + return True elif user.is_student: if group.user.filter(id=user.id).exists(): return view.action in ['retrieve', 'create'] diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index a0a48b32..eb68b1b9 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -41,6 +41,11 @@ def setUp(self): project_id=self.project ) + self.group_not_of_admin = Group.objects.create( + group_nr=2, + project_id=self.project + ) + self.group.user.set([self.admin]) self.submission = Submissions.objects.create( @@ -64,7 +69,18 @@ def test_submit_submission(self): "file": test_file, "group_id": self.group.group_id } - ) + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submissions.objects.count(), 2) + + def test_submit_submission_in_different_group(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group_not_of_admin.group_id + } + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Submissions.objects.count(), 2) @@ -98,8 +114,41 @@ def test_update_not_possible(self): ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_update_not_possible_invalid(self): + with self.assertRaises(Exception): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + def test_delete_submission_not_possible(self): response = self.client.delete( API_ENDPOINT + str(self.submission.submission_id) + '/' ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_delete_submission_invalid(self): + with self.assertRaises(Exception): + self.client.delete( + API_ENDPOINT + '4563153/' + ) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py new file mode 100644 index 00000000..2ec7afac --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py @@ -0,0 +1,159 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.project_not_of_teacher = Project.objects.create( + name="Test Project 2", + course_id=self.course_not_of_teacher + ) + + self.group_not_of_teacher = Group.objects.create( + group_nr=2, + project_id=self.project_not_of_teacher + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_teacher = Submissions.objects.create( + group_id=self.group_not_of_teacher, + file=SimpleUploadedFile("test_file2.txt", b"file_content2") + ) + + self.client.force_authenticate(self.teacher) + + def test_cant_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + def test_retriev_invalid_submissions(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.get( + API_ENDPOINT + str(489454134561) + '/' + ) + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_teacher.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_cant_update_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_cant_delete_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.delete( + API_ENDPOINT + '4561313516/', + ) + self.client.delete( + API_ENDPOINT + '4563153/', + ) From 36bd5408b454816368f10ab590b8c8cedba0a065 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Thu, 14 Mar 2024 02:49:24 +0100 Subject: [PATCH 122/138] add submission 404 and deadline checks --- backend/pigeonhole/apps/submissions/views.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 77382a7f..d7a3c341 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,9 +1,12 @@ +from datetime import datetime + from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission @@ -21,6 +24,18 @@ def submit(self, request, *args, **kwargs): submission = self.get_submission() serializer = SubmissionsSerializer(submission, data=request.data) serializer.is_valid(raise_exception=True) + + group = Group.objects.get(id=serializer.group_id) + if not group: + return Response({"message": "Group not found"}, status=status.HTTP_404_NOT_FOUND) + + project = Project.objects.get(id=group.project_id) + if not project: + return Response({"message": "Project not found"}, status=status.HTTP_404_NOT_FOUND) + + if datetime.now() > project.deadline: + return Response({"message": "Deadline expired"}, status=status.HTTP_410_GONE) + serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) From aa5dd2065d69eb6d2012898a9064322a21858b75 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Thu, 14 Mar 2024 02:56:32 +0100 Subject: [PATCH 123/138] fix lint --- .../apps/projects/migrations/0001_initial.py | 10 +++++-- backend/pigeonhole/apps/projects/views.py | 5 ++-- backend/pigeonhole/apps/submissions/views.py | 2 +- .../apps/users/migrations/0001_initial.py | 30 ++++++++++++++----- .../tests/test_models/test_course.py | 3 +- .../tests/test_models/test_groups.py | 3 +- .../tests/test_models/test_project.py | 3 +- .../tests/test_models/test_submissions.py | 7 +++-- .../pigeonhole/tests/test_models/test_user.py | 1 + .../test_views/test_submission/test_admin.py | 2 +- 10 files changed, 46 insertions(+), 20 deletions(-) diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index ac2c58a0..f2fef4f4 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -31,9 +30,14 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Test', fields=[ - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='projects.project')), + ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, + serialize=False, to='projects.project')), ('test_nr', models.IntegerField()), - ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), + ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), ], ), ] diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index 81c10416..35014a26 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -1,12 +1,13 @@ +from django.db import transaction from rest_framework import status from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from django.db import transaction + from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.groups.models import GroupSerializer -from .models import Project, ProjectSerializer, Course +from .models import Project, ProjectSerializer from .permissions import CanAccessProject diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index a75c985b..58c8d61a 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -37,4 +37,4 @@ def submit(self, request, *args, **kwargs): return Response({"message": "Deadline expired"}, status=status.HTTP_410_GONE) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index ac5f2cc8..6a79a812 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -21,10 +20,22 @@ class Migration(migrations.Migration): fields=[ ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all ' + 'permissions without explicitly ' + 'assigning them.', + verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, + help_text='Required. 150 characters or fewer. Letters, digits and ' + '@/./+/-/_ only.', max_length=150, unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name='username')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into ' + 'this admin site.', + verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be ' + 'treated as active. Unselect this instead ' + 'of deleting accounts.', + verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('email', models.EmailField(max_length=254, unique=True)), @@ -32,8 +43,13 @@ class Migration(migrations.Migration): ('last_name', models.CharField(max_length=150)), ('role', models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=1)), ('course', models.ManyToManyField(to='courses.course')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will ' + 'get all permissions granted to each of their ' + 'groups.', related_name='user_set', + related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', + related_name='user_set', related_query_name='user', + to='auth.permission', verbose_name='user permissions')), ], options={ 'verbose_name': 'user', diff --git a/backend/pigeonhole/tests/test_models/test_course.py b/backend/pigeonhole/tests/test_models/test_course.py index 499d1b12..40d2e12c 100644 --- a/backend/pigeonhole/tests/test_models/test_course.py +++ b/backend/pigeonhole/tests/test_models/test_course.py @@ -1,7 +1,8 @@ +from django.db.utils import DataError from django.test import TestCase + from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.users.models import User -from django.db.utils import DataError # python3 manage.py test backend/ diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 145a116d..bf76c974 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -1,8 +1,9 @@ from django.test import TestCase + from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User # python3 manage.py test backend/ diff --git a/backend/pigeonhole/tests/test_models/test_project.py b/backend/pigeonhole/tests/test_models/test_project.py index b37f7890..7a84141d 100644 --- a/backend/pigeonhole/tests/test_models/test_project.py +++ b/backend/pigeonhole/tests/test_models/test_project.py @@ -1,7 +1,8 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User + from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User class ProjectTestCase(TestCase): diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index d3949d38..cf757485 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -1,10 +1,11 @@ +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from backend.pigeonhole.apps.users.models import User + from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.submissions.models import Submissions -from backend.pigeonhole.apps.groups.models import Group -from django.core.files.uploadedfile import SimpleUploadedFile +from backend.pigeonhole.apps.users.models import User class SubmissionTestCase(TestCase): diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 740da8f0..0e1f9f6c 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -1,4 +1,5 @@ from django.test import TestCase + from backend.pigeonhole.apps.users.models import User diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index f1a6da94..d70fdad3 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -53,7 +53,7 @@ def setUp(self): def test_submit_submission(self): test_file = SimpleUploadedFile("test_file.txt", b"file_content") response = self.client.post( - API_ENDPOINT + f'submissions/', + API_ENDPOINT + 'submissions/', { "file": test_file, "group_id": self.group.group_id From 4d7bc6f9487773a20fc36a14dd6b189b31a80160 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 02:58:12 +0100 Subject: [PATCH 124/138] test_student for submissions --- .../test_submission/test_student.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 backend/pigeonhole/tests/test_views/test_submission/test_student.py diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_student.py b/backend/pigeonhole/tests/test_views/test_submission/test_student.py new file mode 100644 index 00000000..877d2848 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_student.py @@ -0,0 +1,177 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group_not_of_student = Group.objects.create( + group_nr=2, + project_id=self.project + ) + + self.group.user.set([self.student]) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_student = Submissions.objects.create( + group_id=self.group_not_of_student, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.client.force_authenticate(self.student) + + def test_can_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + def test_retriev_invalid_submissions(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.get( + API_ENDPOINT + str(489454134561) + '/' + ) + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_other_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_other_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.delete( + API_ENDPOINT + '4561313516/', + ) + self.client.delete( + API_ENDPOINT + '4563153/', + ) From 24130e74cc75d0f162ece43818b539d749b77d04 Mon Sep 17 00:00:00 2001 From: Axel Lorreyne Date: Thu, 14 Mar 2024 02:58:46 +0100 Subject: [PATCH 125/138] misc lint + auto formatting --- backend/pigeonhole/tests/test_views/test_complete/admin.py | 2 +- backend/pigeonhole/tests/test_views/test_course/test_admin.py | 2 +- backend/pigeonhole/tests/test_views/test_group/test_admin.py | 1 - .../pigeonhole/tests/test_views/test_project/test_student.py | 3 ++- .../pigeonhole/tests/test_views/test_project/test_teacher.py | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_complete/admin.py b/backend/pigeonhole/tests/test_views/test_complete/admin.py index 684bddd9..29c88505 100644 --- a/backend/pigeonhole/tests/test_views/test_complete/admin.py +++ b/backend/pigeonhole/tests/test_views/test_complete/admin.py @@ -4,8 +4,8 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project -from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.submissions.models import Submission +from backend.pigeonhole.apps.users.models import User ROUTES_PREFIX = '/courses/' diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index 9b868595..6bc5636c 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -101,7 +101,7 @@ def test_update_course_not_exist(self): def test_delete_course_not_exist(self): response = self.client.delete(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - + def test_get_projects_of_course(self): response = self.client.get( API_ENDPOINT + f'{self.course.course_id}/get_projects/' diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index c511f92f..f7c50891 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -117,7 +117,6 @@ def test_retrieve_invalid_group(self): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_partial_update_invalid_group(self): response = self.client.patch( API_ENDPOINT + '999/', diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py index 74894246..455ee398 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -1,7 +1,8 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -import json from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index cd3a54e2..a8dce414 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -1,7 +1,8 @@ +import json + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -import json from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project From 6c9601442ff97a05cfb33d214cfd5b47814640e6 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 03:05:28 +0100 Subject: [PATCH 126/138] test_unauthenticated for submissions --- .../test_submission/test_unauthenticated.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py new file mode 100644 index 00000000..f95b26f2 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py @@ -0,0 +1,142 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group_not_of_student = Group.objects.create( + group_nr=2, + project_id=self.project + ) + + self.group.user.set([self.student]) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_student = Submissions.objects.create( + group_id=self.group_not_of_student, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + def test_cant_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_other_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_other_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 1732ec2e92c16d8e04be31ecb8c17ef5070a765c Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 03:16:12 +0100 Subject: [PATCH 127/138] fix lint --- backend/pigeonhole/apps/submissions/permissions.py | 3 +-- backend/pigeonhole/apps/submissions/views.py | 7 +------ .../test_views/test_submission/test_unauthenticated.py | 1 - 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 939d7cce..cb485a04 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -37,8 +37,7 @@ def has_permission(self, request, view): if user.is_admin or user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) elif user.is_teacher: - if teacher_courses.filter(course_id=course).exists(): - return True + return True return False group = Group.objects.get(group_id=group_id) if user.is_admin or user.is_superuser: diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 06eab12d..557b4c24 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,7 +1,3 @@ -from rest_framework import status -from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from datetime import datetime from rest_framework import viewsets, status @@ -41,9 +37,8 @@ def submit(self, request, *args, **kwargs): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) - def update(self, request, *args, **kwargs): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) def destroy(self, request, *args, **kwargs): - return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) \ No newline at end of file + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py index f95b26f2..c94a894c 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py @@ -84,7 +84,6 @@ def test_retrieve_submissions(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_cant_retreive_submissions_of_different_course(self): response = self.client.get( API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' From 644e6221e512adfe8c90fddbbf2d1dbf030bbe43 Mon Sep 17 00:00:00 2001 From: rdyselinck Date: Thu, 14 Mar 2024 03:18:57 +0100 Subject: [PATCH 128/138] fix lint numero dos --- backend/pigeonhole/apps/submissions/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index 557b4c24..eb77e335 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -5,6 +5,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission From 7e1807d9aff6d03ccaf53730d724cb00b2ed94db Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 10:14:36 +0100 Subject: [PATCH 129/138] group unauth testen --- backend/pigeonhole/apps/groups/permission.py | 6 + .../test_group/test_unauthenticated.py | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 3236b6bd..09dc4a4b 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -8,8 +8,13 @@ class CanAccessGroup(permissions.BasePermission): # Custom user class to check if the user can join a group. def has_permission(self, request, view): + if not request.user.is_authenticated: + # If user is not authenticated, deny permission + return False + if view.action in ['create', 'list']: return False + user = request.user group_id = int(view.kwargs.get('pk')) if not Group.objects.filter(group_id=group_id).exists(): @@ -22,6 +27,7 @@ def has_permission(self, request, view): if user.is_admin or user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) return False + course_id = Project.objects.get(project_id=project_id).course_id.course_id if user.is_admin or user.is_superuser: return view.action not in ['join', 'leave'] diff --git a/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py index e69de29b..4bc591d4 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py @@ -0,0 +1,103 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestUnauthorized(TestCase): + + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="teacher", + last_name="lastname", + role=2, + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + def test_retrieve_group(self): + response = self.client.get(API_ENDPOINT + f'{self.group1.group_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_group(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + "name": "Updated Test Group", + "description": "Updated Test Group Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete(API_ENDPOINT + f'{self.group1.group_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + "name": "Updated Test Group", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_group_submissions(self): + response = self.client.get(API_ENDPOINT + f'{self.group1.group_id}/get_submissions/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From dac7341dd61e562bcfc7e000852f113d65d6eb8e Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 10:17:53 +0100 Subject: [PATCH 130/138] test get_submissions --- .../tests/test_views/test_group/test_admin.py | 11 +++++------ .../tests/test_views/test_group/test_teacher.py | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index f7c50891..8cccfb65 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -146,9 +146,8 @@ def test_leave_group(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # TODO get submissions test fixen geeft een 301? - # def test_get_submissions(self): - # response = self.client.get( - # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_get_group_submissions(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py index 97364347..7b950104 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py @@ -145,9 +145,8 @@ def test_leave_group(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # TODO get submissions test fixen geeft een 301? - # def test_get_submissions(self): - # response = self.client.get( - # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_get_group_submission(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) From 33ef36ba93220836c6a5154978c18b1ab3204254 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 10:53:24 +0100 Subject: [PATCH 131/138] fix group tests except join --- backend/pigeonhole/apps/groups/permission.py | 7 +- .../test_views/test_group/test_student.py | 305 +++++++++--------- 2 files changed, 157 insertions(+), 155 deletions(-) diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 09dc4a4b..603b7bc5 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -36,5 +36,10 @@ def has_permission(self, request, view): return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] elif user.is_student: if user.course.filter(course_id=course_id).exists(): - return view.action in ['retrieve', 'join', 'leave'] + # check if the user is already in the group + if Group.objects.get(group_id=group_id).user.filter(id=user.id).exists(): + return view.action in ['retrieve', 'get_submissions', 'leave'] + elif Group.objects.get(group_id=group_id).user.count() < Project.objects.get( + project_id=project_id).group_size: + return view.action in ['retrieve', 'get_submissions', 'join'] return False diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py index d05e22de..2cf65d17 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_student.py @@ -1,154 +1,151 @@ -# from django.test import TestCase -# from rest_framework import status -# from rest_framework.test import APIClient -# -# from backend.pigeonhole.apps.courses.models import Course -# from backend.pigeonhole.apps.groups.models import Group -# from backend.pigeonhole.apps.projects.models import Project -# from backend.pigeonhole.apps.users.models import User -# -# API_ENDPOINT = '/groups/' -# -# -# class GroupTestStudent(TestCase): -# def setUp(self): -# self.client = APIClient() -# -# self.student = User.objects.create( -# username="student_username", -# email="test@gmail.com", -# first_name="Kermit", -# last_name="The Frog", -# role=3 -# ) -# -# self.course = Course.objects.create( -# name="Test Course", -# description="Test Course Description", -# ) -# -# self.student.course.set([self.course]) -# -# self.project = Project.objects.create( -# name="Test Project", -# course_id=self.course, -# number_of_groups=3, -# group_size=2, -# ) -# -# self.group1 = Group.objects.create( -# group_id=0, -# group_nr=1, -# final_score=0, -# project_id=self.project, -# feedback="Test Feedback", -# visible=True, -# ) -# self.group2 = Group.objects.create( -# group_id=1, -# group_nr=2, -# final_score=0, -# project_id=self.project, -# feedback="Test Feedback", -# visible=True, -# ) -# self.group2.user.add(self.student) -# -# self.group_not_visible = Group.objects.create( -# group_id=2, -# group_nr=3, -# final_score=0, -# project_id=self.project, -# feedback="Test Feedback", -# visible=False, -# ) -# -# self.client.force_authenticate(self.student) -# -# def test_student_create_group(self): -# response = self.client.post( -# API_ENDPOINT, -# { -# "name": "Test Group 1", -# "description": "Test Group 1 Description", -# "project_id": self.project.project_id, -# }, -# format='json' -# ) -# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -# -# def test_retrieve_group(self): -# response = self.client.get( -# API_ENDPOINT + f'{self.group1.group_id}/' -# ) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# -# # -# def test_list_groups(self): -# response = self.client.get( -# API_ENDPOINT -# ) -# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -# -# def test_delete_group(self): -# response = self.client.delete( -# API_ENDPOINT + f'{self.group1.group_id}/' -# ) -# self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) -# self.assertEqual(Group.objects.count(), 2) -# -# def test_group_count(self): -# self.assertEqual(Group.objects.count(), 3) -# -# def test_partial_update_group(self): -# response = self.client.patch( -# API_ENDPOINT + f'{self.group1.group_id}/', -# { -# 'feedback': 'Updated Feedback' -# }, -# format='json' -# ) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') -# -# def test_retrieve_invalid_group(self): -# response = self.client.get( -# API_ENDPOINT + '9999/' -# ) -# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) -# -# def test_partial_update_invalid_group(self): -# response = self.client.patch( -# API_ENDPOINT + '999/', -# { -# 'feedback': 'Updated Feedback' -# }, -# format='json' -# ) -# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) -# -# def test_delete_invalid_group(self): -# response = self.client.delete( -# API_ENDPOINT + '999/' -# ) -# self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) -# -# def test_join_group(self): -# response = self.client.post( -# API_ENDPOINT + f'{self.group1.group_id}/join/' -# ) -# -# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -# -# def test_leave_group(self): -# response = self.client.post( -# API_ENDPOINT + f'{self.group1.group_id}/leave/' -# ) -# self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -# -# # TODO get submissions test fixen geeft een 301? -# # def test_get_submissions(self): -# # response = self.client.get( -# # API_ENDPOINT + f'{self.group1.group_id}/get_submissions' -# # ) -# # self.assertEqual(response.status_code, status.HTTP_200_OK) +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + self.group2.user.add(self.student) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + self.client.force_authenticate(self.student) + + def test_student_create_group(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Group 1", + "description": "Test Group 1 Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_groups(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Group.objects.count(), 3) # Count should remain the same + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_submissions(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group2.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_leave_group_not_joined(self): + response = self.client.post( + API_ENDPOINT + f'{self.group_not_visible.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + {'feedback': 'Updated Feedback'}, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + {'feedback': 'Updated Feedback'}, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + """ + def test_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/join/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + """ From 8a4df00fce3c1e80ffee4b5b3e7bef8762bbc460 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 11:20:06 +0100 Subject: [PATCH 132/138] course permission en test fix --- .../pigeonhole/apps/courses/permissions.py | 5 +++- backend/pigeonhole/apps/groups/permission.py | 4 +++ .../test_views/test_course/test_admin.py | 20 ++++++++----- .../test_views/test_course/test_student.py | 28 +++++++++++++++++-- .../test_views/test_course/test_teacher.py | 19 +++++++++++++ .../test_course/test_unauthorized.py | 4 +++ 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 4808e135..7628c5c7 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -1,6 +1,7 @@ from rest_framework import permissions from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.courses.models import Course class CourseUserPermissions(permissions.BasePermission): @@ -11,12 +12,14 @@ def has_permission(self, request, view): if request.user.is_teacher: if view.action in ['create', 'list', 'retrieve']: return True - elif view.action in ['update', 'partial_update', 'destroy'] and User.objects.filter(id=request.user.id, + elif view.action in ['update', 'partial_update', 'destroy', 'get_projects'] and User.objects.filter(id=request.user.id, course=view.kwargs[ 'pk']).exists(): return True return if request.user.is_student: + if view.action == 'get_projects': + return Course.objects.filter(course_id=view.kwargs['pk'], user=request.user).exists() return view.action in ['list', 'retrieve'] return False diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py index 603b7bc5..0efe3c24 100644 --- a/backend/pigeonhole/apps/groups/permission.py +++ b/backend/pigeonhole/apps/groups/permission.py @@ -42,4 +42,8 @@ def has_permission(self, request, view): elif Group.objects.get(group_id=group_id).user.count() < Project.objects.get( project_id=project_id).group_size: return view.action in ['retrieve', 'get_submissions', 'join'] + elif view.action in ['join']: + return Response({'message': 'Group is full'}, status=status.HTTP_400_BAD_REQUEST) + elif view.action in ['leave']: + return Response({'message': 'User is not in the group'}, status=status.HTTP_400_BAD_REQUEST) return False diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index dd3be05b..a910e5c0 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -6,6 +6,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project API_ENDPOINT = '/courses/' @@ -32,6 +33,12 @@ def setUp(self): last_name="The Frog", role=1, ) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + self.teacher.course.set([self.course]) self.client.force_authenticate(user=self.teacher) @@ -102,10 +109,9 @@ def test_delete_course_not_exist(self): response = self.client.delete(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - # def test_get_projects_of_course(self): - # response = self.client.get( - # API_ENDPOINT + f'{self.course.course_id}/get_projects/' - # ) - # self.assertEqual(response.status_code, status.HTTP_200_OK) - # content_json = json.loads(response.content.decode("utf-8")) - # self.assertEqual(content_json["count"], 0) + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py index 21bd6303..a052d3ed 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -6,6 +6,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project API_ENDPOINT = '/courses/' @@ -29,6 +30,14 @@ def setUp(self): self.course = Course.objects.create(**self.course_data) + self.course_not_of_student = Course.objects.create(name="Not of Student", + description="This is not of the student") + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + # Provide a value for the "number" field when creating the Student instance self.student = User.objects.create( username="student", @@ -45,7 +54,7 @@ def setUp(self): def test_create_course(self): response = self.client.post(API_ENDPOINT, self.course_data, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) + self.assertEqual(Course.objects.count(), 2) def test_update_course(self): updated_data = { @@ -61,7 +70,7 @@ def test_update_course(self): def test_delete_course(self): response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual(Course.objects.count(), 1) + self.assertEqual(Course.objects.count(), 2) def test_retrieve_course(self): response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') @@ -74,8 +83,21 @@ def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) content_json = json.loads(response.content.decode("utf-8")) - self.assertEqual(content_json["count"], 1) + self.assertEqual(content_json["count"], 2) def test_retrieve_course_not_exist(self): response = self.client.get(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + def get_project_of_course_not_of_student(self): + response = self.client.get(f'{API_ENDPOINT}{self.course_not_of_student.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 0) + self.assertEqual(response.data, []) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py index 65c65191..d498629d 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -6,6 +6,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project API_ENDPOINT = '/courses/' @@ -32,6 +33,11 @@ def setUp(self): role=2 ) + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + self.teacher.course.set([self.course]) self.client.force_authenticate(user=self.teacher) @@ -86,3 +92,16 @@ def test_retrieve_course(self): def test_retrieve_course_not_exist(self): response = self.client.get(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + def get_projects_of_course_not_of_teacher(self): + response = self.client.get(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 0) + self.assertEqual(response.data, []) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py index a17263b9..378854b7 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py @@ -46,3 +46,7 @@ def test_retrieve_course(self): def test_list_courses(self): response = self.client.get(API_ENDPOINT) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/get_projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 2c41b54024333cd60ae50f7a9d434f6443442a25 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 11:22:05 +0100 Subject: [PATCH 133/138] fix lint --- backend/pigeonhole/apps/courses/permissions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 7628c5c7..54b4ca11 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -12,9 +12,10 @@ def has_permission(self, request, view): if request.user.is_teacher: if view.action in ['create', 'list', 'retrieve']: return True - elif view.action in ['update', 'partial_update', 'destroy', 'get_projects'] and User.objects.filter(id=request.user.id, - course=view.kwargs[ - 'pk']).exists(): + elif view.action in ['update', 'partial_update', 'destroy', 'get_projects'] and User.objects.filter( + id=request.user.id, + course=view.kwargs[ + 'pk']).exists(): return True return From a6b2c73a9393d3421ae8567ad114cc0b3d6b19d1 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 11:36:17 +0100 Subject: [PATCH 134/138] fix visible groups --- backend/pigeonhole/apps/groups/models.py | 9 ++++++++ .../tests/test_views/test_group/test_admin.py | 1 - .../test_views/test_group/test_student.py | 21 +++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index bd2a31f3..0d299876 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -49,6 +49,15 @@ def to_representation(self, instance): data = super().to_representation(instance) request = self.context.get('request') + # if student not in group always hide final_score and feedback + if request and request.user.is_student and not instance.user.filter( + pk=request.user.pk).exists(): + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data + # Check if the user is a student and the group is not visible if request and request.user.is_student and not instance.visible: # Hide sensitive information for students diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py index 8cccfb65..2b40a38f 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -83,7 +83,6 @@ def test_retrieve_group(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) - # def test_list_groups(self): response = self.client.get( API_ENDPOINT diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py index 2cf65d17..3332a72f 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_student.py @@ -78,10 +78,22 @@ def test_student_create_group(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group2.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # test whether feedback and final_score are visbile + self.assertEqual(response.data['feedback'], "Test Feedback") + self.assertEqual(response.data['final_score'], 0) + + def test_score_not_visible_in_other_group(self): response = self.client.get( API_ENDPOINT + f'{self.group1.group_id}/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) + # test whether feedback and final_score are not vin response + self.assertNotIn('feedback', response.data) + self.assertNotIn('final_score', response.data) def test_list_groups(self): response = self.client.get( @@ -143,9 +155,14 @@ def test_retrieve_invalid_group(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) """ - def test_join_group(self): + def test_leave_and_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group2.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.post( - API_ENDPOINT + f'{self.group1.group_id}/join/' + API_ENDPOINT + f'{self.group2.group_id}/join/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) """ From 7aa9d226a535b1346b2570d70b954c53bf0781dc Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 12:44:38 +0100 Subject: [PATCH 135/138] extra course tests --- .../tests/test_views/test_course/test_admin.py | 18 ++++++++++++++++++ .../test_views/test_course/test_student.py | 18 ++++++++++++++++++ .../test_views/test_course/test_teacher.py | 18 ++++++++++++++++++ .../test_course/test_unauthorized.py | 18 ++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index a910e5c0..bc013ff4 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -115,3 +115,21 @@ def get_projects(self): self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['name'], self.project.name) self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py index a052d3ed..d3b40ec9 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -101,3 +101,21 @@ def get_project_of_course_not_of_student(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(len(response.data), 0) self.assertEqual(response.data, []) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py index d498629d..7340f57f 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -105,3 +105,21 @@ def get_projects_of_course_not_of_teacher(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(len(response.data), 0) self.assertEqual(response.data, []) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py index 378854b7..4b0c94bd 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py @@ -50,3 +50,21 @@ def test_list_courses(self): def test_get_projects(self): response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/get_projects/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) From 43d6b4419ebcfa71c7728934d2b25a8595d8a809 Mon Sep 17 00:00:00 2001 From: PJDeSmijter Date: Thu, 14 Mar 2024 12:46:26 +0100 Subject: [PATCH 136/138] small course test fix --- .../pigeonhole/tests/test_views/test_course/test_admin.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py index bc013ff4..d4361de0 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -97,14 +97,6 @@ def test_retrieve_course_not_exist(self): response = self.client.get(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_update_course_not_exist(self): - updated_data = { - 'name': 'Updated Course', - 'description': 'This course has been updated.' - } - response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_delete_course_not_exist(self): response = self.client.delete(f'{API_ENDPOINT}100/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) From 6cb77bf15f67badc3ed074a4ddd4674668551aac Mon Sep 17 00:00:00 2001 From: avoyen Date: Thu, 14 Mar 2024 13:30:37 +0100 Subject: [PATCH 137/138] fix group join --- backend/pigeonhole/apps/groups/views.py | 7 ++++--- .../pigeonhole/tests/test_views/test_group/test_student.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index 079b3e62..b02666c5 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -16,10 +16,11 @@ class GroupViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['post']) def join(self, request, pk=None): - group = self.get_object() + group_id = pk + group = Group.objects.get(group_id=group_id) user = request.user - project = Project.objects.get(project_id=group.project_id) - if group.user.count() < project.max_group_size: + project = Project.objects.get(project_id=group.project_id.project_id) + if group.user.count() < project.group_size: group.user.add(user) group.save() return Response({'message': 'User joined group'}, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py index 3332a72f..8b4ae9fc 100644 --- a/backend/pigeonhole/tests/test_views/test_group/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_group/test_student.py @@ -154,15 +154,16 @@ def test_retrieve_invalid_group(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - """ + def test_leave_and_join_group(self): response = self.client.post( API_ENDPOINT + f'{self.group2.group_id}/leave/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - + self.assertEqual(self.group2.user.count(), 0) response = self.client.post( API_ENDPOINT + f'{self.group2.group_id}/join/' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - """ + self.assertEqual(self.group2.user.count(), 1) + From 2d618397592e3abd1ab1da64a6d8f7a98a7fdd29 Mon Sep 17 00:00:00 2001 From: avoyen Date: Thu, 14 Mar 2024 13:43:38 +0100 Subject: [PATCH 138/138] get_submissions fixed --- backend/pigeonhole/apps/groups/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py index b02666c5..e61cd3d5 100644 --- a/backend/pigeonhole/apps/groups/views.py +++ b/backend/pigeonhole/apps/groups/views.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from backend.pigeonhole.apps.groups.models import Group, GroupSerializer -from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer from .permission import CanAccessGroup from ..projects.models import Project @@ -40,5 +40,5 @@ def leave(self, request, pk=None): @action(detail=True, methods=['get']) def get_submissions(self, request, pk=None): group = self.get_object() - submissions = Submissions.objects.filter(group_id=group).all() - return Response({'submissions': submissions}, status=status.HTTP_200_OK) + submissions = Submissions.objects.filter(group_id=group.group_id) + return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK)