diff --git a/.ci-setup/crontab b/.ci-setup/crontab index 87c9c955d8..b1298d5e39 100644 --- a/.ci-setup/crontab +++ b/.ci-setup/crontab @@ -3,6 +3,7 @@ BASH_ENV=/container.env PATH=/tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bundle/bin 10,15,20,25,30,35,40,45,50,55 * * * * /doubtfire/lib/shell/generate_pdfs.sh +0,10,20,30,40,50 * * * * /doubtfire/lib/shell/send_overseer_notifications.sh 0 5 * * * /doubtfire/lib/shell/check_plagiarism.sh 0 8 * * * /doubtfire/lib/shell/portfolio_autogen_check.sh 0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..7a01fa0427 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Jan 11 2022: Repository wide format (support zeitwerk mode loader) +f4050e826af83166e45b0b4f2af14179b40305dd + +# Oct 27 2026: Rubocop formatting of test files +b644fa2fa51e44e6cf81035cb4ec61015c0f464c diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 2640e2f00c..edd1a58a6a 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -22,16 +22,18 @@ env: DF_TEST_DB_DATABASE: "doubtfire-test" DF_TEST_DB_USERNAME: "dfire" DF_TEST_DB_PASSWORD: "pwd" - OVERSEER_ENABLED: "0" + OVERSEER_ENABLED: "true" DF_ENCRYPTION_PRIMARY_KEY: "AMLOMYA5GV8B4fTK3VKMhVGn8WdvUW8g" DF_ENCRYPTION_DETERMINISTIC_KEY: "anlmuJ6cB3bN3biXRbYvmPsC5ALPFqGG" DF_ENCRYPTION_KEY_DERIVATION_SALT: "hzPR8D4qpOnAg7VeAhkhWw6JmmzKJB10" DF_REDIS_SIDEKIQ_URL: "redis://redis:6379/0" LATEX_CONTAINER_NAME: doubtfire-texlive LATEX_BUILD_PATH: /texlive/shell/latex_build.sh - DF_JPLAG_REPORT_DIR: /jplag/results - LTI_SHARED_API_SECRET: "abc123" + GOTENBERG_IMAGE: doubtfire-gotenberg-development:local + GOTENBERG_FALLBACK_VOLUME_CONTAINER: doubtfire-gotenberg-volumes + LTI_SHARED_API_SECRET: "3e46c34bd730233ac1d503059f3aa92ef5e2dc15614f5cc9ba6bb2a9196476a9" LTI_ENABLED: true + DF_INITIAL_ADMIN_PASSWORD: "password" jobs: unit-tests: @@ -64,6 +66,16 @@ jobs: tags: doubtfire-texlive-development:local cache-from: type=gha,scope=texlive cache-to: type=gha,mode=max,scope=texlive + - name: Build Gotenberg image + uses: docker/build-push-action@v5 + with: + context: . + file: gotenberg.Dockerfile + push: false + load: true + tags: doubtfire-gotenberg-development:local + cache-from: type=gha,scope=gotenberg + cache-to: type=gha,mode=max,scope=gotenberg - name: Build JPlag image uses: docker/build-push-action@v5 with: @@ -104,6 +116,13 @@ jobs: -v ${{ github.workspace }}:/doubtfire -v /var/run/docker.sock:/var/run/docker.sock run: docker exec -t ${{ env.LATEX_CONTAINER_NAME }} lualatex -v + - name: Start Gotenberg volume container + run: > + docker run --detach + --name ${{ env.GOTENBERG_FALLBACK_VOLUME_CONTAINER }} + --network none + --volume ${{ github.workspace }}/tmp/gotenberg:/workdir/gotenberg + alpine sleep infinity - name: Start JPlag service uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a with: @@ -111,7 +130,6 @@ jobs: options: > --name jplag -v ${{ github.workspace }}/student-work:/student-work - -v ${{ github.workspace }}/jplag/results:${{ env.DF_JPLAG_REPORT_DIR }} -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag -v ${{ github.workspace }}/test_files/submissions/jplag:/test_files --detach @@ -152,9 +170,11 @@ jobs: -e DF_REDIS_SIDEKIQ_URL -e LATEX_CONTAINER_NAME -e LATEX_BUILD_PATH - -e DF_JPLAG_REPORT_DIR + -e GOTENBERG_IMAGE + -e GOTENBERG_FALLBACK_VOLUME_CONTAINER -e LTI_SHARED_API_SECRET -e LTI_ENABLED + -e DF_INITIAL_ADMIN_PASSWORD run: bundle exec rake db:populate - name: Run unit tests uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a @@ -164,7 +184,6 @@ jobs: -v ${{ github.workspace }}:/doubtfire -v ${{ github.workspace }}/student-work:/student-work -v /var/run/docker.sock:/var/run/docker.sock - -v ${{ github.workspace }}/jplag/results:${{ env.DF_JPLAG_REPORT_DIR }} -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag -e RAILS_ENV -e DF_STUDENT_WORK_DIR @@ -185,7 +204,8 @@ jobs: -e DF_REDIS_SIDEKIQ_URL -e LATEX_CONTAINER_NAME -e LATEX_BUILD_PATH - -e DF_JPLAG_REPORT_DIR + -e GOTENBERG_IMAGE + -e GOTENBERG_FALLBACK_VOLUME_CONTAINER -e LTI_SHARED_API_SECRET -e LTI_ENABLED run: TERM=xterm bundle exec rails test @@ -193,3 +213,6 @@ jobs: run: docker rm -f ${{ env.LATEX_CONTAINER_NAME }} - name: Stop JPlag service run: docker rm -f jplag + - name: Stop Gotenberg volume container + if: always() + run: docker rm -f ${{ env.GOTENBERG_FALLBACK_VOLUME_CONTAINER }} diff --git a/.gitignore b/.gitignore index 977065f4e4..35a46c3ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ student-work/ .DS_Store .env .env* +/config/credentials/*.yml.enc +/config/credentials/*.key +/config/master.key # IDE configs .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e30380ce..4937b5aa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,776 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [11.0.1](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0...v11.0.1) (2026-10-26) + + +### Bug Fixes + +* allow task title punctuation and clarify validation errors ([#735](https://github.com/b0ink/doubtfire-deploy/issues/735)) ([4675793](https://github.com/b0ink/doubtfire-deploy/commit/467579343cc5085d83e43a846019ca04addb27cb)) + +## [11.0.0](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-79...v11.0.0) (2026-10-26) + +## [11.0.0-79](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-78...v11.0.0-79) (2026-10-26) + + +### Bug Fixes + +* skip docker login job if vars not set ([84f366f](https://github.com/b0ink/doubtfire-deploy/commit/84f366fbde88cddc2f3d6ee1d0810cc25c11a1cf)) + +## [11.0.0-78](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-77...v11.0.0-78) (2026-10-26) + + +### Features + +* allow staff to reuse submissions for feedback ([#732](https://github.com/b0ink/doubtfire-deploy/issues/732)) ([5d02218](https://github.com/b0ink/doubtfire-deploy/commit/5d02218fae35acc1646d52d9ed9319969a438d67)) +* enable sentry for sidekiq ([#698](https://github.com/b0ink/doubtfire-deploy/issues/698)) ([770c49a](https://github.com/b0ink/doubtfire-deploy/commit/770c49a07b45c906d33d9036d6abfc845041069e)) +* setting to restrict task submissions and status changes to staff ([#731](https://github.com/b0ink/doubtfire-deploy/issues/731)) ([aaaff74](https://github.com/b0ink/doubtfire-deploy/commit/aaaff7453558a71a7983108e835c572f81b6beeb)) +* support image and pdf attachments in staff notes ([#734](https://github.com/b0ink/doubtfire-deploy/issues/734)) ([b9a1009](https://github.com/b0ink/doubtfire-deploy/commit/b9a1009458ee9d13f84d39e31abbf25e6a1c470e)) + + +### Bug Fixes + +* prevent pdfgen from claiming sidekiq-owned submissions ([#730](https://github.com/b0ink/doubtfire-deploy/issues/730)) ([ca9e274](https://github.com/b0ink/doubtfire-deploy/commit/ca9e274afbf1eec4a5771c1a5c11658a851b906a)) +* require authentication for scorm extension routes ([ef24b3c](https://github.com/b0ink/doubtfire-deploy/commit/ef24b3ca7efdc6032bd58f8e413a422a05ec1f36)) +* require authentication for Turnitin EULA acceptance ([7846be6](https://github.com/b0ink/doubtfire-deploy/commit/7846be65ae2fe1daf46f38b0ba7472817623dbd5)) +* whitelist similarity languages and avoid shell execution ([#727](https://github.com/b0ink/doubtfire-deploy/issues/727)) ([b47c050](https://github.com/b0ink/doubtfire-deploy/commit/b47c050d824e5d4b6d5b9082e1c6101a0131275a)) + +## [11.0.0-77](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-76...v11.0.0-77) (2026-10-25) + + +### Bug Fixes + +* bump ruby-saml to 1.18.1 ([0a2ee1c](https://github.com/b0ink/doubtfire-deploy/commit/0a2ee1ccd8b5a5f663324f86f41494e407facbdf)) + +## [11.0.0-76](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-75...v11.0.0-76) (2026-10-25) + + +### Features + +* split submission processing and other jobs into parallel serial queues ([#711](https://github.com/b0ink/doubtfire-deploy/issues/711)) ([e97bc27](https://github.com/b0ink/doubtfire-deploy/commit/e97bc27ba53fe3d4f1012eca1b36071662babbec)) + + +### Bug Fixes + +* prevent students from grading their own tasks ([6bbfbb4](https://github.com/b0ink/doubtfire-deploy/commit/6bbfbb4225cb6109ab445c0b548f425106ff57d1)) +* prevent students from grading their own tasks ([#724](https://github.com/b0ink/doubtfire-deploy/issues/724)) ([9c1f74e](https://github.com/b0ink/doubtfire-deploy/commit/9c1f74e85229208f69094b43ec111592e60ba926)) +* restrict tutor feedback moderation to mentors, convenors and admins ([#723](https://github.com/b0ink/doubtfire-deploy/issues/723)) ([161450a](https://github.com/b0ink/doubtfire-deploy/commit/161450adc76e670f188b144e01a0cb6a8bcae1f6)) + +## [11.0.0-75](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-74...v11.0.0-75) (2026-10-23) + + +### Bug Fixes + +* ensure authentication for tii group attachment api ([1fc7cf0](https://github.com/b0ink/doubtfire-deploy/commit/1fc7cf0de66f27ac66fcfbd4567736730b37f629)) +* ensure authentication for tii group attachment api ([#722](https://github.com/b0ink/doubtfire-deploy/issues/722)) ([2b7ee3c](https://github.com/b0ink/doubtfire-deploy/commit/2b7ee3c2428cc76007b49b95aa9d2bfafd7e0968)) +* restrict global learning outcome changes to admins ([f4e402f](https://github.com/b0ink/doubtfire-deploy/commit/f4e402fc8750404b84fed7f66d0da0a4b36c5c6a)) +* restrict global learning outcome changes to admins ([#721](https://github.com/b0ink/doubtfire-deploy/issues/721)) ([a7ad239](https://github.com/b0ink/doubtfire-deploy/commit/a7ad239adec3c8e38f5b365ac1b6663d9696c242)) + +## [11.0.0-74](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-73...v11.0.0-74) (2026-10-22) + + +### Bug Fixes + +* remove obsolete hash fragments from notification links ([63ef0cc](https://github.com/b0ink/doubtfire-deploy/commit/63ef0cc90fa19432b905bc731b1f740c9c9572da)) +* show placeholder for empty task descriptions in portfolio pdfs ([a2745ea](https://github.com/b0ink/doubtfire-deploy/commit/a2745ea767d28d6419d34ef3169aaa4e4ec65603)) + +## [11.0.0-73](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-72...v11.0.0-73) (2026-10-22) + + +### Bug Fixes + +* add missing task status colors for pdf gen ([598639e](https://github.com/b0ink/doubtfire-deploy/commit/598639e5a2e257b2c9b27ebe3832715782b20005)) + +## [11.0.0-72](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-71...v11.0.0-72) (2026-10-21) + + +### Bug Fixes + +* file permissions ([690d1ff](https://github.com/b0ink/doubtfire-deploy/commit/690d1fff2c6ca2bce18516c06fc2cffd25807fd7)) + +## [11.0.0-71](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-70...v11.0.0-71) (2026-10-21) + + +### Features + +* resolve template variable for main convenor name ([6b0b18e](https://github.com/b0ink/doubtfire-deploy/commit/6b0b18e9e0e3d48760170058e7dca70e87f43351)) + + +### Bug Fixes + +* define \lt and \gt for notebook pdf compilation ([2ce78de](https://github.com/b0ink/doubtfire-deploy/commit/2ce78de65ecb9cdc82951efd92cc97e9ccf6e1e5)) +* support docx files in learning summary report uploads ([#714](https://github.com/b0ink/doubtfire-deploy/issues/714)) ([d6d3e6d](https://github.com/b0ink/doubtfire-deploy/commit/d6d3e6dc02e275470ee3d5d2eff4a980c4392a1c)) + +## [11.0.0-70](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-69...v11.0.0-70) (2026-10-20) + +## [11.0.0-69](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-68...v11.0.0-69) (2026-10-16) + + +### Features + +* name downloaded submission files after upload requirements ([a065b0e](https://github.com/b0ink/doubtfire-deploy/commit/a065b0e8af8879f38bc52ca2eb0f9aec52087817)) +* name downloaded submission files after upload requirements ([#707](https://github.com/b0ink/doubtfire-deploy/issues/707)) ([86614eb](https://github.com/b0ink/doubtfire-deploy/commit/86614eb2300c30dba644dc918c16a93b2ed21e77)) + + +### Bug Fixes + +* check prerequisites for tasks with no upload requirement ([446a303](https://github.com/b0ink/doubtfire-deploy/commit/446a3038c3c18fadf7ae8cdd21a1b31c89f2289e)) +* count students once per task status across tutorial streams ([50a36ab](https://github.com/b0ink/doubtfire-deploy/commit/50a36abdfb85e5a262af547046dde0758516961a)) +* ensure submission date is saved correctly ([5ddf9bd](https://github.com/b0ink/doubtfire-deploy/commit/5ddf9bdcb3c124e9f6f042d412612cba8bf88093)) +* skip missing submissions ([7887181](https://github.com/b0ink/doubtfire-deploy/commit/7887181ff5d393693c5d7f9881e1f29d016212f7)) +* skip withdrawn projects ([43e11e7](https://github.com/b0ink/doubtfire-deploy/commit/43e11e72f79af4c8a2aff88b2a54b3d32aa4aba2)) + +## [11.0.0-68](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-67...v11.0.0-68) (2026-10-15) + + +### Bug Fixes + +* handle large jpeg submissions ([61e8ba9](https://github.com/b0ink/doubtfire-deploy/commit/61e8ba99006a93715aabfa675378917c8e137bcf)) + +## [11.0.0-67](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-66...v11.0.0-67) (2026-09-28) + + +### Features + +* detect accounts linked to a different login id ([0f34f4e](https://github.com/b0ink/doubtfire-deploy/commit/0f34f4e9cad0d4e86d4426e77dd6f4c81c04f634)) + +## [11.0.0-66](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-65...v11.0.0-66) (2026-09-28) + + +### Features + +* configure batch feedback chunk size ([bebbb1a](https://github.com/b0ink/doubtfire-deploy/commit/bebbb1a6324ff641724dcc3688c5519f96a211fb)) +* rollover per-grade target dates and include them in task csv ([aab3256](https://github.com/b0ink/doubtfire-deploy/commit/aab3256036381444f5469040ea655ec2f738bcb5)) + + +### Bug Fixes + +* rollover unit content ([a82b291](https://github.com/b0ink/doubtfire-deploy/commit/a82b291e1c4e71bebd9a1ff531f8f5a2230478bd)) +* rotate refresh token when signing in near expiry ([55cef4d](https://github.com/b0ink/doubtfire-deploy/commit/55cef4dd06cee5a409b2d4996d357ef103fab4d7)) + +## [11.0.0-65](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-64...v11.0.0-65) (2026-09-24) + + +### Bug Fixes + +* store batch feedback chunks as separate files instead of locking ([68530ee](https://github.com/b0ink/doubtfire-deploy/commit/68530ee020c89ffb87e501f296aa8e6e9aa4d5f4)) + +## [11.0.0-64](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-63...v11.0.0-64) (2026-09-24) + + +### Features + +* resumable uploads ([c9c5d9a](https://github.com/b0ink/doubtfire-deploy/commit/c9c5d9a663adaa6380b0aa11cfc28e6c63d3e73e)) + + +### Bug Fixes + +* teaching period authentication ([bb7571c](https://github.com/b0ink/doubtfire-deploy/commit/bb7571cf05e1efe0fcaad1b822c826a04a529896)) +* teaching period authentication ([#697](https://github.com/b0ink/doubtfire-deploy/issues/697)) ([80ca5b0](https://github.com/b0ink/doubtfire-deploy/commit/80ca5b018940f9ddbcbd8451edfaf8d298e761bf)) + +## [11.0.0-63](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-62...v11.0.0-63) (2026-09-23) + + +### Features + +* automate lti syncing and add moodle course data ([#690](https://github.com/b0ink/doubtfire-deploy/issues/690)) ([e4b4db6](https://github.com/b0ink/doubtfire-deploy/commit/e4b4db6b307ff8a792620a0f0fa897b11bbcaa0a)) + + +### Bug Fixes + +* avoid sending full error message in task comments ([#695](https://github.com/b0ink/doubtfire-deploy/issues/695)) ([caa8fa1](https://github.com/b0ink/doubtfire-deploy/commit/caa8fa14a55f097b2f43dd9c3d951e2a2179d473)) + +## [11.0.0-62](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-61...v11.0.0-62) (2026-09-22) + + +### Features + +* expose waiting_since date on inbox tasks ([a49205e](https://github.com/b0ink/doubtfire-deploy/commit/a49205e4a411f7c5f9a8418e38b20cd74bf28286)) +* view inbox as another tutor ([2627ac8](https://github.com/b0ink/doubtfire-deploy/commit/2627ac8bcc6b4f0cfc73057cac37f5e96e9e3a1e)) + +## [11.0.0-61](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-60...v11.0.0-61) (2026-09-21) + + +### Bug Fixes + +* advance comment cursor past authored comments ([06a8b48](https://github.com/b0ink/doubtfire-deploy/commit/06a8b48ba61e114a54fe6a00319dc3b0088e4e05)) +* advance comment cursor past authored comments ([#693](https://github.com/b0ink/doubtfire-deploy/issues/693)) ([caaa1ac](https://github.com/b0ink/doubtfire-deploy/commit/caaa1acf9c6523f72e9178e89b83d931cc06ce80)) + +## [11.0.0-60](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-59...v11.0.0-60) (2026-09-17) + + +### Features + +* authorise jplag report downloads for the caddy file server ([d44acf2](https://github.com/b0ink/doubtfire-deploy/commit/d44acf23cb644ab7db01a00ca18c13c14a748b7a)) +* disable jobs outside of currently active teaching period ([#689](https://github.com/b0ink/doubtfire-deploy/issues/689)) ([b27d9ca](https://github.com/b0ink/doubtfire-deploy/commit/b27d9cad298048468b2fac33b649dca05601522b)) + + +### Bug Fixes + +* allow students to submit tasks locked to a tutorial stream ([a8ae905](https://github.com/b0ink/doubtfire-deploy/commit/a8ae905643f489bc5a48d5ebc1cfd33cf9e0d4d8)) +* ensure unique email seed ([c4c7a34](https://github.com/b0ink/doubtfire-deploy/commit/c4c7a344e542bbf7529894ddcbad1027eee2a126)) +* prevent discussion timeout notifications outside of active teaching period ([#687](https://github.com/b0ink/doubtfire-deploy/issues/687)) ([e7da860](https://github.com/b0ink/doubtfire-deploy/commit/e7da8607aba8ade853cac8d19b3bef8f0558257c)) +* prioritise unit staff role for owned projects ([238b6a7](https://github.com/b0ink/doubtfire-deploy/commit/238b6a755552c65285d0921abe3c411b43de5c4d)) +* prioritise unit staff role for owned projects ([#688](https://github.com/b0ink/doubtfire-deploy/issues/688)) ([aac3a8c](https://github.com/b0ink/doubtfire-deploy/commit/aac3a8c485c03dea614d372386aeef63442a2152)) + +## [11.0.0-59](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-58...v11.0.0-59) (2026-09-14) + +## [11.0.0-58](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-57...v11.0.0-58) (2026-09-14) + + +### Bug Fixes + +* handle concurrently cleared submission history markers ([d3c623d](https://github.com/b0ink/doubtfire-deploy/commit/d3c623d675b52393d226ecf311e35cae222f27b3)) +* use redis for production cache ([53695e9](https://github.com/b0ink/doubtfire-deploy/commit/53695e97640eb9e57941726a481511dc1268b96f)) + +## [11.0.0-57](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-56...v11.0.0-57) (2026-09-04) + + +### Bug Fixes + +* ensure blank passwords do not create empty user ([0338a9d](https://github.com/b0ink/doubtfire-deploy/commit/0338a9da6d4638c85157816ee828e69b9eb1bfd3)) +* ensure blank passwords do not create empty user ([#683](https://github.com/b0ink/doubtfire-deploy/issues/683)) ([fa1da37](https://github.com/b0ink/doubtfire-deploy/commit/fa1da375dd1a03fa7970892bd9cf67149d44cfdc)) +* init disk space endpoint config outside of overseer ([566a6b0](https://github.com/b0ink/doubtfire-deploy/commit/566a6b0eee8fb8219ae30ca6fa7584aefd66739e)) + +## [11.0.0-56](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-55...v11.0.0-56) (2026-09-03) + + +### Bug Fixes + +* catch tutorials with no campus set ([d6fcdef](https://github.com/b0ink/doubtfire-deploy/commit/d6fcdefde456cb5eb103ccb2c76b9b381f920cce)) + +## [11.0.0-55](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-54...v11.0.0-55) (2026-09-03) + + +### Features + +* statistics endpoint ([5359c94](https://github.com/b0ink/doubtfire-deploy/commit/5359c94492bc090caeee0e0bacb9e226c758c887)) + +## [11.0.0-54](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-53...v11.0.0-54) (2026-09-03) + + +### Features + +* ensure rules are reordered when deleted ([ef41d86](https://github.com/b0ink/doubtfire-deploy/commit/ef41d86045f20968f6f0ffc41bf44804afb4e8e2)) +* option to pause week count per teaching period break ([#680](https://github.com/b0ink/doubtfire-deploy/issues/680)) ([07b68ca](https://github.com/b0ink/doubtfire-deploy/commit/07b68cac770cd7d6622de4f781720ee6bd650918)) +* store aggregated task status to serve immediately ([bbab019](https://github.com/b0ink/doubtfire-deploy/commit/bbab0199b51bf83196a7a55f2da3c55fd95018a5)) + + +### Bug Fixes + +* rubocop ([db2859e](https://github.com/b0ink/doubtfire-deploy/commit/db2859edaed0b2ed567a3c01063a1760964c1b90)) +* show group overseer reports across member tasks ([#669](https://github.com/b0ink/doubtfire-deploy/issues/669)) ([be022d4](https://github.com/b0ink/doubtfire-deploy/commit/be022d40faa1de2d91026996ad01a10bb9fdc112)) + +## [11.0.0-53](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-52...v11.0.0-53) (2026-08-24) + +## [11.0.0-52](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-51...v11.0.0-52) (2026-08-21) + + +### Bug Fixes + +* ensure discuss timeout emails are not sent to withdrawn students ([569bc55](https://github.com/b0ink/doubtfire-deploy/commit/569bc5595f2660efd14614c60b6cb529f1254966)) + +## [11.0.0-51](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-50...v11.0.0-51) (2026-08-20) + + +### Features + +* allow staff to delete engagement stamps within window ([18bff9e](https://github.com/b0ink/doubtfire-deploy/commit/18bff9e2f9d02591e3ea2e9860d94a77710ffc09)) +* allow staff to delete engagement stamps within window ([#675](https://github.com/b0ink/doubtfire-deploy/issues/675)) ([19ff041](https://github.com/b0ink/doubtfire-deploy/commit/19ff041343cdaad8d7d43a38dddc782ebcbed7f4)) +* track teaching breaks in days with an optional label ([38ee332](https://github.com/b0ink/doubtfire-deploy/commit/38ee332a4164f91f070bf8aa99e5761b99d45105)) + + +### Bug Fixes + +* allow admins to manage staff in units they are not part of ([cb9af9d](https://github.com/b0ink/doubtfire-deploy/commit/cb9af9d9f07d929241f99a6fed6c4f0149276c0a)) +* avoid creating portfolio path unless intended ([6725f75](https://github.com/b0ink/doubtfire-deploy/commit/6725f750ff873d097d9aba103671177f1ac442f5)) + +## [11.0.0-50](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-49...v11.0.0-50) (2026-08-17) + + +### Bug Fixes + +* remove default json value ([cb959bf](https://github.com/b0ink/doubtfire-deploy/commit/cb959bfea92c98f55e57a7476544da3316f33d71)) + +## [11.0.0-49](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-48...v11.0.0-49) (2026-08-17) + + +### Features + +* tutorial duration ([#672](https://github.com/b0ink/doubtfire-deploy/issues/672)) ([c089777](https://github.com/b0ink/doubtfire-deploy/commit/c089777a67f84479a0ebe4f3502d2c2d6331796e)) + +## [11.0.0-48](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-47...v11.0.0-48) (2026-08-17) + + +### Features + +* add separate breaks for different campuses ([#670](https://github.com/b0ink/doubtfire-deploy/issues/670)) ([bfa41bf](https://github.com/b0ink/doubtfire-deploy/commit/bfa41bf0ed9fd7fd6b49a98d1a601857d6c3e3ad)) + +## [11.0.0-47](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-46...v11.0.0-47) (2026-08-17) + + +### Bug Fixes + +* support full width commas ([ed719ab](https://github.com/b0ink/doubtfire-deploy/commit/ed719ab5b76a669544a4a55eeb91e432a21d7e7c)) + +## [11.0.0-46](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-45...v11.0.0-46) (2026-08-17) + + +### Bug Fixes + +* extend valid character list ([c3da5be](https://github.com/b0ink/doubtfire-deploy/commit/c3da5be61751a83414cfc0ae6caee2232a00469a)) + +## [11.0.0-45](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-44...v11.0.0-45) (2026-08-12) + + +### Bug Fixes + +* address uri mitigation and cors regressions ([919b6ab](https://github.com/b0ink/doubtfire-deploy/commit/919b6abf2e65ab20dc4d2af4cff6f3b6dfc849d8)) +* ensure correct context is found for learning chips import ([76f3ff1](https://github.com/b0ink/doubtfire-deploy/commit/76f3ff1370a93a8092a5c07bcc4af11043c37308)) +* mitigate URI credential leakage in relative URI merges ([60082f0](https://github.com/b0ink/doubtfire-deploy/commit/60082f00fa86686efcc8e506f28defd8c02b3d7f)) +* tighten API CORS policy to trusted origins ([cdb7eb6](https://github.com/b0ink/doubtfire-deploy/commit/cdb7eb6ec0c8ab73c5e9b79b850382e2327d0726)) +* validate user input ([#668](https://github.com/b0ink/doubtfire-deploy/issues/668)) ([a9649c7](https://github.com/b0ink/doubtfire-deploy/commit/a9649c7a61d1fac5ba8eef63d6102165fb97e15e)) + +## [11.0.0-44](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-43...v11.0.0-44) (2026-08-08) + + +### Features + +* bulk upload staff notes ([6c3b3e0](https://github.com/b0ink/doubtfire-deploy/commit/6c3b3e0b1b67ad3226718e9e7925b849802e0ae3)) +* bulk upload staff notes ([#665](https://github.com/b0ink/doubtfire-deploy/issues/665)) ([3e0f32d](https://github.com/b0ink/doubtfire-deploy/commit/3e0f32d724f3664deddbff8fef55ca10c8fed3b1)) + +## [11.0.0-43](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-42...v11.0.0-43) (2026-08-03) + + +### Features + +* group engagement ([#658](https://github.com/b0ink/doubtfire-deploy/issues/658)) ([6f02a33](https://github.com/b0ink/doubtfire-deploy/commit/6f02a33942ccbd5107eb4a88f9274b848196e3c5)) + +## [11.0.0-42](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-41...v11.0.0-42) (2026-07-31) + + +### Bug Fixes + +* ensure screenshots embedded in markdown compiles correctly ([9b624d6](https://github.com/b0ink/doubtfire-deploy/commit/9b624d68576710e40abe22ffdcac79b6277b9f02)) + +## [11.0.0-41](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-40...v11.0.0-41) (2026-07-30) + +## [11.0.0-40](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-39...v11.0.0-40) (2026-07-30) + + +### Features + +* enable unit content download ([1757731](https://github.com/b0ink/doubtfire-deploy/commit/17577319e897641cbc8ba20af029ee448fc61379)) + +## [11.0.0-39](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-38...v11.0.0-39) (2026-07-28) + + +### Bug Fixes + +* only record discussion after successful transition ([c8bd418](https://github.com/b0ink/doubtfire-deploy/commit/c8bd4188c758bb1829c46fb3e076d803329a41a9)) + +## [11.0.0-38](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-37...v11.0.0-38) (2026-07-28) + + +### Bug Fixes + +* ensure feedback templates rollover to new unit ([df3bc30](https://github.com/b0ink/doubtfire-deploy/commit/df3bc306da42d46f7187ff40f0fbc0e9d3f2122d)) + +## [11.0.0-37](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-36...v11.0.0-37) (2026-07-24) + + +### Features + +* automated engagement stamps ([#650](https://github.com/b0ink/doubtfire-deploy/issues/650)) ([59641bd](https://github.com/b0ink/doubtfire-deploy/commit/59641bd390da394b8aff20c21433dc885419c319)) + + +### Bug Fixes + +* pass saml settings correctly to logout response ([4f60eff](https://github.com/b0ink/doubtfire-deploy/commit/4f60eff0eea770d20cee2226a13b1ad10dfafc0e)) +* pass saml settings correctly to logout response ([#648](https://github.com/b0ink/doubtfire-deploy/issues/648)) ([8df3ea9](https://github.com/b0ink/doubtfire-deploy/commit/8df3ea9a4ec5dd45b7697e6ae11ab88e54fe2b64)) + +## [11.0.0-36](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-35...v11.0.0-36) (2026-07-24) + + +### Features + +* communication condition to check if student has portfolio ([#651](https://github.com/b0ink/doubtfire-deploy/issues/651)) ([90d362e](https://github.com/b0ink/doubtfire-deploy/commit/90d362e4ff2d32d0e3791d0bec3f5f2d6f649ee1)) + + +### Bug Fixes + +* normalize upload requirement file keys on save ([#653](https://github.com/b0ink/doubtfire-deploy/issues/653)) ([bf457a1](https://github.com/b0ink/doubtfire-deploy/commit/bf457a1a2137e7586b36eb9bc9b14045caad28f4)) + +## [11.0.0-35](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-34...v11.0.0-35) (2026-07-24) + + +### Bug Fixes + +* enable youtube embeds ([a8da36c](https://github.com/b0ink/doubtfire-deploy/commit/a8da36c3fbf64af3fad6bfaf02cd66e9921b903e)) + +## [11.0.0-34](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-33...v11.0.0-34) (2026-07-24) + + +### Features + +* add last sign in and last unit access communication conditions ([50fc821](https://github.com/b0ink/doubtfire-deploy/commit/50fc8215598766fa8927ca480b122a361803d689)) +* discuss timeout ([#646](https://github.com/b0ink/doubtfire-deploy/issues/646)) ([f6dfd80](https://github.com/b0ink/doubtfire-deploy/commit/f6dfd806570f6b30384571210402a3e92c405b2f)) +* track last access to site and unit ([2141fd6](https://github.com/b0ink/doubtfire-deploy/commit/2141fd6876f50c5705f16acdd1f27d9b65152078)) + + +### Bug Fixes + +* allow unit deletion with task prerequisites ([2b3752a](https://github.com/b0ink/doubtfire-deploy/commit/2b3752ac6b888cffc38cefa86a58bbebfb844aff)) +* iterate sentry exception interface values correctly ([4818049](https://github.com/b0ink/doubtfire-deploy/commit/48180497875529f6ac843a00519a6ce69f4ac4bc)) +* prevent deleting task definitions used as prerequisites ([f26c9c5](https://github.com/b0ink/doubtfire-deploy/commit/f26c9c573894543a50220dc7708fc4540e764aaf)) + +## [11.0.0-33](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-32...v11.0.0-33) (2026-07-19) + + +### Features + +* content management system ([#642](https://github.com/b0ink/doubtfire-deploy/issues/642)) ([a87b250](https://github.com/b0ink/doubtfire-deploy/commit/a87b250303b0aff8a431334e8b19a40f790cbd35)) + +## [11.0.0-32](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-31...v11.0.0-32) (2026-07-16) + + +### Features + +* add rediscuss status ([05a44fa](https://github.com/b0ink/doubtfire-deploy/commit/05a44fa8e9954ed82ea149072288ebc546d5f9e9)) +* prompt for admin password when creating initial account ([7640a36](https://github.com/b0ink/doubtfire-deploy/commit/7640a36f682f6dd7240cb764dea6e46b7d01df35)) +* support word document submission ([#647](https://github.com/b0ink/doubtfire-deploy/issues/647)) ([a21daf8](https://github.com/b0ink/doubtfire-deploy/commit/a21daf846a46557331c3235f86f28a5c8a020786)) + +## [11.0.0-31](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-30...v11.0.0-31) (2026-07-08) + + +### Bug Fixes + +* run lualatex a third time to ensure correct page references ([fc40bde](https://github.com/b0ink/doubtfire-deploy/commit/fc40bdea6414870c6457f5266838cb44d6296fcd)) + +## [11.0.0-30](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-29...v11.0.0-30) (2026-07-04) + + +### Bug Fixes + +* ensure admins can manage communications ([25bcafc](https://github.com/b0ink/doubtfire-deploy/commit/25bcafcdc00d76523080f6a198170b452f646923)) + +## [11.0.0-29](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-28...v11.0.0-29) (2026-07-02) + + +### Bug Fixes + +* allow students to get submission history timestamps ([a4bb79a](https://github.com/b0ink/doubtfire-deploy/commit/a4bb79ac27425fa64ed711e932d595cefa2a5598)) + +## [11.0.0-28](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-27...v11.0.0-28) (2026-07-01) + + +### Bug Fixes + +* only allow convenors to pull overflow stats ([d5ca748](https://github.com/b0ink/doubtfire-deploy/commit/d5ca74843fa16040dec81fa9bd66d315736059a0)) + +## [11.0.0-27](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-26...v11.0.0-27) (2026-07-01) + + +### Bug Fixes + +* prevent convenors with observer only perms to be made main convenor ([19a459c](https://github.com/b0ink/doubtfire-deploy/commit/19a459c544a14d3c155d665ba90ead11c3c50288)) + +## [11.0.0-26](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-25...v11.0.0-26) (2026-06-29) + + +### Bug Fixes + +* avoid skipping task names containing 'name' during import ([4c2e194](https://github.com/b0ink/doubtfire-deploy/commit/4c2e19472f73deeaee271ed5b7d5dc66b01f7836)) +* improve error when adding prerequisite to non existing task def ([f217c61](https://github.com/b0ink/doubtfire-deploy/commit/f217c61cf666dae7c781a393fbd9b544dcb32f24)) + +## [11.0.0-25](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-24...v11.0.0-25) (2026-06-29) + + +### Bug Fixes + +* ensure pii is not submitted to sentry ([d98d318](https://github.com/b0ink/doubtfire-deploy/commit/d98d318adc1c5153af9a844d95fdbbe2fde3903b)) + +## [11.0.0-24](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-23...v11.0.0-24) (2026-06-29) + + +### Bug Fixes + +* ensure non text comments cant be deleted ([964e957](https://github.com/b0ink/doubtfire-deploy/commit/964e957c4a00f1dadb2d992583d3f2bb717aa58b)) + +## [11.0.0-23](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-22...v11.0.0-23) (2026-06-26) + + +### Bug Fixes + +* ensure jplag scan continues on failed report ([3215c08](https://github.com/b0ink/doubtfire-deploy/commit/3215c0811aa8c9eb6425cb9d4e080f2fc042e6fb)) + +## [11.0.0-22](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-21...v11.0.0-22) (2026-06-26) + +## [11.0.0-21](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-20...v11.0.0-21) (2026-06-26) + + +### Bug Fixes + +* support mysql 8 reserved keyword in task completion stats query ([87fe813](https://github.com/b0ink/doubtfire-deploy/commit/87fe8136ea5e9f7011cee3b304bbe5c8a68c7d6d)) + +## [11.0.0-20](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-19...v11.0.0-20) (2026-06-25) + + +### Features + +* capture daily snapshots of aggregated task completion data ([#607](https://github.com/b0ink/doubtfire-deploy/issues/607)) ([86b2040](https://github.com/b0ink/doubtfire-deploy/commit/86b2040bf48f494b1e06919fcce50243fe5a0240)) + + +### Bug Fixes + +* ensure overseer image is pulled before running test ([0e0ce34](https://github.com/b0ink/doubtfire-deploy/commit/0e0ce34160dce4e23fc9deea2f035119c5d378dd)) +* relocate submission history on unit code change ([d9085b5](https://github.com/b0ink/doubtfire-deploy/commit/d9085b509b7995ae9d41e8e3973dd57b011a166d)) + +## [11.0.0-19](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-18...v11.0.0-19) (2026-06-25) + +## [11.0.0-18](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-17...v11.0.0-18) (2026-06-25) + + +### Bug Fixes + +* rename tunnel route to avoid ad blockers ([baf4f69](https://github.com/b0ink/doubtfire-deploy/commit/baf4f692be7558bca51fab8daa89b16f3daf2088)) + +## [11.0.0-17](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-16...v11.0.0-17) (2026-06-25) + +## [11.0.0-16](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-15...v11.0.0-16) (2026-06-25) + + +### Features + +* add sentry tunnel support ([3eecd2f](https://github.com/b0ink/doubtfire-deploy/commit/3eecd2f8e7733e79307830e173e6687fc4d2632f)) + +## [11.0.0-15](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-14...v11.0.0-15) (2026-06-24) + + +### Bug Fixes + +* check grade value is valid json ([3dd6106](https://github.com/b0ink/doubtfire-deploy/commit/3dd6106bb65eb739c81f8eab5332684a915d0c9f)) + +## [11.0.0-14](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-13...v11.0.0-14) (2026-06-24) + + +### Bug Fixes + +* avoid serialisation ([1a47564](https://github.com/b0ink/doubtfire-deploy/commit/1a475640e211d3dec7e3b5034b3f0efddaa6e29d)) +* check grade value is valid json ([ea7c5db](https://github.com/b0ink/doubtfire-deploy/commit/ea7c5db44e3f1751cf6d93028a07f0f82cca3bec)) + +## [11.0.0-13](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-12...v11.0.0-13) (2026-06-24) + + +### Bug Fixes + +* allow nullable grade values ([8b586f9](https://github.com/b0ink/doubtfire-deploy/commit/8b586f9b76c9cc82f71a33bcace5532e4ad9c4ec)) +* avoid json default ([3a0c879](https://github.com/b0ink/doubtfire-deploy/commit/3a0c879398a62469524f6e49410cccf5da0a2a33)) + +## [11.0.0-12](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-11...v11.0.0-12) (2026-06-24) + + +### Features + +* customisable grades ([#638](https://github.com/b0ink/doubtfire-deploy/issues/638)) ([57cb9e2](https://github.com/b0ink/doubtfire-deploy/commit/57cb9e250b7fbfe9eb477021c9077d86d4b696ab)) + +## [11.0.0-11](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-10...v11.0.0-11) (2026-06-18) + + +### Features + +* engagement passport ([#631](https://github.com/b0ink/doubtfire-deploy/issues/631)) ([d32145e](https://github.com/b0ink/doubtfire-deploy/commit/d32145ef62c7ad014524e5e3610844b7bda20b76)) + + +### Bug Fixes + +* serve public web cal as raw text ([6836a20](https://github.com/b0ink/doubtfire-deploy/commit/6836a20e582473eff4edb41e2fd1f6c4a5a9751d)) + +## [11.0.0-10](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-9...v11.0.0-10) (2026-06-17) + + +### Features + +* add column for days awaiting feedback including breaks ([f7b877c](https://github.com/b0ink/doubtfire-deploy/commit/f7b877c9759089ce1bb4c4e99490a039c0761181)) +* submission history ([#636](https://github.com/b0ink/doubtfire-deploy/issues/636)) ([4f95d61](https://github.com/b0ink/doubtfire-deploy/commit/4f95d61c8d897183ce83ca6d96b06570e51e4def)) + +## [11.0.0-9](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-8...v11.0.0-9) (2026-06-11) + + +### Features + +* expose route to fetch tasks waiting for pdf gen ([5d3407e](https://github.com/b0ink/doubtfire-deploy/commit/5d3407e2c897fa05f6e5c6ee41d8e0379fb6a367)) +* overflow task claim logs ([#614](https://github.com/b0ink/doubtfire-deploy/issues/614)) ([065180f](https://github.com/b0ink/doubtfire-deploy/commit/065180f248db7e80dd86f0491822e2051a9fa220)) + + +### Bug Fixes + +* ensure more reliable test ([93599d2](https://github.com/b0ink/doubtfire-deploy/commit/93599d27d736ebfaf7b997dfd82d764a45195881)) + +## [11.0.0-8](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-7...v11.0.0-8) (2026-06-09) + + +### Features + +* communications system ([#617](https://github.com/b0ink/doubtfire-deploy/issues/617)) ([9724909](https://github.com/b0ink/doubtfire-deploy/commit/97249090eac3f7e214991f7c665f92768948b6db)) + +## [11.0.0-7](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-6...v11.0.0-7) (2026-06-08) + + +### Bug Fixes + +* ensure start and target dates set correctly if unit start date is a monday ([0717d90](https://github.com/b0ink/doubtfire-deploy/commit/0717d9051181811c7d58dd453abf076b87cb0444)) +* handle incomplete notebook metadata and markdown lists in pdfs ([a01e4d7](https://github.com/b0ink/doubtfire-deploy/commit/a01e4d7e7ca7ca255b2080f1f3e44da48db4c270)) +* normalize notebook source lines ([b950e55](https://github.com/b0ink/doubtfire-deploy/commit/b950e55ad801fbdd6f3dafb3732aefc0b6cc6987)) + +## [11.0.0-6](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-5...v11.0.0-6) (2026-06-04) + +## [11.0.0-5](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-4...v11.0.0-5) (2026-06-04) + + +### Features + +* notify students when overseer tests fail ([#627](https://github.com/b0ink/doubtfire-deploy/issues/627)) ([4280df3](https://github.com/b0ink/doubtfire-deploy/commit/4280df3a6446920b4ae3c9108d28421ab9b3e6c5)) + + +### Bug Fixes + +* correctly get task ([1949b42](https://github.com/b0ink/doubtfire-deploy/commit/1949b42258355b843d7d0e5563be040d4d859164)) + +## [11.0.0-4](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-3...v11.0.0-4) (2026-06-04) + + +### Features + +* suspend any stale overseer assessments ([678f386](https://github.com/b0ink/doubtfire-deploy/commit/678f3866632d174713ab46bc7876c43dd2df56cd)) + + +### Bug Fixes + +* ensure we check for queued jobs ([3790c9f](https://github.com/b0ink/doubtfire-deploy/commit/3790c9f3dc4899d9785f6996a77a5b550f1c5d87)) + +## [11.0.0-3](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-2...v11.0.0-3) (2026-06-04) + + +### Bug Fixes + +* allow error log emails with no backtrace ([f5b383f](https://github.com/b0ink/doubtfire-deploy/commit/f5b383fdeda016dd7d391325f04700356cc58da5)) + +## [11.0.0-2](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-1...v11.0.0-2) (2026-06-04) + + +### Features + +* clear submission in process with no active sidekiq job ([#626](https://github.com/b0ink/doubtfire-deploy/issues/626)) ([bb453ce](https://github.com/b0ink/doubtfire-deploy/commit/bb453ce00f540bcf1d64d70e18c6d97b6f8bdc5a)) + +## [11.0.0-1](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-110...v11.0.0-1) (2026-06-03) + + +### Features + +* support zip file submissions ([#624](https://github.com/b0ink/doubtfire-deploy/issues/624)) ([d5d8742](https://github.com/b0ink/doubtfire-deploy/commit/d5d874214fe31f494b5a30f67f7286e711ba45b2)) + +## [10.0.0](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-110...v10.0.0) (2026-06-03) + + +### Features + +* support zip file submissions ([#624](https://github.com/b0ink/doubtfire-deploy/issues/624)) ([d5d8742](https://github.com/b0ink/doubtfire-deploy/commit/d5d874214fe31f494b5a30f67f7286e711ba45b2)) + +## [10.0.0-110](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-109...v10.0.0-110) (2026-06-03) + +## [10.0.0-109](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-108...v10.0.0-109) (2026-05-13) + + +### Bug Fixes + +* ensure recent feedback has been left for fix ([a824b1b](https://github.com/b0ink/doubtfire-api/commit/a824b1b5fc4f47a12be33d410962af31fe810c89)) + +## [10.0.0-108](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-107...v10.0.0-108) (2026-05-13) + + +### Features + +* enable sentry error logging ([#620](https://github.com/b0ink/doubtfire-api/issues/620)) ([11cc264](https://github.com/b0ink/doubtfire-api/commit/11cc264b83d2c5e161b1926e12c496f2fd9add13)) + +## [10.0.0-107](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-106...v10.0.0-107) (2026-05-05) + +## [10.0.0-106](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-105...v10.0.0-106) (2026-04-28) + + +### Features + +* enable comment editing ([#613](https://github.com/b0ink/doubtfire-api/issues/613)) ([4096f87](https://github.com/b0ink/doubtfire-api/commit/4096f8716b63f4ca5239f1f0ff7a4591096ee10d)) + +## [10.0.0-105](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-104...v10.0.0-105) (2026-04-27) + +## [10.0.0-104](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-103...v10.0.0-104) (2026-04-23) + + +### Features + +* import/export overseer steps via task definition csv ([#604](https://github.com/b0ink/doubtfire-api/issues/604)) ([5dbd575](https://github.com/b0ink/doubtfire-api/commit/5dbd57588c06cb69eae35c5eab12d757bbe0bec5)) + + +### Bug Fixes + +* prevent duplicate submission requests ([#611](https://github.com/b0ink/doubtfire-api/issues/611)) ([1d056ca](https://github.com/b0ink/doubtfire-api/commit/1d056ca36aca7d9de8977b7a96783ed7a8d05367)) + +## [10.0.0-103](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-102...v10.0.0-103) (2026-04-18) + +## [10.0.0-102](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-101...v10.0.0-102) (2026-04-18) + +## [10.0.0-101](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-100...v10.0.0-101) (2026-04-18) + + +### Features + +* batch upload feedback csv ([#609](https://github.com/b0ink/doubtfire-api/issues/609)) ([5baa171](https://github.com/b0ink/doubtfire-api/commit/5baa171baff4343161e13e79613b81eb985acc29)) +* query pinned tasks in task explorer ([7a10685](https://github.com/b0ink/doubtfire-api/commit/7a10685138a99eac0719aa2a37f8f883167edc45)) + +## [10.0.0-100](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-99...v10.0.0-100) (2026-04-15) + + +### Features + +* add enforce feedback unit setting ([4d1c6ee](https://github.com/b0ink/doubtfire-api/commit/4d1c6ee8d227b1d4b0ac57bbeea30f20f47e8b71)) + +## [10.0.0-99](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-98...v10.0.0-99) (2026-04-13) + + +### Features + +* ensure tutorials are reassigned before deleting a unit role ([0bba827](https://github.com/b0ink/doubtfire-api/commit/0bba8279da40a8674f802b961302b84560855e76)) + +## [10.0.0-98](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-97...v10.0.0-98) (2026-03-27) + + +### Features + +* move tii and active record secrets to rails credentials ([#601](https://github.com/b0ink/doubtfire-api/issues/601)) ([4357f5c](https://github.com/b0ink/doubtfire-api/commit/4357f5c17db2d937aa0ab573567d45f41cadcf5b)) + +## [10.0.0-97](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-96...v10.0.0-97) (2026-03-27) + + +### Features + +* prefer encrypted rails credentials over env vars ([#600](https://github.com/b0ink/doubtfire-api/issues/600)) ([b1946bd](https://github.com/b0ink/doubtfire-api/commit/b1946bd8105e70e4baff1e0547b1b728d8a731c8)) + +## [10.0.0-96](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-95...v10.0.0-96) (2026-03-26) + + +### Features + +* allow admin users to view any unit analytics ([#597](https://github.com/b0ink/doubtfire-api/issues/597)) ([eb2195a](https://github.com/b0ink/doubtfire-api/commit/eb2195a18d148264eb0570cdfc3ecfa6b577e1f9)) +* pause days awaiting feedback during breaks ([#599](https://github.com/b0ink/doubtfire-api/issues/599)) ([0aa2208](https://github.com/b0ink/doubtfire-api/commit/0aa220863329083b1fe7d53fc2a4f9ee9a3f9386)) + +## [10.0.0-95](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-94...v10.0.0-95) (2026-03-24) + + +### Bug Fixes + +* normalise test to ensure its consistent ([#594](https://github.com/b0ink/doubtfire-deploy/issues/594)) ([a607486](https://github.com/b0ink/doubtfire-deploy/commit/a607486e9953e28b2bd6b04e9a135a4190b87c6e)) + ## [10.0.0-94](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-93...v10.0.0-94) (2026-03-23) diff --git a/Gemfile b/Gemfile index 90b4021f63..5fe9ba187e 100644 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem 'puma' gem 'bootsnap', require: false gem 'csv' +gem 'uri', '>= 1.1.1' # CVE-2025-61594 # Extend irb for better output gem 'hirb' @@ -84,6 +85,7 @@ gem 'grape-swagger-rails' gem 'bunny-pub-sub', '0.5.2' gem 'ci_reporter' gem 'dotenv' +gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' gem 'require_all', '>=1.3.3' @@ -102,6 +104,7 @@ gem 'net-smtp', require: false gem 'tca_client' # Async jobs +gem 'ice_cube' gem 'sidekiq' gem 'sidekiq-cron' gem 'sidekiq-status' @@ -120,3 +123,7 @@ gem 'pdf-reader' gem 'oauth2' gem "sys-filesystem" + +gem "sentry-rails" +gem "sentry-ruby" +gem "sentry-sidekiq", "~> 6.5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 6bb903f2a6..739c367bab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,29 +2,29 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (2.0.1) - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.5.1) + actionpack (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.5.1) + actionview (= 8.0.5.1) + activesupport (= 8.0.5.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -32,35 +32,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.5.1) + actionpack (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.5.1) + activesupport (= 8.0.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.5.1) + activesupport (= 8.0.5.1) + activerecord (8.0.5.1) + activemodel (= 8.0.5.1) + activesupport (= 8.0.5.1) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activesupport (= 8.0.5.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.5.1) base64 benchmark (>= 0.3) bigdecimal @@ -318,6 +318,8 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.12) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) rack-session (2.1.0) @@ -327,20 +329,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.5.1) + actioncable (= 8.0.5.1) + actionmailbox (= 8.0.5.1) + actionmailer (= 8.0.5.1) + actionpack (= 8.0.5.1) + actiontext (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activemodel (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.5.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -358,13 +360,14 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) @@ -450,12 +453,22 @@ GEM ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruby-saml (1.18.0) + ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) rubyzip (2.4.1) securerandom (0.4.1) + sentry-rails (6.5.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.5.0) + sentry-ruby (6.5.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + logger + sentry-sidekiq (6.5.0) + sentry-ruby (~> 6.5.0) + sidekiq (>= 5.0) set (1.1.1) sexp_processor (4.17.3) shellwords (0.2.2) @@ -529,6 +542,7 @@ GEM thor (1.3.2) tilt (2.6.0) timeout (0.4.3) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) typhoeus (1.4.1) @@ -539,7 +553,7 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.3) + uri (1.1.1) useragent (0.16.11) version_gem (1.1.6) warden (1.2.9) @@ -582,6 +596,7 @@ DEPENDENCIES grape-swagger-rails hirb icalendar + ice_cube json-jwt listen minitest @@ -593,6 +608,7 @@ DEPENDENCIES oauth2 pdf-reader puma + rack-attack rack-cors rails (~> 8.0) rails-latex @@ -613,6 +629,9 @@ DEPENDENCIES ruby-lsp ruby-saml rubyzip + sentry-rails + sentry-ruby + sentry-sidekiq (~> 6.5.0) shellwords sidekiq sidekiq-cron @@ -623,6 +642,7 @@ DEPENDENCIES sprockets-rails sys-filesystem tca_client + uri (>= 1.1.1) webmock RUBY VERSION diff --git a/README.md b/README.md index e1207a2a90..23006732cd 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,234 @@ -![Doubtfire Logo](https://github.com/doubtfire-lms/doubtfire-web/raw/6.2.x/src/assets/icons/android-chrome-192x192.png) +

+ OnTrack logo +

-# Doubtfire API [![test-doubtfire-api](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml) [![CodeQL](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/codeql.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/codeql.yml) [![RuboCop](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/rubocop.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/rubocop.yml) +# OnTrack API -Doubtfire is a feedback-driven learning support system. +[![test-doubtfire-api](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/push.yml) [![CodeQL](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/codeql.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/codeql.yml) [![RuboCop](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/rubocop.yml/badge.svg)](https://github.com/doubtfire-lms/doubtfire-api/actions/workflows/rubocop.yml) -## Table of Contents +OnTrack (formerly Doubtfire) is a feedback-driven learning support system. This repository contains +the Rails and Grape API used by OnTrack. -- [Doubtfire API](#doubtfire-api--) - - [Table of Contents](#table-of-contents) - - [Getting started](#getting-started) - - [Clone Repository](#clone-repository) - - [Install script](#install-script) - - [Manual install](#manual-install) - - [Environment variables](#environment-variables) - - [Get it up and running!](#get-it-up-and-running) -- [Running Rake Tasks](#running-rake-tasks) -- [Testing](#testing) -- [Contributing](#contributing) -- [License](#license) +## Development and deployment -## Getting started +Use the +[doubtfire-deploy](https://github.com/doubtfire-lms/doubtfire-deploy) +repository for the supported development environment and the deployment guide. +It checks out this API as a submodule and provides the database, Redis, PDF +services, web app, and required configuration in one place. -See [Doubtfire Deploy](https://github.com/doubtfire-lms/doubtfire-deploy) for instructions on deploying, and contributing, to the Doubtfire project. +When the development environment is running, the API documentation is available +at . ## Environment variables -Doubtfire requires multiple environment variables that help define settings about the Doubtfire instance running. Whilst these will default to other values, you may want to override them in production. - -| Key | Description | Default | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` | `database` | -| | for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/), or `SAML2` for [SAML2.0 auth](https://en.wikipedia.org/wiki/SAML_2.0). | | -| `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | -| `DF_ARCHIVE_DIR` | The directory to move archived unit files to, and access from. | `DF_STUDENT_WORK_DIR/archive` | -| `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _Doubtfire University_ | -| `DF_INSTITUTION_EMAIL_DOMAIN` | The email domain from which emails are sent to and from in your institution. | `doubtfire.com` | -| `DF_INSTITUTION_HOST` | The host running the Doubtfire instance. | `localhost:3000` | -| `DF_COOKIE_DOMAIN` | The domain to be associated with secure cookies. | Attempts to read from host | -| `DF_INSTITUTION_PRODUCT_NAME` | The name of the product (i.e. Doubtfire) at your institution. | _Doubtfire_ | -| `DF_INSTITUTION_HAS_LOGO` | Set to true (or 1) if there is an associated institution logo to be included in the header. | false | -| `DF_INSTITUTION_LOGO_URL` | The url of the logo to include in the header if there is a logo. | /assets/images/institution-logo.png | -| `DF_INSTITUTION_LOGO_LINK_URL` | The url used for the hyperlink associated with clicking the logo. | / | -| `DF_SECRET_KEY_BASE` | The Rails secret key. | Default key provided. | -| `DF_SECRET_KEY_ATTR` | The secret key to encrypt certain database fields. | Default key provided. | -| `DF_SECRET_KEY_DEVISE` | The secret key provided to Devise. | Default key provided. | -| `DF_SECRET_KEY_MOSS` | The secret key provided to [Moss](http://theory.stanford.edu/~aiken/moss/) for plagiarism detection. This value will need to be set to run `rake submission:check_plagiarism` (otherwise you **won't** need it). You will need to register for a Moss account to use this. | No default. | -| `DF_INSTITUTION_PRIVACY` | A statement related to the need for students to submit their own work, and that this work may be uploaded to 3rd parties for the purpose of plagiarism detection. | Default statement provided | -| `DF_INSTITUTION_PLAGIARISM` | A statement clarifying the terms plagiarism and collusion. | Default statement provided | -| `DF_INSTITUTION_SETTINGS_RB` | The path of the institution specific settings rb code - used to map student imports from institutional exports to a format understood by Doubtfire. | No default | -| `DF_FFMPEG_PATH` | The path of to the ffmpeg binary for audio processing. | ffmpeg | -| `DF_REDIS_CACHE_URL` | The redis URL for rails used for development and production, ignored in the test env. | `redis://localhost:6379/0` | -| `DF_REDIS_SIDEKIQ_URL` | The redis URL for sidekiq. A working redis server is **mandatory** for sidekiq in all environments. | `redis://localhost:6379/1` | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| **Turn It In Integration** | | | -| `TII_ENABLED` | Whether or not Turn It In integration is enabled. | 0 / false | -| `TII_INDEX_SUBMISSIONS` | Whether or not to index submissions in Turn It In. Should be set to 1 or true in production environments | 0 / false | -| `TII_REGISTER_WEBHOOK` | Whether or not to register a webhook with Turn It In. Should be set to 1 or true in production environments | 0 / false | -| `TCA_API_KEY` | The API key for Turn It In integration, acquire from the Turn It In administration interface. | No default | -| `TCA_HOST` | The host for the Turn It In integration, eg: https://institution.turnitin.com | No default | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| **D2L Integration** | | | -| `D2L_ENABLED` | Whether or not D2L integration is enabled. | 0 / false | -| `D2L_CLIENT_ID` | The client ID for D2L integration - from the oauth registration in D2L | No default | -| `D2L_CLIENT_SECRET` | The client secret for D2L integration - from the oauth registration in D2L | No default | -| `D2L_REDIRECT_URI` | The redirect URI for D2L integration. Must redirect to https://host/api/d2l/callback which must match the oauth registration in D2L | No default | -| `D2L_API_HOST` | The specific institutional URL for the D2L server, eg: https://d2l.institution.edu | No default | -| `D2L_OAUTH_SITE` | The location of the D2L authentication server. | `https://auth.brightspace.com` | -| `D2L_OAUTH_SITE_AUTHORIZE_URL` | The URL to authorize the D2L integration. | `/oauth2/auth` | -| `D2L_OAUTH_SITE_TOKEN_URL` | The URL to get the token for the D2L integration. | `/core/connect/token` | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| **Latex Configuration** | | | -| `LATEX_CONTAINER_NAME` | The name of the container housing the latex image. Used when pdfs are generated. | No default | -| `LATEX_BUILD_PATH` | The path to the latex build script within the container. | /texlive/shell/latex_build.sh | - -If you have chosen to use AAF Rapid Connect authentication, then you will also need to provide the following: - -| Key | Description | Default | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `DF_AAF_ISSUER_URL` | The URL of the AFF issuer, either `https://rapid.test.aaf.edu.au` for testing or `https://rapid.aaf.edu.au` for production. | `https://rapid.test.aaf.edu.au` | -| `DF_AAF_AUDIENCE_URL` | The URL of the AAF registered application. | No default - required | -| `DF_AAF_CALLBACK_URL` | The secure endpoint within your application that AAF Rapid Connect should POST responses to. It **must end with `/api/auth/jwt`** to access the Doubtfire JWT authentication endpoint. | No default - required | -| `DF_AAF_UNIQUE_URL` | The unique URL provided by AAF Rapid Connect used for redirection out of Doubtfire. | No default - required | -| `DF_AAF_IDENTITY_PROVIDER_URL` | The URL of the AAF-registered identity provider. | No default - required | -| `DF_AAF_AUTH_SIGNOUT_URL` | The URL to redirect to on sign out in order to log out of AAF Rapid Connect. | No default - required | -| `DF_SECRET_KEY_AAF` | The secret used to register your application with AAF. | `secretsecret12345` | - -If you are authenticating using SAML2, then you will also need to provide the following: - -| Key | Description | Default | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `DF_SAML_METADATA_URL` | The URL to getch the SAML metadata. Either the url or a file path to the meta data must be provided. Where the url is provided it will be used. | No default | -| `DF_SAML_METADATA_FILE_PATH` | The path to the metadata xml file. When the url is not set, this path will be used to get the metadata for the saml settings. | No default | -| `DF_SAML_CONSUMER_SERVICE_URL` | The URL of the SAML application. | No default - required | -| `DF_SAML_IDP_TARGET_URL` | The IDP SAML login URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") - OnTrack will redirect to this for login. | No default - required | -| `DF_SAML_IDP_SIGNOUT_URL` | The IDP SAML logout URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") - OnTrack will redirect to this for logout. | the SAML login url | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `DF_AAF_CALLBACK_URL` | The secure endpoint within your application that AAF Rapid Connect should POST responses to. It **must end with `/api/auth/jwt`** to access the Doubtfire JWT authentication endpoint. | No default - required | -| `DF_AAF_UNIQUE_URL` | The unique URL provided by AAF Rapid Connect used for redirection out of Doubtfire. | No default - required | -| `DF_AAF_IDENTITY_PROVIDER_URL` | The URL of the AAF-registered identity provider. | No default - required | -| `DF_AAF_AUTH_SIGNOUT_URL` | The URL to redirect to on sign out in order to log out of AAF Rapid Connect. | No default - required | -| `DF_SECRET_KEY_AAF` | The secret used to register your application with AAF. | `secretsecret12345` | - -You may choose to keep your environment variables inside a `.env` file using key-value pairs: - -``` -DF_INSTITUTION_HOST=doubtfire.unifoo.edu.au -DF_INSTITUTION_NAME="University of Foo" -``` - -You can also keep multiple `.env` files for different environments, e.g.: `.env.production` is different to `.env.develoment`. Doubtfire uses the [dotenv](https://github.com/bkeepers/dotenv) gem to make this happen. - -### Get it up and running! - -Once you've installed using either in install script or the manual install steps. - -``` -$ bundle exec rails s -``` - -You should see all the Doubtfire endpoints at **[http://localhost:3000/api/docs/](http://localhost:3000/api/docs/)**, which means the API is running. - -# Running Rake Tasks - -You can perform developer-specific tasks using `rake`. For a list of all tasks, execute in the root directory: - -``` -rake --tasks -``` - -# Testing - -Our aim with testing Doubtfire is to migrate to a [Test-Driven Development](https://en.wikipedia.org/wiki/Test-driven_development) -strategy, testing all new models and API endpoints (although we plan on writing -more tests for _existing_ models and API endpoints). If you are writing a new -API endpoint or model, we strongly suggest you include unit tests in the -appropriate folders (see below). - -To run unit tests, execute: - -```bash -$ rake test +The API supports the environment variables below. The production deployment +template is maintained in +[`doubtfire-deploy`](https://github.com/doubtfire-lms/doubtfire-deploy/blob/main/production/api/.env.production). +An unset variable uses its application default where one exists; an empty value +may override that default with an empty string. + +### Application and database + +| Variable | Purpose | Default | +| --------------------------- | ----------------------------------------- | -------------------------- | +| `RAILS_ENV` | Rails runtime environment. | `development` | +| `DF_LOG_TO_STDOUT` | Send Rails logs to standard output. | `false` | +| `DF_PRODUCTION_DB_ADAPTER` | Production database adapter. | Deployment-specific | +| `DF_PRODUCTION_DB_HOST` | Production database host. | Deployment-specific | +| `DF_PRODUCTION_DB_DATABASE` | Production database name. | Deployment-specific | +| `DF_PRODUCTION_DB_USERNAME` | Production database username. | Deployment-specific | +| `DF_PRODUCTION_DB_PASSWORD` | Production database password. | Deployment-specific | +| `DF_REDIS_CACHE_URL` | Redis connection used by the Rails cache. | `redis://localhost:6379/0` | +| `DF_REDIS_SIDEKIQ_URL` | Redis connection used by Sidekiq. | `redis://localhost:6379/1` | + +### Storage and processing + +| Variable | Purpose | Default | +| ------------------------------------- | ---------------------------------------------------------------- | ------------------------------- | +| `DF_STUDENT_WORK_DIR` | Directory containing uploaded student work. | `student_work` | +| `DF_ARCHIVE_DIR` | Directory containing archived student work. | `DF_STUDENT_WORK_DIR/archive` | +| `DF_ARCHIVE_UNITS` | Enable automatic unit archiving. | `false` | +| `DF_UNIT_ARCHIVE_PERIOD` | Years to retain units before archiving. | `2` | +| `DF_MAX_PDF_GEN_PROCESSES` | Maximum concurrent PDF-generation processes. | `2` | +| `DF_MAX_FILE_SIZE` | Maximum uploaded file size in bytes. | `10000000` | +| `DF_ZIP_ENTRY_LIMIT` | Maximum number of entries in an uploaded ZIP. | `1000` | +| `DF_ZIP_COMPRESSION_RATIO_LIMIT` | Maximum permitted ZIP compression ratio. | `100` | +| `DF_ZIP_UNCOMPRESSED_SIZE_MULTIPLIER` | Maximum expanded ZIP size as a multiple of `DF_MAX_FILE_SIZE`. | `10` | +| `BATCH_FEEDBACK_CHUNK_SIZE_MB` | Chunk size in MB for batch feedback uploads. | `50` | +| `DF_AUDITOR_UNIT_ACCESS_YEARS` | Number of years of units visible to auditors. | `2` | +| `DF_IMPORT_STUDENTS_WEEKS_BEFPRE` | Weeks before a teaching period that student imports are allowed. | `1` | +| `DF_FFMPEG_PATH` | Path to FFmpeg for audio processing. | `ffmpeg` | +| `LATEX_CONTAINER_NAME` | Container used for LaTeX PDF generation. | Unset | +| `LATEX_BUILD_PATH` | LaTeX build script inside that container. | `/texlive/shell/latex_build.sh` | + +### Institution + +| Variable | Purpose | Default | +| ------------------------------ | --------------------------------------------- | ------------------------------------- | +| `DF_INSTITUTION_NAME` | Institution display name. | Institution configuration | +| `DF_INSTITUTION_EMAIL_DOMAIN` | Institution email domain. | Institution configuration | +| `DF_INSTITUTION_HOST` | Public application URL. | Institution configuration | +| `DF_COOKIE_DOMAIN` | Domain assigned to secure cookies. | Derived from `DF_INSTITUTION_HOST` | +| `DF_INSTITUTION_PRODUCT_NAME` | Product name shown to users. | Institution configuration | +| `DF_INSTITUTION_HAS_LOGO` | Enable an institution logo. | `false` | +| `DF_INSTITUTION_LOGO_URL` | Institution logo URL. | `/assets/images/institution-logo.png` | +| `DF_INSTITUTION_LOGO_LINK_URL` | Destination opened from the institution logo. | `/` | +| `DF_INSTITUTION_PRIVACY` | Submission privacy and authorship statement. | Institution configuration | +| `DF_INSTITUTION_PLAGIARISM` | Plagiarism and collusion statement. | Institution configuration | +| `DF_INSTITUTION_SETTINGS_RB` | Institution-specific Ruby configuration file. | Unset | + +### Authentication and encryption + +Rails credentials take precedence over the corresponding environment fallback +for `DF_SECRET_KEY_BASE`, `DF_SECRET_KEY_ATTR`, `DF_SECRET_KEY_DEVISE`, +`DF_SECRET_KEY_AAF`, `DF_SECRET_KEY_MOSS`, and `LTI_SHARED_API_SECRET`. + +| Variable | Purpose | Default | +| ----------------------------------- | ------------------------------------------------------------ | -------------------------------- | +| `DF_AUTH_METHOD` | Authentication method: `database`, `ldap`, `aaf`, or `saml`. | `database` | +| `DF_ACCESS_TOKEN_EXPIRY_SECONDS` | Access-token lifetime in seconds. | `7200` | +| `DF_REFRESH_TOKEN_EXPIRY_SECONDS` | Refresh-token lifetime in seconds. | `604800` | +| `ONTRACK_AUTH_RATE_LIMIT_ENABLED` | Enable auth throttling; accepts true/false or 1/0. | `true` | +| `ONTRACK_AUTH_RATE_LIMIT` | Maximum authentication requests per IP in one window. | `5` | +| `ONTRACK_AUTH_RATE_PERIOD_SECONDS` | Authentication throttle window in seconds. | `20` | +| `DF_SECRET_KEY_BASE` | Rails secret key base fallback. | Required in production | +| `DF_SECRET_KEY_ATTR` | Legacy encrypted-attribute key fallback. | Required in production | +| `DF_SECRET_KEY_DEVISE` | Devise secret fallback. | Required in production | +| `DF_SECRET_KEY_MOSS` | MOSS integration secret fallback. | Unset | +| `DF_ENCRYPTION_PRIMARY_KEY` | Active Record Encryption primary key. | Required when encryption is used | +| `DF_ENCRYPTION_DETERMINISTIC_KEY` | Active Record Encryption deterministic key. | Required when encryption is used | +| `DF_ENCRYPTION_KEY_DERIVATION_SALT` | Active Record Encryption derivation salt. | Required when encryption is used | + +#### AAF Rapid Connect + +| Variable | Purpose | Default | +| ------------------------------ | ----------------------------------------- | ------------------------------- | +| `DF_AAF_ISSUER_URL` | AAF issuer URL. | `https://rapid.test.aaf.edu.au` | +| `DF_AAF_AUDIENCE_URL` | Registered application URL. | Required for AAF | +| `DF_AAF_CALLBACK_URL` | API JWT callback URL. | Required for AAF | +| `DF_AAF_IDENTITY_PROVIDER_URL` | Registered identity-provider URL. | Required for AAF | +| `DF_AAF_UNIQUE_URL` | Rapid Connect authentication-request URL. | Required for AAF | +| `DF_AAF_AUTH_SIGNOUT_URL` | URL used after sign-out. | Unset | +| `DF_SECRET_KEY_AAF` | AAF shared-secret fallback. | Required for AAF in production | + +#### SAML + +| Variable | Purpose | Default | +| ----------------------------------------- | ----------------------------------------------------------- | ---------------------- | +| `DF_SAML_METADATA_URL` | Identity-provider metadata URL. | Unset | +| `DF_SAML_METADATA_FILE_PATH` | Local identity-provider metadata file. | Unset | +| `DF_SAML_CONSUMER_SERVICE_URL` | SAML assertion consumer URL. | Required for SAML | +| `DF_SAML_SP_ENTITY_ID` | Service-provider entity ID. | Required for SAML | +| `DF_SAML_IDP_TARGET_URL` | Identity-provider login URL. | Required for SAML | +| `DF_SAML_IDP_SIGNOUT_URL` | Identity-provider logout URL. | Unset | +| `DF_SAML_IDP_CERT` | Identity-provider certificate when metadata is unavailable. | Conditionally required | +| `DF_SAML_IDP_SAML_NAME_IDENTIFIER_FORMAT` | SAML NameID format. | Email address | + +#### LDAP + +| Variable | Purpose | Default | +| --------------------------- | ---------------------------------------------------- | -------------------- | +| `DF_LDAP_HOST` | LDAP server host. | Required for LDAP | +| `DF_LDAP_PORT` | LDAP server port. | LDAP library default | +| `DF_LDAP_ATTRIBUTE` | LDAP attribute used as the login identifier. | Required for LDAP | +| `DF_LDAP_BASE` | LDAP search base. | Required for LDAP | +| `DF_LDAP_SSL` | Enable an encrypted LDAP connection. | `false` | +| `DF_LDAP_USE_ADMIN_TO_BIND` | Bind with an administrator account before searching. | `false` | +| `DF_LDAP_ADMIN_USER` | LDAP administrator bind user. | Unset | +| `DF_LDAP_ADMIN_PWD` | LDAP administrator bind password. | Unset | + +#### D2L + +| Variable | Purpose | Default | +| ------------------------------ | ----------------------------------------------------- | ------------------------------ | +| `D2L_ENABLED` | Enable D2L integration. | `false` | +| `D2L_CLIENT_ID` | D2L OAuth client ID. | Unset | +| `D2L_CLIENT_SECRET` | D2L OAuth client secret. | Unset | +| `D2L_REDIRECT_URI` | D2L OAuth callback URL ending in `/api/d2l/callback`. | Unset | +| `D2L_API_HOST` | Institution D2L API host. | Unset | +| `D2L_OAUTH_SITE` | D2L authorization server. | `https://auth.brightspace.com` | +| `D2L_OAUTH_SITE_AUTHORIZE_URL` | D2L authorization path. | `/oauth2/auth` | +| `D2L_OAUTH_SITE_TOKEN_URL` | D2L token path. | `/core/connect/token` | + +### Email + +| Variable | Purpose | Default | +| ---------------------------- | ---------------------------------------- | ------------------- | +| `DF_MAIL_PERFORM_DELIVERIES` | Enable outgoing email delivery. | Disabled | +| `DF_MAIL_DELIVERY_METHOD` | Action Mailer delivery method. | Deployment-specific | +| `DF_SMTP_ADDRESS` | SMTP server address. | Unset | +| `DF_SMTP_PORT` | SMTP server port. | Unset | +| `DF_SMTP_DOMAIN` | SMTP HELO domain. | Unset | +| `DF_SMTP_USERNAME` | SMTP username. | Unset | +| `DF_SMTP_PASSWORD` | SMTP password. | Unset | +| `DF_SMTP_AUTHENTICATION` | SMTP authentication method. | Unset | +| `DF_EMAIL_ERRORS_TO` | Address receiving PDF-generation errors. | Unset | + +### Integrations and background services + +| Variable | Purpose | Default | +| -------------------------------- | --------------------------------------------------------------- | ------- | +| `TII_ENABLED` | Enable Turnitin integration. | `false` | +| `TII_INDEX_SUBMISSIONS` | Index submissions in Turnitin. | `false` | +| `TII_REGISTER_WEBHOOK` | Register the Turnitin webhook. | `false` | +| `TCA_API_KEY` | Turnitin Core API key. | Unset | +| `TCA_HOST` | Turnitin institution host. | Unset | +| `DF_JPLAG_MIN_TOKENS` | Minimum matching-token threshold used by JPlag. | `-1` | +| `DF_JPLAG_SKIP_CLUSTER_CHECK` | Skip JPlag cluster calculation. | `false` | +| `DF_JPLAG_MAX_SHOWN_COMPARISONS` | Maximum comparisons retained in a JPlag report; `-1` means all. | `2500` | +| `LTI_ENABLED` | Enable LTI routes and authentication. | `false` | +| `LTI_SHARED_API_SECRET` | Shared secret between the Rails and LTI APIs. | Unset | +| `DF_ENFORCE_LOGIN_ID_MATCH` | Refuse accounts that already belong to a different login id. | `false` | +| `MODERATION_SCORE_FACTOR` | Multiplier applied to moderation score changes. | `1.0` | + +### Overseer and container access + +| Variable | Purpose | Default | +| ---------------------------------------------------- | ------------------------------------------------------------- | --------------------------------- | +| `OVERSEER_ENABLED` | Enable Overseer assessment services. | `false` | +| `OVERSEER_WORKDIR_VOLUME_MOUNT` | Host directory used for isolated Overseer work. | Required when Overseer is enabled | +| `OVERSEER_FALLBACK_VOLUME_CONTAINER` | Shared-container fallback when a host mount cannot be used. | Unset | +| `OVERSEER_STUDENT_NOTIFICATION_GRACE_PERIOD_MINUTES` | Delay before notifying students of unread failed assessments. | `30` | +| `DISK_SPACE_ENDPOINT_ENABLED` | Expose host storage availability to Overseer administrators. | `false` | +| `DOCKER_REGISTRY_URL` | Registry used for Overseer images. | Unset | +| `DOCKER_PROXY_URL` | Docker proxy or registry login endpoint. | Unset | +| `DOCKER_USER` | Docker registry username. | Unset | +| `DOCKER_TOKEN` | Docker registry access token. | Unset | + +### Error reporting + +| Variable | Purpose | Default | +| -------------------- | -------------------------------------------- | ----------------- | +| `SENTRY_DSN` | Sentry project data-source name. | Unset | +| `SENTRY_ENVIRONMENT` | Environment label attached to Sentry events. | Rails environment | + +## Testing + +OnTrack aims to follow a +[Test-Driven Development](https://en.wikipedia.org/wiki/Test-driven_development) +approach. New or changed models and API endpoints should be accompanied by +tests that describe and verify their expected behaviour. + +Run API commands inside the `doubtfire-deploy` Dev Container: + +```sh +cd /workspace/doubtfire-api +rails test + +# Running an individual test +rails test test/api/settings_test:14 # test_get_config_details ``` -Unit tests are located in the `test` directory, where **model** tests are under -the `model` subdirectory and **API** tests are under the `api` subdirectory. - -Any **helpers** should be included in the `helpers` subdirectory and helper -modules should be written under the `TestHelpers` module. +Tests are grouped under `test/models`, `test/api`, and `test/helpers`. Shared +test helpers should be placed in `test/helpers`, with helper modules defined +under the `TestHelpers` namespace. List the available maintenance and +development tasks with `rails --tasks`. -# Contributing +## Contributing -Refer to CONTRIBUTING.md +See [CONTRIBUTING.md](CONTRIBUTING.md). -# License +## License -Licensed under GNU Affero General Public License (AGPL) v3 +Licensed under the GNU Affero General Public License (AGPL) v3. diff --git a/app/api/admin/overseer_admin_api.rb b/app/api/admin/overseer_admin_api.rb index 25f3e86d8f..406bd297c1 100644 --- a/app/api/admin/overseer_admin_api.rb +++ b/app/api/admin/overseer_admin_api.rb @@ -143,5 +143,44 @@ class OverseerAdminApi < Grape::API rescue Sys::Filesystem::Error => e error!({ error: "Filesystem stat failed", detail: e.message }, 500) end + + desc 'Get basic institution statistics' + get '/admin/statistics' do + unless authorise? current_user, User, :admin_overseer + error!({ error: 'Not authorised to view institution statistics' }, 403) + end + + now = Time.current + activity_windows = { + fiveMinutes: 5.minutes, + fifteenMinutes: 15.minutes, + thirtyMinutes: 30.minutes, + oneHour: 1.hour, + twentyFourHours: 24.hours, + sevenDays: 7.days + } + + active_users = activity_windows.transform_values do |duration| + User.where(last_access_at: (now - duration)..).count + end + + disk_space_gb = nil + if Doubtfire::Application.config.disk_space_endpoint_enabled + stat = Sys::Filesystem.stat('/') + disk_space_gb = (stat.block_size * stat.blocks_available).to_f / 1024 / 1024 / 1024 + end + + present({ + activeUsers: active_users, + totalUsers: User.count, + diskSpaceGb: disk_space_gb&.round(2) + }, with: Grape::Presenters::Presenter) + rescue Sys::Filesystem::Error + present({ + activeUsers: active_users, + totalUsers: User.count, + diskSpaceGb: nil + }, with: Grape::Presenters::Presenter) + end end end diff --git a/app/api/api_root.rb b/app/api/api_root.rb index e36e21226e..5f4a659237 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -10,9 +10,6 @@ class ApiRoot < Grape::API format :json before do - header['Access-Control-Allow-Origin'] = '*' - header['Access-Control-Request-Method'] = '*' - Thread.current.thread_variable_set(:ip, request.ip) end @@ -47,6 +44,7 @@ class ApiRoot < Grape::API message = "Sorry... something went wrong with your request." status = 500 end + Sentry.capture_exception(e) Rack::Response.new({ error: message }.to_json, status, { 'Content-type' => 'text/error' }) end @@ -59,7 +57,9 @@ class ApiRoot < Grape::API mount AuthenticationApi mount BreaksApi mount DiscussionCommentApi + mount EngagementsApi mount ExtensionCommentsApi + mount LmsIntegrationApi mount ScormExtensionCommentsApi mount GroupSetsApi mount LearningOutcomesApi @@ -79,6 +79,7 @@ class ApiRoot < Grape::API mount SidekiqApi mount LtiApi if Doubtfire::Application.config.lti_enabled mount TaskPrerequisitesApi + mount CommunicationRulesApi mount Tii::TurnItInApi mount Tii::TurnItInHooksApi @@ -93,6 +94,7 @@ class ApiRoot < Grape::API mount TutorialStreamsApi mount TutorialEnrolmentsApi mount UnitRolesApi + mount UnitContentsApi mount UnitsApi mount TutorNotesApi @@ -116,7 +118,9 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to ActivityTypesAuthenticatedApi AuthenticationHelpers.add_auth_to BreaksApi AuthenticationHelpers.add_auth_to DiscussionCommentApi + AuthenticationHelpers.add_auth_to EngagementsApi AuthenticationHelpers.add_auth_to ExtensionCommentsApi + AuthenticationHelpers.add_auth_to LmsIntegrationApi AuthenticationHelpers.add_auth_to ScormExtensionCommentsApi AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi @@ -134,6 +138,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to SidekiqApi AuthenticationHelpers.add_auth_to LtiApi if Doubtfire::Application.config.lti_enabled AuthenticationHelpers.add_auth_to TaskPrerequisitesApi + AuthenticationHelpers.add_auth_to CommunicationRulesApi AuthenticationHelpers.add_auth_to Tii::TurnItInApi AuthenticationHelpers.add_auth_to Tii::TiiGroupAttachmentApi @@ -159,7 +164,7 @@ class ApiRoot < Grape::API add_swagger_documentation \ base_path: nil, - doc_version: 'v10.0.0', + doc_version: 'v11.0.0', hide_documentation_path: true, info: { title: 'Doubtfire API Documentation', diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index f2b525af3a..f64f53ce87 100644 --- a/app/api/authentication_api.rb +++ b/app/api/authentication_api.rb @@ -40,7 +40,7 @@ class AuthenticationApi < Grape::API # User lookup username = username.downcase institution_email_domain = Doubtfire::Application.config.institution[:email_domain] - user = User.find_or_create_by(username: username) do |new_user| + user = User.find_or_initialize_by(username: username) do |new_user| new_user.first_name = 'First Name' new_user.last_name = 'Surname' new_user.email = "#{username}@#{institution_email_domain}" @@ -50,10 +50,12 @@ class AuthenticationApi < Grape::API end # Try to authenticate with password - if password.present? && !user.authenticate?(password) + # A supplied password must always be authenticated. Using `present?` here + # allows blank and whitespace-only passwords to skip both credential checks. + if password && !user.authenticate?(password) error!({ error: 'Invalid email or password.' }, 401) return - elsif auth_token.present? && !authenticated?(:login) + elsif auth_token && !authenticated?(:login) error!({ error: 'Invalid user or auth token.' }, 401) return end @@ -77,10 +79,12 @@ class AuthenticationApi < Grape::API token&.destroy! token = user.generate_authentication_token! + user.record_sign_in! # Return user details present :user, user, with: Entities::UserEntity present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry set_refresh_cookie_in_response(remember) end end @@ -108,11 +112,9 @@ class AuthenticationApi < Grape::API logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id + # Lookup using username or email otherwise and set login_id # Otherwise create new - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + user = UserIdentity.find_user(login_id: user_id_data[:login_id], email: user_id_data[:email], username: user_id_data[:username]) || User.create do |new_user| # Update new user with details from the SAML response Doubtfire::Application.config.institution_settings.update_user_from_saml_response( @@ -122,13 +124,11 @@ class AuthenticationApi < Grape::API ) end - # Set login id + username if not yet specified - if user.login_id.nil? || user.username.nil? - user.update( - login_id: user_id_data[:login_id], - username: user_id_data[:username] - ) + if UserIdentity.blocked?(user, login_id: user_id_data[:login_id], email: user_id_data[:email], source: 'saml') + error!({ error: 'This account is linked to a different login. ' \ + 'Please get in contact with the system administrators.' }, 403) end + UserIdentity.link_identity(user, login_id: user_id_data[:login_id], username: user_id_data[:username]) # Try and save the user once authenticated if new if user.new_record? @@ -162,8 +162,10 @@ class AuthenticationApi < Grape::API requires :SAMLResponse, type: String, desc: 'SAML logout response data.' end post '/auth/saml_logout' do - response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], allowed_clock_drift: 1.second, - settings: AuthenticationHelpers.saml_settings) + response = OneLogin::RubySaml::Logoutresponse.new( + params[:SAMLResponse], + AuthenticationHelpers.saml_settings + ) # Check if the SAML response is valid - if not log an error unless response.is_valid? @@ -194,7 +196,8 @@ class AuthenticationApi < Grape::API # end end post '/auth/lti' do - token = decode_lti_token(params[:ltik]) + ensure_lti_service_request! + token = decode_lti_token(params[:ltik], purpose: 'auth') member = token['member'] if member.nil? @@ -206,20 +209,17 @@ class AuthenticationApi < Grape::API error!({ error: "Missing required fields: #{missing.join(', ')}" }, 400) end - user_id_data = { - login_id: member['ext_user_username'] || member['user_id'], - email: member['email'], - username: member['email']&.split('@')&.first - } + lti_identity = %w[user_id ext_user_username lis_person_sourcedid email].index_with { |key| member[key] } + logger.info "LTI user is logging in: #{lti_identity.to_json}" + + user_id_data = UserIdentity.lti_user_id_data(member) logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" # Lookup using login_id if it exists - # Lookup using email otherwise and set login_id + # Lookup using username or email otherwise and set login_id # Otherwise create new - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + user = UserIdentity.find_user(login_id: user_id_data[:login_id], email: user_id_data[:email], username: user_id_data[:username]) || User.create do |new_user| # Update new user with details from the LTI response Doubtfire::Application.config.institution_settings.update_user_from_lti_response( @@ -229,13 +229,11 @@ class AuthenticationApi < Grape::API ) end - # Set login id + username if not yet specified - if user.login_id.nil? || user.username.nil? - user.update( - login_id: user_id_data[:login_id], - username: user_id_data[:username] - ) + if UserIdentity.blocked?(user, login_id: user_id_data[:login_id], email: user_id_data[:email], source: 'lti_launch') + error!({ error: 'This account is linked to a different login. ' \ + 'Please get in contact with your unit convenor or the system administrators.' }, 403) end + UserIdentity.link_identity(user, login_id: user_id_data[:login_id], username: user_id_data[:username]) # Try and save the user once authenticated if new if user.new_record? @@ -249,6 +247,17 @@ class AuthenticationApi < Grape::API user.save end + # Fill a missing student ID from the LMS person identifier when it is available and unused. + sourced_id = member['lis_person_sourcedid'].to_s.strip.presence + if user.role_id == Role.student_id && user.student_id.blank? && sourced_id.present? && + !User.exists?(student_id: sourced_id) + begin + user.update(student_id: sourced_id) + rescue ActiveRecord::RecordNotUnique + # Another launch claimed this ID between the lookup and update. + end + end + # Generate a temporary auth_token for future requests onetime_token = user.generate_temporary_authentication_token! @@ -370,12 +379,14 @@ class AuthenticationApi < Grape::API # Invalidate the token and regenrate a new one token.destroy! token = user.generate_authentication_token! + user.record_sign_in! logger.info "Login #{params[:username]} from #{request.ip}" # Respond user details with new auth token present :user, user, with: Entities::UserEntity present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry set_refresh_cookie_in_response(params[:remember]) end end @@ -438,6 +449,7 @@ class AuthenticationApi < Grape::API end delete '/auth' do user = User.find_by(username: headers['username'] || headers['Username']) + signing_out_user = user token = user&.token_for_text?(headers['auth-token'] || headers['Auth-Token'], :general) if token.present? @@ -450,6 +462,7 @@ class AuthenticationApi < Grape::API user_param = cookies['username'] user = User.find_by(username: user_param) + signing_out_user ||= user token = user&.token_for_text?(auth_param, :refresh_token) if token.present? logger.info "Destroy refresh token for #{user.username} from #{request.ip}" @@ -459,6 +472,8 @@ class AuthenticationApi < Grape::API # Remove the refresh token cookie - if remember is false set_refresh_cookie_in_response(false) unless params[:remember] + signing_out_user&.auth_tokens&.where(token_type: :content)&.destroy_all + set_content_cookie_in_response present nil end @@ -479,12 +494,28 @@ class AuthenticationApi < Grape::API end end + desc 'Get unit content authentication token' + get '/auth/content' do + if authenticated?(:general) + token = current_user.auth_tokens.find_by(token_type: :content) + if token.nil? || token.auth_token_expiry <= Time.zone.now + token&.destroy + token = current_user.generate_content_authentication_token! + end + + set_content_cookie_in_response(token) + present :content_access, true + end + end + desc 'Get access token from the refresh token cookie' params do optional :delete_auth_token, type: Boolean, desc: 'Delete the auth token if also provided', default: true end post '/auth/access-token' do if authenticated_via_refresh_token? + current_user.record_access! + # Check if we have a auth token as well if params[:delete_auth_token] user_param, auth_param = get_user_and_token_from(:header) @@ -499,8 +530,10 @@ class AuthenticationApi < Grape::API end end # Return user details + token = current_user.generate_authentication_token!(token_type: :general, force_new: false) present :user, current_user, with: Entities::UserEntity - present :auth_token, current_user.generate_authentication_token!(token_type: :general, force_new: false).authentication_token + present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry else present nil end diff --git a/app/api/breaks_api.rb b/app/api/breaks_api.rb index 2bcb24601e..01187be5aa 100644 --- a/app/api/breaks_api.rb +++ b/app/api/breaks_api.rb @@ -11,7 +11,10 @@ class BreaksApi < Grape::API desc 'Add a new break to the teaching period' params do requires :start_date, type: Date, desc: 'The start date of the break' - requires :number_of_weeks, type: Integer, desc: 'Break duration' + requires :number_of_days, type: Integer, desc: 'Break duration in days' + optional :label, type: String, desc: 'A label describing the break' + optional :campus_ids, type: Array[Integer], desc: 'Campuses this break applies to; empty applies to all campuses' + optional :pause_week_count, type: Boolean, desc: 'Does this break pause the teaching period week count - only valid for breaks that are a multiple of 7 days' end post '/teaching_periods/:teaching_period_id/breaks' do unless authorise? current_user, User, :handle_teaching_period @@ -22,16 +25,19 @@ class BreaksApi < Grape::API teaching_period = TeachingPeriod.find(params[:teaching_period_id]) start_date = params[:start_date] - number_of_weeks = params[:number_of_weeks] + number_of_days = params[:number_of_days] - result = teaching_period.add_break(start_date, number_of_weeks) + result = teaching_period.add_break(start_date, number_of_days, params[:campus_ids], params[:label], params[:pause_week_count]) present result, with: Entities::BreakEntity end desc 'Update a break in the teaching period' params do optional :start_date, type: Date, desc: 'The start date of the break' - optional :number_of_weeks, type: Integer, desc: 'Break duration' + optional :number_of_days, type: Integer, desc: 'Break duration in days' + optional :label, type: String, desc: 'A label describing the break' + optional :campus_ids, type: Array[Integer], desc: 'Campuses this break applies to; empty applies to all campuses' + optional :pause_week_count, type: Boolean, desc: 'Does this break pause the teaching period week count - only valid for breaks that are a multiple of 7 days' end put '/teaching_periods/:teaching_period_id/breaks/:id' do unless authorise? current_user, User, :handle_teaching_period @@ -43,9 +49,9 @@ class BreaksApi < Grape::API id = params[:id] start_date = params[:start_date] - number_of_weeks = params[:number_of_weeks] + number_of_days = params[:number_of_days] - result = teaching_period.update_break(id, start_date, number_of_weeks) + result = teaching_period.update_break(id, start_date, number_of_days, params[:campus_ids], params[:label], params[:pause_week_count]) present result, with: Entities::BreakEntity end diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb new file mode 100644 index 0000000000..701610fa07 --- /dev/null +++ b/app/api/communication_rules_api.rb @@ -0,0 +1,850 @@ +require 'grape' +require 'entities/communication_set_entity' +require 'entities/communication_rule_entity' +require 'entities/communication_condition_entity' +require 'entities/communication_action_entity' +require 'entities/communication_set_schedule_entity' +require 'entities/sidekiq_job_entity' + +class CommunicationRulesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers SidekiqHelper + helpers do + def permitted_schedule_params(raw_schedule) + ActionController::Parameters.new(raw_schedule).permit( + :id, + :name, + :active, + :anchor_week, + :anchor_day, + :hour, + :minute, + :timezone, + :recurrence, + :interval, + :repeat_count, + :until_at + ) + end + + def permitted_condition_params(raw_condition) + ActionController::Parameters.new(raw_condition).permit( + :type, + :operator, + :target_grade, + :task_definition_id, + :task_status_count, + :task_target_grade, + :last_sign_in_at, + :activity_days, + :spec_con_days, + :tutorial_id, + :tutorial_stream_id, + :campus_id, + :group_set_id, + :group_id, + :submitted_portfolio, + task_statuses: [] + ).to_h.compact + end + + # `project_id` is what clients use to subtract students claimed by earlier rules. + def preview_student_payload(project) + user = project.user + + { + project_id: project.id, + first_name: user&.first_name, + last_name: user&.last_name, + preferred_name: user&.nickname, + username: user&.username, + student_id: user&.student_id, + full_name: [user&.first_name, user&.last_name].compact.join(' '), + target_grade: project.target_grade, + spec_con_days: project.spec_con_days, + has_portfolio: project.portfolio_exists?, + last_sign_in_at: user&.last_sign_in_at, + last_viewed_at: project.last_viewed_at, + campus: project.campus&.name + } + end + + def schedule_params_from_request + communication_set_params = params[:communication_set] || params['communication_set'] || {} + communication_set_params[:schedules] || communication_set_params['schedules'] + end + + def reject_unresolved!(communication_set) + return if communication_set.executable? + + error!( + { + error: 'This communication set references records that do not exist in this unit', + unresolved_rules: communication_set.unresolved_rules.map { |rule| { id: rule.id, name: rule.name } } + }, + 409 + ) + end + + def sync_set_schedules!(communication_set, raw_schedules) + schedules = Array(raw_schedules).map { |schedule| permitted_schedule_params(schedule).to_h } + + keep_ids = schedules.filter_map { |schedule| schedule['id'] || schedule[:id] } + + communication_set.transaction do + communication_set.communication_set_schedules.where.not(id: keep_ids).destroy_all + + schedules.each do |schedule_attrs| + schedule_id = schedule_attrs.delete('id') || schedule_attrs.delete(:id) + + if schedule_id.present? + communication_set.communication_set_schedules.find(schedule_id).update!(schedule_attrs) + else + communication_set.communication_set_schedules.create!(schedule_attrs) + end + end + end + end + end + + before do + authenticated? + + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :mannage_communications + error!({ error: 'Not authorised to manage unit communications' }, 403) + end + end + + desc 'Get communication sets for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_sets.includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]), + with: Entities::CommunicationSetEntity + end + + desc 'Get a communication set for a unit' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets + .includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]) + .find(params[:id]) + + # Previews are loaded per rule -- running the whole set here times out on + # large units. + present( + id: communication_set.id, + unit_id: communication_set.unit_id, + name: communication_set.name, + active: communication_set.active, + executable: communication_set.executable?, + eligible_student_count: communication_set.eligible_project_count, + schedules: Entities::CommunicationSetScheduleEntity.represent(communication_set.communication_set_schedules), + rules: Entities::CommunicationRuleEntity.represent(communication_set.communication_rules) + ) + end + + desc 'Create a communication set for a unit' + params do + requires :unit_id, type: Integer + requires :communication_set, type: Hash do + requires :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + post '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set = unit.communication_sets.create!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Update a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_set, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :id, type: Integer + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + put '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set.update!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) if schedule_params_from_request.present? || (params[:communication_set] || params['communication_set'] || {}).key?(:schedules) || (params[:communication_set] || params['communication_set'] || {}).key?('schedules') + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Delete a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + unit.communication_sets.find(params[:id]).destroy! + status 204 + end + + desc 'Execute a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_sets/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + reject_unresolved!(communication_set) + job_id = ExecuteCommunicationSetJob.perform_async(communication_set.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Get schedules for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + present communication_set.communication_set_schedules.order(:id), + with: Entities::CommunicationSetScheduleEntity + end + + desc 'Get a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Create a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_set_schedule, type: Hash do + requires :name, type: String + optional :active, type: Boolean + requires :anchor_week, type: Integer + requires :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule = communication_set.communication_set_schedules.create!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Update a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + requires :communication_set_schedule, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + put '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule.update!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Delete a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + communication_set.communication_set_schedules.find(params[:id]).destroy! + status 204 + end + + desc 'Get communication rules for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Get communication rules for a set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + + present communication_set.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Create a communication rule for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_rule, type: Hash do + requires :name, type: String + requires :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule_params[:position] = communication_set.communication_rules.count if rule_params[:position].nil? + rule = communication_set.communication_rules.create!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Update a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_rule, type: Hash do + optional :name, type: String + optional :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + put '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule.update!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Execute a communication rule within its communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_rules/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + reject_unresolved!(rule.communication_set) + job_id = ExecuteCommunicationSetJob.perform_async(rule.communication_set_id, rule.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + # Callers reproduce the set's "first matching rule claims the student" + # behaviour by subtracting the students returned for earlier rules. + desc 'Preview projects matched by a communication rule, evaluated in isolation' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_rules/:id/preview' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to preview unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + communication_set = rule.communication_set + matched_projects = communication_set.independent_matches_for_rule(rule) + + present( + rule_id: rule.id, + rule_name: rule.name, + position: rule.position, + eligible_student_count: communication_set.eligible_projects.length, + evaluated_at: Time.current, + students: matched_projects.map { |project| preview_student_payload(project) } + ) + end + + desc 'Get communication conditions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_conditions, with: Entities::CommunicationConditionEntity + end + + desc 'Delete a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + communication_set = rule.communication_set + + CommunicationRule.transaction do + rule.destroy! + communication_set.communication_rules.each_with_index do |remaining_rule, position| + remaining_rule.update!(position: position) + end + end + status 204 + end + + desc 'Create a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_condition, type: Hash do + requires :type, type: String + requires :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :activity_days, type: Integer + optional :spec_con_days, type: Integer + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + optional :group_set_id, type: Integer + optional :group_id, type: Integer + optional :submitted_portfolio, type: Boolean + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + condition = rule.communication_conditions.create!(permitted_condition_params(params[:communication_condition])) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Update a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_condition, type: Hash do + optional :type, type: String + optional :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :activity_days, type: Integer + optional :spec_con_days, type: Integer + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + optional :group_set_id, type: Integer + optional :group_id, type: Integer + optional :submitted_portfolio, type: Boolean + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + condition = rule.communication_conditions.find(params[:id]) + condition.update!(permitted_condition_params(params[:communication_condition])) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Get communication actions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_actions, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_conditions.find(params[:id]).destroy! + status 204 + end + + desc 'Create a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_action, type: Hash do + requires :type, type: String + optional :subject, type: String + optional :body, type: String + optional :plain_text, type: Boolean + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit( + :type, + :subject, + :body, + :plain_text, + :email_tutors, + :email_convenors, + :target_grade, + :task_definition_id + ) + + action = rule.communication_actions.create!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Update a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_action, type: Hash do + optional :type, type: String + optional :subject, type: String + optional :body, type: String + optional :plain_text, type: Boolean + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action = rule.communication_actions.find(params[:id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit(:type, :subject, :body, :plain_text, :email_tutors, :email_convenors, :target_grade, :task_definition_id) + + action.update!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_actions.find(params[:id]).destroy! + status 204 + end + + desc 'Copy a communication set as a portable document' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:id/export' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present CommunicationTransfer.export_set(unit.communication_sets.find(params[:id])) + end + + desc 'Copy a communication rule as a portable document' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_rules/:id/export' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present CommunicationTransfer.export_rule(unit.communication_rules.find(params[:id])) + end + + desc 'Import a communication set from a copied document' + params do + requires :unit_id, type: Integer + requires :document, type: Hash + end + post '/units/:unit_id/communication_sets/import' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + begin + communication_set = CommunicationTransfer.import_set(params[:document], unit) + rescue CommunicationTransfer::InvalidDocument => e + error!({ error: e.message }, 400) + end + + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Import a communication rule into an existing set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :document, type: Hash + end + post '/units/:unit_id/communication_sets/:communication_set_id/rules/import' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + + begin + rule = CommunicationTransfer.import_rule(params[:document], communication_set) + rescue CommunicationTransfer::InvalidDocument => e + error!({ error: e.message }, 400) + end + + present rule, with: Entities::CommunicationRuleEntity + end +end diff --git a/app/api/discussion_comment_api.rb b/app/api/discussion_comment_api.rb index ffd70117fc..72658f15b1 100644 --- a/app/api/discussion_comment_api.rb +++ b/app/api/discussion_comment_api.rb @@ -149,7 +149,7 @@ class DiscussionCommentApi < Grape::API discussion_comment = task.all_comments.find(params[:task_comment_id]) # discussion_comment.mark_discussion_completed # mark comment read for student - discussion_comment.mark_as_read(current_user, project.unit) + discussion_comment.mark_as_read(current_user) error!({ error: 'No discussion comment found for the given task' }, 403) if discussion_comment.nil? diff --git a/app/api/discussion_prompts_api.rb b/app/api/discussion_prompts_api.rb index 77e9aa4987..34b11641be 100644 --- a/app/api/discussion_prompts_api.rb +++ b/app/api/discussion_prompts_api.rb @@ -108,7 +108,7 @@ class DiscussionPromptsApi < Grape::API error!({ error: 'You do not have permission to access this project' }, 403) end - tasks_to_discuss = project.tasks.where(task_status: [TaskStatus.discuss, TaskStatus.attention_required, TaskStatus.demonstrate]) + tasks_to_discuss = project.tasks.where(task_status: [TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.attention_required, TaskStatus.demonstrate]) task_definition_ids = tasks_to_discuss.pluck(:task_definition_id) result = DiscussionPrompt diff --git a/app/api/engagements_api.rb b/app/api/engagements_api.rb new file mode 100644 index 0000000000..d5255fccbc --- /dev/null +++ b/app/api/engagements_api.rb @@ -0,0 +1,302 @@ +require 'grape' + +class EngagementsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileStreamHelper + + before do + authenticated? + end + + helpers do + def engagements_for(project) + Engagement + .left_joins(:engagement_projects) + .where( + 'engagements.project_id = :project_id OR engagement_projects.project_id = :project_id', + project_id: project.id + ) + .distinct + end + + def engagement_for(project) + engagements_for(project).find(params[:id]) + end + + def validate_evidence!(attachment, evidence_url, remove_evidence: false) + if remove_evidence && (attachment.present? || evidence_url.present?) + error!({ error: 'Cannot remove evidence and provide replacement evidence together.' }, 400) + end + + if attachment.present? && evidence_url.present? + error!({ error: 'Provide either an attachment or an evidence URL, not both.' }, 400) + end + + return nil if attachment.blank? + + error!({ error: 'Attachment is empty.' }, 400) if File.size?(attachment['tempfile'].path).blank? + if File.size?(attachment['tempfile'].path) >= 30_000_000 + error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 400) + end + + image_result = FileHelper.accept_file(attachment, 'engagement evidence image', 'image') + return 'image' if image_result[:accepted] + + pdf_result = FileHelper.accept_file(attachment, 'engagement evidence PDF', 'document') + return 'pdf' if pdf_result[:accepted] + + error!({ error: "File is not an acceptable image or PDF: #{pdf_result[:msg]}" }, 400) + end + end + + desc 'Get engagements for a project' + get '/projects/:project_id/engagements' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view these engagements.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagements = engagements_for(project) + .includes(:user, :engagement_comments, project: :user, additional_projects: :user) + .order(:occurred_at, :created_at) + present engagements, with: Entities::EngagementEntity + end + + desc 'Get an engagement for a project' + get '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view this engagement.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagement = engagements_for(project) + .includes(:user, project: :user, additional_projects: :user, engagement_comments: :user) + .find(params[:id]) + present engagement, with: Entities::EngagementDetailEntity + end + + desc 'Record an automatic class discussion engagement' + params do + requires :task_status_updates, type: Array do + requires :task_definition_id, type: Integer + requires :from_status, type: String + requires :to_status, type: String + end + end + post '/projects/:project_id/engagements/class_discussion' do + project = Project.find(params[:project_id]) + unless authorise?(current_user, project, :create_engagement) + error!({ error: 'You do not have permission to record a class discussion.' }, 403) + end + + task_status_updates = params[:task_status_updates].map do |update| + task_definition = project.unit.task_definitions.find(update[:task_definition_id]) + from_status = TaskStatus.status_for_name(update[:from_status]) + to_status = TaskStatus.status_for_name(update[:to_status]) + error!({ error: 'Invalid task status supplied for class discussion.' }, 422) unless from_status && to_status + + { + task: task_definition.abbreviation, + from: from_status.name, + to: to_status.name + } + end + + engagement = EngagementTracker.record_class_discussion( + user: current_user, + project: project, + task_status_updates: task_status_updates + ) + + present({ recorded: engagement.present? }, with: Grape::Presenters::Presenter) + end + + desc 'Create an engagement for a project' + params do + requires :engagement_type, type: String + requires :note, type: String + requires :occurred_at, type: DateTime + optional :project_ids, type: Array[Integer] + optional :evidence_url, type: String + optional :attachment, type: File + end + post '/projects/:project_id/engagements' do + project = Project.find(params[:project_id]) + project_ids = [project.id, *params.fetch(:project_ids, [])].uniq + projects = Project.where(id: project_ids).to_a + + unless projects.size == project_ids.size + error!({ error: 'One or more selected students could not be found.' }, 404) + end + unless projects.all? { |recipient| recipient.unit_id == project.unit_id } + error!({ error: 'All students must belong to the same unit.' }, 422) + end + unless projects.all? { |recipient| authorise?(current_user, recipient, :create_engagement) } + error!( + { error: 'You do not have permission to create an engagement for one or more selected students.' }, + 403 + ) + end + + attachment = params[:attachment] + attachment_type = validate_evidence!(attachment, params[:evidence_url]) + + engagement = nil + Engagement.transaction do + engagement = project.engagements.create!( + user: current_user, + engagement_type: params[:engagement_type], + note: params[:note], + occurred_at: params[:occurred_at], + evidence_url: attachment.present? ? nil : params[:evidence_url] + ) + engagement.additional_projects = projects.reject { |recipient| recipient.id == project.id } + end + + engagement.replace_attachment(attachment, attachment_type) if attachment.present? + present engagement, with: Entities::EngagementDetailEntity + rescue StandardError + engagement&.destroy + raise + end + + desc 'Update an engagement for a project' + params do + optional :engagement_type, type: String + optional :note, type: String + optional :occurred_at, type: DateTime + optional :evidence_url, type: String + optional :attachment, type: File + optional :remove_evidence, type: Boolean, default: false + end + put '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + + can_edit = authorise?(current_user, project, :edit_engagement) && engagement.user_id == current_user.id + error!({ error: 'You do not have permission to edit this engagement.' }, 403) unless can_edit + + attachment = params[:attachment] + evidence_url = params.key?(:evidence_url) ? params[:evidence_url] : nil + attachment_type = validate_evidence!( + attachment, + evidence_url, + remove_evidence: params[:remove_evidence] + ) + + attributes = {} + attributes[:engagement_type] = params[:engagement_type] if params.key?(:engagement_type) + attributes[:note] = params[:note] if params.key?(:note) + attributes[:occurred_at] = params[:occurred_at] if params.key?(:occurred_at) + + if params[:remove_evidence] + engagement.remove_attachment + engagement.evidence_url = nil + elsif attachment.present? + engagement.assign_attributes(attributes) + engagement.replace_attachment(attachment, attachment_type) + attributes = {} + elsif params.key?(:evidence_url) + engagement.remove_attachment + engagement.evidence_url = evidence_url + end + + engagement.update!(attributes) + present engagement.reload, with: Entities::EngagementDetailEntity + end + + desc 'Delete an engagement for a project' + delete '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + + # Convenors can delete any engagement, tutors can only delete their own + can_delete_any = authorise?(current_user, project.unit, :delete_engagement) + can_delete_own = engagement.user_id == current_user.id && + authorise?(current_user, project, :delete_engagement) + error!({ error: 'You do not have permission to delete this engagement.' }, 403) unless can_delete_any || can_delete_own + + # Convenors get a longer window to delete engagements than their tutors do + delete_window = can_delete_any ? Engagement::CONVENOR_DELETE_WINDOW : Engagement::DELETE_WINDOW + unless engagement.within_delete_window?(delete_window) + error!( + { error: "Engagements can only be deleted within #{delete_window.inspect} of being created." }, + 403 + ) + end + + engagement.destroy! + present engagement.destroyed?, with: Grape::Presenters::Presenter + end + + desc 'Get evidence attached to an engagement' + params do + optional :as_attachment, type: Boolean, default: false + end + get '/projects/:project_id/engagements/:id/attachment' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view this evidence.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagement = engagement_for(project) + error!({ error: 'No attachment for this engagement.' }, 404) unless engagement.attachment? + error!({ error: 'File missing.' }, 404) unless File.exist?(engagement.attachment_path) + + content_type engagement.attachment_mime_type + env['api.format'] = :binary + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{engagement.attachment_file_name}" + end + + stream_file engagement.attachment_path + end + + desc 'Add a comment to an engagement' + params do + requires :comment, type: String + optional :reply_to_id, type: Integer + end + post '/projects/:project_id/engagements/:id/comments' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to comment on this engagement.' }, 403) unless authorise?(current_user, project, :comment_engagement) + + engagement = engagement_for(project) + reply_to = engagement.engagement_comments.find(params[:reply_to_id]) if params[:reply_to_id].present? + comment = engagement.engagement_comments.create!( + user: current_user, + comment: params[:comment], + reply_to: reply_to + ) + present comment, with: Entities::EngagementCommentEntity + end + + desc 'Update an engagement comment' + params do + requires :comment, type: String + end + put '/projects/:project_id/engagements/:id/comments/:comment_id' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to comment on this engagement.' }, 403) unless authorise?(current_user, project, :comment_engagement) + + engagement = engagement_for(project) + comment = engagement.engagement_comments.find(params[:comment_id]) + error!({ error: 'You can only edit your own comments.' }, 403) unless comment.user_id == current_user.id + if comment.created_at < 10.minutes.ago + error!({ error: 'Comments can only be edited within 10 minutes of being created.' }, 403) + end + + comment.update!(comment: params[:comment]) + present comment, with: Entities::EngagementCommentEntity + end + + desc 'Delete an engagement comment' + delete '/projects/:project_id/engagements/:id/comments/:comment_id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + comment = engagement.engagement_comments.find(params[:comment_id]) + + can_delete_own = comment.user_id == current_user.id && + authorise?(current_user, project, :comment_engagement) + can_delete_any = authorise?(current_user, project.unit, :delete_engagement) + error!({ error: 'You do not have permission to delete this comment.' }, 403) unless can_delete_own || can_delete_any + + comment.destroy! + present comment.destroyed?, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/entities/break_entity.rb b/app/api/entities/break_entity.rb index f21c06e576..2714c955d8 100644 --- a/app/api/entities/break_entity.rb +++ b/app/api/entities/break_entity.rb @@ -10,6 +10,9 @@ class BreakEntity < Grape::Entity expose :start_date end - expose :number_of_weeks + expose :number_of_days + expose :label + expose :campus_ids + expose :pause_week_count end end diff --git a/app/api/entities/communication_action_entity.rb b/app/api/entities/communication_action_entity.rb new file mode 100644 index 0000000000..eaf6484a58 --- /dev/null +++ b/app/api/entities/communication_action_entity.rb @@ -0,0 +1,15 @@ +module Entities + class CommunicationActionEntity < Grape::Entity + expose :id + expose :type + expose :communication_rule_id + expose :subject + expose :body + expose :plain_text + expose :email_tutors + expose :email_convenors + expose :target_grade + expose :task_definition_id + expose :unresolved?, as: :unresolved + end +end diff --git a/app/api/entities/communication_condition_entity.rb b/app/api/entities/communication_condition_entity.rb new file mode 100644 index 0000000000..28b3c0aeb4 --- /dev/null +++ b/app/api/entities/communication_condition_entity.rb @@ -0,0 +1,23 @@ +module Entities + class CommunicationConditionEntity < Grape::Entity + expose :id + expose :type + expose :communication_id, as: :communication_rule_id + expose :operator + expose :target_grade + expose :task_definition_id + expose :unresolved?, as: :unresolved + expose :task_statuses + expose :task_status_count + expose :task_target_grade + expose :last_sign_in_at + expose :activity_days + expose :spec_con_days + expose :tutorial_id + expose :tutorial_stream_id + expose :campus_id + expose :group_set_id + expose :group_id + expose :submitted_portfolio + end +end diff --git a/app/api/entities/communication_rule_entity.rb b/app/api/entities/communication_rule_entity.rb new file mode 100644 index 0000000000..5efc40cf6c --- /dev/null +++ b/app/api/entities/communication_rule_entity.rb @@ -0,0 +1,18 @@ +module Entities + class CommunicationRuleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :operator + expose :position + expose :active + expose :send_log_to_convenors + expose :unresolved?, as: :unresolved + expose :communication_conditions, + as: :conditions, + using: Entities::CommunicationConditionEntity + expose :communication_actions, + as: :actions, + using: Entities::CommunicationActionEntity + end +end diff --git a/app/api/entities/communication_set_entity.rb b/app/api/entities/communication_set_entity.rb new file mode 100644 index 0000000000..a38dace91b --- /dev/null +++ b/app/api/entities/communication_set_entity.rb @@ -0,0 +1,18 @@ +require 'entities/communication_rule_entity' +require 'entities/communication_set_schedule_entity' + +module Entities + class CommunicationSetEntity < Grape::Entity + expose :id + expose :unit_id + expose :name + expose :active + expose :executable?, as: :executable + expose :communication_set_schedules, + as: :schedules, + using: Entities::CommunicationSetScheduleEntity + expose :communication_rules, + as: :rules, + using: Entities::CommunicationRuleEntity + end +end diff --git a/app/api/entities/communication_set_schedule_entity.rb b/app/api/entities/communication_set_schedule_entity.rb new file mode 100644 index 0000000000..1c1573b024 --- /dev/null +++ b/app/api/entities/communication_set_schedule_entity.rb @@ -0,0 +1,21 @@ +module Entities + class CommunicationSetScheduleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :active + expose :anchor_week + expose :anchor_day + expose :hour + expose :minute + expose :timezone + expose :recurrence + expose :interval + expose :repeat_count + expose :until_at + expose :ice_cube_schedule + expose :next_run_at + expose :last_run_at + expose :last_enqueued_at + end +end diff --git a/app/api/entities/engagement_comment_entity.rb b/app/api/entities/engagement_comment_entity.rb new file mode 100644 index 0000000000..4abf5deb8a --- /dev/null +++ b/app/api/entities/engagement_comment_entity.rb @@ -0,0 +1,10 @@ +module Entities + class EngagementCommentEntity < Grape::Entity + expose :id + expose :comment + expose :reply_to_id + expose :user, using: Entities::Minimal::MinimalUserEntity + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/engagement_detail_entity.rb b/app/api/entities/engagement_detail_entity.rb new file mode 100644 index 0000000000..d0bf9fc591 --- /dev/null +++ b/app/api/entities/engagement_detail_entity.rb @@ -0,0 +1,7 @@ +module Entities + class EngagementDetailEntity < EngagementEntity + expose :engagement_comments, + as: :comments, + using: Entities::EngagementCommentEntity + end +end diff --git a/app/api/entities/engagement_entity.rb b/app/api/entities/engagement_entity.rb new file mode 100644 index 0000000000..0841439c00 --- /dev/null +++ b/app/api/entities/engagement_entity.rb @@ -0,0 +1,24 @@ +module Entities + class EngagementEntity < Grape::Entity + expose :id + expose :project_id + expose :engagement_type + expose :note + expose :occurred_at + expose :evidence_url + expose :content_type + expose :has_attachment do |engagement, _options| + engagement.attachment? + end + expose :attachment_file_name, if: ->(engagement, _) { engagement.attachment? } + expose :user, using: Entities::Minimal::MinimalUserEntity + expose :students, using: Entities::Minimal::MinimalUserEntity do |engagement| + [engagement.project.user, *engagement.additional_projects.map(&:user)] + end + expose :comment_count do |engagement| + engagement.engagement_comments.size + end + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/lms_group_mapping_entity.rb b/app/api/entities/lms_group_mapping_entity.rb new file mode 100644 index 0000000000..1df7696240 --- /dev/null +++ b/app/api/entities/lms_group_mapping_entity.rb @@ -0,0 +1,14 @@ +module Entities + class LmsGroupMappingEntity < Grape::Entity + expose :id + expose :lms_group_id + expose :lms_group_name + expose :target_type + expose :group_set_id + expose :group_id + expose :campus_id + expose :tutorial_stream_id + expose :tutorial_id + expose :create_if_missing + end +end diff --git a/app/api/entities/lms_integration_entity.rb b/app/api/entities/lms_integration_entity.rb new file mode 100644 index 0000000000..eac407a7ac --- /dev/null +++ b/app/api/entities/lms_integration_entity.rb @@ -0,0 +1,25 @@ +require 'entities/lms_group_mapping_entity' + +module Entities + class LmsIntegrationEntity < Grape::Entity + expose :id + expose :source + expose :assignment_id + expose :assignment_name + expose :fetch_extensions + expose :auto_sync_students + expose :withdraw_missing_students + expose :auto_sync_extensions + expose :group_mapping_enabled + expose :skip_ungraded + expose :send_grade_rationale + expose :validated + expose :validated_at + expose :auto_sync_failing_since + expose :auto_sync_last_error + expose :auto_sync_turn_off_date + expose :lms_group_mappings, + as: :group_mappings, + using: Entities::LmsGroupMappingEntity + end +end diff --git a/app/api/entities/minimal/minimal_unit_entity.rb b/app/api/entities/minimal/minimal_unit_entity.rb index bbead2cd81..453c6c9e40 100644 --- a/app/api/entities/minimal/minimal_unit_entity.rb +++ b/app/api/entities/minimal/minimal_unit_entity.rb @@ -17,9 +17,15 @@ class MinimalUnitEntity < Grape::Entity with_options(format_with: :date_only) do expose :start_date expose :end_date + expose :last_teaching_date end expose :active + expose :grade_values + expose :grade_definitions + expose :discuss_timeout_enabled, unless: :summary_only + expose :discuss_timeout_active?, as: :discuss_timeout_active, unless: :summary_only + expose :discuss_timeout_expire_days, unless: :summary_only end end end diff --git a/app/api/entities/overseer_assessment_entity.rb b/app/api/entities/overseer_assessment_entity.rb index e44d84d4ee..4e893a5d0b 100644 --- a/app/api/entities/overseer_assessment_entity.rb +++ b/app/api/entities/overseer_assessment_entity.rb @@ -2,6 +2,7 @@ module Entities class OverseerAssessmentEntity < Grape::Entity expose :id expose :task_id + expose :submission_history_id expose :submission_timestamp expose :result_task_status expose :status diff --git a/app/api/entities/staff_note_entity.rb b/app/api/entities/staff_note_entity.rb index 468a298a55..e9992a4fcf 100644 --- a/app/api/entities/staff_note_entity.rb +++ b/app/api/entities/staff_note_entity.rb @@ -9,6 +9,10 @@ class StaffNoteEntity < Grape::Entity expose :updated_at expose :reply_to_id + expose :attachment_type + expose :has_attachment do |note, _options| + note.attachment? + end end end diff --git a/app/api/entities/submission_history_entity.rb b/app/api/entities/submission_history_entity.rb new file mode 100644 index 0000000000..d33eee7193 --- /dev/null +++ b/app/api/entities/submission_history_entity.rb @@ -0,0 +1,13 @@ +module Entities + class SubmissionHistoryEntity < Grape::Entity + expose :id + expose :task_id + expose :submission_timestamp + expose :created_at + expose :has_submission_files?, as: :has_submission_files + + expose :overseer_assessment_id do |history| + history.overseer_assessment&.id + end + end +end diff --git a/app/api/entities/task_definition_entity.rb b/app/api/entities/task_definition_entity.rb index 04c15e74d0..aa10c6d133 100644 --- a/app/api/entities/task_definition_entity.rb +++ b/app/api/entities/task_definition_entity.rb @@ -1,3 +1,5 @@ +require 'entities/unit_content_link_entity' + module Entities class TaskDefinitionEntity < Grape::Entity format_with(:date_only) do |date| @@ -19,16 +21,10 @@ def staff?(my_role) expose :target_date expose :due_date expose :start_date - # expose :p_target_date, expose_nil: false - expose :c_target_date, expose_nil: false - expose :d_target_date, expose_nil: false - expose :hd_target_date, expose_nil: false - - expose :c_start_date, expose_nil: false - expose :d_start_date, expose_nil: false - expose :hd_start_date, expose_nil: false end + expose :grade_due_date_overrides, as: :grade_due_dates, expose_nil: false + expose :upload_requirements, expose_nil: false do |task_definition, options| if staff?(options[:my_role]) task_definition.upload_requirements @@ -43,12 +39,17 @@ def staff?(my_role) end expose :plagiarism_warn_pct, if: ->(unit, options) { staff?(options[:my_role]) } expose :restrict_status_updates, if: ->(unit, options) { staff?(options[:my_role]) } + expose :staff_managed_task expose :group_set_id, expose_nil: false expose :has_task_sheet?, as: :has_task_sheet expose :has_task_resources?, as: :has_task_resources expose :has_task_assessment_resources?, as: :has_task_assessment_resources, if: ->(unit, options) { staff?(options[:my_role]) } expose :has_task_assessment_script?, as: :has_task_assessment_script, if: ->(unit, options) { staff?(options[:my_role]) } expose :has_scorm_data?, as: :has_scorm_data + expose :has_content_link?, as: :has_content_link + expose :content_link, using: UnitContentLinkEntity, expose_nil: false + expose :has_task_resource_link?, as: :has_task_resource_link + expose :task_resource_link, using: UnitContentLinkEntity, expose_nil: false expose :scorm_enabled expose :scorm_allow_review expose :scorm_bypass_test diff --git a/app/api/entities/task_entity.rb b/app/api/entities/task_entity.rb index 1de8fb6b39..8f3a696075 100644 --- a/app/api/entities/task_entity.rb +++ b/app/api/entities/task_entity.rb @@ -16,8 +16,12 @@ class TaskEntity < Grape::Entity expose :completion_date, expose_nil: false expose :target_due_date, expose_nil: false expose :target_start_date, expose_nil: false + expose :discuss_timeout_expiry_date, expose_nil: false end + expose :moved_to_discuss_at, expose_nil: false + expose :discuss_timeout_expiry_at, expose_nil: false + expose :extensions expose :scorm_extensions diff --git a/app/api/entities/teaching_period_entity.rb b/app/api/entities/teaching_period_entity.rb index 67a9b570e6..b105596bb7 100644 --- a/app/api/entities/teaching_period_entity.rb +++ b/app/api/entities/teaching_period_entity.rb @@ -5,11 +5,12 @@ class TeachingPeriodEntity < Grape::Entity expose :year expose :start_date expose :end_date + expose :last_teaching_date expose :active_until expose :active do |teaching_period, options| - object.active_until > DateTime.now + teaching_period.active? end - expose :breaks, if: :full_details, using: Entities::BreakEntity + expose :breaks, if: :include_breaks, using: Entities::BreakEntity expose :units, if: :full_details do |teaching_period, options| Entities::UnitEntity.represent teaching_period.units, summary_only: true, user: options[:user] end diff --git a/app/api/entities/tutorial_entity.rb b/app/api/entities/tutorial_entity.rb index 331420b4f5..292e57bdbd 100644 --- a/app/api/entities/tutorial_entity.rb +++ b/app/api/entities/tutorial_entity.rb @@ -7,6 +7,7 @@ class TutorialEntity < Grape::Entity expose :abbreviation expose :campus_id, expose_nil: false expose :capacity + expose :duration_minutes expose :tutorial_stream_abbr, expose_nil: false do |tutorial, options| tutorial.tutorial_stream.abbreviation unless tutorial.tutorial_stream.nil? end diff --git a/app/api/entities/unit_content_link_entity.rb b/app/api/entities/unit_content_link_entity.rb new file mode 100644 index 0000000000..6e548428ad --- /dev/null +++ b/app/api/entities/unit_content_link_entity.rb @@ -0,0 +1,13 @@ +require 'entities/unit_content_site_entity' + +module Entities + class UnitContentLinkEntity < Grape::Entity + expose :id + expose :unit_id + expose :unit_content_site_id + expose :context_type + expose :context_key + expose :route + expose :unit_content_site, as: :site, using: Entities::UnitContentSiteEntity + end +end diff --git a/app/api/entities/unit_content_site_entity.rb b/app/api/entities/unit_content_site_entity.rb new file mode 100644 index 0000000000..002e1bc4ca --- /dev/null +++ b/app/api/entities/unit_content_site_entity.rb @@ -0,0 +1,15 @@ +module Entities + class UnitContentSiteEntity < Grape::Entity + expose :id + expose :unit_id + expose :name + expose :original_filename + expose :root_dir + expose :content_version + expose :root_dir_options + expose :file_paths, if: ->(_site, options) { options[:include_file_paths] } + expose :is_main + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index 4743f67511..0eb96d96f6 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -1,3 +1,5 @@ +require 'entities/unit_content_link_entity' + module Entities class UnitEntity < Grape::Entity format_with(:date_only) do |date| @@ -31,10 +33,28 @@ def can_read_unit_config?(my_role) with_options(format_with: :date_only) do expose :start_date expose :end_date + expose :last_teaching_date expose :portfolio_auto_generation_date, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }, expose_nil: false end + expose :current_unit_week do |unit| + unit.week_number(Time.current) + end + expose :active + expose :grade_values + expose :grade_definitions + expose :has_main_content_site?, as: :has_main_content_site, unless: :summary_only + expose :main_content_site_id, unless: :summary_only do |unit| + unit.unit_content_sites.find_by(is_main: true)&.id + end + expose :content_site_versions, unless: :summary_only do |unit| + unit.unit_content_sites.pluck(:id, :content_version).to_h + end + expose :unit_content_links, + as: :content_links, + using: UnitContentLinkEntity, + unless: :summary_only expose :overseer_image_id, unless: :summary_only, if: lambda { |unit, options| can_read_unit_config?(options[:my_role]) } expose :assessment_enabled, unless: :summary_only @@ -67,5 +87,12 @@ def can_read_unit_config?(my_role) expose :feedback_warning_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } expose :feedback_overflow_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } + + expose :discuss_timeout_enabled, unless: :summary_only + expose :discuss_timeout_active?, as: :discuss_timeout_active, unless: :summary_only + expose :discuss_timeout_warning_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } + expose :discuss_timeout_expire_days, unless: :summary_only + + expose :enforce_feedback_before_discussed_in_class, if: lambda { |unit, options| is_staff?(options[:my_role]) } end end diff --git a/app/api/lms_integration_api.rb b/app/api/lms_integration_api.rb new file mode 100644 index 0000000000..c6f65e3b31 --- /dev/null +++ b/app/api/lms_integration_api.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require 'grape' +require 'entities/lms_integration_entity' +require 'entities/sidekiq_job_entity' + +# +# The unit LMS tab. Links are created by launching OnTrack from the LMS; everything here reads +# the link and course data through the LTI service and manages how that data is imported. +# +class LmsIntegrationApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers SidekiqHelper + + before do + authenticated? + end + + helpers do + def lms_unit!(permission = :update) + error!({ error: 'LTI is not enabled for this OnTrack deployment' }, 404) unless Doubtfire::Application.config.lti_enabled + + unit = Unit.find(params[:unit_id]) + error!({ error: 'Not authorised to manage the LMS integration for this unit' }, 403) unless authorise?(current_user, unit, permission) + unit + end + + def lms_error!(error) + status = error.respond_to?(:status) && error.status.to_i.between?(400, 599) ? error.status.to_i : 422 + error!({ error: error.message }, status) + end + + def require_link!(unit) + link = LtiServer.new(unit.id).link + error!({ error: 'This unit is not linked to an LMS course. Link it by launching OnTrack from the LMS.' }, 409) if link.nil? + link + rescue LtiServer::Error => e + lms_error!(e) + end + end + + desc 'Get the LMS link and integration settings for a unit' + get '/units/:unit_id/lms' do + unit = lms_unit! + link = nil + link_error = nil + begin + link = LtiServer.new(unit.id).link + rescue LtiServer::Error => e + link_error = e.message + end + + integration = unit.lms_integration || unit.build_lms_integration + { + link: link, + link_error: link_error, + integration: Entities::LmsIntegrationEntity.represent(integration) + } + end + + desc 'Unlink the unit from its LMS course. It can only be linked again from an LMS launch.' + delete '/units/:unit_id/lms/link' do + unit = lms_unit! + begin + LtiServer.new(unit.id).unlink + rescue LtiServer::Error => e + lms_error!(e) + end + unit.lms_integration&.mark_unvalidated! + logger.info "Unlinked unit #{unit.code} from its LMS course by #{current_user.username}" + { unlinked: true } + end + + desc 'Update LMS integration settings for a unit' + params do + optional :assignment_id, type: Integer + optional :assignment_name, type: String + optional :fetch_extensions, type: Boolean, default: false + optional :auto_sync_students, type: Boolean, default: false + optional :withdraw_missing_students, type: Boolean, default: false + optional :auto_sync_extensions, type: Boolean, default: false + optional :group_mapping_enabled, type: Boolean, default: false + optional :group_mappings, type: Array do + requires :lms_group_id, type: Integer + requires :lms_group_name, type: String + requires :target_type, type: String, values: LmsGroupMapping::TARGET_TYPES + optional :group_set_id, type: Integer + optional :group_id, type: Integer + optional :campus_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :tutorial_id, type: Integer + optional :create_if_missing, type: Boolean, default: false + end + end + put '/units/:unit_id/lms' do + unit = lms_unit! + + integration = unit.lms_integration || unit.build_lms_integration + LmsIntegration.transaction do + integration.fetch_extensions = params[:fetch_extensions] + integration.assignment_id = params[:fetch_extensions] ? params[:assignment_id] : nil + integration.assignment_name = params[:fetch_extensions] ? params[:assignment_name] : nil + integration.auto_sync_students = params[:auto_sync_students] + integration.withdraw_missing_students = params[:withdraw_missing_students] + integration.auto_sync_extensions = params[:fetch_extensions] && params[:auto_sync_extensions] + integration.group_mapping_enabled = params[:group_mapping_enabled] + integration.validated = false + integration.validated_at = nil + integration.save! + + if integration.group_mapping_enabled? + integration.lms_group_mappings.delete_all + Array(params[:group_mappings]).each do |mapping| + integration.lms_group_mappings.create!( + lms_group_id: mapping[:lms_group_id], + lms_group_name: mapping[:lms_group_name], + target_type: mapping[:target_type], + group_set_id: mapping[:group_set_id], + group_id: mapping[:group_id], + campus_id: mapping[:campus_id], + tutorial_stream_id: mapping[:tutorial_stream_id], + tutorial_id: mapping[:tutorial_id], + create_if_missing: mapping[:create_if_missing] + ) + end + end + end + + integration.lms_group_mappings.reload + present integration, with: Entities::LmsIntegrationEntity + end + + desc 'Update individual LMS integration settings without replacing the group mappings' + params do + optional :fetch_extensions, type: Boolean + optional :auto_sync_students, type: Boolean + optional :withdraw_missing_students, type: Boolean + optional :auto_sync_extensions, type: Boolean + optional :group_mapping_enabled, type: Boolean + optional :skip_ungraded, type: Boolean + optional :send_grade_rationale, type: Boolean + optional :assignment_id, type: Integer + optional :assignment_name, type: String + all_or_none_of :assignment_id, :assignment_name + at_least_one_of :fetch_extensions, :auto_sync_students, :withdraw_missing_students, :auto_sync_extensions, :group_mapping_enabled, :skip_ungraded, :send_grade_rationale, :assignment_id + end + patch '/units/:unit_id/lms/settings' do + unit = lms_unit! + + integration = unit.lms_integration || unit.build_lms_integration + changes = declared(params, include_missing: false).to_h.symbolize_keys.except(:unit_id) + integration.assign_attributes(changes) + integration.auto_sync_extensions = false unless integration.fetch_extensions? + # Turning a feature on or changing the assignment adds something to check; turning one off does not + turned_on = %i[fetch_extensions group_mapping_enabled].any? { |setting| integration.will_save_change_to_attribute?(setting, to: true) } + if turned_on || integration.assignment_id_changed? + integration.validated = false + integration.validated_at = nil + end + integration.save! + + present integration, with: Entities::LmsIntegrationEntity + end + + desc 'Get course details, groups and assignments from the LMS course-data plugin' + get '/units/:unit_id/lms/course_data' do + unit = lms_unit! + require_link!(unit) + + source = LmsIntegration.data_source_for(unit) + begin + error!({ error: 'The Moodle OnTrack course-data plugin is not available for this course.' }, 422) unless source.course_data_available? + + source.course_data + rescue LtiCourseDataSource::Error => e + lms_error!(e) + end + end + + desc 'Validate LMS group mappings and the selected assignment against the LMS course' + post '/units/:unit_id/lms/validate' do + unit = lms_unit! + integration = unit.lms_integration + error!({ error: 'Save the LMS settings for this unit first' }, 422) if integration.blank? + require_link!(unit) + + source = integration.data_source + begin + needs_course_data = integration.group_mapping_enabled? || integration.fetch_extensions? + if needs_course_data && !source.course_data_available? + error!({ error: 'Group mapping and extensions need the Moodle OnTrack course-data plugin.' }, 422) + end + + LmsIntegrationValidator.new(integration).validate( + groups: integration.group_mapping_enabled? ? source.groups : [], + assignments: integration.fetch_extensions? ? source.assignments : [] + ) + rescue LtiCourseDataSource::Error => e + lms_error!(e) + end + end + + desc 'Pre-fill LMS group mappings using institution settings' + params do + requires :groups, type: Array do + requires :id, type: Integer + requires :name, type: String + optional :idnumber, type: String + end + end + post '/units/:unit_id/lms/prefill_group_mappings' do + unit = lms_unit! + + settings = Doubtfire::Application.config.institution_settings + groups = params[:groups].map { |group| group.to_h.symbolize_keys } + mappings = if settings.respond_to?(:prefill_lms_group_mappings) + settings.prefill_lms_group_mappings(unit, groups) + else + groups.map do |group| + { lms_group_id: group[:id], lms_group_name: group[:name], target_type: 'ignore' } + end + end + { group_mappings: mappings } + end + + desc 'Import LMS course members into a unit' + params do + requires :preview_only, type: Boolean, default: false + optional :withdraw_missing, type: Boolean, desc: 'Withdraw enrolled students who are not active students in the LMS' + end + post '/units/:unit_id/lms/import_students' do + unit = lms_unit!(:upload_csv) + link = require_link!(unit) + + integration = unit.lms_integration + # Without the course-data plugin there are no groups, so mappings are not applied + if integration&.group_mapping_enabled? && !integration.validated? && link['courseDataAvailable'] == true + error!({ error: 'Validate the LMS group mappings before importing students' }, 422) + end + + withdraw_missing = params[:withdraw_missing].nil? ? integration&.withdraw_missing_students == true : params[:withdraw_missing] + job_id = ImportLmsStudentsJob.perform_async(unit.id, params[:preview_only], withdraw_missing, false) + present setup_job(job_id), with: Entities::SidekiqJobEntity + end + + desc 'Import LMS assignment extensions into a unit' + params do + requires :preview_only, type: Boolean, default: false + end + post '/units/:unit_id/lms/import_extensions' do + unit = lms_unit! + require_link!(unit) + + integration = unit.lms_integration + unless integration&.fetch_extensions? && integration.assignment_id.present? + error!({ error: 'Enable extension imports and select an LMS assignment first' }, 422) + end + error!({ error: 'Validate the LMS integration before importing extensions' }, 422) unless integration.validated? + + job_id = ImportLmsExtensionsJob.perform_async(unit.id, params[:preview_only], false) + present setup_job(job_id), with: Entities::SidekiqJobEntity + end + + desc 'Get the LMS grade item linked to a unit' + get '/units/:unit_id/lms/grade_line_item' do + unit = lms_unit! + begin + LtiServer.new(unit.id).grade_line_item + rescue LtiServer::Error => e + lms_error!(e) + end + end + + desc 'Find or create the LMS grade item linked to a unit' + post '/units/:unit_id/lms/grade_line_item' do + unit = lms_unit! + require_link!(unit) + begin + LtiServer.new(unit.id).ensure_grade_line_item + rescue LtiServer::Error => e + lms_error!(e) + end + end + + desc 'Send OnTrack grades to the LMS grade item' + params do + optional :preview_only, type: Boolean, default: false + end + post '/units/:unit_id/lms/sync_grades' do + unit = lms_unit! + require_link!(unit) + + # A preview only reads course members, so it does not need the grade item + unless params[:preview_only] + begin + grade_line_item = LtiServer.new(unit.id).grade_line_item + unless grade_line_item['configured'] == true + message = grade_line_item['message'].presence || 'Grade sync is not configured for this LMS course.' + error!({ error: message }, 422) + end + rescue LtiServer::Error => e + lms_error!(e) + end + end + + job_id = SyncLmsGradesJob.perform_async(unit.id, params[:preview_only]) + present setup_job(job_id), with: Entities::SidekiqJobEntity + end +end diff --git a/app/api/lti_api.rb b/app/api/lti_api.rb index 7e9bbd4e23..bd137cc5e6 100644 --- a/app/api/lti_api.rb +++ b/app/api/lti_api.rb @@ -7,9 +7,9 @@ class LtiApi < Grape::API helpers SidekiqHelper include LogHelper - # before do - # authenticated? - # end + before do + ensure_lti_service_request! + end desc 'Returns success if current user is allowed to link requested unit' params do @@ -22,7 +22,7 @@ class LtiApi < Grape::API error!({ error: "Not authorised to link this unit." }, 403) end - token = decode_lti_token(params[:ltik]) + token = decode_lti_token(params[:ltik], purpose: 'link') unit_id = token["unit_id"] if unit_id.nil? @@ -35,7 +35,10 @@ class LtiApi < Grape::API error!({ error: 'Unit does not exist.' }, 404) end - unless authorise? current_user, unit, :enrol_student + ensure_lti_launch_user!(token) + + # The link decides where scheduled syncs read the roster from, so it needs the same permission as the LMS tab + unless authorise? current_user, unit, :update error!({ error: "Not authorised to link this unit." }, 403) end @@ -49,7 +52,7 @@ class LtiApi < Grape::API post '/lti/enrol' do authenticated? - token = decode_lti_token(params[:ltik]) + token = decode_lti_token(params[:ltik], purpose: 'enrol') unit_id = token["unit_id"] if unit_id.nil? @@ -71,6 +74,9 @@ class LtiApi < Grape::API error!({ error: "Missing required fields: #{missing.join(', ')}" }, 400) end + # The member's LMS roles decide the staff role given to current_user, so they must be the same person + ensure_lti_launch_user!(token) + # if current_user.role_id != Role.student_id # return status 204 # end @@ -112,7 +118,7 @@ class LtiApi < Grape::API post '/lti/enrol/bulk' do authenticated? - token = decode_lti_token(params[:ltik]) + token = decode_lti_token(params[:ltik], purpose: 'enrol_bulk') unit_id = token["unit_id"] if unit_id.nil? @@ -140,7 +146,7 @@ class LtiApi < Grape::API post '/lti/grades' do authenticated? - token = decode_lti_token(params[:ltik]) + token = decode_lti_token(params[:ltik], purpose: 'grades') unless authorise? current_user, User, :convene_units error!({ error: "Not authorised to sync grades." }, 403) @@ -181,4 +187,21 @@ class LtiApi < Grape::API projects_hash end + + desc 'Issue a one-time login token so an embedded LTI session can open OnTrack in its own tab' + params do + requires :ltik, type: String, desc: 'LtiKey asserting the launch user of an active LTI session' + end + post '/lti/app-handoff' do + authenticated? + + token = decode_lti_token(params[:ltik], purpose: 'app_handoff') + ensure_lti_launch_user!(token) + + onetime_token = current_user.generate_temporary_authentication_token! + logger.info "LTI app handoff for #{current_user.username} from #{request.ip}" + + present :username, current_user.username + present :auth_token, onetime_token.authentication_token + end end diff --git a/app/api/marking_sessions_api.rb b/app/api/marking_sessions_api.rb index 54edc83e7f..36d16af2f5 100644 --- a/app/api/marking_sessions_api.rb +++ b/app/api/marking_sessions_api.rb @@ -27,7 +27,7 @@ class MarkingSessionsApi < Grape::API end unit_role = unit.unit_role_for(current_user) - unless unit_role + unless unit_role || current_user.role == Role.admin error!({ error: "You are not authorised to view marking sessions for this unit" }, 403) end @@ -51,7 +51,7 @@ class MarkingSessionsApi < Grape::API .where(unit: unit) .where(start_time: start_date..end_date) - sessions = sessions.where(user_id: current_user.id) if unit_role.role != Role.convenor + sessions = sessions.where(user_id: current_user.id) if unit_role && unit_role&.role != Role.convenor present sessions, with: Entities::MarkingSessionEntity end diff --git a/app/api/overseer_steps_api.rb b/app/api/overseer_steps_api.rb index 018f3cc547..b26ce43c3c 100644 --- a/app/api/overseer_steps_api.rb +++ b/app/api/overseer_steps_api.rb @@ -14,7 +14,7 @@ class OverseerStepsApi < Grape::API requires :overseer_step, type: Hash do requires :name, type: String optional :description, type: String - optional :display_name, type: String + requires :display_name, type: String optional :display_description, type: String optional :run_command, type: String optional :timeout, type: Integer @@ -86,7 +86,9 @@ class OverseerStepsApi < Grape::API if result.nil? error!({ error: 'No overseer step added' }, 403) else - present result, with: Entities::OverseerStepEntity + present result, + with: Entities::OverseerStepEntity, + my_role: task_definition.unit.role_for(current_user) end end @@ -175,7 +177,9 @@ class OverseerStepsApi < Grape::API overseer_step.update!(overseer_step_params) - present overseer_step, with: Entities::OverseerStepEntity + present overseer_step, + with: Entities::OverseerStepEntity, + my_role: task_definition.unit.role_for(current_user) end desc 'Delete an overseer step' @@ -202,8 +206,12 @@ class OverseerStepsApi < Grape::API end unit = project.unit + task_definition = unit.task_definitions.find(params[:task_def_id]) + task = project.task_for_task_definition(task_definition) - overseer_assessment = OverseerAssessment.find(params[:id]) + overseer_assessment = OverseerAssessment + .where(submission_history_id: task.related_submission_histories.select(:id)) + .find(params[:id]) present overseer_assessment.overseer_step_results, with: Entities::OverseerStepResultEntity, my_role: unit.role_for(current_user) end end diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index fb88a683a1..61a66c1fdd 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -23,11 +23,22 @@ class ProjectsApi < Grape::API desc 'Get project' params do requires :id, type: Integer, desc: 'The id of the project to get' + optional :record_attendance, type: Boolean, default: false, + desc: 'Record attendance when fetching the project during an enrolled tutorial' end get '/projects/:id' do project = Project.eager_load(:unit, :user).find(params[:id]) if authorise? current_user, project, :get + if params[:record_attendance] + unless authorise?(current_user, project, :create_engagement) + error!({ error: 'You do not have permission to record attendance for this project.' }, 403) + end + + EngagementTracker.record_attendance(user: current_user, project: project) + end + + project.update!(last_viewed_at: Time.current) if project.user_id == current_user.id present project, with: Entities::ProjectEntity, user: current_user, for_student: true, in_project: true else error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) @@ -98,6 +109,10 @@ class ProjectsApi < Grape::API error!({ error: "You do not have permissions to change this student" }, 403) end + unless project.unit.grade_value?(params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end + project.target_grade = params[:target_grade] project.save elsif !params[:submitted_grade].nil? @@ -107,6 +122,9 @@ class ProjectsApi < Grape::API if project.portfolio_exists? error!({ error: "You cannot change your submitted grade after portfolio submission" }, 403) end + unless project.unit.grade_value?(params[:submitted_grade]) + error!({ error: 'Submitted grade is not enabled for this unit' }, 422) + end project.submitted_grade = params[:submitted_grade] project.save @@ -126,7 +144,6 @@ class ProjectsApi < Grape::API if params[:old_grade] != project.grade error!({ error: 'Existing project grade does not match current grade. Refresh project and try again.' }, 403) end - for_student = false project.grade = params[:grade] @@ -215,4 +232,17 @@ class ProjectsApi < Grape::API present portfolio_tasks.map(&:id) end + desc 'Get IDs of tasks that are still processing a PDF ' + get '/projects/:id/tasks_processing' do + project = Project.find(params[:id]) + + unless authorise? current_user, project, :get + error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) + end + + portfolio_tasks = project.tasks_processing_pdf + + present portfolio_tasks.map(&:id) + end + end diff --git a/app/api/scorm_extension_comments_api.rb b/app/api/scorm_extension_comments_api.rb index 7a8626dc58..e6bda6d998 100644 --- a/app/api/scorm_extension_comments_api.rb +++ b/app/api/scorm_extension_comments_api.rb @@ -4,6 +4,10 @@ class ScormExtensionCommentsApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + before do + authenticated? + end + desc 'Request a scorm extension for a task' params do requires :comment, type: String, desc: 'The details of the request' diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 49968392ca..ca016c23e9 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -13,7 +13,8 @@ class SettingsApi < Grape::API logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url], overseerEnabled: Doubtfire::Application.config.overseer_enabled, tiiEnabled: TurnItIn.enabled?, - d2lEnabled: D2lIntegration.enabled? + d2lEnabled: D2lIntegration.enabled?, + ltiEnabled: Doubtfire::Application.config.lti_enabled == true } present response, with: Grape::Presenters::Presenter diff --git a/app/api/staff_notes_api.rb b/app/api/staff_notes_api.rb index b202f39645..32f4aeff8e 100644 --- a/app/api/staff_notes_api.rb +++ b/app/api/staff_notes_api.rb @@ -3,6 +3,7 @@ class StaffNotesApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers FileStreamHelper before do authenticated? @@ -27,7 +28,8 @@ class StaffNotesApi < Grape::API desc "Create a new staff note for a project" params do requires :project_id, type: Integer, desc: 'Project to add the staff note for' - requires :note, type: String, desc: 'The text to add to the staff note' + optional :note, type: String, desc: 'The text to add to the staff note' + optional :attachment, type: File, desc: 'Image or PDF attachment' optional :reply_to_id, type: Integer, desc: 'ID of the staff note this is being replied to' end post '/projects/:project_id/staff_notes' do @@ -38,6 +40,24 @@ class StaffNotesApi < Grape::API end text_note = params[:note] + attached_file = params[:attachment] + + if attached_file.present? + file_size = File.size?(attached_file['tempfile'].path) + error!({ error: 'Attachment is empty.' }, 400) if file_size.blank? + error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 400) if file_size >= 30_000_000 + + image_result = FileHelper.accept_file(attached_file, 'staff note image', 'image') + attachment_type = if image_result[:accepted] + 'image' + elsif File.extname(attached_file[:filename].to_s).downcase == '.pdf' && + FileHelper.accept_file(attached_file, 'staff note PDF', 'document')[:accepted] + 'pdf' + end + error!({ error: 'Only images and PDFs can be attached to staff notes.' }, 400) if attachment_type.nil? + end + + error!({ error: 'Note text or an attachment is required.' }, 400) if text_note.blank? && attached_file.blank? reply_to_id = params[:reply_to_id] if reply_to_id.present? @@ -46,15 +66,45 @@ class StaffNotesApi < Grape::API error!(error: 'Original staff note is not in this project.') if project.staff_notes.find(reply_to_id).blank? end - result = project.add_staff_note(current_user, text_note, reply_to_id) + result = project.add_staff_note(current_user, text_note, reply_to_id, attachment: attached_file.present?) if result.nil? error!({ error: 'Duplicate note.' }, 403) else + if attached_file.present? + begin + result.add_attachment(attached_file, attachment_type) + rescue StandardError + result.destroy! + raise + end + end present result, with: Entities::StaffNoteEntity, user: current_user end end + desc 'Get an attachment related to a staff note' + params do + optional :as_attachment, type: Boolean, default: false + end + get '/projects/:project_id/staff_notes/:id/attachment' do + project = Project.find(params[:project_id]) + unless authorise?(current_user, project, :get_staff_note) + error!({ error: 'You do not have permission to access this project' }, 403) + end + + staff_note = project.staff_notes.find(params[:id]) + error!({ error: 'No attachment for this staff note.' }, 404) unless staff_note.attachment? + + content_type staff_note.content_type + env['api.format'] = :binary + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{staff_note.attachment_file_name}" + end + + stream_file staff_note.attachment_path + end + desc "Delete a staff note for a project" delete '/projects/:project_id/staff_notes/:id' do project = Project.find(params[:project_id]) diff --git a/app/api/submission/batch_task_api.rb b/app/api/submission/batch_task_api.rb index c53d721cdd..adfcf8a0ad 100644 --- a/app/api/submission/batch_task_api.rb +++ b/app/api/submission/batch_task_api.rb @@ -5,6 +5,7 @@ class BatchTaskApi < Grape::API helpers GenerateHelpers helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers SidekiqHelper before do authenticated? @@ -61,5 +62,119 @@ class BatchTaskApi < Grape::API # present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter # end # post + + desc 'Upload a batch feedback CSV or zip package for a selected task definition.' + params do + requires :file, type: File, desc: 'Batch feedback csv or zip upload' + requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' + requires :task_definition_id, type: Integer, desc: 'Task definition ID the uploaded CSV relates to.' + end + post '/submission/batch_feedback_csv/' do + unit = Unit.find(params[:unit_id]) + unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, unit, :provide_bulk_feedback + error!({ error: 'Not authorised to batch upload feedback csv' }, 401) + end + + error!({ error: "No file uploaded" }, 403) if params[:file].blank? + + import_dir = Rails.root.join(FileHelper.tmp_file_dir, 'batch-feedback') + FileUtils.mkdir_p(import_dir) + + extension = File.extname(params[:file][:filename].to_s) + extension = '.upload' if extension.blank? + file_name = File.join( + import_dir, + "batch-feedback-#{unit.id}-#{params[:task_definition_id]}-#{Process.pid}-#{Thread.current.object_id}-#{current_user.id}#{extension}" + ) + + FileUtils.cp(params[:file][:tempfile].path, file_name) + + job_id = ImportBatchFeedbackJob.perform_async(unit.id, current_user.id, params[:task_definition_id], file_name) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + helpers do + def authorise_batch_feedback_upload!(unit) + error!({ error: 'Not authorised to batch upload feedback csv' }, 401) unless authorise?(current_user, unit, :provide_bulk_feedback) + end + + def find_batch_feedback_upload! + upload = BatchFeedbackUpload.find(params[:id]) + error!({ error: 'Upload not found' }, 404) if upload.nil? || upload.user_id != current_user.id + upload + end + end + + rescue_from BatchFeedbackUpload::OffsetMismatch do |e| + error!({ error: e.message, offset: e.offset }, 409) + end + + rescue_from BatchFeedbackUpload::NotFound do + error!({ error: 'Upload not found' }, 404) + end + + rescue_from BatchFeedbackUpload::Error do |e| + error!({ error: e.message }, 422) + end + + desc 'Start a chunked batch feedback upload for a selected task definition.' + params do + requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' + requires :task_definition_id, type: Integer, desc: 'Task definition ID the upload relates to.' + requires :filename, type: String, desc: 'Name of the csv or zip being uploaded.' + requires :size, type: Integer, desc: 'Total size of the upload in bytes.' + end + post '/submission/batch_feedback_uploads' do + unit = Unit.find(params[:unit_id]) + task_definition = unit.task_definitions.find(params[:task_definition_id]) + authorise_batch_feedback_upload!(unit) + + upload = BatchFeedbackUpload.create( + unit: unit, + task_definition: task_definition, + user: current_user, + filename: params[:filename], + size: params[:size] + ) + upload.status + end + + desc 'Get how much of a chunked batch feedback upload has been received.' + get '/submission/batch_feedback_uploads/:id' do + find_batch_feedback_upload!.status + end + + desc 'Append a chunk to a batch feedback upload.' + params do + requires :offset, type: Integer, desc: 'Byte offset this chunk starts at.' + requires :chunk, type: File, desc: 'The next part of the file.' + requires :sha256, type: String, regexp: /\A\h{64}\z/, desc: 'Hex SHA-256 of the chunk.' + end + patch '/submission/batch_feedback_uploads/:id' do + upload = find_batch_feedback_upload! + upload.append(params[:offset], params[:chunk][:tempfile].path, params[:sha256]) + upload.status + end + + desc 'Finish a batch feedback upload and start importing it.' + post '/submission/batch_feedback_uploads/:id/complete' do + upload = find_batch_feedback_upload! + authorise_batch_feedback_upload!(Unit.find(upload.unit_id)) + + job_id = upload.complete! do |path| + ImportBatchFeedbackJob.perform_async(upload.unit_id, current_user.id, upload.task_definition_id, path) + end + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Cancel a batch feedback upload.' + delete '/submission/batch_feedback_uploads/:id' do + find_batch_feedback_upload!.destroy + body false + end end end diff --git a/app/api/submission/generate_helpers.rb b/app/api/submission/generate_helpers.rb index 1d74ac443e..c78564e4a2 100644 --- a/app/api/submission/generate_helpers.rb +++ b/app/api/submission/generate_helpers.rb @@ -18,7 +18,7 @@ def scoop_files(params, upload_reqs) files[key][:id] = files[key]['name'] files[key][:name] = detail['name'] - files[key][:type] = detail['type'] + files[key][:type] = detail['type'] == 'archive' ? 'zip' : detail['type'] end # File didn't get assigned an id above, then reject it since there was a mismatch diff --git a/app/api/submission/portfolio_api.rb b/app/api/submission/portfolio_api.rb index 611616813e..2c810e942e 100644 --- a/app/api/submission/portfolio_api.rb +++ b/app/api/submission/portfolio_api.rb @@ -35,7 +35,12 @@ class PortfolioApi < Grape::API end # Move file into place - result = project.move_to_portfolio(file, name, kind) # returns details of file + result = begin + project.move_to_portfolio(file, name, kind) # returns details of file + rescue FileHelper::DocumentConversionError => e + Rails.logger.error "Failed to convert portfolio document to PDF: #{e.message}" + error!({ error: 'Could not convert the Word document to PDF. Please try again or upload a PDF.' }, 422) + end present result, Grape::Presenters::Presenter end # post @@ -55,6 +60,10 @@ class PortfolioApi < Grape::API # Remove file or portfolio? if params[:idx].nil? && params[:name].nil? && params[:kind].nil? + project.update!({ + portfolio_submission_date: nil, + portfolio_production_date: nil + }) project.remove_portfolio # returns details of file elsif !(params[:idx].nil? || params[:name].nil? || params[:kind].nil?) idx = params[:idx] diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index d17d340a27..fd5ffe38d3 100644 --- a/app/api/submission/portfolio_evidence_api.rb +++ b/app/api/submission/portfolio_evidence_api.rb @@ -18,15 +18,6 @@ def self.logger authenticated? end - TASK_STATES = { - ready_for_feedback: 1, - assess_in_portfolio: 1, - discuss: 2, - attention_required: 0, - demonstrate: 2, - complete: 3 - }.freeze - desc 'Upload and generate doubtfire-task-specific submission document' params do optional :file0, type: File, desc: 'file 0.' @@ -46,36 +37,18 @@ def self.logger error!({ error: "Not authorised to submit task '#{task_definition.name}'" }, 401) end + if task_definition.staff_managed_task && !authorise?(current_user, project, :assess) + error!({ error: 'Only teaching staff can upload a submission for this task.' }, 403) + end + task = project.task_for_task_definition(task_definition) if task.group_task? && !task.group error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403) end - # Check that prerequisite tasks are in the required minimum submitted state - prerequisites = task_definition.task_prerequisites - prerequisites.each do |prerequisite| - prereq_td = prerequisite.prerequisite - minimum_status = TaskStatus.find(prerequisite.task_status_id) - - - prereq_task = project.task_for_task_definition(prereq_td) - - verb = - case minimum_status - when TaskStatus.complete - 'completed' - when TaskStatus.discuss - 'discussed' - when TaskStatus.demonstrate - 'demonstrated' - when TaskStatus.ready_for_feedback - 'submitted' - end - - if !prereq_task.ready_or_complete? || TASK_STATES[prereq_task.status] < TASK_STATES[minimum_status.status_key] - error!({ error: "Cannot submit this task until prerequisite '#{prereq_td.abbreviation}' has been #{verb}" }, 409) - end + if (prerequisite_error = task.unmet_prerequisite_error) + error!({ error: prerequisite_error }, 409) end trigger = if params[:trigger] && params[:trigger].tr('"\'', '') == 'need_help' @@ -111,6 +84,9 @@ def self.logger optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' end get '/projects/:id/task_def_id/:task_definition_id/submission' do + # Requests through Caddy are intercepted before the general /api proxy. + # Rails authorises those requests via SubmissionDownloadAuthorizationsController, + # then Caddy serves the PDF. This remains the direct-Rails fallback path. project = Project.eager_load(:unit).find(params[:id]) task_definition = project.unit.task_definitions.select(:id, :name, :abbreviation).find(params[:task_definition_id]) @@ -178,10 +154,54 @@ def self.logger error!({ error: "A submission for this task definition have never been created" }, 401) end - result = OverseerAssessment.where(task_id: task.id).order(submission_timestamp: :desc).limit(10) + result = OverseerAssessment.where(submission_history_id: task.related_submission_histories.select(:id)) + .order(submission_timestamp: :desc) + .limit(10) present result, with: Entities::OverseerAssessmentEntity end + desc 'Get all retained submission histories for a task' + get '/projects/:id/task_def_id/:task_definition_id/submission_histories' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + unless task + error!({ error: 'A submission for this task definition has never been created' }, 404) + end + + present task.related_submission_histories.order(submission_timestamp: :desc), + with: Entities::SubmissionHistoryEntity + end + + desc 'Download a retained submission history archive' + get '/projects/:id/task_def_id/:task_definition_id/submission_histories/:history_id/files' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project.unit, :provide_feedback + error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + history = task&.related_submission_histories&.find_by(id: params[:history_id]) + error!({ error: 'Submission history was not found' }, 404) unless history + error!({ error: 'Submission history files are not available' }, 404) unless history.has_submission_files? + + filename = "#{project.student.username}-#{task_definition.abbreviation}-#{history.submission_timestamp}.zip" + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{filename}" + submission_zip_data = history.submission_zip_data + header['Content-Length'] = submission_zip_data.bytesize.to_s + env['api.format'] = :binary + body submission_zip_data + end + desc 'Trigger an overseer assessment to run again' put '/projects/:id/task_def_id/:task_definition_id/overseer_assessment/:oa_id/trigger' do project = Project.find(params[:id]) @@ -199,7 +219,9 @@ def self.logger oa_id = timestamp = params[:oa_id] - oa = task.overseer_assessments.find(oa_id) + oa = OverseerAssessment + .where(submission_history_id: task.related_submission_histories.select(:id)) + .find(oa_id) response = oa.send_to_overseer if response[:error].present? error!({ error: response[:error] }, 403) @@ -280,12 +302,12 @@ def self.logger error!({ error: 'A submission for this task definition have never been created' }, 401) end - oa = task.overseer_assessments.find_by(submission_timestamp: params[:timestamp]) - unless oa - error!({ error: "No overseer assessment found for timestamp '#{params[:timestamp]}'" }, 404) + history = task.related_submission_histories.find_by(submission_timestamp: params[:timestamp]) + unless history + error!({ error: "No submission history found for timestamp '#{params[:timestamp]}'" }, 404) end - unless oa.has_submission_files? + unless history.has_submission_files? error!({ error: "No submission files are available for timestamp '#{params[:timestamp]}'" }, 404) end @@ -294,7 +316,10 @@ def self.logger content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{filename}" - stream_file oa.submission_zip_file_name + submission_zip_data = history.submission_zip_data + header['Content-Length'] = submission_zip_data.bytesize.to_s + env['api.format'] = :binary + body submission_zip_data end desc 'Get the result of the submission of a task made last' diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb index 51eaf2f5da..cfe2500a87 100644 --- a/app/api/task_comments_api.rb +++ b/app/api/task_comments_api.rb @@ -178,6 +178,21 @@ class TaskCommentsApi < Grape::API error!({ error: 'Not authorised to delete this comment' }, 403) end + # Comments that don't reveal a delete button + protected_comment_types = [ + AssessmentComment, + ExtensionComment, + ScormComment, + TaskCheckedInComment, + TaskDiscussedComment, + TaskFeedbackReviewRequestComment, + TaskStatusComment + ] + + if protected_comment_types.any? { |comment_type| task_comment.is_a?(comment_type) } + error!({ error: 'This comment type cannot be deleted' }, 403) + end + task_comment.destroy SessionTracker.record_assessment_activity( @@ -191,6 +206,54 @@ class TaskCommentsApi < Grape::API present false end + desc 'Edit a comment' + params do + requires :comment, type: String, desc: 'The updated comment text' + end + put '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :make_submission + error!({ error: 'Not authorised to edit this comment' }, 403) + end + + task = project.task_for_task_definition(task_definition) + task_comment = task.all_comments.find(params[:id]) + + unless task_comment.user == current_user + error!({ error: 'You can only edit your own comments' }, 403) + end + + unless task_comment.content_type.blank? || task_comment.content_type == 'text' + error!({ error: 'Only text comments can be edited' }, 403) + end + + if task_comment.created_at < 10.minutes.ago + error!({ error: 'Comments can only be edited within 10 minutes of being created' }, 403) + end + + if params[:comment].blank? + error!({ error: 'Comment text is empty, unable to update comment' }, 403) + end + + task_comment.comment = params[:comment] + + unless task_comment.save + error!({ error: task_comment.errors.full_messages.to_sentence.presence || 'Unable to update comment' }, 403) + end + + SessionTracker.record_assessment_activity( + action: 'edit-comment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + + present task_comment.serialize(current_user), with: Grape::Presenters::Presenter + end + desc 'Mark a comment as unread' post '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do project = Project.find(params[:project_id]) diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 8da40c69ad..72a90c63c8 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -1,4 +1,5 @@ require 'grape' +require 'mime/types' class TaskDefinitionsApi < Grape::API helpers AuthenticationHelpers @@ -27,6 +28,7 @@ class TaskDefinitionsApi < Grape::API optional :due_date, type: Date, desc: 'The deadline date' requires :abbreviation, type: String, desc: 'The abbreviation of the task' requires :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' + optional :staff_managed_task, type: Boolean, desc: 'Only staff can upload submissions and change task status' optional :upload_requirements, type: String, desc: 'Task file upload requirements' requires :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' requires :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' @@ -64,6 +66,7 @@ class TaskDefinitionsApi < Grape::API :due_date, :abbreviation, :restrict_status_updates, + :staff_managed_task, :plagiarism_warn_pct, :scorm_enabled, :scorm_allow_review, @@ -86,6 +89,10 @@ class TaskDefinitionsApi < Grape::API task_params[:unit_id] = unit.id task_params[:upload_requirements] = params[:task_def][:upload_requirements].present? ? JSON.parse(params[:task_def][:upload_requirements]) : [] + unless unit.grade_value?(task_params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end + task_def = TaskDefinition.new(task_params) # Set the tutorial stream @@ -123,6 +130,7 @@ class TaskDefinitionsApi < Grape::API optional :due_date, type: Date, desc: 'The deadline date' optional :abbreviation, type: String, desc: 'The abbreviation of the task' optional :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' + optional :staff_managed_task, type: Boolean, desc: 'Only staff can upload submissions and change task status' optional :upload_requirements, type: String, desc: 'Task file upload requirements' optional :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' optional :scorm_enabled, type: Boolean, desc: 'Whether or not SCORM test assessment is enabled for this task' @@ -139,13 +147,11 @@ class TaskDefinitionsApi < Grape::API optional :requires_discussion, type: Boolean, desc: 'Whether task must be discussed in class before it can be signed off as complete' optional :use_resources_for_jplag_base_code, type: Boolean, desc: 'Include the common base code from task resources for JPlag comparisons' optional :lock_assessments_to_tutorial_stream, type: Boolean, desc: 'Only allow tutors in this tutorial stream to assess this task' - # optional :p_target_date, type: Date, desc: 'Pass due date override' - optional :c_target_date, type: Date, desc: 'Credit due date override' - optional :d_target_date, type: Date, desc: 'Distinction due date override' - optional :hd_target_date, type: Date, desc: 'High Distinction due date override' - optional :c_start_date, type: Date, desc: 'Credit start date override' - optional :d_start_date, type: Date, desc: 'Distinction start date override' - optional :hd_start_date, type: Date, desc: 'High Distinction start date override' + optional :grade_due_dates, type: Array do + requires :target_grade, type: Integer + optional :target_due_date, type: Date + optional :start_date, type: Date + end end end put '/units/:unit_id/task_definitions/:id' do @@ -157,14 +163,7 @@ class TaskDefinitionsApi < Grape::API end # strip these out so TaskDefinition#update! never sees them - grade_due_overrides = params[:task_def].slice( - 'p_target_date', 'c_target_date', 'd_target_date', 'hd_target_date', - 'p_start_date', 'c_start_date', 'd_start_date', 'hd_start_date' - ) - params[:task_def].except!( - 'p_target_date', 'c_target_date', 'd_target_date', 'hd_target_date', - 'p_start_date', 'c_start_date', 'd_start_date', 'hd_start_date' - ) + grade_due_date_rows = params[:task_def].delete('grade_due_dates') task_params = ActionController::Parameters.new(params) .require(:task_def) @@ -178,6 +177,7 @@ class TaskDefinitionsApi < Grape::API :due_date, :abbreviation, :restrict_status_updates, + :staff_managed_task, :plagiarism_warn_pct, :scorm_enabled, :scorm_allow_review, @@ -212,6 +212,10 @@ class TaskDefinitionsApi < Grape::API end end + if task_params.key?(:target_grade) && !unit.grade_value?(task_params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end + # Bulk update task definition with permitted parameters task_def.update!(task_params) @@ -239,29 +243,32 @@ class TaskDefinitionsApi < Grape::API end end - grade_number = { 'c' => 1, 'd' => 2, 'hd' => 3 } - field_map = { 'target_date' => :target_due_date, 'start_date' => :start_date } - - grade_due_overrides.each do |key, date| - next if date.blank? - - # if task_def.start_date > date - # error!({ error: 'Target date cannot be earlier than start date' }, 400) - # end - + if grade_due_date_rows.present? unless unit.allow_flexible_dates error!({ error: 'This unit must have Allow Flexible Dates enabled to modify target dates per grade' }, 403) end - grade_key, kind = key.to_s.split('_', 2) # e.g. "c", "target_date" - next unless grade_number.key?(grade_key) - next unless field_map.key?(kind) + grade_due_date_rows.each do |row_params| + target_grade = row_params[:target_grade] || row_params['target_grade'] + next if target_grade.to_i.zero? + + unless unit.grade_value?(target_grade) + error!({ error: "Target grade #{target_grade} is not enabled for this unit" }, 422) + end - row = TaskDefinitionGradeDueDate.find_or_initialize_by( - task_definition: task_def, - target_grade: grade_number[grade_key] - ) - row.update!(field_map[kind] => date) + target_due_date = row_params[:target_due_date] || row_params['target_due_date'] + start_date = row_params[:start_date] || row_params['start_date'] + row = TaskDefinitionGradeDueDate.find_or_initialize_by( + task_definition: task_def, + target_grade: target_grade + ) + + if target_due_date.blank? && start_date.blank? + row.destroy if row.persisted? + else + row.update!(target_due_date: target_due_date, start_date: start_date) + end + end end present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) @@ -319,7 +326,8 @@ class TaskDefinitionsApi < Grape::API end task_def.destroy - task_def.destroyed? + error!({ error: task_def.errors.full_messages.last }, 403) unless task_def.destroyed? + true end desc 'Upload the task sheet for a given task' @@ -559,6 +567,7 @@ class TaskDefinitionsApi < Grape::API .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") .joins("LEFT OUTER JOIN (#{subquery}) as sq ON sq.project_id = projects.id") .joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id') + .joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{current_user.id}") .select( 'sq.tutorial_stream_id as tutorial_stream_id', 'sq.tutorial_id as tutorial_id', @@ -572,7 +581,8 @@ class TaskDefinitionsApi < Grape::API 'grade', 'quality_pts', "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", - 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' + 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count', + 'COUNT(distinct task_pins.task_id) != 0 as pinned' ) .where('task_definition_id = :id', id: params[:task_def_id]) .group( @@ -603,7 +613,8 @@ class TaskDefinitionsApi < Grape::API similarity_flag: t.similar_to_count > 0, grade: t.grade, quality_pts: t.quality_pts, - has_extensions: t.has_extensions + has_extensions: t.has_extensions, + pinned: t.pinned } end @@ -653,7 +664,14 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to download task details of unit' }, 403) end - if task_def.has_task_resources? + resource = task_def.linked_task_resource + + if resource + path = resource[:path] + filename = File.basename(resource[:filename]).gsub(/[\r\n"]/, '_') + content_type MIME::Types.type_for(filename).first&.content_type || 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=\"#{filename}\"" + elsif task_def.has_uploaded_task_resources? path = task_def.task_resources content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-resources.zip" @@ -781,7 +799,7 @@ class TaskDefinitionsApi < Grape::API logger.debug "This is the has_jplag_report? #{task_def.has_jplag_report?}" if task_def.has_jplag_report? path = FileHelper.task_jplag_report_path(unit, task_def) - header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-jplag-report.jplag" + header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{task_def.abbreviation}-jplag-report.jplag" else path = Rails.root.join("public/resources/FileNotFound.pdf") content_type 'application/pdf' diff --git a/app/api/tasks_api.rb b/app/api/tasks_api.rb index ac3a636fdd..3822923755 100644 --- a/app/api/tasks_api.rb +++ b/app/api/tasks_api.rb @@ -132,17 +132,16 @@ class TasksApi < Grape::API task.extensions = params[:extensions] task.save! - comment = TaskComment.create( + TaskComment.create( task: task, user: current_user, comment: "Planned date adjusted to #{task.due_date.strftime('%d %b')}.", content_type: :plan, + attention_audience: :none, recipient: project.student, extension_weeks: params[:extensions] ) - comment.mark_as_read(project.tutor_for(task_definition)) - present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true else error!({ error: "You are not permitted to adjust the plan." }, 403) @@ -158,6 +157,8 @@ class TasksApi < Grape::API optional :grade, type: Integer, desc: 'Grade value if task is a graded task (required if task definition is a graded task)' optional :quality_pts, type: Integer, desc: 'Quality points value if task has quality assessment' optional :discussed, type: Boolean, desc: 'Mark task as discussed' + optional :trigger_recursive_fix, desc: 'If marking fix and resubmit, recursively update preqreuisite submissions to fix' + optional :reuse_existing_submission, type: Boolean, desc: 'Staff may reuse submitted evidence when requesting feedback' end put '/projects/:id/task_def_id/:task_definition_id' do project = Project.find(params[:id]) @@ -167,19 +168,41 @@ class TasksApi < Grape::API # check the user can put this task if authorise? current_user, project, :make_submission - task = project.task_for_task_definition(task_definition) + if params[:trigger].present? && task_definition.staff_managed_task && !authorise?(current_user, project, :assess) + error!({ error: 'Only teaching staff can change the status of this task.' }, 403) + end - if !params[:discussed].nil? && authorise?(current_user, project, :assess) - task.add_discussed_comment(current_user) + if !grade.nil? && !authorise?(current_user, project, :assess) + error!({ error: 'Not authorised to grade this task' }, 403) end + task = project.task_for_task_definition(task_definition) + mark_as_discussed = params[:discussed] == true && authorise?(current_user, project, :assess) + needs_discussed_comment = mark_as_discussed && !task.has_discussed_in_class_comment? + # if trigger supplied... unless params[:trigger].nil? + reuse_existing_submission = params[:reuse_existing_submission] == true + if reuse_existing_submission && (params[:trigger] != 'ready_for_feedback' || + !authorise?(current_user, project, :assess)) + error!({ error: 'Not authorised to reuse an existing submission.' }, 403) + end + # Check if they should be using portfolio_evidence api - if needs_upload_docs && params[:trigger] == 'ready_for_feedback' + if needs_upload_docs && params[:trigger] == 'ready_for_feedback' && !reuse_existing_submission error!({ error: 'Cannot set this task status to ready to mark without uploading documents.' }, 403) end + if reuse_existing_submission && + (task.task_status == TaskStatus.ready_for_feedback || !task.has_pdf || task.submission_date.nil?) + error!({ error: 'This task has no existing submission available for feedback.' }, 409) + end + + if !needs_upload_docs && %w[ready_for_feedback assess_in_portfolio].include?(params[:trigger]) && + (prerequisite_error = task.unmet_prerequisite_error) + error!({ error: prerequisite_error }, 409) + end + if params[:trigger] == 'assess_in_portfolio' && !authorise?(current_user, project, :assess) # Prevent students from upading status if task definition doesn't enable it if !task_definition.assess_in_portfolio_only @@ -198,15 +221,40 @@ class TasksApi < Grape::API error!({ error: 'This task can only be assessed in portfolio.' }, 403) end - if task.task_definition.requires_discussion && params[:trigger] == 'complete' && !task.has_discussed_in_class_comment? + if task.task_definition.requires_discussion && params[:trigger] == 'complete' && + !task.has_discussed_in_class_comment? && !mark_as_discussed error!({ error: 'This task must be discussed in class before it can be marked complete.' }, 403) end logger.info "#{current_user.username} assessing task #{task.id} to #{params[:trigger]}" - result = task.trigger_transition(trigger: params[:trigger], by_user: current_user, quality: params[:quality_pts]) + begin + task.discussion_confirmed_for_transition = mark_as_discussed + result = if reuse_existing_submission + task.reuse_existing_submission_for_feedback(current_user) + else + task.trigger_transition( + trigger: params[:trigger], + by_user: current_user, + quality: params[:quality_pts], + recursive_fix: params[:trigger_recursive_fix], + check_feedback: true + ) + end + ensure + task.discussion_confirmed_for_transition = false + end + + if result.nil? && task.errors.any? + error!({ error: task.errors.full_messages.to_sentence }, 403) + end + if result.nil? && reuse_existing_submission + error!({ error: 'This task cannot reuse its existing submission for feedback.' }, 409) + end if result.nil? && task.task_definition.restrict_status_updates error!({ error: 'This task can only be updated by your tutor.' }, 403) end + task.add_discussed_comment(current_user) if result && needs_discussed_comment + SessionTracker.record_assessment_activity( action: "assessing", user: current_user, @@ -216,6 +264,8 @@ class TasksApi < Grape::API ) end + task.add_discussed_comment(current_user) if params[:trigger].nil? && needs_discussed_comment + # if grade was supplied unless grade.nil? # try to grade the task @@ -327,7 +377,7 @@ class TasksApi < Grape::API ) # Find the file - file_loc = FileHelper.zip_file_path_for_done_task(task) + file_loc = task.downloadable_submission_zip_path if file_loc.nil? || !File.exist?(file_loc) file_loc = Rails.root.join('public/resources/FileNotFound.pdf') @@ -379,16 +429,15 @@ class TasksApi < Grape::API "Planned date reset: #{task_definition.start_date.strftime('%d %b')} - #{task_definition.target_date.strftime('%d %b')}." end - comment = TaskComment.create( + TaskComment.create( task: task, user: current_user, comment: comment_text, content_type: :plan, + attention_audience: :none, recipient: project.student ) - comment.mark_as_read(project.tutor_for(task_definition)) - present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true else error!({ error: "You are not permitted to adjust the plan." }, 403) @@ -418,15 +467,14 @@ class TasksApi < Grape::API ) comment_text = "Planned date reset: #{task.task_definition.start_date.strftime('%d %b')} - #{task.task_definition.target_date.strftime('%d %b')}." - comment = TaskComment.create( + TaskComment.create( task: task, user: current_user, comment: comment_text, content_type: :plan, + attention_audience: :none, recipient: project.student ) - - comment.mark_as_read(project.tutor_for(task.task_definition)) end present project, with: Entities::ProjectEntity, user: current_user, for_student: true, in_project: true @@ -506,19 +554,30 @@ class TasksApi < Grape::API error!({ error: "This task has already been claimed by another tutor" }, 409) end - inactive_claim = task.overflow_task_claim - inactive_claim&.destroy! + claimed_at = Time.zone.now + original_tutor = task.tutor + + ActiveRecord::Base.transaction do + task.overflow_task_claim&.destroy! - task_claim = OverflowTaskClaim.create!({ - task: task, - claimed_by_unit_role_id: my_unit_role.id - }) + OverflowTaskClaim.create!( + task: task, + claimed_by_unit_role: my_unit_role + ) - unless task_claim.valid? - error!({ error: "Failed to claim task" }, 400) + OverflowTaskClaimLog.create!( + unit: unit, + task: task, + claimed_by_unit_role: my_unit_role, + claimed_by_user: current_user, + original_tutor_user: original_tutor, + student_user: project.student, + days_awaiting_feedback: task.days_awaiting_feedback(claimed_at), + claimed_at: claimed_at + ) end - logger.info "Overflow task claim: {\"user_id\": #{current_user.id},\"task_id\": #{task.id}, \"timestamp\": \"#{Time.zone.now}\", \"original_tutor_user_id\": #{task.tutor ? task.tutor.id : -1}}" + logger.info "Overflow task claim: {\"user_id\": #{current_user.id},\"task_id\": #{task.id}, \"timestamp\": \"#{claimed_at}\", \"original_tutor_user_id\": #{original_tutor ? original_tutor.id : -1}}" true end diff --git a/app/api/teaching_periods_authenticated_api.rb b/app/api/teaching_periods_authenticated_api.rb index 4670cf9984..96ab5819e6 100644 --- a/app/api/teaching_periods_authenticated_api.rb +++ b/app/api/teaching_periods_authenticated_api.rb @@ -15,6 +15,7 @@ class TeachingPeriodsAuthenticatedApi < Grape::API requires :year, type: Integer, desc: 'The year of the teaching period' requires :start_date, type: Date, desc: 'The start date of the teaching period' requires :end_date, type: Date, desc: 'The end date of the teaching period' + optional :last_teaching_date, type: Date, desc: 'The final day of teaching, inclusive' requires :active_until, type: Date, desc: 'The teaching period will be active until this date' end end @@ -28,6 +29,7 @@ class TeachingPeriodsAuthenticatedApi < Grape::API :year, :start_date, :end_date, + :last_teaching_date, :active_until) result = TeachingPeriod.create!(teaching_period_parameters) @@ -47,6 +49,7 @@ class TeachingPeriodsAuthenticatedApi < Grape::API optional :year, type: Integer, desc: 'The year of the teaching period' optional :start_date, type: Date, desc: 'The start date of the teaching period' optional :end_date, type: Date, desc: 'The end date of the teaching period' + optional :last_teaching_date, type: Date, desc: 'The final day of teaching, inclusive' optional :active_until, type: Date, desc: 'The teaching period will be active until this date' end end @@ -61,6 +64,7 @@ class TeachingPeriodsAuthenticatedApi < Grape::API :year, :start_date, :end_date, + :last_teaching_date, :active_until) teaching_period.update!(teaching_period_parameters) diff --git a/app/api/teaching_periods_public_api.rb b/app/api/teaching_periods_public_api.rb index 9f17860541..ced5a92fb6 100644 --- a/app/api/teaching_periods_public_api.rb +++ b/app/api/teaching_periods_public_api.rb @@ -4,12 +4,12 @@ class TeachingPeriodsPublicApi < Grape::API desc "Get a teaching period's details" get '/teaching_periods/:id' do teaching_period = TeachingPeriod.find(params[:id]) - present teaching_period, with: Entities::TeachingPeriodEntity, full_details: true, user: current_user + present teaching_period, with: Entities::TeachingPeriodEntity, full_details: true, include_breaks: true, user: authenticated_user end desc 'Get all the Teaching Periods' get '/teaching_periods' do teaching_periods = TeachingPeriod.all - present teaching_periods, with: Entities::TeachingPeriodEntity + present teaching_periods, with: Entities::TeachingPeriodEntity, include_breaks: true end end diff --git a/app/api/tii/tii_group_attachment_api.rb b/app/api/tii/tii_group_attachment_api.rb index ae73765e88..9b85c7d5f2 100644 --- a/app/api/tii/tii_group_attachment_api.rb +++ b/app/api/tii/tii_group_attachment_api.rb @@ -6,6 +6,10 @@ class TiiGroupAttachmentApi < Grape::API helpers AuthorisationHelpers include LogHelper + before do + authenticated? + end + desc 'Get the group attachments for a given task definition' get '/units/:unit_id/task_definitions/:task_def_id/tii_group_attachments' do unit = Unit.find(params[:unit_id]) diff --git a/app/api/tii/turn_it_in_api.rb b/app/api/tii/turn_it_in_api.rb index d743ab4f7a..288fa7e3d0 100644 --- a/app/api/tii/turn_it_in_api.rb +++ b/app/api/tii/turn_it_in_api.rb @@ -17,6 +17,8 @@ class TurnItInApi < Grape::API requires :id, type: Integer, desc: 'The user id who is accepting the EULA' end put 'tii_eula/users/:id/accept' do + authenticated? + if current_user.id != params[:id] error!({ error: "You are not authorised to accept the EULA on behalf of another user" }, 403) end diff --git a/app/api/tutorials_api.rb b/app/api/tutorials_api.rb index 57b554c883..6a15e7cec3 100644 --- a/app/api/tutorials_api.rb +++ b/app/api/tutorials_api.rb @@ -19,6 +19,7 @@ class TutorialsApi < Grape::API optional :campus_id, type: Integer, desc: 'Id of the campus' optional :capacity, type: Integer, desc: 'Capacity of the tutorial' optional :meeting_time, type: String, desc: 'Time of the tutorial' + optional :duration_minutes, type: Integer, desc: 'Duration of the tutorial, in minutes' end end put '/tutorials/:id' do @@ -38,7 +39,8 @@ class TutorialsApi < Grape::API :meeting_day, :meeting_time, :campus_id, - :capacity + :capacity, + :duration_minutes ) if tut_params[:tutor_id] @@ -66,6 +68,7 @@ class TutorialsApi < Grape::API requires :meeting_day, type: String, desc: 'Day of the tutorial', allow_blank: false requires :meeting_time, type: String, desc: 'Time of the tutorial', allow_blank: false requires :tutorial_stream_abbr, type: String, desc: 'Abbreviation of the associated tutorial stream', allow_blank: false + optional :duration_minutes, type: Integer, desc: 'Duration of the tutorial, in minutes (defaults to 120)', allow_blank: true end end post '/tutorials' do @@ -89,6 +92,9 @@ class TutorialsApi < Grape::API tutorial = unit.add_tutorial(tut_params[:meeting_day], tut_params[:meeting_time], tut_params[:meeting_location], tutor, campus, tut_params[:capacity], tut_params[:abbreviation], tutorial_stream) + # Duration defaults to 120 minutes (2 hours) at the database level - only override it if the caller supplied one. + tutorial&.update!(duration_minutes: tut_params[:duration_minutes]) if tut_params[:duration_minutes].present? + present tutorial, with: Entities::TutorialEntity end diff --git a/app/api/unit_contents_api.rb b/app/api/unit_contents_api.rb new file mode 100644 index 0000000000..88b110f229 --- /dev/null +++ b/app/api/unit_contents_api.rb @@ -0,0 +1,185 @@ +require 'grape' +require 'entities/unit_content_link_entity' +require 'entities/unit_content_site_entity' + +class UnitContentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileStreamHelper + helpers MimeCheckHelpers + + helpers do + def authorise_unit_content_management!(unit) + return if authorise?(current_user, unit, :manage_unit_content) || + authorise?(current_user, User, :admin_units) + + error!({ error: 'Not authorised to manage unit content' }, 403) + end + end + + before do + authenticated? + end + + desc 'List unit content sites' + get '/units/:id/content/sites' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + present unit.unit_content_sites.order(created_at: :desc), + with: Entities::UnitContentSiteEntity, + include_file_paths: true + end + + desc 'Download a unit content site archive' + get '/units/:id/content/sites/:site_id/archive' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + site = unit.unit_content_sites.find(params[:site_id]) + filename = FileHelper.sanitized_filename(site.original_filename) + + content_type 'application/zip' + header['Content-Disposition'] = "attachment; filename=\"#{filename}\"" + stream_file site.archive_path + end + + desc 'Upload a unit content site archive' + params do + requires :file, type: File, desc: 'The static content site zip' + optional :name, type: String, desc: 'Display name for the uploaded site' + end + post '/units/:id/content/sites' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + file = params[:file] + check_mime_against_list! file[:tempfile].path, + 'zip', + ['application/zip', + 'multipart/x-gzip', + 'multipart/x-zip', + 'application/x-gzip', + 'application/octet-stream'] + + site = UnitContentSite.store_upload!(unit, file, name: params[:name]) + present site, with: Entities::UnitContentSiteEntity, include_file_paths: true + end + + desc 'Delete a unit content site' + delete '/units/:id/content/sites/:site_id' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + unit.unit_content_sites.find(params[:site_id]).destroy! + true + end + + desc 'Update a unit content site' + params do + optional :file, type: File, desc: 'Replacement static content site zip' + optional :name, type: String, desc: 'Display name for the uploaded site' + optional :root_dir, type: String, desc: 'Folder within the zip to serve as the site root' + optional :is_main, type: Boolean, desc: 'Use this as the default site for unit content' + end + put '/units/:id/content/sites/:site_id' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + site = unit.unit_content_sites.find(params[:site_id]) + update_params = declared(params, include_missing: false).slice(:name, :root_dir, :is_main) + file = params[:file] + root_dir = update_params[:root_dir] + + if file.present? + check_mime_against_list! file[:tempfile].path, + 'zip', + ['application/zip', + 'multipart/x-gzip', + 'multipart/x-zip', + 'application/x-gzip', + 'application/octet-stream'] + end + + root_dir_options = + file.present? ? UnitContentSite.root_dir_options_for(file[:tempfile].path) : site.root_dir_options + + if root_dir.present? && !root_dir_options.include?(root_dir) + error!({ error: 'Root directory is not available in this content site archive' }, 422) + end + + if update_params[:is_main] + unit.unit_content_sites.where.not(id: site.id).find_each do |content_site| + content_site.update!(is_main: false) + end + end + + if file.present? + site.replace_upload!(file, root_dir: root_dir) + update_params.except!(:root_dir) + end + + previous_root_dir = site.root_dir + site.update!(update_params) + if file.blank? && update_params.key?(:root_dir) + begin + site.extract_for_serving! + rescue StandardError + site.update!(root_dir: previous_root_dir) + raise + end + end + present site, with: Entities::UnitContentSiteEntity, include_file_paths: true + end + + desc 'List unit content links' + get '/units/:id/content/links' do + unit = Unit.find(params[:id]) + + unless authorise?(current_user, unit, :get_unit) || authorise?(current_user, User, :admin_units) + error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) + end + + present unit.unit_content_links.includes(:unit_content_site).order(:context_type, :context_key), + with: Entities::UnitContentLinkEntity + end + + desc 'Replace unit content links' + params do + requires :links, type: Array do + requires :context_type, type: String + requires :context_key, type: String + requires :unit_content_site_id, type: Integer + optional :route, type: String + end + end + put '/units/:id/content/links' do + unit = Unit.find(params[:id]) + authorise_unit_content_management!(unit) + + submitted_contexts = params[:links].map do |link_params| + [link_params[:context_type], link_params[:context_key]] + end + + unit.unit_content_links + .where(context_type: %w[grade grade_overview task_definition task_definition_resource]) + .find_each do |link| + link.destroy! unless submitted_contexts.include?([link.context_type, link.context_key]) + end + + links = params[:links].map do |link_params| + site = unit.unit_content_sites.find(link_params[:unit_content_site_id]) + link = unit.unit_content_links.find_or_initialize_by( + context_type: link_params[:context_type], + context_key: link_params[:context_key] + ) + + link.unit_content_site = site + link.route = link_params[:route].presence || '/' + link.save! + link + end + + present links, with: Entities::UnitContentLinkEntity + end +end diff --git a/app/api/unit_roles_api.rb b/app/api/unit_roles_api.rb index ff0d9f9004..a553ad7841 100644 --- a/app/api/unit_roles_api.rb +++ b/app/api/unit_roles_api.rb @@ -25,6 +25,10 @@ class UnitRolesApi < Grape::API end desc 'Delete a unit role' + params do + requires :id, type: Integer, desc: 'The id of the unit role to delete' + optional :reassign_to_unit_role_id, type: Integer, desc: 'The unit role to reassign tutorials to before deletion' + end delete '/unit_roles/:id' do unit_role = UnitRole.find(params[:id]) @@ -32,7 +36,28 @@ class UnitRolesApi < Grape::API error!({ error: "You do not have permission to perform this action" }, 403) end - unit_role.destroy! + tutorials = unit_role.unit.tutorials.where(unit_role_id: unit_role.id) + + if tutorials.exists? + if params[:reassign_to_unit_role_id].blank? + error!({ error: 'Unable to delete this unit role while tutorials are assigned without providing a reassignment target' }, 400) + end + + reassignment_role = unit_role.unit.staff.find_by(id: params[:reassign_to_unit_role_id]) + + if reassignment_role.nil? || reassignment_role.id == unit_role.id + error!({ error: 'Unable to delete this unit role with the provided reassignment target' }, 400) + end + + ActiveRecord::Base.transaction do + tutorials.find_each do |tutorial| + tutorial.update!(unit_role: reassignment_role) + end + unit_role.destroy! + end + else + unit_role.destroy! + end end desc 'Employ a user as a teaching role in a unit' @@ -76,7 +101,7 @@ class UnitRolesApi < Grape::API end end put '/unit_roles/:id' do - unit_role = UnitRole.find_by(id: params[:id]) + unit_role = UnitRole.find(params[:id]) unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) error!({ error: "Not authorised to update unit role with id=#{params[:id]}" }, 403) @@ -87,13 +112,17 @@ class UnitRolesApi < Grape::API error!({ error: "You cannot make yourself an observer" }, 403) end - # Once they're an observer, they'll no longer have access to this route to remove the observer status from themselves - # But let's double check just in case this route gets whitelisted... - unit = unit_role.unit current_unit_role = unit.unit_role_for(current_user) - if current_unit_role.observer_only + if current_unit_role.nil? + # Only administrators can manage staff within a unit that they are not employed in + unless current_user.has_admin_capability? + error!({ error: "You are not authorised to update this staff member." }, 403) + end + elsif current_unit_role.observer_only + # Once they're an observer, they'll no longer have access to this route to remove the observer status from themselves + # But let's double check just in case this route gets whitelisted... error!({ error: "You are not authorised to update this staff member." }, 403) end @@ -133,7 +162,8 @@ class UnitRolesApi < Grape::API end current_unit_role = unit.unit_role_for(current_user) - unless tutor.mentor == current_unit_role || current_unit_role.role == Role.convenor + unless current_user.has_admin_capability? || + (current_unit_role.present? && (tutor.mentor_id == current_unit_role.id || current_unit_role.role == Role.convenor)) error!({ error: 'You do not have permission to moderate this feedback' }, 400) end diff --git a/app/api/units_api.rb b/app/api/units_api.rb index a20c8a3d7a..b9be1afdd9 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -14,7 +14,7 @@ class UnitsApi < Grape::API authenticated? if params[:unit] - for key in [:start_date, :end_date] do + for key in [:start_date, :end_date, :last_teaching_date] do if params[:unit][key].present? date_val = DateTime.parse(params[:unit][key]) params[:unit][key] = date_val @@ -46,6 +46,7 @@ class UnitsApi < Grape::API { unit_roles: [:role, :user] }, { task_definitions: :tutorial_stream }, :learning_outcomes, + { unit_content_links: :unit_content_site }, { tutorial_streams: :activity_type }, { tutorials: [:tutor, :tutorial_stream] }, :tutorial_enrolments, @@ -76,6 +77,7 @@ class UnitsApi < Grape::API optional :teaching_period_id, type: Integer optional :start_date, type: Date optional :end_date, type: Date + optional :last_teaching_date, type: Date optional :main_convenor_id, type: Integer optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied' optional :mark_late_submissions_as_assess_in_portfolio, type: Boolean, desc: 'Indicates if late submissions should be set Time Exceeded or Assess in Portfolio' @@ -92,9 +94,20 @@ class UnitsApi < Grape::API optional :assessment_enabled, type: Boolean optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox' optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking' + optional :discuss_timeout_enabled, type: Boolean, desc: 'Move stale Discuss tasks back to Fix and Resubmit after a warning period' + optional :discuss_timeout_warning_days, type: Integer, desc: 'Number of days in Discuss before warning the student' + optional :discuss_timeout_expire_days, type: Integer, desc: 'Number of days in Discuss before moving the task to Fix and Resubmit' + optional :enforce_feedback_before_discussed_in_class, type: Boolean, desc: 'Require feedback to be completed before tasks can be marked discussed in class' + optional :grade_definitions, type: Array do + requires :id, type: String + requires :value, type: Integer + requires :label, type: String + requires :abbreviation, type: String + end mutually_exclusive :teaching_period_id, :start_date mutually_exclusive :teaching_period_id, :end_date + mutually_exclusive :teaching_period_id, :last_teaching_date end end put '/units/:id' do @@ -109,6 +122,7 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :last_teaching_date, :teaching_period_id, :active, :main_convenor_id, @@ -126,7 +140,12 @@ class UnitsApi < Grape::API :overseer_image_id, :assessment_enabled, :feedback_warning_threshold_days, - :feedback_overflow_threshold_days + :feedback_overflow_threshold_days, + :discuss_timeout_enabled, + :discuss_timeout_warning_days, + :discuss_timeout_expire_days, + :enforce_feedback_before_discussed_in_class, + grade_definitions: [:id, :value, :label, :abbreviation] ) if unit.teaching_period_id.present? && (unit_parameters.key?(:start_date) || unit_parameters['teaching_period_id'] == -1) @@ -161,6 +180,7 @@ class UnitsApi < Grape::API optional :teaching_period_id, type: Integer optional :start_date, type: Date optional :end_date, type: Date + optional :last_teaching_date, type: Date optional :main_convenor_user_id, type: Integer optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied', default: true optional :mark_late_submissions_as_assess_in_portfolio, type: Boolean, desc: 'Indicates if late submissions should be set to Time Exceeded or Assess in Portfolio', default: true @@ -174,9 +194,20 @@ class UnitsApi < Grape::API optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox' optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking' + optional :discuss_timeout_enabled, type: Boolean, desc: 'Move stale Discuss tasks back to Fix and Resubmit after a warning period', default: false + optional :discuss_timeout_warning_days, type: Integer, desc: 'Number of days in Discuss before warning the student', default: 7 + optional :discuss_timeout_expire_days, type: Integer, desc: 'Number of days in Discuss before moving the task to Fix and Resubmit', default: 14 + optional :enforce_feedback_before_discussed_in_class, type: Boolean, desc: 'Require feedback to be completed before tasks can be marked discussed in class', default: false + optional :grade_definitions, type: Array do + requires :id, type: String + requires :value, type: Integer + requires :label, type: String + requires :abbreviation, type: String + end mutually_exclusive :teaching_period_id, :start_date mutually_exclusive :teaching_period_id, :end_date + mutually_exclusive :teaching_period_id, :last_teaching_date all_or_none_of :start_date, :end_date end end @@ -194,6 +225,7 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :last_teaching_date, :auto_apply_extension_before_deadline, :mark_late_submissions_as_assess_in_portfolio, :send_notifications, @@ -205,7 +237,12 @@ class UnitsApi < Grape::API :portfolio_auto_generation_date, :allow_student_change_tutorial, :feedback_warning_threshold_days, - :feedback_overflow_threshold_days + :feedback_overflow_threshold_days, + :discuss_timeout_enabled, + :discuss_timeout_warning_days, + :discuss_timeout_expire_days, + :enforce_feedback_before_discussed_in_class, + grade_definitions: [:id, :value, :label, :abbreviation] ) # Ensure the user is authorised to convene units @@ -257,10 +294,12 @@ class UnitsApi < Grape::API optional :teaching_period_id, type: Integer, desc: 'The teaching period to rollover to' optional :start_date, type: Date, desc: 'The start date of the new unit' optional :end_date, type: Date, desc: 'The end date of the new unit' + optional :last_teaching_date, type: Date, desc: 'The final day of teaching for a unit without a teaching period' optional :new_unit_code, type: String, desc: 'The unit code for the new unit' exactly_one_of :teaching_period_id, :start_date all_or_none_of :start_date, :end_date + mutually_exclusive :teaching_period_id, :last_teaching_date end post '/units/:id/rollover' do unit = Unit.find(params[:id]) @@ -275,7 +314,7 @@ class UnitsApi < Grape::API tp = TeachingPeriod.find(teaching_period_id) result = unit.rollover(tp, nil, nil, params[:new_unit_code]) else - result = unit.rollover(nil, params[:start_date], params[:end_date], params[:new_unit_code]) + result = unit.rollover(nil, params[:start_date], params[:end_date], params[:new_unit_code], params[:last_teaching_date]) end my_role = result.role_for(current_user) @@ -298,6 +337,7 @@ class UnitsApi < Grape::API desc 'Download the tasks that should be listed under the task inbox' params do optional :my_students_only, type: Boolean, desc: 'Show tasks from all tutorials or just the ones you teach' + optional :view_as_unit_role_id, type: Integer, desc: 'Show the inbox of another staff member in this unit' end get '/units/:id/tasks/inbox' do unit = Unit.find(params[:id]) @@ -306,9 +346,18 @@ class UnitsApi < Grape::API error!({ error: 'Not authorised to provide feedback for this unit' }, 403) end + inbox_user = current_user + if params[:view_as_unit_role_id].present? + unless authorise? current_user, unit, :view_staff_inbox + error!({ error: 'Not authorised to view the inbox of other staff' }, 403) + end + + inbox_user = unit.staff.find(params[:view_as_unit_role_id]).user + end + my_students_only = params[:my_students_only] || false - tasks = unit.tasks_for_task_inbox(current_user, my_students_only) + tasks = unit.tasks_for_task_inbox(inbox_user, my_students_only) present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter end @@ -416,6 +465,36 @@ class UnitsApi < Grape::API present job, with: Entities::SidekiqJobEntity end + desc 'Upload CSV of staff notes for students in a unit' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/csv/units/:id/staff_notes' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :upload_staff_notes_csv + error!({ error: "Not authorised to upload staff notes to #{unit.code}" }, 403) + end + + if params[:file].blank? + error!({ error: "No file uploaded" }, 403) + end + + ensure_csv!(params[:file][:tempfile]) + + import_csv_dir = Rails.root.join(FileHelper.tmp_file_dir, 'csv') + file_name = File.join(import_csv_dir, "import-staff-notes-csv-#{unit.id}-#{Process.pid}-#{Thread.current.object_id}-#{current_user.id}.csv") + FileUtils.mkdir_p(import_csv_dir) + + csv = CSV.read(params[:file][:tempfile], headers: true) + CSV.open(file_name, "w", write_headers: true, headers: csv.headers) do |out| + csv.each { |row| out << row } + end + + job_id = ImportStaffNotesCsvJob.perform_async(unit.id, current_user.id, file_name) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Upload CSV with the students to un-enrol from the unit' params do requires :file, type: File, desc: 'CSV upload file.' @@ -532,6 +611,18 @@ class UnitsApi < Grape::API present job, with: Entities::SidekiqJobEntity end + desc 'Download CSV of overflow task claims in this unit' + get '/csv/units/:id/overflow_task_claims' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_overflow_stats + error!({ error: "Not authorised to download overflow task claim stats for #{unit.code}" }, 403) + end + + job_id = DownloadOverflowTaskClaimsCsvJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Download CSV of all student tasks in this unit' get '/csv/units/:id/task_completion' do unit = Unit.find(params[:id]) @@ -574,6 +665,54 @@ class UnitsApi < Grape::API present unit.student_task_completion_stats, with: Grape::Presenters::Presenter end + desc 'Get historical task completion snapshots' + params do + optional :start_date, type: Date, desc: 'Include snapshots captured on or after this date' + optional :end_date, type: Date, desc: 'Include snapshots captured on or before this date' + optional :limit, type: Integer, desc: 'Maximum number of snapshots to return', default: 365 + end + get '/units/:id/stats/task_completion_snapshots' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + # Already aggregated and ordered newest first, so this only has to filter. + snapshots = unit.task_completion_snapshot_stats + if params[:start_date].present? + start_timestamp = params[:start_date].in_time_zone.beginning_of_day.to_i + snapshots = snapshots.select { |snapshot| snapshot['snapshot_timestamp'].to_i >= start_timestamp } + end + if params[:end_date].present? + end_timestamp = params[:end_date].in_time_zone.end_of_day.to_i + snapshots = snapshots.select { |snapshot| snapshot['snapshot_timestamp'].to_i <= end_timestamp } + end + snapshots = snapshots.first([params[:limit].to_i, 365].min) + + present snapshots, with: Grape::Presenters::Presenter + end + + desc 'Capture task completion snapshot immediately for this unit' + post '/units/:id/stats/task_completion_snapshots/capture' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :capture_task_completion_snapshot + error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) + end + + # Check if a snapshot was captured within the past 30 minutes + recent_snapshot = unit.task_completion_snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) > ?', 30.minutes.ago.to_i).order(snapshot_timestamp: :desc).first + if recent_snapshot.present? + recent_snapshot_time = recent_snapshot.snapshot_time + remaining_seconds = [(recent_snapshot_time + 30.minutes - Time.zone.now).ceil, 0].max + remaining_minutes = [(remaining_seconds / 60.0).ceil, 1].max + error!({ error: "A snapshot was captured at #{recent_snapshot_time.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before capturing another snapshot." }, 429) + end + + job_id = AggregateTaskCompletionStatsJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) diff --git a/app/api/webcal_public_api.rb b/app/api/webcal_public_api.rb index a2bc091e9f..4c18aebc1f 100644 --- a/app/api/webcal_public_api.rb +++ b/app/api/webcal_public_api.rb @@ -14,9 +14,10 @@ class WebcalPublicApi < Grape::API webcal = Webcal.find_by!(guid: params[:guid]) # Serve the iCalendar with the correct MIME type. + env['api.format'] = :txt content_type 'text/calendar' - # Seve ical. - present webcal.to_ical.to_ical + # Serve ical. + webcal.to_ical.to_ical end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a3e2ff1aba..c10b31f4c7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,5 +7,13 @@ class ApplicationController < ActionController::Base # redirect_to root_url, alert: exception.message # end - delegate :headers, to: :request + private + + def send_download(path, filename:, type:) + # The file-serving proxy sets this to "file-server" when it serves a + # download itself, so the header says which tier streamed the bytes. + response.headers['X-OnTrack-Served-By'] = 'rails' + + send_file path, type: type, disposition: 'attachment', filename: filename + end end diff --git a/app/controllers/concerns/download_authorization.rb b/app/controllers/concerns/download_authorization.rb new file mode 100644 index 0000000000..d825129f06 --- /dev/null +++ b/app/controllers/concerns/download_authorization.rb @@ -0,0 +1,82 @@ +require 'pathname' + +# Shared plumbing for the endpoints Caddy calls before serving a protected file. +# Caddy forwards the browser's original request here; on success we return the +# file's student-work-relative path in X-OnTrack-* headers and Caddy serves it +# straight from disk. +module DownloadAuthorization + extend ActiveSupport::Concern + + INTERNAL_SECRET_HEADER = 'X-OnTrack-Download-Auth'.freeze + ORIGINAL_URI_HEADER = 'X-Forwarded-Uri'.freeze + + private + + def trusted_caddy_request? + expected = Doubtfire::Application.config.caddy_download_auth_secret.to_s + provided = request.headers[INTERNAL_SECRET_HEADER].to_s + return false if expected.blank? || provided.blank? + + ActiveSupport::SecurityUtils.secure_compare(provided, expected) + end + + def original_uri + request.headers[ORIGINAL_URI_HEADER].to_s + end + + def download_header_user + username, token = get_user_and_token_from(:header) + return unless username.present? && token.present? + return unless user_auth_token_type(username, token, :general) == :valid + + current_user + end + + # Confine file_path to the student work directory, and optionally to a + # narrower root within it. Returns [absolute, student-work-relative] or nils. + def authorised_file_path(file_path, within: nil) + return [nil, nil] if file_path.blank? || !File.file?(file_path) + + root = Pathname.new(Doubtfire::Application.config.student_work_dir).realpath + resolved = Pathname.new(file_path).realpath + return [nil, nil] unless path_contained?(resolved, root) + return [nil, nil] if within.present? && !path_contained?(resolved, Pathname.new(within).realpath) + + [resolved, resolved.relative_path_from(root).to_s] + rescue Errno::ENOENT, Errno::EACCES + [nil, nil] + end + + def path_contained?(resolved, root) + resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") + end + + def serve_via_caddy(relative_path:, filename:, content_type:, disposition:, cache_control: nil) + content_disposition = ActionDispatch::Http::ContentDisposition.format( + disposition: disposition, + filename: filename + ) + + response.set_header('X-OnTrack-File', url_encoded_path(relative_path)) + response.set_header('X-OnTrack-Content-Disposition', content_disposition) + response.set_header('X-OnTrack-Content-Type', content_type) + response.set_header('X-OnTrack-Cache-Control', cache_control) if cache_control.present? + head :ok + end + + # Caddy rewrites the request to this path, so it is parsed as a URL. Encode + # each segment or a '?' truncates the path and a '%' decodes to another file. + def url_encoded_path(relative_path) + relative_path.to_s.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/') + end + + def attachment_requested?(query) + ActiveModel::Type::Boolean.new.cast(Rack::Utils.parse_nested_query(query.to_s)['as_attachment']) + end + + # e.g. "2026-09-14 13:47:22-portfolios-COS10011-alice.zip" + def timestamped_download_name(*parts, extension:) + name = [Time.zone.now.strftime('%Y-%m-%d %H:%M:%S'), *parts].join('-') + "#{FileHelper.sanitized_filename(name.tr('\\/', '-'))}.#{extension}" + end +end diff --git a/app/controllers/concerns/native_download_cookie.rb b/app/controllers/concerns/native_download_cookie.rb new file mode 100644 index 0000000000..2b3d92c42a --- /dev/null +++ b/app/controllers/concerns/native_download_cookie.rb @@ -0,0 +1,70 @@ +# Short-lived access cookies for downloads the browser fetches natively, by +# following a plain link, instead of through Angular's authenticated XHR. +# +# Each kind is scoped by path to the single URL it authorises, so a cookie +# issued for one unit or task definition is never offered anywhere else. +module NativeDownloadCookie + extend ActiveSupport::Concern + + LIFETIME = 30.seconds + + KINDS = { + portfolio: { + cookie: 'ontrack_portfolio_download', + url: ->(unit_id:) { "/api/submission/unit/#{unit_id}/portfolio" } + }, + task_submission_files: { + cookie: 'ontrack_task_submission_files_download', + url: lambda { |unit_id:, task_definition_id:| + "/api/submission/unit/#{unit_id}/task_definitions/#{task_definition_id}/download_submissions" + } + }, + task_submission_pdfs: { + cookie: 'ontrack_task_submission_pdfs_download', + url: lambda { |unit_id:, task_definition_id:| + "/api/submission/unit/#{unit_id}/task_definitions/#{task_definition_id}/student_pdfs" + } + } + }.freeze + + def self.cookie_name(kind) + KINDS.fetch(kind)[:cookie] + end + + def self.url_path(kind, **ids) + KINDS.fetch(kind)[:url].call(**ids) + end + + private + + def native_download_user(kind, **ids) + download_header_user || native_download_cookie_user(kind, **ids) + end + + def issue_native_download_cookie(kind, user:, **ids) + expires_at = Time.current + LIFETIME + + cookies.encrypted[NativeDownloadCookie.cookie_name(kind)] = { + value: { user_id: user.id, expires_at: expires_at.to_i, **ids }.to_json, + expires: expires_at, + domain: Doubtfire::Application.config.institution[:cookie_domain], + path: NativeDownloadCookie.url_path(kind, **ids), + secure: request.ssl? || Rails.env.production?, + httponly: true, + same_site: :strict + } + end + + # Valid for its whole lifetime, not single-use: browsers resume an interrupted + # download by re-requesting the same URL. + def native_download_cookie_user(kind, **ids) + payload = JSON.parse(cookies.encrypted[NativeDownloadCookie.cookie_name(kind)].to_s) + return if ids.empty? + return unless ids.all? { |key, value| payload[key.to_s].to_s == value.to_s } + return unless payload['expires_at'].to_i > Time.current.to_i + + User.find_by(id: payload['user_id']) + rescue JSON::ParserError + nil + end +end diff --git a/app/controllers/lecture_resource_downloads_controller.rb b/app/controllers/lecture_resource_downloads_controller.rb index f91e3eddbb..b10d9d7dde 100644 --- a/app/controllers/lecture_resource_downloads_controller.rb +++ b/app/controllers/lecture_resource_downloads_controller.rb @@ -32,11 +32,11 @@ def index error!({ error: 'No files to download' }, 403) if output_zip.nil? - download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-resources-#{unit.code}" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}-resources-#{unit.code}" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) - send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_download output_zip, filename: "#{download_id}.zip", type: 'application/zip' rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/native_download_authorizations_controller.rb b/app/controllers/native_download_authorizations_controller.rb new file mode 100644 index 0000000000..0bae9a6fad --- /dev/null +++ b/app/controllers/native_download_authorizations_controller.rb @@ -0,0 +1,80 @@ +# Base class for bulk downloads the browser fetches natively. +# +# POST .../access authenticates with the Auth-Token header and mints a +# short-lived, path-scoped cookie. Caddy then calls #show for the download +# itself, which accepts either that cookie or the header. +# +# Subclasses declare their cookie kind and the URI they answer for, then +# implement #download_ids and #locate_download. +class NativeDownloadAuthorizationsController < ApplicationController + include AuthenticationHelpers + include AuthorisationHelpers + include DownloadAuthorization + include NativeDownloadCookie + + # API authentication for :create comes from the Auth-Token header, which a + # cross-origin form cannot forge. + skip_forgery_protection only: :create + + class_attribute :download_kind, :download_uri, instance_writer: false + + def self.native_download(kind, uri:) + self.download_kind = kind + self.download_uri = uri + end + + def show + return head :not_found unless trusted_caddy_request? + + route_params = download_uri.match(original_uri) + return head :not_found unless route_params + + ids = canonical_ids(route_params.named_captures.symbolize_keys) + user = native_download_user(download_kind, **ids) + return head :unauthorized unless user + + respond_with_download(user, ids) + end + + def create + user = download_header_user + return head :unauthorized unless user + + ids = canonical_ids(download_ids) + download = locate_download(user, **ids) + return head download unless download.is_a?(Hash) + return head :not_found unless authorised_file_path(download[:path]).first + + issue_native_download_cookie(download_kind, user: user, **ids) + head :no_content + end + + private + + def respond_with_download(user, ids) + download = locate_download(user, **ids) + return head download unless download.is_a?(Hash) + + _resolved, relative_path = authorised_file_path(download[:path]) + return head :not_found unless relative_path + + serve_via_caddy(relative_path: relative_path, **download.except(:path)) + end + + # Every id here is a numeric primary key. Normalising keeps the cookie's path + # and payload identical whichever route the ids arrived through. + def canonical_ids(ids) + ids.transform_values(&:to_i) + end + + # Map this controller's route params to the download's ids. + def download_ids + raise NotImplementedError + end + + # Return { path:, filename:, content_type:, disposition: } for an authorised + # request, or a status symbol (:not_found, :forbidden) to refuse it. + def locate_download(_user, **_ids) + raise NotImplementedError + end +end diff --git a/app/controllers/pdf_file_download_authorizations_controller.rb b/app/controllers/pdf_file_download_authorizations_controller.rb new file mode 100644 index 0000000000..d6ebd9064d --- /dev/null +++ b/app/controllers/pdf_file_download_authorizations_controller.rb @@ -0,0 +1,175 @@ +class PdfFileDownloadAuthorizationsController < ApplicationController + include AuthenticationHelpers + include AuthorisationHelpers + include DownloadAuthorization + + # Each entry maps one download URL to the method that checks permissions and + # returns the file. Handlers receive the MatchData for their own pattern. + ROUTES = { + %r{\A/api/units/(?\d+)/task_definitions/(?\d+)/task_pdf(?:\.json)?(?:\?(?.*))?\z} => :task_sheet_download, + %r{\A/api/units/(?\d+)/task_definitions/(?\d+)/jplag_report(?:\?(?.*))?\z} => :jplag_report_download, + %r{\A/api/submission/project/(?\d+)/portfolio(?:\?(?.*))?\z} => :portfolio_download, + %r{\A/api/tasks/(?\d+)/similarities/(?\d+)/contents/(?\d+)(?:\?(?.*))?\z} => :similarity_download, + %r{\A/api/projects/(?\d+)/task_def_id/(?\d+)/comments/(?\d+)(?:\?(?.*))?\z} => :comment_download, + %r{\A/api/projects/(?\d+)/engagements/(?\d+)/attachment(?:\?(?.*))?\z} => :engagement_download + }.freeze + + def show + return head :not_found unless trusted_caddy_request? + return head :unauthorized unless download_header_user + + download = locate_download(original_uri) + return head download unless download.is_a?(Hash) + + _resolved, relative_path = authorised_file_path(download[:path]) + return head :not_found unless relative_path + + serve_via_caddy( + relative_path: relative_path, + filename: FileHelper.sanitized_filename(download[:filename]), + content_type: download[:content_type], + disposition: attachment_requested?(download[:query]) ? 'attachment' : 'inline' + ) + end + + private + + def locate_download(uri) + ROUTES.each do |pattern, handler| + route = pattern.match(uri) + return send(handler, route) if route + end + + :not_found + end + + def task_sheet_download(route) + unit = Unit.find_by(id: route[:unit_id]) + return :not_found unless unit + + task_definition = unit.task_definitions.find_by(id: route[:task_definition_id]) + return :not_found unless task_definition + return :forbidden unless authorise?(current_user, unit, :get_unit) + return :not_found unless task_definition.has_task_sheet? + + { + path: task_definition.task_sheet(false), + filename: "#{unit.code}-#{task_definition.abbreviation}.pdf", + content_type: 'application/pdf', + query: route[:query] + } + end + + def jplag_report_download(route) + unit = Unit.find_by(id: route[:unit_id]) + return :not_found unless unit + + task_definition = unit.task_definitions.find_by(id: route[:task_definition_id]) + return :not_found unless task_definition + return :forbidden unless authorise?(current_user, unit, :download_jplag_report) + + { + path: FileHelper.task_jplag_report_path(unit, task_definition), + filename: "#{unit.code}-#{task_definition.abbreviation}-jplag-report.jplag", + content_type: 'application/octet-stream', + query: route[:query] + } + end + + def portfolio_download(route) + project = Project.find_by(id: route[:project_id]) + return :not_found unless project + return :forbidden unless authorise?(current_user, project, :get_submission) + + { + path: project.portfolio_path, + filename: "#{project.unit.code}-#{project.student.username}-portfolio.pdf", + content_type: 'application/pdf', + query: route[:query] + } + end + + def similarity_download(route) + task = Task.find_by(id: route[:task_id]) + return :not_found unless task + return :forbidden unless authorise?(current_user, task, :view_plagiarism) + + similarity = task.task_similarities.find_by(id: route[:similarity_id]) + + case similarity + when MossTaskSimilarity then moss_similarity_download(similarity, route) + when TiiTaskSimilarity then tii_similarity_download(similarity, route) + else :not_found + end + end + + def moss_similarity_download(similarity, route) + selected = if route[:index] == '0' + similarity + elsif route[:index] == '1' && authorise?(current_user, similarity.other_task, :view_plagiarism) + similarity.other_similarity + end + return :not_found unless selected + + { + path: FileHelper.path_to_plagarism_html(selected), + filename: "#{selected.student.username}_#{selected.other_student&.username}_#{selected.pct}.html", + content_type: 'text/html', + query: route[:query] + } + end + + def tii_similarity_download(similarity, route) + { + path: similarity.similarity_pdf_path, + filename: "similarity-#{similarity.id}.pdf", + content_type: 'application/pdf', + query: route[:query] + } + end + + def comment_download(route) + project = Project.find_by(id: route[:project_id]) + return :not_found unless project + return :forbidden unless authorise?(current_user, project, :get) + + task_definition = project.unit.task_definitions.find_by(id: route[:task_definition_id]) + return :not_found unless task_definition + + task = project.task_for_task_definition(task_definition) + return :not_found unless task + + comment = task.comments.find_by(id: route[:comment_id]) + return :not_found unless comment && %w[audio image pdf].include?(comment.content_type) + + SessionTracker.record_assessment_activity( + action: 'get-comment-attachment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + + attachment_download(comment, route[:query]) + end + + def engagement_download(route) + project = Project.find_by(id: route[:project_id]) + return :not_found unless project + return :forbidden unless authorise?(current_user, project, :get_engagements) + + engagement = project.engagements.find_by(id: route[:engagement_id]) + return :not_found unless engagement&.attachment? + + attachment_download(engagement, route[:query]) + end + + def attachment_download(record, query) + { + path: record.attachment_path, + filename: record.attachment_file_name, + content_type: record.attachment_mime_type, + query: query + } + end +end diff --git a/app/controllers/portfolio_download_authorizations_controller.rb b/app/controllers/portfolio_download_authorizations_controller.rb new file mode 100644 index 0000000000..7ac194b4db --- /dev/null +++ b/app/controllers/portfolio_download_authorizations_controller.rb @@ -0,0 +1,23 @@ +class PortfolioDownloadAuthorizationsController < NativeDownloadAuthorizationsController + native_download :portfolio, + uri: %r{\A/api/submission/unit/(?\d+)/portfolio(?:\?.*)?\z} + + private + + def download_ids + { unit_id: params[:id] } + end + + def locate_download(user, unit_id:) + unit = Unit.find_by(id: unit_id) + return :not_found unless unit + return :forbidden unless authorise?(user, unit, :get_students) + + { + path: unit.get_portfolio_zip_filename(user), + filename: timestamped_download_name('portfolios', unit.code, user.username, extension: 'zip'), + content_type: 'application/zip', + disposition: 'attachment' + } + end +end diff --git a/app/controllers/portfolio_downloads_controller.rb b/app/controllers/portfolio_downloads_controller.rb index 4f8206c5e6..35e5094099 100644 --- a/app/controllers/portfolio_downloads_controller.rb +++ b/app/controllers/portfolio_downloads_controller.rb @@ -4,6 +4,8 @@ class PortfolioDownloadsController < ApplicationController include AuthenticationHelpers include AuthorisationHelpers include LogHelper + include DownloadAuthorization + include NativeDownloadCookie class MyException < RuntimeError attr_reader :status @@ -19,22 +21,24 @@ def error!(message, status = options[:default_status], _headers = {}, _backtrace # desc "Retrieve portfolios for a unit" def index - unless authenticated? + download_user = native_download_user(:portfolio, unit_id: params[:id].to_i) + + unless download_user error!({ error: "Not authorised to download portfolios for unit '#{params[:id]}'" }, 401) end unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :get_students + unless authorise? download_user, unit, :get_students error!({ error: "Not authorised to download portfolios for unit '#{params[:id]}'" }, 401) end - output_zip = unit.get_portfolio_zip_filename(current_user) + output_zip = unit.get_portfolio_zip_filename(download_user) error!({ error: 'No files to download' }, 403) unless File.exist?(output_zip) # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-portfolios-#{unit.code}-#{current_user.username}" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}-portfolios-#{unit.code}-#{download_user.username}" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" @@ -48,7 +52,7 @@ def index # File.binread output_zip # sending_file = true - send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_download output_zip, filename: "#{download_id}.zip", type: 'application/zip' rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/submission_download_authorizations_controller.rb b/app/controllers/submission_download_authorizations_controller.rb new file mode 100644 index 0000000000..2dc6b7510b --- /dev/null +++ b/app/controllers/submission_download_authorizations_controller.rb @@ -0,0 +1,61 @@ +class SubmissionDownloadAuthorizationsController < ApplicationController + include AuthenticationHelpers + include AuthorisationHelpers + include DownloadAuthorization + + DOWNLOAD_PATH = %r{\A/api/projects/(?\d+)/task_def_id/(?\d+)/(?submission|submission_files)(?:\?(?.*))?\z} + + def show + return head :not_found unless trusted_caddy_request? + return head :unauthorized unless download_header_user + + route = DOWNLOAD_PATH.match(original_uri) + return head :not_found unless route + + download = locate_download(route) + return head download unless download.is_a?(Hash) + + _resolved, relative_path = authorised_file_path(download[:path]) + return head :not_found unless relative_path + + serve_via_caddy(relative_path: relative_path, **download.except(:path)) + end + + private + + def locate_download(route) + project = Project.find_by(id: route[:project_id]) + return :not_found unless project + + task_definition = project.unit.task_definitions.find_by(id: route[:task_definition_id]) + return :not_found unless task_definition + return :forbidden unless authorise?(current_user, project, :get_submission) + + task = project.task_for_task_definition(task_definition) + return :not_found unless task + + if route[:kind] == 'submission_files' + submission_files_download(task, task_definition, project) + else + submission_pdf_download(task, task_definition, route[:query]) + end + end + + def submission_files_download(task, task_definition, project) + { + path: task.downloadable_submission_zip_path, + filename: FileHelper.sanitized_filename("#{project.student.username}-#{task_definition.abbreviation}.zip"), + content_type: 'application/octet-stream', + disposition: 'attachment' + } + end + + def submission_pdf_download(task, task_definition, query) + { + path: task.final_pdf_path, + filename: FileHelper.sanitized_filename("#{task_definition.abbreviation}.pdf"), + content_type: 'application/pdf', + disposition: attachment_requested?(query) ? 'attachment' : 'inline' + } + end +end diff --git a/app/controllers/task_downloads_controller.rb b/app/controllers/task_downloads_controller.rb index 2a0c8d0763..a5f8e3bb9e 100644 --- a/app/controllers/task_downloads_controller.rb +++ b/app/controllers/task_downloads_controller.rb @@ -4,6 +4,8 @@ class TaskDownloadsController < ApplicationController include AuthenticationHelpers include AuthorisationHelpers include LogHelper + include DownloadAuthorization + include NativeDownloadCookie class MyException < RuntimeError attr_reader :status @@ -19,25 +21,31 @@ def error!(message, status = options[:default_status], _headers = {}, _backtrace # desc "Retrieve tasks for a unit" def index - unless authenticated? + download_user = native_download_user( + :task_submission_files, + unit_id: params[:id].to_i, + task_definition_id: params[:task_def_id].to_i + ) + + unless download_user error!({ error: "Not authorised to download tasks for unit '#{params[:id]}'" }, 401) end unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :get_students + unless authorise? download_user, unit, :get_students error!({ error: "Not authorised to download tasks for unit '#{params[:id]}'" }, 401) end td = unit.task_definitions.find(params[:task_def_id]) - output_zip = unit.get_task_submissions_zip(current_user, td) + output_zip = unit.get_task_submissions_zip(download_user, td) error!({ error: 'No files to download' }, 403) if output_zip.nil? # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-files" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}-#{unit.code}-#{td.abbreviation}-#{download_user.username}-files" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" @@ -45,7 +53,7 @@ def index logger.debug "Downloading task for #{td.abbreviation} from #{output_zip}" - send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_download output_zip, filename: "#{download_id}.zip", type: 'application/zip' rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/task_submission_files_download_authorizations_controller.rb b/app/controllers/task_submission_files_download_authorizations_controller.rb new file mode 100644 index 0000000000..5934e2644e --- /dev/null +++ b/app/controllers/task_submission_files_download_authorizations_controller.rb @@ -0,0 +1,28 @@ +class TaskSubmissionFilesDownloadAuthorizationsController < NativeDownloadAuthorizationsController + native_download :task_submission_files, + uri: %r{\A/api/submission/unit/(?\d+)/task_definitions/(?\d+)/download_submissions(?:\?.*)?\z} + + private + + def download_ids + { unit_id: params[:id], task_definition_id: params[:task_def_id] } + end + + def locate_download(user, unit_id:, task_definition_id:) + unit = Unit.find_by(id: unit_id) + return :not_found unless unit + + task_definition = unit.task_definitions.find_by(id: task_definition_id) + return :not_found unless task_definition + return :forbidden unless authorise?(user, unit, :get_students) + + { + path: unit.task_submissions_zip_path(user, task_definition), + filename: timestamped_download_name( + unit.code, task_definition.abbreviation, user.username, 'files', extension: 'zip' + ), + content_type: 'application/zip', + disposition: 'attachment' + } + end +end diff --git a/app/controllers/task_submission_pdfs_controller.rb b/app/controllers/task_submission_pdfs_controller.rb index fec8fc20e9..db623c39ed 100644 --- a/app/controllers/task_submission_pdfs_controller.rb +++ b/app/controllers/task_submission_pdfs_controller.rb @@ -4,6 +4,8 @@ class TaskSubmissionPdfsController < ApplicationController include AuthenticationHelpers include AuthorisationHelpers include LogHelper + include DownloadAuthorization + include NativeDownloadCookie class MyException < RuntimeError attr_reader :status @@ -19,25 +21,31 @@ def error!(message, status = options[:default_status], _headers = {}, _backtrace # desc "Retrieve student PDFs for a unit" def index - unless authenticated? + download_user = native_download_user( + :task_submission_pdfs, + unit_id: params[:id].to_i, + task_definition_id: params[:task_def_id].to_i + ) + + unless download_user error!({ error: "Not authorised to download student PDFs for unit '#{params[:id]}'" }, 401) end unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :get_students + unless authorise? download_user, unit, :get_students error!({ error: "Not authorised to download student PDFs for unit '#{params[:id]}'" }, 401) end td = unit.task_definitions.find(params[:task_def_id]) - output_zip = unit.get_task_submissions_pdf_zip(current_user, td) + output_zip = unit.get_task_submissions_pdf_zip(download_user, td) error!({ error: 'No files to download' }, 403) if output_zip.nil? # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-pdfs" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}-#{unit.code}-#{td.abbreviation}-#{download_user.username}-pdfs" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" @@ -45,7 +53,7 @@ def index logger.debug "Downloading task for #{td.abbreviation} from #{output_zip}" - send_file output_zip, content_type: 'application/octet-stream', disposition: "attachment; filename=#{download_id}.zip" + send_download output_zip, filename: "#{download_id}.zip", type: 'application/zip' rescue MyException => e render json: e.message, status: e.status end diff --git a/app/controllers/task_submission_pdfs_download_authorizations_controller.rb b/app/controllers/task_submission_pdfs_download_authorizations_controller.rb new file mode 100644 index 0000000000..0b4afc2e93 --- /dev/null +++ b/app/controllers/task_submission_pdfs_download_authorizations_controller.rb @@ -0,0 +1,28 @@ +class TaskSubmissionPdfsDownloadAuthorizationsController < NativeDownloadAuthorizationsController + native_download :task_submission_pdfs, + uri: %r{\A/api/submission/unit/(?\d+)/task_definitions/(?\d+)/student_pdfs(?:\?.*)?\z} + + private + + def download_ids + { unit_id: params[:id], task_definition_id: params[:task_def_id] } + end + + def locate_download(user, unit_id:, task_definition_id:) + unit = Unit.find_by(id: unit_id) + return :not_found unless unit + + task_definition = unit.task_definitions.find_by(id: task_definition_id) + return :not_found unless task_definition + return :forbidden unless authorise?(user, unit, :get_students) + + { + path: unit.task_submissions_pdf_zip_path(user, task_definition), + filename: timestamped_download_name( + unit.code, task_definition.abbreviation, user.username, 'pdfs', extension: 'zip' + ), + content_type: 'application/zip', + disposition: 'attachment' + } + end +end diff --git a/app/controllers/unit_content_download_authorizations_controller.rb b/app/controllers/unit_content_download_authorizations_controller.rb new file mode 100644 index 0000000000..cd21651cc6 --- /dev/null +++ b/app/controllers/unit_content_download_authorizations_controller.rb @@ -0,0 +1,129 @@ +require 'mime/types' +require 'rack/files' +require 'uri' + +class UnitContentDownloadAuthorizationsController < ApplicationController + include AuthorisationHelpers + include DownloadAuthorization + + skip_after_action :verify_same_origin_request, only: :serve + + CONTENT_VERSION = '[0-9a-f]{64}'.freeze + CONTENT_PATH = %r{\A/api/units/(?\d+)/content/sites/(?\d+)/files(?:/v/(?#{CONTENT_VERSION}))?(?/[^?]*)?(?:\?.*)?\z} + VERSIONED_ROUTE = %r{\Av/(?#{CONTENT_VERSION})(?/.*)?\z} + VERSIONED_CACHE_CONTROL = 'private, max-age=604800, immutable'.freeze + LEGACY_CACHE_CONTROL = 'private, no-cache'.freeze + + def show + return head :not_found unless trusted_caddy_request? + + route_params = CONTENT_PATH.match(original_uri) + return head :not_found unless route_params + + result = authorised_content( + unit_id: route_params[:unit_id], + site_id: route_params[:site_id], + route: route_params[:route], + content_version: route_params[:content_version] + ) + return head result unless result.is_a?(Hash) + + serve_via_caddy( + relative_path: result[:relative_path], + filename: result[:path].basename.to_s, + content_type: content_type_for(result[:path]), + disposition: 'inline', + cache_control: result[:cache_control] + ) + end + + def serve + route, content_version = route_and_version(params[:route]) + result = authorised_content( + unit_id: params[:unit_id], + site_id: params[:site_id], + route: route, + content_version: content_version + ) + return head result unless result.is_a?(Hash) + + disposition = ActionDispatch::Http::ContentDisposition.format( + disposition: 'inline', + filename: result[:path].basename.to_s + ) + file_server = Rack::Files.new( + nil, + { + 'accept-ranges' => 'bytes', + 'cache-control' => result[:cache_control], + 'content-disposition' => disposition + }, + content_type_for(result[:path]) + ) + file_status, file_headers, file_body = file_server.serving(request, result[:path].to_s) + self.status = file_status + file_headers.each { |name, value| response.set_header(name, value) } + self.response_body = file_body + end + + private + + def authenticated_content_user + username = request.cookies['username'].to_s + token_text = request.cookies[AuthenticationHelpers::CONTENT_TOKEN_COOKIE].to_s + return nil if username.blank? || token_text.blank? + + user = User.eager_load(:role).find_by(username: username) + return nil unless user + + token = user.auth_tokens.where(token_type: :content).detect do |candidate| + ActiveSupport::SecurityUtils.secure_compare(candidate.authentication_token, token_text) + end + return nil unless token + + if token.auth_token_expiry <= Time.zone.now + token.destroy! + return nil + end + + user + end + + def authorised_content(unit_id:, site_id:, route:, content_version: nil) + user = authenticated_content_user + return :unauthorized unless user + + unit = Unit.find_by(id: unit_id) + return :not_found unless unit + return :forbidden unless authorise?(user, unit, :get_unit) || authorise?(user, User, :admin_units) + + site = unit.unit_content_sites.find_by(id: site_id) + return :not_found unless site + return :not_found if content_version.present? && !valid_content_version?(site, content_version) + + file_path = site.served_file_path(route.presence || '/') + resolved_path, relative_path = authorised_file_path(file_path, within: site.served_dir) + return :not_found unless resolved_path + + { + path: resolved_path, + relative_path: relative_path, + cache_control: content_version.present? ? VERSIONED_CACHE_CONTROL : LEGACY_CACHE_CONTROL + } + end + + def route_and_version(route) + match = VERSIONED_ROUTE.match(route.to_s) + return [route, nil] unless match + + [match[:route], match[:content_version]] + end + + def valid_content_version?(site, content_version) + ActiveSupport::SecurityUtils.secure_compare(site.content_version, content_version) + end + + def content_type_for(path) + MIME::Types.type_for(path.to_s).first&.content_type || 'application/octet-stream' + end +end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index d43c635c09..6f9d2afab3 100644 --- a/app/helpers/authentication_helpers.rb +++ b/app/helpers/authentication_helpers.rb @@ -7,6 +7,7 @@ # This is used by the grape api. # module AuthenticationHelpers + CONTENT_TOKEN_COOKIE = 'content_token'.freeze # private functions # Check that the user and token are valid @@ -73,12 +74,18 @@ def authenticated_via_refresh_token? # :cookie - from the request cookie # @return [String, String] The username and token def get_user_and_token_from(source) - if source == :header - user_param = headers['username'] || headers['Username'] || params['username'] - auth_param = headers['auth-token'] || headers['Auth-Token'] || params['authToken'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token'] - elsif source == :cookie + case source + when :header + user_param = request.headers['username'] || request.headers['Username'] || params['username'] + auth_param = request.headers['auth-token'] || request.headers['Auth-Token'] || params['authToken'] || + request.headers['Auth_Token'] || request.headers['auth_token'] || params['auth_token'] || + params['Auth_Token'] + when :cookie user_param = cookies['username'] auth_param = cookies['refresh_token'] + when :content_cookie + user_param = cookies['username'] + auth_param = cookies[CONTENT_TOKEN_COOKIE] else # Default to nil user_param = nil @@ -98,6 +105,7 @@ def authenticated?(token_type = :general) user_param, auth_param = get_user_and_token_from(:cookie) else user_param, auth_param = get_user_and_token_from(:header) + auth_param = params['content_token'] if token_type == :content end case user_auth_token_type(user_param, auth_param, token_type) @@ -119,10 +127,43 @@ def authenticated?(token_type = :general) # Get the current user either from warden or from the header # def current_user - username = headers['username'] || headers['Username'] || params['username'] || cookies['username'] + username = request.headers['username'] || request.headers['Username'] || params['username'] || cookies['username'] User.eager_load(:role, :auth_tokens).find_by(username: username) end + # + # Get the current user for routes that do not require authentication - nil unless the auth token is valid + # + def authenticated_user + user_param, auth_param = get_user_and_token_from(:header) + current_user if user_auth_token_type(user_param, auth_param, :general) == :valid + end + + def set_content_cookie_in_response(token = nil) + domain = Doubtfire::Application.config.institution[:cookie_domain] + common_options = { + domain: domain, + path: '/api/units/', + secure: request.ssl? || Rails.env.production?, + same_site: :strict, + httponly: true + } + + if token.present? + cookies['username'] = common_options.merge( + value: current_user.username, + expires: token.auth_token_expiry + ) + cookies[CONTENT_TOKEN_COOKIE] = common_options.merge( + value: token.authentication_token, + expires: token.auth_token_expiry + ) + else + cookies.delete('username', **common_options) + cookies.delete(CONTENT_TOKEN_COOKIE, **common_options) + end + end + # # Add the required auth_token to each of the routes for the provided # Grape::API. @@ -217,12 +258,9 @@ def set_refresh_cookie_in_response(remember) # rubocop:enable Naming/AccessorMethodName if remember - token = current_user.auth_tokens.where(token_type: :refresh_token).last - - # Generate a new token when the old one is absent or getting close to expiring - if token.nil? || token.auth_token_expiry <= Time.zone.now - 12.hours - token = current_user.generate_authentication_token!(token_type: :refresh_token, expiry: Time.zone.now + 1.week) - end + # Reuse a recent refresh token, but rotate it once it is past 75% of its lifetime (or + # expired) so that signing in again always yields a cookie with most of its life left. + token = current_user.generate_authentication_token!(token_type: :refresh_token, force_new: false) domain = Doubtfire::Application.config.institution[:cookie_domain] diff --git a/app/helpers/authorisation_helpers.rb b/app/helpers/authorisation_helpers.rb index b27fd59024..0b25682df6 100644 --- a/app/helpers/authorisation_helpers.rb +++ b/app/helpers/authorisation_helpers.rb @@ -26,7 +26,9 @@ def get_permission_hash(role, perm_hash, _other) :get_staff_note, :get_members, :get_groups, - :get_discussion_prompt + :get_discussion_prompt, + :get_engagements, + :get_tutor_times ].freeze # @@ -40,6 +42,13 @@ def get_permission_hash(role, perm_hash, _other) def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), other = nil) # Can pass in instance or class obj_class = object.class == Class ? object : object.class + perm_hash = obj_class.permissions + + # System administrator permissions take precedence over contextual roles. + if user.has_admin_capability? + system_perms = perm_get_fn.call(user.role.to_sym, perm_hash, other) + return true if system_perms&.include?(action) + end role_obj = object.role_for(user) @@ -58,7 +67,6 @@ def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), end role = role_obj.to_sym - perm_hash = obj_class.permissions perms = perm_get_fn.call(role, perm_hash, other) # No permissions, default to false authorise, else check if the action diff --git a/app/helpers/csv_helper.rb b/app/helpers/csv_helper.rb index e56af31a94..923e5877c6 100644 --- a/app/helpers/csv_helper.rb +++ b/app/helpers/csv_helper.rb @@ -1,4 +1,19 @@ module CsvHelper + SPREADSHEET_FORMULA_PREFIX = /\A[=+\-@\t\r\n]/ + + # CSV quoting does not prevent spreadsheet applications from evaluating a + # cell as a formula. Prefix formula-like values with an apostrophe so the + # spreadsheet treats the value as text. + def escape_spreadsheet_formula(value) + return value unless value.is_a?(String) && value.match?(SPREADSHEET_FORMULA_PREFIX) + + "'#{value}" + end + + def escape_spreadsheet_formulas(values) + values.map { |value| escape_spreadsheet_formula(value) } + end + def csv_date_to_date(date) return if date.blank? @@ -31,4 +46,6 @@ def missing_headers(row, headers) module_function :csv_date_to_date module_function :missing_headers + module_function :escape_spreadsheet_formula + module_function :escape_spreadsheet_formulas end diff --git a/app/helpers/d2l_integration.rb b/app/helpers/d2l_integration.rb index 05f3808ae3..d575291338 100644 --- a/app/helpers/d2l_integration.rb +++ b/app/helpers/d2l_integration.rb @@ -335,7 +335,7 @@ def self.result_file_path(unit) end def self.d2l_grade_job_present?(unit) - queue = Sidekiq::Queue.new("default") + queue = Sidekiq::Queue.new(D2lPostGradesJob.get_sidekiq_options['queue']) queue.each do |job| return true if job.klass == 'D2lPostGradesJob' && job.args[0] == unit.id end diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 1f589f08a8..c57691568f 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -6,14 +6,28 @@ require 'open3' require 'shellwords' require 'pdf-reader' +require 'zlib' +require 'rubygems/package' module FileHelper extend LogHelper extend TimeoutHelper extend MimeCheckHelpers + ZIP_NESTED_ARCHIVE_EXTENSIONS = %w[ + .7z .bz2 .ear .gz .jar .rar .tar .tar.bz2 .tar.gz .tar.xz .tbz .tbz2 .tgz .txz .war .xz .zip + ].freeze + WORD_DOCUMENT_EXTENSION = '.docx' + WORD_DOCUMENT_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + COMPOUND_FILE_SIGNATURE = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b.freeze + WORD_DOCUMENT_ENCRYPTION_STREAM_NAMES = %w[EncryptedPackage EncryptionInfo].map do |name| + name.encode(Encoding::UTF_16LE).b.freeze + end.freeze + + class DocumentConversionError < StandardError; end + def known_extension?(extn) - allow_extensions = %w(pdf ps csv xls xlsx pas cpp c cs csv h hpp java py js html coffee scss yaml yml xml json ts r rb rmd rnw rhtml rpres tex vb sql txt md jack hack asm hdl tst out cmp vm sh bat dat ipynb css png bmp tiff tif jpeg jpg gif zip gz tar wav ogg mp3 mp4 webm aac pcm aiff flac wma alac pml vue) + allow_extensions = %w(pdf ps docx csv xls xlsx pas cpp c cs csv h hpp java py js html coffee scss yaml yml xml json ts r rb rmd rnw rhtml rpres tex vb sql txt md jack hack asm hdl tst out cmp vm sh bat dat ipynb css png bmp tiff tif jpeg jpg gif zip gz tgz tar wav ogg mp3 mp4 webm aac pcm aiff flac wma alac pml vue) # Allow empty or nil extensions for blobs otherwise check that it matches the allowed list extn.blank? || allow_extensions.include?(extn) @@ -24,6 +38,8 @@ def known_extension?(extn) # - file is passed the file uploaded to Doubtfire (a hash with all relevant data about the file) # def accept_file(file, name, kind) + word_document = kind == 'document' && word_document?(file[:filename] || file['tempfile'].path) + case kind when 'image' mime_allow_list = ['image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/jpeg', 'image/x-ms-bmp'] @@ -33,7 +49,21 @@ def accept_file(file, name, kind) 'text/x-yaml', 'application/xml', 'text/x-typescript', 'text/x-vhdl', 'text/x-asm', 'text/x-jack', 'application/x-httpd-php', 'application/tst', 'text/x-cmp', 'text/x-vm', 'application/x-sh', 'application/x-bat', 'application/dat', 'application/x-wine-extension-ini'] when 'document' - mime_allow_list = [ 'application/pdf' ] + mime_allow_list = if word_document + [WORD_DOCUMENT_MIME_TYPE] + else + ['application/pdf'] + end + when 'zip', 'archive' + mime_allow_list = [ + 'application/zip', + 'application/x-zip', + 'application/x-zip-compressed', + 'multipart/x-zip', + 'application/x-tar', + 'application/gzip', + 'application/x-gzip' + ] when 'audio' mime_allow_list = ['audio/', 'video/webm', 'application/ogg', 'application/octet-stream'] when 'comment_attachment' @@ -54,6 +84,24 @@ def accept_file(file, name, kind) } end + if word_document && !word_document_conversion_configured? + msg = 'Word documents are currently not supported. Please export your document to PDF.' + logger.error 'Word document upload rejected because conversion is not configured' + return { + accepted: false, + msg: msg + } + end + + if word_document && encrypted_word_document?(file['tempfile'].path) + msg = 'Word document is encrypted or password protected. Remove the password protection and upload it again.' + logger.debug 'Word document is encrypted or password protected' + return { + accepted: false, + msg: msg + } + end + mime_check = mime_in_list?(file['tempfile'].path, mime_allow_list) unless mime_check msg = 'invalid file MIME type, file is likely corrupted.' @@ -64,8 +112,9 @@ def accept_file(file, name, kind) } end - # Extra checks for PDF documents - if kind == 'document' + # Extra checks for PDF documents. DOCX files are converted and validated as + # PDFs when the submission is processed asynchronously. + if kind == 'document' && !word_document pdf_validation_result = validate_pdf(file['tempfile'].path) if pdf_validation_result[:encrypted] @@ -87,6 +136,18 @@ def accept_file(file, name, kind) end end + if %w[zip archive].include?(kind) + zip_validation_result = validate_zip_upload(file['tempfile'].path, File.basename(file[:filename].to_s)) + + unless zip_validation_result[:valid] + logger.debug "Zip file is invalid: #{zip_validation_result[:msg]}" + return { + accepted: false, + msg: zip_validation_result[:msg] + } + end + end + logger.debug 'Uploaded file is accepted' # All checks are done @@ -96,6 +157,127 @@ def accept_file(file, name, kind) } end + def word_document?(path) + File.extname(path.to_s).casecmp(WORD_DOCUMENT_EXTENSION).zero? + end + + def word_document_conversion_configured? + config = Doubtfire::Application.config + config.gotenberg_image.present? && + (config.gotenberg_workdir_volume_mount.present? || config.gotenberg_fallback_volume_container.present?) + end + + # Password-protected OOXML files are stored in an OLE compound file rather + # than the ZIP container used by normal DOCX files. Confirm the compound-file + # signature and both encryption stream names to avoid treating any malformed + # or incorrectly named DOCX as encrypted. + def encrypted_word_document?(path) + longest_stream_name = WORD_DOCUMENT_ENCRYPTION_STREAM_NAMES.map(&:bytesize).max + matched_stream_names = Array.new(WORD_DOCUMENT_ENCRYPTION_STREAM_NAMES.length, false) + + File.open(path, 'rb') do |file| + return false unless file.read(COMPOUND_FILE_SIGNATURE.bytesize) == COMPOUND_FILE_SIGNATURE + + buffer = ''.b + while (chunk = file.read(16 * 1024)) + buffer << chunk + WORD_DOCUMENT_ENCRYPTION_STREAM_NAMES.each_with_index do |stream_name, index| + matched_stream_names[index] ||= buffer.include?(stream_name) + end + return true if matched_stream_names.all? + + buffer = buffer.byteslice(-(longest_stream_name - 1), longest_stream_name - 1) || ''.b + end + end + + false + rescue Errno::ENOENT, Errno::EACCES, IOError + false + end + + def gotenberg_work_id(unit_code:, kind:, username:, label:) + [Time.current.to_i, unit_code, kind, username, label, SecureRandom.uuid] + .map { |part| part.to_s.gsub(/[^A-Za-z0-9_-]/, '_') } + .join('-') + end + + def convert_word_document_to_pdf(source_path, destination_path, work_id: SecureRandom.uuid) + work_id = work_id.to_s + unless work_id.match?(/\A[A-Za-z0-9_-]+\z/) + raise DocumentConversionError, 'Invalid Gotenberg work id.' + end + + work_root = Rails.root.join("tmp/gotenberg") + work_dir = work_root.join(work_id) + input_path = work_dir.join('input.docx') + output_path = work_dir.join('output.pdf') + + FileUtils.mkdir_p(work_dir) + FileUtils.chmod(0o777, work_dir) + FileUtils.cp(source_path, input_path) + # Uploaded tempfiles can be 0600; Gotenberg reads this copy as a different user. + FileUtils.chmod(0o644, input_path) + + stdout, stderr, status = run_word_document_conversion(work_id) + unless status.success? + details = [stdout, stderr].compact.join("\n").strip.first(1_000) + message = "Word document conversion failed with exit status #{status.exitstatus}." + message = "#{message} #{details}" if details.present? + raise DocumentConversionError, message + end + + unless File.exist?(output_path) && validate_pdf(output_path)[:valid] + raise DocumentConversionError, 'Gotenberg did not produce a valid PDF for the Word document.' + end + + FileUtils.mv(output_path, destination_path) + destination_path + rescue SystemCallError => e + raise DocumentConversionError, "Word document conversion failed: #{e.message}" + ensure + FileUtils.rm_rf(work_dir) if defined?(work_dir) && work_dir + end + + def run_word_document_conversion(work_id) + Open3.capture3(*word_document_conversion_command(work_id)) + end + + def word_document_conversion_command(work_id) + config = Doubtfire::Application.config + raise DocumentConversionError, 'GOTENBERG_IMAGE is not configured.' if config.gotenberg_image.blank? + + timeout_seconds = config.word_document_conversion_timeout_seconds.to_i + timeout_seconds = 120 unless timeout_seconds.positive? + + [ + 'docker', 'run', '--rm', + '--cpus', '1', + '--network', 'none', + *gotenberg_volume_arguments(work_id), + '--name', "gotenberg-word-#{work_id}", + '--env', "WORD_DOCUMENT_CONVERSION_TIMEOUT_SECONDS=#{timeout_seconds}", + '--entrypoint', config.word_document_build_path, + config.gotenberg_image, + work_id + ] + end + + def gotenberg_volume_arguments(work_id) + config = Doubtfire::Application.config + mount = config.gotenberg_workdir_volume_mount + + if mount.present? + host_work_dir = File.join(mount, work_id) + container_work_dir = "/workdir/gotenberg/#{work_id}" + ['--volume', "#{host_work_dir}:#{container_work_dir}"] + elsif config.gotenberg_fallback_volume_container.present? + ['--volumes-from', config.gotenberg_fallback_volume_container] + else + raise DocumentConversionError, + 'Set GOTENBERG_WORKDIR_VOLUME_MOUNT or GOTENBERG_FALLBACK_VOLUME_CONTAINER.' + end + end + # # Sanitize the passed in paths, and ensure each part is valid # Will kill things like ../ etc or spaces in paths @@ -276,6 +458,34 @@ def unit_portfolio_dir(unit, create: true, archived: true) dst end + def unit_analytics_dir(unit, create: true, archived: true) + dst = unit_work_root(unit, archived: archived) + dst << 'analytics/' + + FileUtils.mkdir_p(dst) if create + dst + end + + def unit_task_status_snapshot_path(unit, create: true, archived: true) + analytics_dir = unit_analytics_dir(unit, create: create, archived: archived) + FileUtils.mkdir_p(analytics_dir) if create + File.join(analytics_dir, 'task-status-snapshots.zip') + end + + # Aggregated stats for every snapshot, kept beside the zip so the analytics endpoint never + # has to parse the CSVs. Disposable - it is rebuilt from the zip whenever it goes stale. + # Gzipped because the stats repeat the same status keys per task, per tutorial, per campus. + def unit_task_status_snapshot_stats_path(unit, create: true, archived: true) + analytics_dir = unit_analytics_dir(unit, create: create, archived: archived) + FileUtils.mkdir_p(analytics_dir) if create + File.join(analytics_dir, 'task-status-snapshots.json.gz') + end + + def snapshot_csv_filename(snapshot_timestamp) + return nil if snapshot_timestamp.blank? + "#{sanitized_filename(snapshot_timestamp.to_s)}.csv" + end + # # Generates a path for storing student portfolios # @@ -293,19 +503,45 @@ def student_portfolio_path(unit, username, create: true, archived: true) File.join(student_portfolio_dir(unit, username, create: create, archived: archived), FileHelper.sanitized_filename("#{username}-portfolio.pdf")) end - def task_jplag_report_dir(unit) - file_server = Doubtfire::Application.config.jplag_report_dir - "#{file_server}/#{unit.code}-#{unit.id}/" # trust the server config and passed in type for paths + def root_jplag_report_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/jplag/results/" + end + + def unit_jplag_report_dir(unit, create: false, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + File.join(root_jplag_report_dir(archived: true), sanitized_path("#{unit.code}-#{unit.id}")) + else + File.join(root_jplag_report_dir(archived: false), sanitized_path("#{unit.code}-#{unit.id}")) + end + + FileUtils.mkdir_p dst if create + "#{dst}/" + end + + def task_jplag_report_dir(unit, create: false, archived: true) + unit_jplag_report_dir(unit, create: create, archived: archived) end def task_jplag_report_path(unit, task) - File.join(task_jplag_report_dir(unit), FileHelper.sanitized_filename("#{task.abbreviation}-result.jplag")) + File.join(unit_jplag_report_dir(unit), FileHelper.sanitized_filename("#{task.abbreviation}-result.jplag")) end def comment_attachment_path(task_comment, attachment_extension) "#{File.join(student_work_dir(:comment, task_comment.task), "#{task_comment.id.to_s}#{attachment_extension}")}" end + def engagement_attachment_path(engagement, attachment_extension) + dir = File.join(project_work_root(engagement.project), 'engagement') + FileUtils.mkdir_p(dir) + File.join(dir, "#{engagement.id}#{attachment_extension}") + end + def comment_prompt_path(task_comment, attachment_extension, count) "#{File.join(student_work_dir(:discussion, task_comment.task), "#{task_comment.id.to_s}_#{count.to_s}#{attachment_extension}")}" end @@ -315,9 +551,9 @@ def comment_reply_prompt_path(discussion_comment, attachment_extension) end def compress_image_to_dest(source, dest, delete_frames = false) - exec = "convert -quiet \ + exec = "convert -quiet -define jpeg:size=2048x2048 \ \"#{source}\" \ - #{delete_frames ? '-delete 1--1' : ''} -strip -density 72 -quality 85% -resize 2048x2048\\> -resize 48x48\\< \ + #{delete_frames ? '-delete 1--1' : ''} -auto-orient -strip -density 72 -quality 85% -resize 2048x2048\\> -resize 48x48\\< \ \"#{dest}\" >>/dev/null 2>>/dev/null" system_try_within 40, 'compressing image using convert', exec @@ -383,6 +619,193 @@ def compress_pdf(path, max_size: 2_500_000, timeout_seconds: 30) FileUtils.rm_f tmp_file end + def zip_path_safe?(path) + return false if path.blank? + + clean_path = path.tr('\\', '/') + return false if clean_path.start_with?('/') || clean_path.include?("\0") + + clean_path.sub!(%r{\A\./+}, '') + clean_path.split('/').none? { |part| part.blank? || part == '.' || part == '..' } + end + + def zip_entry_limit + limit = Doubtfire::Application.config.zip_entry_limit.to_i + limit.positive? ? limit : 1_000 + end + + def zip_compression_ratio_limit + limit = Doubtfire::Application.config.zip_compression_ratio_limit.to_i + limit.positive? ? limit : 100 + end + + def zip_uncompressed_size_multiplier + multiplier = Doubtfire::Application.config.zip_uncompressed_size_multiplier.to_i + multiplier.positive? ? multiplier : 10 + end + + def zip_nested_archive?(path) + clean_path = path.to_s.downcase + ZIP_NESTED_ARCHIVE_EXTENSIONS.any? { |extension| clean_path.end_with?(extension) } + end + + def validate_zip_upload_entry!(name, size, zip_stats, _max_file_size, max_uncompressed_size) + raise 'Zip contains a file with an unsafe path.' unless zip_path_safe?(name) + raise 'Zip contains another archive file. Nested archives are not allowed.' if zip_nested_archive?(name) + + zip_stats[:entries] += 1 + zip_stats[:total_uncompressed_size] += size.to_i + + raise "Zip contains too many files. Limit is #{zip_entry_limit} files." if zip_stats[:entries] > zip_entry_limit + # raise "Zip contains a file larger than the #{max_file_size / 1_000_000}MB file limit." if size.to_i > max_file_size + if zip_stats[:total_uncompressed_size] > max_uncompressed_size + raise "Zip expands beyond the #{max_uncompressed_size / 1_000_000}MB uncompressed size limit." + end + end + + def validate_zip_file(path, max_file_size, max_uncompressed_size) + stats = { entries: 0, total_uncompressed_size: 0 } + + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + raise 'Encrypted zip entries are not supported.' if entry.respond_to?(:encrypted?) && entry.encrypted? + raise 'Zip contains an unsupported link entry.' if entry.respond_to?(:ftype) && entry.ftype == :symlink + next if entry.directory? + + validate_zip_upload_entry!(entry.name, entry.size, stats, max_file_size, max_uncompressed_size) + end + end + + stats + end + + def validate_tar_file(io, max_file_size, max_uncompressed_size) + stats = { entries: 0, total_uncompressed_size: 0 } + + Gem::Package::TarReader.new(io) do |tar| + tar.each do |entry| + next if entry.directory? + raise 'Zip contains an unsupported non-file entry.' unless entry.file? + + validate_zip_upload_entry!(entry.full_name, entry.header.size, stats, max_file_size, max_uncompressed_size) + end + end + + stats + end + + def validate_zip_upload(path, filename) + max_file_size = Doubtfire::Application.config.max_file_size.to_i + max_file_size = 10_000_000 if max_file_size <= 0 + max_uncompressed_size = max_file_size * zip_uncompressed_size_multiplier + return { valid: false, msg: "Zip exceeds the #{max_file_size / 1_000_000}MB file limit." } if File.size(path) > max_file_size + + begin + stats = + if filename.downcase.end_with?('.zip') + validate_zip_file(path, max_file_size, max_uncompressed_size) + elsif filename.downcase.end_with?('.tar') + File.open(path, 'rb') { |file| validate_tar_file(file, max_file_size, max_uncompressed_size) } + elsif filename.downcase.end_with?('.tar.gz', '.tgz') + Zlib::GzipReader.open(path) { |gzip| validate_tar_file(gzip, max_file_size, max_uncompressed_size) } + else + return { valid: false, msg: 'Unsupported zip format. Use .zip, .tar, .tar.gz, or .tgz.' } + end + + return { valid: false, msg: 'Zip must contain at least one file.' } if stats[:entries].zero? + + compressed_size = [File.size(path), 1].max + if stats[:total_uncompressed_size] / compressed_size > zip_compression_ratio_limit + return { valid: false, msg: "Zip compression ratio is too high. Limit is #{zip_compression_ratio_limit}:1." } + end + + { valid: true, msg: 'success' } + rescue Zip::Error, Zlib::Error, Gem::Package::TarInvalidError, EOFError + { valid: false, msg: 'Zip file is corrupted or not a supported zip.' } + rescue StandardError => e + { valid: false, msg: e.message } + end + end + + def zip_tree_add_path(tree, path) + clean_path = path.to_s.tr('\\', '/').sub(%r{\A\./+}, '').sub(%r{/+\z}, '') + return if clean_path.blank? + + parts = clean_path.split('/').reject(&:blank?) + node = tree + + parts.each_with_index do |part, index| + key = index == parts.length - 1 ? part : "#{part}/" + node[key] ||= {} + node = node[key] + end + end + + def zip_tree_walk(node, prefix = '', lines = []) + sorted_entries = node.keys.sort_by { |key| [key.end_with?('/') ? 0 : 1, key.downcase] } + + sorted_entries.each_with_index do |name, index| + last = index == sorted_entries.length - 1 + connector = '↳ ' + lines << "#{prefix}#{connector}#{name}" + zip_tree_walk(node[name], "#{prefix} ", lines) if node[name].any? + end + + lines + end + + def zip_file_tree(path, filename, display_limit: 200) + tree = {} + entries = 0 + + read_entry = lambda do |entry_name| + return unless zip_path_safe?(entry_name) + + entries += 1 + zip_tree_add_path(tree, entry_name) + end + + if filename.downcase.end_with?('.zip') + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + + read_entry.call(entry.name) + end + end + elsif filename.downcase.end_with?('.tar') + File.open(path, 'rb') do |file| + Gem::Package::TarReader.new(file) do |tar| + tar.each do |entry| + next if entry.directory? + + read_entry.call(entry.full_name) + end + end + end + elsif filename.downcase.end_with?('.tar.gz', '.tgz', '.gz') + Zlib::GzipReader.open(path) do |gzip| + Gem::Package::TarReader.new(gzip) do |tar| + tar.each do |entry| + next if entry.directory? + + read_entry.call(entry.full_name) + end + end + end + end + + all_lines = zip_tree_walk(tree) + lines = all_lines.first(display_limit) + { lines: lines, entries: entries, tree_lines: all_lines.length, truncated: all_lines.length > display_limit } + rescue Zip::Error, Zlib::Error, Gem::Package::TarInvalidError, EOFError => e + logger.debug "Could not read zip file tree for #{filename}: #{e.message}" + { lines: [], entries: 0, truncated: false, error: true } + rescue StandardError => e + logger.debug "Could not read zip file tree for #{filename}: #{e.message}" + { lines: [], entries: 0, truncated: false, error: true } + end + def pages_in_pdf(path) exec = "qpdf --show-npages #{path}" @@ -409,34 +832,17 @@ def qpdf(path) # - retain_from = true if you want to keep from, otherwise it is deleted # - only_before = date for files to move (only if retain from is true) def move_files(from_path, to_path, retain_from = false, only_before = nil) - # move into the new dir - and mv files to the in_process_dir - begin - pwd = FileUtils.pwd - rescue - # if no pwd, reset to the root - pwd = Rails.root + FileUtils.mkdir_p(to_path) + files = Dir.glob('*', base: from_path).map { |name| File.join(from_path, name) }.filter do |path| + (!retain_from || only_before.nil? || File.ctime(path) < only_before) && (!block_given? || yield(path)) end + FileUtils.mv(files, to_path, force: true) begin - FileUtils.mkdir_p(to_path) - Dir.chdir(from_path) - FileUtils.mv Dir.glob('*').filter{|fn| !retain_from || only_before.nil? || File.ctime(fn) < only_before}, to_path, force: true - Dir.chdir(to_path) - begin - # remove from_path as files are now "in process" - # these can be retained when the old folder wants to be kept - FileUtils.rm_rf(from_path) unless retain_from - rescue - logger.warn "failed to rm #{from_path}" - end - ensure - if FileUtils.pwd != pwd - if Dir.exist? pwd - FileUtils.chdir(pwd) - else - FileUtils.chdir(student_work_dir) - end - end + # Remove from_path as files are now "in process" unless retained. + FileUtils.rm_rf(from_path) unless retain_from + rescue StandardError + logger.warn "failed to rm #{from_path}" end end @@ -765,6 +1171,14 @@ def line_wrap(path, width: 160) end # Export functions as module functions module_function :accept_file + module_function :word_document? + module_function :word_document_conversion_configured? + module_function :encrypted_word_document? + module_function :gotenberg_work_id + module_function :convert_word_document_to_pdf + module_function :run_word_document_conversion + module_function :word_document_conversion_command + module_function :gotenberg_volume_arguments module_function :sanitized_path module_function :sanitized_filename module_function :task_file_dir_for_unit @@ -778,11 +1192,16 @@ def line_wrap(path, width: 160) module_function :unit_dir module_function :root_portfolio_dir module_function :unit_portfolio_dir + module_function :unit_analytics_dir + module_function :unit_task_status_snapshot_path + module_function :unit_task_status_snapshot_stats_path + module_function :snapshot_csv_filename module_function :unit_work_root module_function :project_work_root module_function :student_portfolio_dir module_function :student_portfolio_path module_function :comment_attachment_path + module_function :engagement_attachment_path module_function :comment_prompt_path module_function :comment_reply_prompt_path module_function :compress_image_to_dest @@ -790,6 +1209,18 @@ def line_wrap(path, width: 160) module_function :qpdf module_function :move_files module_function :validate_pdf + module_function :zip_path_safe? + module_function :zip_entry_limit + module_function :zip_compression_ratio_limit + module_function :zip_uncompressed_size_multiplier + module_function :zip_nested_archive? + module_function :validate_zip_upload_entry! + module_function :validate_zip_file + module_function :validate_tar_file + module_function :validate_zip_upload + module_function :zip_tree_add_path + module_function :zip_tree_walk + module_function :zip_file_tree module_function :copy_pdf module_function :read_file_to_str module_function :path_to_plagarism_html @@ -815,6 +1246,8 @@ def line_wrap(path, width: 160) module_function :known_extension? module_function :pages_in_pdf module_function :line_wrap + module_function :root_jplag_report_dir + module_function :unit_jplag_report_dir module_function :task_jplag_report_dir module_function :task_jplag_report_path end diff --git a/app/helpers/file_stream_helper.rb b/app/helpers/file_stream_helper.rb index 1644b3fe4b..8317b9d393 100644 --- a/app/helpers/file_stream_helper.rb +++ b/app/helpers/file_stream_helper.rb @@ -1,4 +1,9 @@ module FileStreamHelper + def expose_headers(*headers_to_expose) + exposed_headers = header['Access-Control-Expose-Headers'].to_s.split(',').map(&:strip) + header['Access-Control-Expose-Headers'] = (exposed_headers + headers_to_expose).compact_blank.uniq.join(',') + end + # Extract part of the contents so that is can be streamed to the client # file_path is the path to the file to be streamed # this will set the headers and return the content @@ -33,7 +38,7 @@ def stream_file(file_path) begin_point = 0 end_point = 10_485_760 else - header['Access-Control-Expose-Headers'] = 'Content-Disposition' if header.key?('Content-Disposition') + expose_headers('Content-Disposition') if header.key?('Content-Disposition') sendfile file_path return @@ -41,7 +46,7 @@ def stream_file(file_path) # Return the requested content content_length = [end_point - begin_point + 1, 0].max # Ensure we don't attempt to read a negative length - header['Access-Control-Expose-Headers'] = header.key?('Content-Disposition') ? 'Content-Disposition,Content-Range,Accept-Ranges' : 'Content-Range,Accept-Ranges' + expose_headers(('Content-Disposition' if header.key?('Content-Disposition')), 'Content-Range', 'Accept-Ranges') header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" header['Content-Length'] = content_length.to_s header['Accept-Ranges'] = 'bytes' @@ -52,5 +57,6 @@ def stream_file(file_path) body = File.binread(file_path, content_length, begin_point) end + module_function :expose_headers module_function :stream_file end diff --git a/app/helpers/lti_helper.rb b/app/helpers/lti_helper.rb index 335b33d805..5a1d58e4ec 100644 --- a/app/helpers/lti_helper.rb +++ b/app/helpers/lti_helper.rb @@ -1,5 +1,14 @@ module LtiHelper - def decode_lti_token(token) + # The LTI service signs tokens that expire after 30 seconds + LTI_TOKEN_MAX_LIFETIME = 60 + + # Records a token id, returning false if it was already used. One SET NX, so concurrent replays can't both pass + def self.first_use?(jti, ttl) + Sidekiq.redis { |redis| redis.call('SET', "lti:jti:#{jti}", '1', 'NX', 'EX', ttl) } == 'OK' + end + + # Verifies a token the LTI service signed for this purpose, and rejects one that was already used + def decode_lti_token(token, purpose:) begin secret_key = Doubtfire::Application.config.lti_api_secret response = JWT.decode(token, secret_key, true, algorithm: 'HS256').first @@ -9,16 +18,42 @@ def decode_lti_token(token) raise "Missing jti" if jti.nil? raise "Missing exp" if exp.nil? + raise "Expires more than #{LTI_TOKEN_MAX_LIFETIME}s ahead" if exp.to_i > Time.now.to_i + LTI_TOKEN_MAX_LIFETIME + raise "Signed for #{response['purpose'].inspect}, not #{purpose}" unless response['purpose'] == purpose rescue JWT::DecodeError => e logger.debug "Failed to validate Lti Token: #{e}" return error!({ error: 'Invalid LTI token.' }, 403) rescue StandardError => e - logger.debug "Missing token properties: #{e}" + logger.debug "Invalid token properties: #{e}" return error!({ error: 'Invalid LTI token.' }, 403) end + + # Kept a little past expiry, after which JWT.decode rejects the token anyway + unless LtiHelper.first_use?(jti, exp.to_i - Time.now.to_i + 5) + logger.warn "Rejected a replayed LTI token from #{request.ip}" + error!({ error: 'Invalid LTI token.' }, 403) + end response end + # Checks the connecting address, not request.ip, which trusts X-Forwarded-For from any private address + def ensure_lti_service_request! + remote_addr = request.env['REMOTE_ADDR'] + return if LtiServiceAddresses.allowed?(remote_addr) + + logger.warn "Rejected LTI service request from #{remote_addr}; it is not listed in LTI_SERVICE_HOSTS" + error!({ error: 'Only the LTI service can make this request.' }, 403) + end + + # Rejects a token whose LMS launch user is not the signed-in OnTrack user + def ensure_lti_launch_user!(token) + launch_email = token['email'].to_s.strip + return if launch_email.present? && current_user.email.to_s.casecmp?(launch_email) + + logger.warn "Rejected LTI request for #{current_user.username} from #{request.ip}: not the launch user" + error!({ error: 'This OnTrack session does not belong to the LMS user who launched OnTrack. Relaunch OnTrack from the LMS.' }, 403) + end + def valid_lti_member?(member) required_fields = %w[user_id email roles given_name family_name name] missing = required_fields.select { |f| member[f].nil? || member[f].to_s.strip.empty? } diff --git a/app/helpers/turn_it_in.rb b/app/helpers/turn_it_in.rb index 7bf73e4f7a..c2c0e765ef 100644 --- a/app/helpers/turn_it_in.rb +++ b/app/helpers/turn_it_in.rb @@ -31,7 +31,7 @@ def self.load_config(config) # Setup authorization TCAClient.configure do |tii_config| # Configure API key authorization: api_key - tii_config.api_key['api_key'] = ENV.fetch('TCA_API_KEY', nil) + tii_config.api_key['api_key'] = Doubtfire::Application.fetch_credential_or_env(:tii, :api_key, env_key: 'TCA_API_KEY') # Uncomment the following line to set a prefix for the API key, e.g. 'Bearer' (defaults to nil) tii_config.api_key_prefix['api_key'] = 'Bearer' tii_config.host = ENV.fetch('TCA_HOST', nil) @@ -84,6 +84,7 @@ def self.handle_tii_error(action, error) @@delay_call_until = DateTime.now + 1.minute when 403 # forbidden, issue with authentication... notify admin begin + Sentry.capture_exception(error) if defined?(Sentry) ErrorLogMailer.error_message('TII Credentials', "TII Error: #{error.message}", error).deliver rescue StandardError => e Rails.logger.error "Failed to send error email: #{e}" diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb new file mode 100644 index 0000000000..a23a1f83fb --- /dev/null +++ b/app/mailers/communications_mailer.rb @@ -0,0 +1,42 @@ +class CommunicationsMailer < ApplicationMailer + def communication_email(to:, from:, subject:, body:, recipient:, sender:, plain_text: false) + if plain_text + @body = body.to_s + return mail(to: to, from: from, subject: subject) do |format| + format.text { render plain: @body } + end + end + + @recipient = recipient + @sender = sender + @body = body.to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + mail(to: to, from: from, subject: subject) + end + + def action_log_email(payload) + @recipient = payload[:recipient] + @sender = payload[:sender] + @unit = payload[:unit] + @rule = payload[:rule] + @body = payload[:body].to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + @affected_students_count = payload[:affected_students_count] + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + attachments[payload[:csv_filename]] = { + mime_type: 'text/csv', + content: payload[:csv_content] + } + + mail(to: payload[:to], from: payload[:from], subject: payload[:subject]) + end +end diff --git a/app/mailers/error_log_mailer.rb b/app/mailers/error_log_mailer.rb index e08607fd31..578a6f6fc3 100644 --- a/app/mailers/error_log_mailer.rb +++ b/app/mailers/error_log_mailer.rb @@ -8,7 +8,8 @@ def error_message(subject, message, exception) end @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] - @error_log = "#{message}\n\n#{exception.message}\n\n#{exception.backtrace.join("\n")}" + backtrace = exception.backtrace&.join("\n") || 'No backtrace available' + @error_log = "#{message}\n\n#{exception.message}\n\n#{backtrace}" mail(to: email, from: email, subject: "#{@doubtfire_product_name} Error Log - #{subject}") end diff --git a/app/mailers/lms_integration_mailer.rb b/app/mailers/lms_integration_mailer.rb new file mode 100644 index 0000000000..4b99c22c6b --- /dev/null +++ b/app/mailers/lms_integration_mailer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class LmsIntegrationMailer < ApplicationMailer + SYNC_NAMES = { students: 'student sync', extensions: 'extension sync' }.freeze + SETTING_NAMES = { auto_sync_students: 'Sync students daily', auto_sync_extensions: 'Sync extensions daily' }.freeze + + def auto_sync_failed(integration, sync, error, turned_off, outage) + @unit = integration.unit + @convenor = @unit.main_convenor&.user + return nil if @convenor.nil? || @convenor.email.blank? + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @failing_since = integration.auto_sync_failing_since if outage + @sync_name = SYNC_NAMES.fetch(sync, 'sync') + @issues = error.respond_to?(:issues) ? error.issues : [] + @error_message = error.message + @turned_off = turned_off.map { |setting| SETTING_NAMES[setting] } + @lms_url = "#{@doubtfire_host}/units/#{@unit.id}/admin/lms" + + convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) + mail(to: convenor_email, from: convenor_email, subject: "#{@unit.code}: Daily LMS #{@sync_name} failed and has been turned off") + end +end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 748d5bd029..f4b0d255ad 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -1,4 +1,7 @@ class NotificationsMailer < ApplicationMailer + layout 'discussion_deadline_mailer', + only: %i[discussion_deadline_approaching discussion_deadline_missed] + def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @@ -61,7 +64,7 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) @student_engagements = @engagements.select { |e| [TaskStatus.not_started.name, TaskStatus.need_help.name, TaskStatus.working_on_it.name, TaskStatus.ready_for_feedback.name].include? e.engagement }.count - @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count + @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.rediscuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count @task_states = project.tasks.joins(:task_status).select("count(tasks.id) as number, task_statuses.name as status").group("task_statuses.name") @@ -80,6 +83,27 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) mail(to: email_with_name, from: tutor_email, subject: subject) end + def discussion_deadline_approaching(task, sender, expiry_date) + add_discussion_deadline_details(task, sender) + @deadline = task.unit.formatted_discuss_timeout_date(expiry_date) + + mail( + to: %("#{@student.name}" <#{@student.email}>), + from: %("#{@sender.name}" <#{@sender.email}>), + subject: "#{@unit.code}: Discussion deadline approaching for #{@task.task_definition.abbreviation}" + ) + end + + def discussion_deadline_missed(task, sender) + add_discussion_deadline_details(task, sender) + + mail( + to: %("#{@student.name}" <#{@student.email}>), + from: %("#{@sender.name}" <#{@sender.email}>), + subject: "#{@unit.code}: Discussion deadline missed for #{@task.task_definition.abbreviation}" + ) + end + def top_task_desc(tt) "#{tt[:task_definition].abbreviation} - #{tt[:task_definition].name} #{"- which you need to discuss with your tutor" if tt[:status] == :discuss}" end @@ -112,4 +136,16 @@ def this_these(num) helper_method :were_was helper_method :are_is helper_method :this_these + + private + + def add_discussion_deadline_details(task, sender) + add_general + @task = task + @project = task.project + @unit = task.unit + @student = @project.student + @sender = sender + @task_url = "#{@doubtfire_host}/projects/#{@project.id}/dashboard/#{@task.task_definition.abbreviation}" + end end diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 7b2f56d817..743503c25b 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -17,7 +17,7 @@ def task_pdf_failed(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) - subject = "#{project.unit.name}: Task PDFs ready to view" + subject = "#{project.unit.code} #{project.unit.name}: Task submission processing failed" mail(to: email_with_name, from: tutor_email, subject: subject) end @@ -54,6 +54,22 @@ def task_feedback_ready(project, tasks) mail(to: email_with_name, from: tutor_email, subject: subject) end + def overseer_assessment_failed(project, tasks) + return nil if project.nil? || tasks.nil? || tasks.empty? + + add_general + @student = project.student + @project = project + @tasks = tasks.sort_by { |t| t.task_definition.abbreviation } + @tutor = project.main_convenor_user + return nil if @tutor.nil? || @student.nil? + + email_with_name = %("#{@student.name}" <#{@student.email}>) + tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) + subject = "#{project.unit.code} #{project.unit.name}: Automated feedback needs your attention" + mail(to: email_with_name, from: tutor_email, subject: subject) + end + def portfolio_ready(project) return nil if project.nil? diff --git a/app/middleware/sentry_tunnel_middleware.rb b/app/middleware/sentry_tunnel_middleware.rb new file mode 100644 index 0000000000..8556130117 --- /dev/null +++ b/app/middleware/sentry_tunnel_middleware.rb @@ -0,0 +1,65 @@ +require 'rest-client' +require 'uri' + +class SentryTunnelMiddleware + PATH = '/api/client-reports'.freeze + + def initialize(app) + @app = app + end + + def call(env) + return @app.call(env) unless env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == PATH + + forward_envelope(env) + [204, {}, []] + end + + private + + def forward_envelope(env) + envelope_url = sentry_envelope_url + return if envelope_url.blank? + + body = env['rack.input'].read + return if body.blank? + + RestClient::Request.execute( + method: :post, + url: envelope_url, + payload: body, + headers: sentry_headers(env), + timeout: 5, + open_timeout: 2 + ) + rescue RestClient::ExceptionWithResponse => e + Rails.logger.warn "Unable to forward Sentry envelope: #{e.class} #{e.response&.code}" + rescue RestClient::Exception, SocketError, Timeout::Error => e + Rails.logger.warn "Unable to forward Sentry envelope: #{e.class}" + ensure + env['rack.input'].rewind if env['rack.input'].respond_to?(:rewind) + end + + def sentry_headers(env) + headers = { + content_type: env['CONTENT_TYPE'].presence || 'application/x-sentry-envelope' + } + + headers[:content_encoding] = env['HTTP_CONTENT_ENCODING'] if env['HTTP_CONTENT_ENCODING'].present? + headers + end + + def sentry_envelope_url + dsn = ENV.fetch('SENTRY_DSN', nil) + return nil if dsn.blank? + + uri = URI.parse(dsn) + project_id = uri.path.delete_prefix('/') + public_key = uri.user + return nil if project_id.blank? || public_key.blank? + + "#{uri.scheme}://#{uri.host}/api/#{project_id}/envelope/?sentry_key=#{URI.encode_www_form_component(public_key)}" + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/auth_token.rb b/app/models/auth_token.rb index e494567de3..35e4079bac 100644 --- a/app/models/auth_token.rb +++ b/app/models/auth_token.rb @@ -10,7 +10,8 @@ class AuthToken < ApplicationRecord general: 0, login: 1, scorm: 2, - refresh_token: 3 + refresh_token: 3, + content: 4 } def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours, token_type = :general) diff --git a/app/models/break.rb b/app/models/break.rb index f857fd12ba..8382dc7424 100644 --- a/app/models/break.rb +++ b/app/models/break.rb @@ -1,9 +1,14 @@ class Break < ApplicationRecord belongs_to :teaching_period, optional: false + after_save :refresh_teaching_period_communication_schedule_caches + after_destroy :refresh_teaching_period_communication_schedule_caches + validates :start_date, presence: true - validates :number_of_weeks, presence: true + validates :number_of_days, presence: true validates :teaching_period_id, presence: true + validate :ensure_campuses_exist + validate :ensure_pause_week_count_is_whole_weeks validate :ensure_start_date_is_within_teaching_period, :ensure_break_end_is_within_teaching_period, :ensure_break_is_not_colliding @@ -14,14 +19,16 @@ def ensure_start_date_is_within_teaching_period end def ensure_break_end_is_within_teaching_period - if start_date + number_of_weeks.weeks > teaching_period.end_date - errors.add(:number_of_weeks, "is exceeding Teaching Period end date") + if start_date + number_of_days.days > teaching_period.end_date + errors.add(:number_of_days, "is exceeding Teaching Period end date") end end def ensure_break_is_not_colliding for break_in_teaching_period in teaching_period.breaks do - if break_in_teaching_period.id != id && break_in_teaching_period.end_date >= start_date && break_in_teaching_period.start_date <= end_date + same_scope = campuses_overlap?(break_in_teaching_period) + dates_overlap = break_in_teaching_period.end_date >= start_date && break_in_teaching_period.start_date <= end_date + if break_in_teaching_period.id != id && same_scope && dates_overlap errors.add(:base, "overlaps another break") break end @@ -29,7 +36,13 @@ def ensure_break_is_not_colliding end def duration - number_of_weeks.weeks + number_of_days.days + end + + # The number of whole teaching weeks this break spans - partial weeks count as + # a full week, as teaching weeks cannot be split. + def weeks_spanned + (number_of_days / 7.0).ceil end def first_monday @@ -40,10 +53,47 @@ def first_monday end def monday_after_break - first_monday + number_of_weeks.weeks + first_monday + number_of_days.days end def end_date start_date + duration end + + def campus_ids + value = super + value.is_a?(String) ? JSON.parse(value) : Array(value) + rescue JSON::ParserError + [] + end + + def applies_to?(campus) + campus_ids.blank? || (campus.present? && campus_ids.map(&:to_i).include?(campus.id)) + end + + private + + def campuses_overlap?(other_break) + campus_ids.blank? || + other_break.campus_ids.blank? || + campus_ids.map(&:to_i).intersect?(other_break.campus_ids.map(&:to_i)) + end + + def ensure_pause_week_count_is_whole_weeks + return unless pause_week_count? + return if number_of_days.blank? + + unless (number_of_days % 7).zero? + errors.add(:pause_week_count, 'can only be set on breaks that are a multiple of 7 days') + end + end + + def ensure_campuses_exist + invalid_ids = Array(campus_ids).map(&:to_i) - Campus.where(id: campus_ids).pluck(:id) + errors.add(:campus_ids, 'contains an invalid campus') if invalid_ids.any? + end + + def refresh_teaching_period_communication_schedule_caches + teaching_period.refresh_communication_schedule_caches + end end diff --git a/app/models/comments/assessment_comment.rb b/app/models/comments/assessment_comment.rb index 1b614b41f5..60dd4d26b4 100644 --- a/app/models/comments/assessment_comment.rb +++ b/app/models/comments/assessment_comment.rb @@ -1,6 +1,7 @@ class AssessmentComment < TaskComment before_create do self.content_type = :assessment + self.attention_audience = :student end def serialize(user) diff --git a/app/models/comments/comment_read_cursor.rb b/app/models/comments/comment_read_cursor.rb new file mode 100644 index 0000000000..70c725af68 --- /dev/null +++ b/app/models/comments/comment_read_cursor.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CommentReadCursor < ApplicationRecord + belongs_to :task + belongs_to :user + belongs_to :last_read_comment, class_name: 'TaskComment' + + validates :task, :user, :last_read_comment, :read_at, presence: true + validate :last_read_comment_belongs_to_task + + def self.advance(task:, user:, comment:, read_at: Time.current) + cursor = create_or_find_by!(task: task, user: user) do |new_cursor| + new_cursor.last_read_comment = comment + new_cursor.read_at = read_at + end + + # A cursor is a high-water mark, so an older comment cannot move it backwards. + return cursor if cursor.last_read_comment_id >= comment.id + + cursor.with_lock do + cursor.update!(last_read_comment: comment, read_at: read_at) if cursor.last_read_comment_id < comment.id + end + + cursor + end + + private + + def last_read_comment_belongs_to_task + return if last_read_comment.nil? || last_read_comment.task_id == task_id + + errors.add(:last_read_comment, 'must belong to the same task') + end +end diff --git a/app/models/comments/discuss_timeout_comment.rb b/app/models/comments/discuss_timeout_comment.rb new file mode 100644 index 0000000000..e6a2e58810 --- /dev/null +++ b/app/models/comments/discuss_timeout_comment.rb @@ -0,0 +1,16 @@ +class DiscussTimeoutComment < TaskComment + WARNING_CONTENT_TYPE = 'discuss_timeout_warning'.freeze + EXPIRED_CONTENT_TYPE = 'discuss_timeout_expired'.freeze + + before_create do + self.attention_audience = :student + end + + def self.warning + WARNING_CONTENT_TYPE + end + + def self.expired + EXPIRED_CONTENT_TYPE + end +end diff --git a/app/models/comments/discussion_comment.rb b/app/models/comments/discussion_comment.rb index 40f77ff9b4..56fc97978c 100644 --- a/app/models/comments/discussion_comment.rb +++ b/app/models/comments/discussion_comment.rb @@ -1,6 +1,10 @@ class DiscussionComment < TaskComment include FileHelper + before_create do + self.attention_audience = :student + end + def status return "not started" if not started and not completed return "opened" if started and not completed diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb index abd9d1030c..1a3be16f39 100644 --- a/app/models/comments/extension_comment.rb +++ b/app/models/comments/extension_comment.rb @@ -19,11 +19,8 @@ def assessed? # Make sure we can access super's version of mark_as_read for assess extension alias :super_mark_as_read :mark_as_read - # Allow individual staff and the student to read this... but stop - # the main tutor reading without assessing. As only the main tutor - # propagates reads, this will work as required - other staff cant - # make it read for the main tutor. - def mark_as_read(user, unit = self.unit) + # Do not let the recipient tutor mark the request as read before assessing it. + def mark_as_read(user) super if assessed? || user == project.student || user != recipient end diff --git a/app/models/comments/scorm_comment.rb b/app/models/comments/scorm_comment.rb index df7bcc9f56..0603b62a5a 100644 --- a/app/models/comments/scorm_comment.rb +++ b/app/models/comments/scorm_comment.rb @@ -1,6 +1,7 @@ class ScormComment < TaskComment before_create do self.content_type = :scorm + self.attention_audience = :student end def serialize(user) diff --git a/app/models/comments/scorm_extension_comment.rb b/app/models/comments/scorm_extension_comment.rb index 74bc9d0c8c..ad0280e594 100644 --- a/app/models/comments/scorm_extension_comment.rb +++ b/app/models/comments/scorm_extension_comment.rb @@ -16,11 +16,8 @@ def assessed? # Make sure we can access super's version of mark_as_read for assess extension alias super_mark_as_read mark_as_read - # Allow individual staff and the student to read this... but stop - # the main tutor reading without assessing. As only the main tutor - # propagates reads, this will work as required - other staff cant - # make it read for the main tutor. - def mark_as_read(user, unit = self.unit) + # Do not let the recipient tutor mark the request as read before assessing it. + def mark_as_read(user) super if assessed? || user == project.student || user != recipient end diff --git a/app/models/comments/task_checked_in_comment.rb b/app/models/comments/task_checked_in_comment.rb index 36dfd4b135..956b71b063 100644 --- a/app/models/comments/task_checked_in_comment.rb +++ b/app/models/comments/task_checked_in_comment.rb @@ -1,10 +1,7 @@ class TaskCheckedInComment < TaskComment before_create do self.content_type = :checked_in - end - - after_create do - mark_as_read(self.recipient) + self.attention_audience = :none end def serialize(user) diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index c74883d014..555dbe10a8 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -8,6 +8,8 @@ class TaskComment < ApplicationRecord include FileHelper include AuthorisationHelpers + enum :attention_audience, { none: 0, student: 1, staff: 2 }, prefix: :attention + belongs_to :task, optional: false # Foreign key belongs_to :user, optional: false has_one :unit, through: :task @@ -16,6 +18,10 @@ class TaskComment < ApplicationRecord belongs_to :recipient, class_name: 'User', optional: false has_many :comments_read_receipts, class_name: 'CommentsReadReceipts', dependent: :destroy, inverse_of: :task_comment + has_many :comment_read_cursors, + foreign_key: :last_read_comment_id, + inverse_of: :last_read_comment, + dependent: :restrict_with_exception # Can optionally be a reply to a comment belongs_to :task_comment, optional: true @@ -29,12 +35,15 @@ class TaskComment < ApplicationRecord validates :comment, length: { minimum: 0, maximum: 4095, allow_blank: true } validate :valid_reply_to?, on: :create + before_validation :set_default_attention_audience, on: :create + # After create, mark as read by user creating after_create do mark_as_read(self.user) end # Delete action - before dependent association + before_destroy :rewind_comment_read_cursors, prepend: true before_destroy :delete_associated_files def valid_reply_to? @@ -76,8 +85,8 @@ def serialize(user) } end - def create_comment_read_receipt_entry(user) - comment_read_receipt = CommentsReadReceipts.find_or_create_by(user: user, task_comment: self) + def advance_read_cursor(user) + CommentReadCursor.advance(task: task, user: user, comment: self) end def comment @@ -135,18 +144,30 @@ def attachment_mime_type end def remove_comment_read_entry(user) - CommentsReadReceipts.delete_all(user: user, task_comment: self) - end + cursor = CommentReadCursor.find_by(task_id: task_id, user_id: user.id) + return if cursor.nil? || cursor.last_read_comment_id < id - def mark_as_read(user, unit = self.unit) - return if read_by?(user) # avoid propagating if not needed + previous_comment_id = TaskComment + .where(task_id: task_id) + .where('id < ?', id) + .maximum(:id) - if user == project.tutor_for(task.task_definition) - unit.staff.each do |staff_member| - create_comment_read_receipt_entry(staff_member.user) - end + if previous_comment_id.nil? + cursor.destroy! else - create_comment_read_receipt_entry(user) + cursor.update!( + last_read_comment_id: previous_comment_id, + read_at: Time.current + ) + end + end + + def mark_as_read(user) + assigned_tutor = project.tutor_for(task.task_definition) + + CommentReadCursor.transaction do + advance_read_cursor(user) unless read_by?(user) + remove_unneeded_staff_cursors(assigned_tutor) if user == assigned_tutor end end @@ -155,15 +176,62 @@ def mark_as_unread(user) end def new_for?(user) - !read_by? user + requires_attention_for?(user) && !read_by?(user) end def read_by?(user) - CommentsReadReceipts.find_by(user: user, task_comment: self).present? + return true if self.user == user || !requires_attention_for?(user) + + cursor = CommentReadCursor.find_by(task_id: task_id, user_id: user.id) + cursor.present? && cursor.last_read_comment_id >= id end def time_read_by(user) - read_reciept = CommentsReadReceipts.find_by(user: user, task_comment: self) - read_reciept&.created_at + return nil unless requires_attention_for?(user) + + cursor = CommentReadCursor.find_by(task_id: task_id, user_id: user.id) + cursor&.read_at if cursor&.last_read_comment_id.to_i >= id + end + + def requires_attention_for?(user) + return true if attention_audience.nil? + return attention_student? if task.student_participant?(user) + + attention_staff? + end + + def rewind_comment_read_cursors + previous_comment_id = TaskComment + .where(task_id: task_id) + .where('id < ?', id) + .maximum(:id) + + cursors = CommentReadCursor.where(last_read_comment_id: id) + if previous_comment_id.nil? + cursors.delete_all + else + # A single comment can be the cursor for every teaching staff member. + # Keep destruction bounded to one SQL update. + # rubocop:disable Rails/SkipsModelValidations + cursors.update_all( + last_read_comment_id: previous_comment_id, + updated_at: Time.current + ) + # rubocop:enable Rails/SkipsModelValidations + end + end + + private + + def remove_unneeded_staff_cursors(assigned_tutor) + retained_user_ids = task.student_participant_ids << assigned_tutor.id + CommentReadCursor.where(task_id: task_id).where.not(user_id: retained_user_ids).delete_all + end + + def set_default_attention_audience + return if attention_audience.present? || user.nil? || task.nil? + return if task.group_submission.present? && task.student_participant?(user) + + self.attention_audience = user == task.project.student ? :staff : :student end end diff --git a/app/models/comments/task_discussed_comment.rb b/app/models/comments/task_discussed_comment.rb index 5c4ee3a71a..5e8ac32e59 100644 --- a/app/models/comments/task_discussed_comment.rb +++ b/app/models/comments/task_discussed_comment.rb @@ -1,10 +1,7 @@ class TaskDiscussedComment < TaskComment before_create do self.content_type = :discussed_in_class - end - - after_create do - mark_as_read(self.recipient) + self.attention_audience = :none end def serialize(user) diff --git a/app/models/comments/task_feedback_review_request_comment.rb b/app/models/comments/task_feedback_review_request_comment.rb index 3d3f6182a3..edfe901e97 100644 --- a/app/models/comments/task_feedback_review_request_comment.rb +++ b/app/models/comments/task_feedback_review_request_comment.rb @@ -1,10 +1,7 @@ class TaskFeedbackReviewRequestComment < TaskComment before_create do self.content_type = :feedback_review_request - end - - after_create do - mark_as_read(self.recipient) + self.attention_audience = :none end def serialize(user) diff --git a/app/models/comments/task_status_comment.rb b/app/models/comments/task_status_comment.rb index a224279db8..932d073835 100644 --- a/app/models/comments/task_status_comment.rb +++ b/app/models/comments/task_status_comment.rb @@ -3,10 +3,7 @@ class TaskStatusComment < TaskComment before_create do self.content_type = :status - end - - after_create do - mark_as_read(self.recipient) + self.attention_audience = :none end def serialize(user) diff --git a/app/models/communication/campus_condition.rb b/app/models/communication/campus_condition.rb new file mode 100644 index 0000000000..da80941b45 --- /dev/null +++ b/app/models/communication/campus_condition.rb @@ -0,0 +1,3 @@ +class CampusCondition < CommunicationCondition + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/change_target_grade_action.rb b/app/models/communication/change_target_grade_action.rb new file mode 100644 index 0000000000..efcfc2cb8e --- /dev/null +++ b/app/models/communication/change_target_grade_action.rb @@ -0,0 +1,12 @@ +class ChangeTargetGradeAction < CommunicationAction + validates :target_grade, presence: true + validate :target_grade_enabled_for_unit + + private + + def target_grade_enabled_for_unit + return if target_grade.nil? || communication_rule&.unit&.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/communication_action.rb b/app/models/communication/communication_action.rb new file mode 100644 index 0000000000..2864c9cbc0 --- /dev/null +++ b/app/models/communication/communication_action.rb @@ -0,0 +1,27 @@ +class CommunicationAction < ApplicationRecord + VALID_TYPES = %w[ + EmailStudentAction + EmailStaffAction + ChangeTargetGradeAction + TaskCommentAction + ].freeze + + REQUIRED_REFERENCES = { 'TaskCommentAction' => :task_definition }.freeze + + belongs_to :communication_rule, class_name: 'CommunicationRule' + belongs_to :task_definition, optional: true + + validates :type, presence: true, inclusion: { in: VALID_TYPES } + + def required_reference + REQUIRED_REFERENCES[type] + end + + def unresolved? + reference = required_reference + return false if reference.nil? + + target = public_send(reference) + target.blank? || target.unit_id != communication_rule.unit_id + end +end diff --git a/app/models/communication/communication_condition.rb b/app/models/communication/communication_condition.rb new file mode 100644 index 0000000000..3b0c27dc44 --- /dev/null +++ b/app/models/communication/communication_condition.rb @@ -0,0 +1,136 @@ +class CommunicationCondition < ApplicationRecord + VALID_TYPES = %w[ + TargetGradeCondition + TaskDefinitionStatusCondition + TaskStatusCountCondition + LoginStatusCondition + UnitViewedStatusCondition + SpecConCondition + TutorialEnrolmentCondition + TutorialStreamEnrolmentCondition + CampusCondition + GroupSetEnrolmentCondition + GroupEnrolmentCondition + PortfolioSubmittedCondition + ].freeze + + GRADE_OPERATORS = %w[ + greater_than + greater_than_or_equal_to + less_than + less_than_or_equal_to + equal_to + not_equal_to + ].freeze + + EQUALITY_OPERATORS = %w[equal_to not_equal_to].freeze + ACTIVITY_OPERATORS = %w[more_than within_last].freeze + ENROLMENT_OPERATORS = %w[enrolled_in not_enrolled_in].freeze + TASK_STATUS_KEYS = %w[ + not_started + complete + need_help + working_on_it + fix_and_resubmit + feedback_exceeded + redo + discuss + ready_for_feedback + demonstrate + fail + time_exceeded + assess_in_portfolio + attention_required + rediscuss + ].freeze + + REQUIRED_REFERENCES = { + 'TaskDefinitionStatusCondition' => :task_definition, + 'TutorialEnrolmentCondition' => :tutorial, + 'TutorialStreamEnrolmentCondition' => :tutorial_stream, + 'CampusCondition' => :campus, + 'GroupSetEnrolmentCondition' => :group_set, + 'GroupEnrolmentCondition' => :group + }.freeze + + belongs_to :communication, + class_name: 'CommunicationRule', + inverse_of: :communication_conditions + + belongs_to :task_definition, optional: true + belongs_to :tutorial, optional: true + belongs_to :tutorial_stream, optional: true + belongs_to :campus, optional: true + belongs_to :group_set, optional: true + belongs_to :group, optional: true + + attribute :task_statuses, :json, default: -> { [] } + + validates :type, presence: true, inclusion: { in: VALID_TYPES } + validates :operator, presence: true + before_validation :normalize_task_statuses + + def required_reference + REQUIRED_REFERENCES[type] + end + + # A missing reference cannot be left to evaluate: an absent task definition + # reads as 'not_started' for every student, widening the rule instead of + # narrowing it. + def unresolved? + reference = required_reference + return false if reference.nil? + + target = public_send(reference) + return true if target.blank? + + # Campuses are shared between units; everything else must be our own. + owning_unit_id = reference_unit_id(target) + owning_unit_id.present? && owning_unit_id != communication.unit_id + end + + def task_statuses_must_be_present + unless task_statuses.is_a?(Array) && task_statuses.any?(&:present?) + errors.add(:task_statuses, 'must include at least one task status') + return + end + + invalid_statuses = task_statuses.reject { |status| TASK_STATUS_KEYS.include?(status) } + return if invalid_statuses.empty? + + errors.add(:task_statuses, "contains invalid task statuses: #{invalid_statuses.join(', ')}") + end + + private + + # Groups reach their unit through their group set rather than a column of + # their own, and campuses have no unit at all. + def reference_unit_id(target) + return target.unit_id if target.respond_to?(:unit_id) + + target.group_set&.unit_id if target.respond_to?(:group_set) + end + + def normalize_task_statuses + parsed_statuses = + case task_statuses + when nil + nil + when String + begin + JSON.parse(task_statuses) + rescue JSON::ParserError + [task_statuses] + end + when Array + task_statuses + else + Array(task_statuses) + end + + self.task_statuses = + parsed_statuses&.filter_map do |status| + status.is_a?(String) ? status.strip.presence : status.presence + end + end +end diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb new file mode 100644 index 0000000000..703e0a48d2 --- /dev/null +++ b/app/models/communication/communication_rule.rb @@ -0,0 +1,181 @@ +class CommunicationRule < ApplicationRecord + LOGICAL_OPERATORS = %w[and or].freeze + + belongs_to :communication_set, class_name: 'CommunicationSet' + delegate :unit, :unit_id, to: :communication_set + + has_many :communication_conditions, + class_name: 'CommunicationCondition', + foreign_key: :communication_id, + inverse_of: :communication, + dependent: :destroy + has_many :communication_actions, class_name: 'CommunicationAction', dependent: :destroy + + validates :name, presence: true + validates :operator, presence: true, inclusion: { in: LOGICAL_OPERATORS } + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + def unresolved_records + (communication_conditions.to_a + communication_actions.to_a).select(&:unresolved?) + end + + def unresolved? + unresolved_records.any? + end + + def matching_projects(projects = nil) + # Only reached by the editor preview -- ExecuteCommunicationSetJob refuses + # the whole set before any rule runs, so nothing is sent on the back of this. + return [] if unresolved? + + projects ||= communication_set.eligible_projects + return projects if communication_conditions.empty? + + evaluated_at = Time.current + projects.select do |project| + matches = communication_conditions.map { |condition| condition_match?(project, condition, evaluated_at) } + + operator == 'or' ? matches.any? : matches.all? + end + end + + private + + def condition_match?(project, condition, evaluated_at) + case condition.type + when 'TargetGradeCondition' + target_grade_condition_match?(project, condition) + when 'TaskDefinitionStatusCondition' + task_definition_status_condition_match?(project, condition) + when 'TaskStatusCountCondition' + task_status_count_condition_match?(project, condition) + when 'LoginStatusCondition' + activity_condition_match?(project.user&.last_sign_in_at, condition, evaluated_at) + when 'UnitViewedStatusCondition' + activity_condition_match?(project.last_viewed_at, condition, evaluated_at) + when 'SpecConCondition' + spec_con_condition_match?(project, condition) + when 'TutorialEnrolmentCondition' + tutorial_enrolment_condition_match?(project, condition) + when 'TutorialStreamEnrolmentCondition' + tutorial_stream_enrolment_condition_match?(project, condition) + when 'CampusCondition' + campus_condition_match?(project, condition) + when 'GroupSetEnrolmentCondition' + group_set_enrolment_condition_match?(project, condition) + when 'GroupEnrolmentCondition' + group_enrolment_condition_match?(project, condition) + when 'PortfolioSubmittedCondition' + portfolio_submitted_condition_match?(project, condition) + else + false + end + end + + def target_grade_condition_match?(project, condition) + project_target_grade = project.target_grade + return false if project_target_grade.nil? + + compare_value(project_target_grade, condition.target_grade, condition.operator) + end + + def task_definition_status_condition_match?(project, condition) + task = project.tasks.find { |t| t.task_definition_id == condition.task_definition_id } + status = task&.task_status&.status_key&.to_s || 'not_started' + statuses = condition.task_statuses || [] + + case condition.operator + when 'equal_to' then statuses.include?(status) + when 'not_equal_to' then !statuses.include?(status) + else false + end + end + + def task_status_count_condition_match?(project, condition) + statuses = condition.task_statuses || [] + relevant_task_definitions = project.unit.task_definitions.select do |task_definition| + task_definition.target_grade == condition.task_target_grade + end + + count = relevant_task_definitions.count do |task_definition| + task = project.tasks.find { |project_task| project_task.task_definition_id == task_definition.id } + task_status = task&.task_status&.status_key&.to_s || 'not_started' + + statuses.include?(task_status) + end + + compare_value(count, condition.task_status_count, condition.operator) + end + + def activity_condition_match?(last_activity_at, condition, evaluated_at) + return false if condition.activity_days.blank? + + threshold = evaluated_at - condition.activity_days.days + case condition.operator + when 'more_than' then last_activity_at.nil? || last_activity_at < threshold + when 'within_last' then last_activity_at.present? && last_activity_at >= threshold + else false + end + end + + def spec_con_condition_match?(project, condition) + compare_value(project.spec_con_days, condition.spec_con_days, condition.operator) + end + + def tutorial_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? { |enrolment| enrolment.tutorial_id == condition.tutorial_id } + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def tutorial_stream_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? do |enrolment| + enrolment.tutorial&.tutorial_stream_id == condition.tutorial_stream_id + end + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def campus_condition_match?(project, condition) + enrolled = project.campus_id == condition.campus_id + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + # A student belongs to a group set when they are in any of its groups. + def group_set_enrolment_condition_match?(project, condition) + enrolled = active_group_memberships(project).any? do |membership| + membership.group&.group_set_id == condition.group_set_id + end + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def group_enrolment_condition_match?(project, condition) + enrolled = active_group_memberships(project).any? { |membership| membership.group_id == condition.group_id } + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + # Memberships are kept after a student leaves a group, so only the active ones + # say where they are now. + def active_group_memberships(project) + project.group_memberships.select(&:active?) + end + + def portfolio_submitted_condition_match?(project, condition) + project.portfolio_exists? == condition.submitted_portfolio + end + + def compare_value(left, right, operator) + case operator + when 'greater_than' then left > right + when 'greater_than_or_equal_to' then left >= right + when 'less_than' then left < right + when 'less_than_or_equal_to' then left <= right + when 'equal_to' then left == right + when 'not_equal_to' then left != right + else false + end + end +end diff --git a/app/models/communication/communication_set.rb b/app/models/communication/communication_set.rb new file mode 100644 index 0000000000..5f3b5eb8b4 --- /dev/null +++ b/app/models/communication/communication_set.rb @@ -0,0 +1,163 @@ +class CommunicationSet < ApplicationRecord + belongs_to :unit + + has_many :communication_set_schedules, + class_name: 'CommunicationSetSchedule', + inverse_of: :communication_set, + dependent: :destroy + + has_many :communication_rules, + -> { order(:position) }, + class_name: 'CommunicationRule', + inverse_of: :communication_set, + dependent: :destroy + + validates :name, presence: true + + # Memoised so a request loads the student data once, not once per rule. + def eligible_projects + @eligible_projects ||= + unit.projects + .where(enrolled: true) + .includes( + :user, + :campus, + { unit: :task_definitions }, + { tasks: [:task_status, :task_definition] }, + { tutorial_enrolments: :tutorial }, + { group_memberships: :group } + ) + .to_a + end + + def eligible_project_count + unit.projects.where(enrolled: true).count + end + + # Matching is a pure per-project predicate, so the set's cascade is exactly + # these matches minus the students earlier rules claimed. Lets callers + # evaluate one rule at a time instead of re-running the set. + def independent_matches_for_rule(target_rule) + target_rule.matching_projects(eligible_projects) + end + + def preview_projects_for_rule(target_rule) + preview_allocations_for_rule(target_rule) + .find { |allocation| allocation[:rule].id == target_rule.id } + &.fetch(:projects, []) || [] + end + + def preview_allocations_by_rule + allocations = [] + remaining_projects = eligible_projects + + communication_rules.each_with_object({}) do |rule, allocations_by_rule| + matched_projects = rule.matching_projects(remaining_projects) + allocations << { rule: rule, projects: matched_projects } + allocations_by_rule[rule.id] = allocations.dup + remaining_projects -= matched_projects + end + end + + def preview_allocations_for_rule(target_rule) + remaining_projects = eligible_projects + allocations = [] + + communication_rules.each do |rule| + matched_projects = rule.matching_projects(remaining_projects) + allocations << { rule: rule, projects: matched_projects } + return allocations if rule.id == target_rule.id + + remaining_projects -= matched_projects + end + + allocations + end + + def unresolved_rules + communication_rules.select(&:unresolved?) + end + + def executable? + unresolved_rules.empty? + end + + def copy_to(other_unit) + new_set = dup + new_set.unit = other_unit + new_set.save! + + communication_set_schedules.each do |schedule| + new_schedule = schedule.dup + new_schedule.communication_set = new_set + new_schedule.ice_cube_schedule = nil + new_schedule.next_run_at = nil + new_schedule.last_run_at = nil + new_schedule.last_enqueued_at = nil + new_schedule.save! + end + + communication_rules.each do |rule| + new_rule = rule.dup + new_rule.communication_set = new_set + new_rule.save! + + rule.communication_conditions.each do |condition| + new_condition = condition.dup + new_condition.communication = new_rule + new_condition.task_definition = matching_task_definition(other_unit, condition) + new_condition.tutorial_stream = matching_tutorial_stream(other_unit, condition) + new_condition.tutorial = matching_tutorial(other_unit, condition) + new_condition.group_set = matching_group_set(other_unit, condition) + new_condition.group = matching_group(other_unit, condition) + new_condition.save! + end + + rule.communication_actions.each do |action| + new_action = action.dup + new_action.communication_rule = new_rule + new_action.task_definition = matching_task_definition(other_unit, action) + new_action.save! + end + end + + new_set + end + + private + + def matching_task_definition(unit, condition) + return nil if condition.task_definition.blank? + + unit.task_definitions.find_by(abbreviation: condition.task_definition.abbreviation) + end + + def matching_tutorial_stream(unit, condition) + return nil if condition.tutorial_stream.blank? + + unit.tutorial_streams.find_by(abbreviation: condition.tutorial_stream.abbreviation) + end + + # Group sets and groups are named rather than abbreviated, and a group name is + # only unique inside its set. + def matching_group_set(unit, condition) + return nil if condition.group_set.blank? + + unit.group_sets.find_by(name: condition.group_set.name) + end + + def matching_group(unit, condition) + return nil if condition.group.blank? + + unit.group_sets.find_by(name: condition.group.group_set.name)&.groups&.find_by(name: condition.group.name) + end + + def matching_tutorial(unit, condition) + return nil if condition.tutorial.blank? + + unit.tutorials.find_by( + abbreviation: condition.tutorial.abbreviation, + campus_id: condition.tutorial.campus_id + ) + end +end diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb new file mode 100644 index 0000000000..563468006f --- /dev/null +++ b/app/models/communication/communication_set_schedule.rb @@ -0,0 +1,170 @@ +class CommunicationSetSchedule < ApplicationRecord + RECURRENCES = %w[none daily weekly monthly].freeze + VALID_DAYS = Date::DAYNAMES.freeze + DAY_ABBREVIATIONS = Date::ABBR_DAYNAMES.freeze + + belongs_to :communication_set, class_name: 'CommunicationSet', inverse_of: :communication_set_schedules + delegate :unit, to: :communication_set + + validates :name, presence: true + validates :anchor_week, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :anchor_day, presence: true + validates :hour, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 24 } + validates :minute, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 60 } + validates :interval, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :recurrence, presence: true, inclusion: { in: RECURRENCES } + validates :repeat_count, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :active, inclusion: { in: [true, false] } + validate :anchor_day_supported + validate :timezone_supported + validate :anchor_date_resolves + + before_validation :normalize_anchor_day + before_validation :default_timezone + before_validation :sync_schedule_cache + + scope :active, -> { where(active: true) } + scope :due, ->(time = Time.zone.now) { where('next_run_at IS NOT NULL AND next_run_at <= ?', time) } + scope :with_active_unit, -> { joins(communication_set: :unit).where(units: { active: true }) } + + def resolved_anchor_date + return nil if unit.blank? || anchor_week.blank? || anchor_day.blank? + + unit.date_for_week_and_day(anchor_week, anchor_day_abbreviation) + end + + def resolved_start_at + date = resolved_anchor_date + return nil if date.nil? + + timezone_object.local(date.year, date.month, date.day, hour, minute) + end + + def build_ice_cube_schedule + start_at = resolved_start_at + return nil if start_at.nil? + + schedule = IceCube::Schedule.new(start_at) + rule = recurrence_rule + schedule.add_recurrence_rule(rule) if rule + schedule + end + + def next_occurrence_after(time = Time.zone.now) + schedule = build_ice_cube_schedule + return nil if schedule.nil? + + time_for_schedule = time.in_time_zone(timezone_object) + occurrence = if recurrence == 'none' + occurrence = schedule.start_time + occurrence >= time_for_schedule ? occurrence : nil + else + schedule.next_occurrence(time_for_schedule) + end + + return nil if occurrence.blank? + return nil if unit.present? && !unit.within_teaching_dates?(occurrence) + + occurrence + end + + def due?(time = Time.zone.now) + active_for_scheduling? && next_run_at.present? && next_run_at <= time + end + + def refresh_next_run_at!(from_time = Time.zone.now) + update!(next_run_at: active_for_scheduling? ? next_occurrence_after(from_time) : nil) + end + + def anchor_day_abbreviation + return nil if anchor_day.blank? + + day = anchor_day.to_s.strip + return day if DAY_ABBREVIATIONS.include?(day) + + index = VALID_DAYS.index(day.titlecase) + index.nil? ? nil : DAY_ABBREVIATIONS[index] + end + + private + + def recurrence_rule + case recurrence + when 'daily' + IceCube::Rule.daily(interval) + when 'weekly' + IceCube::Rule.weekly(interval) + when 'monthly' + IceCube::Rule.monthly(interval) + end&.tap do |rule| + rule.count(repeat_count) if repeat_count.present? + rule.until(until_at.in_time_zone(timezone_object)) if until_at.present? + end + end + + def timezone_object + ActiveSupport::TimeZone[timezone.presence || Time.zone.name] || Time.zone + end + + def normalize_anchor_day + return if anchor_day.blank? + + full_day = + if VALID_DAYS.include?(anchor_day.to_s.titlecase) + anchor_day.to_s.titlecase + else + day_index = DAY_ABBREVIATIONS.index(anchor_day.to_s.titlecase) + day_index.nil? ? anchor_day : VALID_DAYS[day_index] + end + + self.anchor_day = full_day + end + + def default_timezone + self.timezone = timezone.presence || Time.zone.name + end + + def sync_schedule_cache + schedule = build_ice_cube_schedule + self.ice_cube_schedule = schedule.present? ? JSON.generate(schedule.to_hash) : nil + self.next_run_at = next_occurrence_after(Time.zone.now) if active_for_scheduling? && should_refresh_next_run_at? + self.next_run_at = nil unless active_for_scheduling? + end + + def active_for_scheduling? + active? && unit&.active? + end + + def should_refresh_next_run_at? + will_save_change_to_anchor_week? || + will_save_change_to_anchor_day? || + will_save_change_to_hour? || + will_save_change_to_minute? || + will_save_change_to_timezone? || + will_save_change_to_recurrence? || + will_save_change_to_interval? || + will_save_change_to_repeat_count? || + will_save_change_to_until_at? || + will_save_change_to_active? || + next_run_at.blank? + end + + def anchor_day_supported + return if anchor_day.blank? || VALID_DAYS.include?(anchor_day.to_s.titlecase) + + errors.add(:anchor_day, 'must be a valid day name') + end + + def timezone_supported + return if timezone.blank? || ActiveSupport::TimeZone[timezone].present? + + errors.add(:timezone, 'must be a valid timezone') + end + + def anchor_date_resolves + return if unit.blank? || anchor_week.blank? || anchor_day.blank? + return if resolved_anchor_date.present? + + errors.add(:base, 'schedule anchor could not be resolved for this unit') + end +end diff --git a/app/models/communication/email_staff_action.rb b/app/models/communication/email_staff_action.rb new file mode 100644 index 0000000000..fc1c2d36f3 --- /dev/null +++ b/app/models/communication/email_staff_action.rb @@ -0,0 +1,15 @@ +class EmailStaffAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true + validates :email_tutors, inclusion: { in: [true, false] } + validates :email_convenors, inclusion: { in: [true, false] } + validate :staff_recipient? + + private + + def staff_recipient? + return if email_tutors || email_convenors + + errors.add(:base, 'must email tutors or convenors') + end +end diff --git a/app/models/communication/email_student_action.rb b/app/models/communication/email_student_action.rb new file mode 100644 index 0000000000..fdd0ecb17d --- /dev/null +++ b/app/models/communication/email_student_action.rb @@ -0,0 +1,4 @@ +class EmailStudentAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true +end diff --git a/app/models/communication/group_enrolment_condition.rb b/app/models/communication/group_enrolment_condition.rb new file mode 100644 index 0000000000..f80c14c877 --- /dev/null +++ b/app/models/communication/group_enrolment_condition.rb @@ -0,0 +1,14 @@ +class GroupEnrolmentCondition < CommunicationCondition + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } + + # The editor narrows the group list by group set first. Deriving the set from + # the group keeps the two in step, so reopening the condition shows the set + # the group actually sits in. + before_validation :align_group_set_with_group + + private + + def align_group_set_with_group + self.group_set_id = group&.group_set_id + end +end diff --git a/app/models/communication/group_set_enrolment_condition.rb b/app/models/communication/group_set_enrolment_condition.rb new file mode 100644 index 0000000000..80ff0abbff --- /dev/null +++ b/app/models/communication/group_set_enrolment_condition.rb @@ -0,0 +1,3 @@ +class GroupSetEnrolmentCondition < CommunicationCondition + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/login_status_condition.rb b/app/models/communication/login_status_condition.rb new file mode 100644 index 0000000000..cf6cbebea5 --- /dev/null +++ b/app/models/communication/login_status_condition.rb @@ -0,0 +1,6 @@ +class LoginStatusCondition < CommunicationCondition + validates :activity_days, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 1 } + validates :operator, inclusion: { in: ACTIVITY_OPERATORS } +end diff --git a/app/models/communication/portfolio_submitted_condition.rb b/app/models/communication/portfolio_submitted_condition.rb new file mode 100644 index 0000000000..ea63f16e14 --- /dev/null +++ b/app/models/communication/portfolio_submitted_condition.rb @@ -0,0 +1,4 @@ +class PortfolioSubmittedCondition < CommunicationCondition + validates :submitted_portfolio, inclusion: { in: [true, false] } + validates :operator, inclusion: { in: EQUALITY_OPERATORS } +end diff --git a/app/models/communication/spec_con_condition.rb b/app/models/communication/spec_con_condition.rb new file mode 100644 index 0000000000..cebeb8565f --- /dev/null +++ b/app/models/communication/spec_con_condition.rb @@ -0,0 +1,4 @@ +class SpecConCondition < CommunicationCondition + validates :spec_con_days, presence: true, numericality: { only_integer: true } + validates :operator, inclusion: { in: GRADE_OPERATORS } +end diff --git a/app/models/communication/target_grade_condition.rb b/app/models/communication/target_grade_condition.rb new file mode 100644 index 0000000000..77f195a473 --- /dev/null +++ b/app/models/communication/target_grade_condition.rb @@ -0,0 +1,13 @@ +class TargetGradeCondition < CommunicationCondition + validates :target_grade, presence: true + validates :operator, inclusion: { in: GRADE_OPERATORS } + validate :target_grade_enabled_for_unit + + private + + def target_grade_enabled_for_unit + return if target_grade.nil? || communication&.unit&.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/task_comment_action.rb b/app/models/communication/task_comment_action.rb new file mode 100644 index 0000000000..5a9deea530 --- /dev/null +++ b/app/models/communication/task_comment_action.rb @@ -0,0 +1,3 @@ +class TaskCommentAction < CommunicationAction + validates :body, presence: true +end diff --git a/app/models/communication/task_definition_status_condition.rb b/app/models/communication/task_definition_status_condition.rb new file mode 100644 index 0000000000..b767c16f76 --- /dev/null +++ b/app/models/communication/task_definition_status_condition.rb @@ -0,0 +1,4 @@ +class TaskDefinitionStatusCondition < CommunicationCondition + validates :operator, inclusion: { in: EQUALITY_OPERATORS } + validate :task_statuses_must_be_present +end diff --git a/app/models/communication/task_status_count_condition.rb b/app/models/communication/task_status_count_condition.rb new file mode 100644 index 0000000000..42659a930e --- /dev/null +++ b/app/models/communication/task_status_count_condition.rb @@ -0,0 +1,15 @@ +class TaskStatusCountCondition < CommunicationCondition + validates :task_status_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :task_target_grade, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :operator, inclusion: { in: GRADE_OPERATORS } + validate :task_statuses_must_be_present + validate :task_target_grade_enabled_for_unit + + private + + def task_target_grade_enabled_for_unit + return if task_target_grade.nil? || communication&.unit&.grade_value?(task_target_grade) + + errors.add(:task_target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/tutorial_enrolment_condition.rb b/app/models/communication/tutorial_enrolment_condition.rb new file mode 100644 index 0000000000..d2da906642 --- /dev/null +++ b/app/models/communication/tutorial_enrolment_condition.rb @@ -0,0 +1,3 @@ +class TutorialEnrolmentCondition < CommunicationCondition + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/tutorial_stream_enrolment_condition.rb b/app/models/communication/tutorial_stream_enrolment_condition.rb new file mode 100644 index 0000000000..e451ebd89d --- /dev/null +++ b/app/models/communication/tutorial_stream_enrolment_condition.rb @@ -0,0 +1,3 @@ +class TutorialStreamEnrolmentCondition < CommunicationCondition + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/unit_viewed_status_condition.rb b/app/models/communication/unit_viewed_status_condition.rb new file mode 100644 index 0000000000..9ce4152885 --- /dev/null +++ b/app/models/communication/unit_viewed_status_condition.rb @@ -0,0 +1,6 @@ +class UnitViewedStatusCondition < CommunicationCondition + validates :activity_days, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 1 } + validates :operator, inclusion: { in: ACTIVITY_OPERATORS } +end diff --git a/app/models/engagement.rb b/app/models/engagement.rb new file mode 100644 index 0000000000..b1cc77ba9f --- /dev/null +++ b/app/models/engagement.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'uri' + +class Engagement < ApplicationRecord + include FileHelper + include MimeCheckHelpers + + # Engagements can only be deleted for a short period after they are created. + # Staff can delete their own engagements within this window... + DELETE_WINDOW = 1.hour + # ... while convenors can delete any engagement in their unit within this window. + CONVENOR_DELETE_WINDOW = 1.week + + belongs_to :project, optional: false, inverse_of: :engagements + belongs_to :user, optional: false, inverse_of: :engagements + + has_many :engagement_projects, dependent: :destroy, inverse_of: :engagement + has_many :additional_projects, through: :engagement_projects, source: :project + has_many :engagement_comments, + -> { order(:created_at) }, + dependent: :destroy, + inverse_of: :engagement + + validates :engagement_type, presence: true, length: { maximum: 255 } + validates :note, presence: true, length: { maximum: 4095 } + validates :occurred_at, presence: true + validates :evidence_url, length: { maximum: 2048, allow_blank: true } + validates :content_type, inclusion: { in: %w[image pdf], allow_nil: true } + validate :valid_evidence_url + validate :single_evidence_source + validate :consistent_attachment_metadata + + before_validation :normalise_text + before_destroy :delete_attachment + + # Can this engagement still be deleted, or has the delete window passed? + def within_delete_window?(window = DELETE_WINDOW) + created_at.present? && created_at > window.ago + end + + def attachment? + content_type.present? && attachment_extension.present? + end + + def attachment_path + FileHelper.engagement_attachment_path(self, attachment_extension) + end + + def attachment_file_name + "engagement-#{id}#{attachment_extension}" + end + + def attachment_mime_type + mime_type(attachment_path) + end + + def replace_attachment(file_upload, attachment_type) + delete_attachment + self.evidence_url = nil + self.content_type = attachment_type + + if attachment_type == 'image' + self.attachment_extension = + if mime_type(file_upload['tempfile'].path).starts_with?('image/gif') + '.gif' + else + '.jpg' + end + save! + image_saved = FileHelper.compress_image_to_dest(file_upload['tempfile'].path, attachment_path) + raise 'Failed to save engagement image attachment' unless image_saved && File.exist?(attachment_path) + else + self.attachment_extension = '.pdf' + save! + FileHelper.compress_pdf(file_upload['tempfile'].path) + FileUtils.mv(file_upload['tempfile'].path, attachment_path) + raise 'Failed to save engagement PDF attachment' unless File.exist?(attachment_path) + end + + file_upload['tempfile'].unlink if File.exist?(file_upload['tempfile'].path) + true + end + + def remove_attachment + delete_attachment + self.content_type = nil + self.attachment_extension = nil + end + + private + + def normalise_text + self.engagement_type = engagement_type&.strip + self.note = note&.strip + self.evidence_url = evidence_url&.strip.presence + end + + def valid_evidence_url + return if evidence_url.blank? + + uri = URI.parse(evidence_url) + return if uri.is_a?(URI::HTTP) && uri.host.present? + + errors.add(:evidence_url, 'must be a valid HTTP or HTTPS URL') + rescue URI::InvalidURIError + errors.add(:evidence_url, 'must be a valid HTTP or HTTPS URL') + end + + def single_evidence_source + return unless evidence_url.present? && (content_type.present? || attachment_extension.present?) + + errors.add(:base, 'An engagement can have either an evidence URL or an attachment, not both') + end + + def consistent_attachment_metadata + return if content_type.present? == attachment_extension.present? + + errors.add(:base, 'Attachment content type and extension must both be present') + end + + def delete_attachment + FileUtils.rm_f(attachment_path) if attachment_extension.present? + end +end diff --git a/app/models/engagement_comment.rb b/app/models/engagement_comment.rb new file mode 100644 index 0000000000..d02b4fca89 --- /dev/null +++ b/app/models/engagement_comment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class EngagementComment < ApplicationRecord + belongs_to :engagement, optional: false, inverse_of: :engagement_comments + belongs_to :user, optional: false, inverse_of: :engagement_comments + belongs_to :reply_to, class_name: 'EngagementComment', optional: true + has_many :replies, + class_name: 'EngagementComment', + foreign_key: :reply_to_id, + dependent: :nullify, + inverse_of: :reply_to + + validates :comment, presence: true, length: { maximum: 4095 } + validate :reply_belongs_to_engagement + + before_validation do + self.comment = comment&.strip + end + + private + + def reply_belongs_to_engagement + return if reply_to.nil? || reply_to.engagement_id == engagement_id + + errors.add(:reply_to, 'must belong to the same engagement') + end +end diff --git a/app/models/engagement_project.rb b/app/models/engagement_project.rb new file mode 100644 index 0000000000..0c86fbb334 --- /dev/null +++ b/app/models/engagement_project.rb @@ -0,0 +1,6 @@ +class EngagementProject < ApplicationRecord + belongs_to :engagement, optional: false, inverse_of: :engagement_projects + belongs_to :project, optional: false, inverse_of: :engagement_projects + + validates :project_id, uniqueness: { scope: :engagement_id } +end diff --git a/app/models/feedback/feedback_chip.rb b/app/models/feedback/feedback_chip.rb index deabbeccff..e39208f95d 100644 --- a/app/models/feedback/feedback_chip.rb +++ b/app/models/feedback/feedback_chip.rb @@ -196,15 +196,23 @@ def self.context_for_csv(row, context_type, context) # Find unit - which contains task definition if present unit_code = row['unit_code'] if unit_code.present? - unit = Unit.find_by(code: unit_code) + unit = if context_type == 'TaskDefinition' + context.unit + elsif context_type == 'LearningOutcome' && context.context_type == 'TaskDefinition' + context.context.unit + elsif context_type == 'LearningOutcome' + context.context + else + context + end if unit.nil? return { success: false, message: "Unit #{unit_code} not found" } - elsif (context_type == 'Unit' && context.id != unit.id) || (context_type == 'TaskDefinition' && context.unit_id != unit.id) - return { success: false, message: "Is not for unit #{context.code}" } + elsif unit_code != unit.code + return { success: false, message: "Unit #{unit_code} does not match unit #{unit.code}" } end elsif %w[Unit TaskDefinition].include? context_type # the unit code is not present, but the context is a unit or task definition - return { success: false, message: "Is not for unit #{context.code}" } + return { success: false, message: "Is not for unit #{context_type == 'Unit' ? context.code : context.unit.code}" } end task_abbreviation = row['task_abbreviation'] @@ -230,16 +238,8 @@ def self.context_for_csv(row, context_type, context) return { success: false, message: 'Missing learning outcome abbreviation' } end - search_context = if context_type == 'LearningOutcome' - context.context - else - context - end - + search_context = task_definition || (context_type == 'LearningOutcome' ? context.context : context) learning_outcome = LearningOutcome.find_by(abbreviation: learning_outcome_abbreviation, context: search_context) - if learning_outcome.nil? && task_definition.present? && context_type == 'Unit' - learning_outcome = LearningOutcome.find_by(abbreviation: learning_outcome_abbreviation, context: task_definition) - end if learning_outcome.nil? return { success: false, message: "Learning outcome #{learning_outcome_abbreviation} not found" } diff --git a/app/models/learning_outcome.rb b/app/models/learning_outcome.rb index db074a1a50..7494d36ec5 100644 --- a/app/models/learning_outcome.rb +++ b/app/models/learning_outcome.rb @@ -19,7 +19,6 @@ def self.permissions convenor_role_permissions = [ :update, :get_los, - :update_glos, :upload_csv, :create_feedback_chips, :get_feedback_chips @@ -36,8 +35,7 @@ def self.permissions tutor_role_permissions = [ :update, - :get_los, - :update_glos + :get_los ] auditor_role_permissions = [ diff --git a/app/models/lms_group_mapping.rb b/app/models/lms_group_mapping.rb new file mode 100644 index 0000000000..cfa86484b4 --- /dev/null +++ b/app/models/lms_group_mapping.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class LmsGroupMapping < ApplicationRecord + TARGET_TYPES = %w[group campus tutorial ignore].freeze + + belongs_to :lms_integration + belongs_to :group_set, optional: true + belongs_to :group, optional: true + belongs_to :campus, optional: true + belongs_to :tutorial_stream, optional: true + belongs_to :tutorial, optional: true + + validates :lms_group_id, numericality: { only_integer: true, greater_than: 0 } + validates :lms_group_name, presence: true + validates :target_type, inclusion: { in: TARGET_TYPES } + validate :valid_target + + private + + def valid_target + unit = lms_integration&.unit + + case target_type + when 'group' + errors.add(:group_set, 'must be selected') if group_set.blank? + errors.add(:group_set, 'must belong to this unit') if group_set.present? && group_set.unit != unit + if create_if_missing? + if tutorial.blank? == tutorial_stream.blank? + errors.add(:base, 'select an existing tutorial or a tutorial stream for the new group') + end + errors.add(:tutorial, 'must belong to this unit') if tutorial.present? && tutorial.unit != unit + if tutorial_stream.present? && tutorial_stream.unit != unit + errors.add(:tutorial_stream, 'must belong to this unit') + end + else + errors.add(:group, 'must be selected') if group.blank? + if group.present? && (group.group_set != group_set || group.unit != unit) + errors.add(:group, 'must belong to the selected group set') + end + end + when 'campus' + errors.add(:campus, 'must be selected') if campus.blank? + when 'tutorial' + errors.add(:tutorial_stream, 'must be selected') if tutorial_stream.blank? + if tutorial_stream.present? && tutorial_stream.unit != unit + errors.add(:tutorial_stream, 'must belong to this unit') + end + errors.add(:tutorial, 'must be selected') if tutorial.blank? + if tutorial.present? && (tutorial.tutorial_stream != tutorial_stream || tutorial.unit != unit) + errors.add(:tutorial, 'must belong to the selected tutorial stream') + end + end + end +end diff --git a/app/models/lms_integration.rb b/app/models/lms_integration.rb new file mode 100644 index 0000000000..e3c7a00af7 --- /dev/null +++ b/app/models/lms_integration.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +class LmsIntegration < ApplicationRecord + SOURCES = %w[lti].freeze + # Nights an outage can keep scheduled syncs failing before they are turned off + AUTO_SYNC_GRACE_DAYS = 3 + # LTI service statuses for problems with the LMS course or link, rather than an outage + CONVENOR_ERROR_STATUSES = [404, 409, 422].freeze + + belongs_to :unit + has_many :lms_group_mappings, dependent: :destroy + + validates :source, inclusion: { in: SOURCES } + validates :assignment_id, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :unit_id, uniqueness: true + + scope :auto_syncing, -> { where(auto_sync_students: true).or(where(auto_sync_extensions: true)) } + + before_save :clear_auto_sync_failure, if: :auto_sync_turned_on? + + # Errors a convenor has to fix. Anything else could be an outage, so the next night's sync retries it. + def self.convenor_error?(error) + return true if error.is_a?(LmsIntegrationValidator::ValidationError) + + server_error = [error, error.cause].find { |cause| cause.is_a?(LtiServer::Error) } + return CONVENOR_ERROR_STATUSES.include?(server_error.status) if server_error + + error.is_a?(LtiCourseDataSource::Error) + end + + def self.data_source_for(unit) + source = unit.lms_integration&.source.presence || 'lti' + case source + when 'lti' + LtiCourseDataSource.new(unit) + end + end + + def data_source + LmsIntegration.data_source_for(unit) + end + + def mark_unvalidated! + update!(validated: false, validated_at: nil) if validated? || validated_at.present? + end + + def auto_sync_failed!(sync, error) + if LmsIntegration.convenor_error?(error) + turn_off_auto_sync!(sync, error, outage: false) + else + record_auto_sync_outage!(sync, error) + end + end + + def record_auto_sync_outage!(sync, error) + with_lock do + self.auto_sync_failing_since ||= Time.current + self.auto_sync_last_error = error.message + save! + end + turn_off_auto_sync!(sync, error, outage: true) if auto_sync_turn_off_date <= Time.zone.today + end + + # Once auto sync is off, the failure stays visible until a convenor turns it back on + def auto_sync_succeeded! + with_lock do + update!(auto_sync_failing_since: nil, auto_sync_last_error: nil) if auto_sync_students? || auto_sync_extensions? + end + end + + def auto_sync_turn_off_date + auto_sync_failing_since&.to_date&.advance(days: AUTO_SYNC_GRACE_DAYS) + end + + private + + def auto_sync_turned_on? + will_save_change_to_auto_sync_students?(to: true) || will_save_change_to_auto_sync_extensions?(to: true) + end + + def clear_auto_sync_failure + self.auto_sync_failing_since = nil + self.auto_sync_last_error = nil + end + + # Only the failure that turns auto sync off emails the main convenor + def turn_off_auto_sync!(sync, error, outage:) + turned_off = with_lock do + settings = %i[auto_sync_students auto_sync_extensions].select { |setting| self[setting] } + if settings.any? + assign_attributes(settings.index_with(false)) + self.auto_sync_last_error = error.message + save! + end + settings + end + return if turned_off.empty? + + begin + LmsIntegrationMailer.auto_sync_failed(self, sync, error, turned_off, outage).deliver_now + rescue StandardError => e + Rails.logger.error "Failed to email the LMS auto sync failure for unit #{unit_id}: #{e.message}" + end + end +end diff --git a/app/models/overflow_task_claim.rb b/app/models/overflow_task_claim.rb index 2559ce5964..986d055b6d 100644 --- a/app/models/overflow_task_claim.rb +++ b/app/models/overflow_task_claim.rb @@ -1,4 +1,5 @@ class OverflowTaskClaim < ApplicationRecord belongs_to :task + belongs_to :claimed_by_unit_role, class_name: 'UnitRole' end diff --git a/app/models/overflow_task_claim_log.rb b/app/models/overflow_task_claim_log.rb new file mode 100644 index 0000000000..9b4b606115 --- /dev/null +++ b/app/models/overflow_task_claim_log.rb @@ -0,0 +1,8 @@ +class OverflowTaskClaimLog < ApplicationRecord + belongs_to :unit + belongs_to :task, optional: true + belongs_to :claimed_by_unit_role, class_name: 'UnitRole', optional: true + belongs_to :claimed_by_user, class_name: 'User', optional: true + belongs_to :original_tutor_user, class_name: 'User', optional: true + belongs_to :student_user, class_name: 'User', optional: true +end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index 84a2077dd0..6dc0b808a9 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -1,6 +1,7 @@ # rubocop:disable Rails/Output class OverseerAssessment < ApplicationRecord belongs_to :task, optional: false + belongs_to :submission_history, optional: false has_one :project, through: :task has_many :assessment_comments, as: :commentable, dependent: :destroy @@ -11,22 +12,82 @@ class OverseerAssessment < ApplicationRecord validates :submission_timestamp, presence: true validates :submission_timestamp, uniqueness: { scope: :task_id } + validates :submission_history_id, uniqueness: true + validate :submission_history_matches_task enum :status, { pre_queued: 0, passed: 1, failed: 2 } - after_destroy :delete_associated_files + def submission_history_matches_task + return if submission_history.nil? || task.nil? || submission_history.task_id == task_id + + errors.add(:submission_history, 'must belong to the same task') + end + + def self.student_notification_grace_period + Doubtfire::Application.config.overseer_student_notification_grace_period + end + + scope :awaiting_student_failure_notification, lambda { |grace_period: student_notification_grace_period| + notification_cutoff = grace_period.ago + + joins(task: { project: :user }) + .joins(<<~SQL.squish) + INNER JOIN task_comments assessment_comments + ON assessment_comments.commentable_type = 'OverseerAssessment' + AND assessment_comments.commentable_id = overseer_assessments.id + AND assessment_comments.type = 'AssessmentComment' + SQL + .joins(<<~SQL.squish) + LEFT JOIN comment_read_cursors student_read_cursor + ON student_read_cursor.task_id = assessment_comments.task_id + AND student_read_cursor.user_id = projects.user_id + SQL + .where(status: statuses[:failed], student_notified_at: nil) + .where(users: { receive_task_notifications: true }) + .where('overseer_assessments.updated_at <= ?', notification_cutoff) + .where( + 'student_read_cursor.last_read_comment_id IS NULL ' \ + 'OR student_read_cursor.last_read_comment_id < assessment_comments.id' + ) + .where(<<~SQL.squish) + assessment_comments.id = ( + SELECT latest_comment.id + FROM task_comments latest_comment + WHERE latest_comment.commentable_type = 'OverseerAssessment' + AND latest_comment.commentable_id = overseer_assessments.id + AND latest_comment.type = 'AssessmentComment' + ORDER BY latest_comment.created_at DESC, latest_comment.id DESC + LIMIT 1 + ) + SQL + .where(<<~SQL.squish) + NOT EXISTS ( + SELECT 1 + FROM overseer_assessments newer_assessments + WHERE newer_assessments.task_id = overseer_assessments.task_id + AND ( + newer_assessments.created_at > overseer_assessments.created_at OR + ( + newer_assessments.created_at = overseer_assessments.created_at AND + newer_assessments.id > overseer_assessments.id + ) + ) + ) + SQL + } # TODO: track how many tests ran, and how many tests total at the time # TODO: we might not have an overseerStepResult because a new test was added later # Creates an OverseerAssessment object for a new submission - def self.create_for(task, test_submission) + def self.create_for(submission_history, test_submission) # Create only if: # unit's assessment is enabled && # task's assessment is enabled && # task definition has an assessment resources zip file && # task has a student submission + task = submission_history.task task_definition = task.task_definition unit = task_definition.unit @@ -40,50 +101,21 @@ def self.create_for(task, test_submission) return nil if docker_image_name_tag.nil? || docker_image_name_tag.strip.empty? - result = OverseerAssessment.create!( + OverseerAssessment.create!( task: task, + submission_history: submission_history, status: :pre_queued, - submission_timestamp: Time.now.utc.to_i + submission_timestamp: submission_history.submission_timestamp ) - - # Create the submission folder and give access - FileUtils.mkdir_p result.output_path - result.grant_access_to_submission - - result.copy_latest_files_to_submission - - result end - def has_submission_files? - File.exist? submission_zip_file_name - end - - def submission_zip_file_name - "#{output_path}/submission.zip" - end + delegate :has_submission_files?, + :submission_zip_file_name, + :output_path, + to: :submission_history - def grant_access_to_submission - # TODO: Use FACL instead in future. - `chmod o+w #{output_path}` - end - - def copy_latest_files_to_submission - zip_file_path = submission_zip_file_name - - if task.has_new_files? - puts "Copying new files to submission at: #{zip_file_path}" - # Generate a zip file for this particular submission with timestamp value and put it here - task.compress_new_to_done zip_file_path: zip_file_path, rm_task_dir: false, rename_files: true - else - puts "Copying done file to submission at: #{zip_file_path}" - task.copy_done_to zip_file_path - end - end - - # Path to where the submission and output are stored - includes the submission when it is to be processed - def output_path - FileHelper.task_submission_identifier_path_with_timestamp(:done, task, submission_timestamp) + def latest_assessment_comment + assessment_comments.order(created_at: :desc, id: :desc).first end def add_assessment_comment(text = 'Automated Assessment Started') @@ -156,7 +188,7 @@ def send_to_overseer(test_submission: false) return { error: "This assessment is no longer setup for automated feedback. Automated feedback is turned off at either the unit or task level, or the task does not have the scripts needed to automate assessment." } end - unless File.exist? submission_zip_file_name + unless has_submission_files? puts "ERROR: Student submission history zip file doesn't exist #{submission_zip_file_name}. Unable to send - OverseerAssessment #{id}" return { error: "We no longer have the files associated with this submission. Please test a later submission, or upload your work again." } end @@ -259,10 +291,6 @@ def update_from_output(work_dir_path) self.save! end - def delete_associated_files - FileUtils.rm_rf output_path - end - def base64?(value) value.is_a?(String) && Base64.strict_encode64(Base64.decode64(value)) == value end diff --git a/app/models/overseer_step.rb b/app/models/overseer_step.rb index 6273f0db06..33da31aa5b 100644 --- a/app/models/overseer_step.rb +++ b/app/models/overseer_step.rb @@ -1,6 +1,7 @@ class OverseerStep < ApplicationRecord belongs_to :task_definition, optional: false + validates :name, :display_name, :step_type, presence: true validates :timeout, presence: true, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 300, message: 'must be between 1 and 300' } validates :sort_order, presence: true, numericality: { greater_than_or_equal_to: 0 } end diff --git a/app/models/pdf_generation/project_compile_portfolio_module.rb b/app/models/pdf_generation/project_compile_portfolio_module.rb index f5e2c8bba5..b3a5dbf086 100644 --- a/app/models/pdf_generation/project_compile_portfolio_module.rb +++ b/app/models/pdf_generation/project_compile_portfolio_module.rb @@ -42,6 +42,9 @@ class ProjectAppController < ApplicationController :base_path, :image_path, :learning_summary_report, + :submission_date, + :formatted_submission_date, + :formatted_submission_time, :ordered_tasks, :portfolio_tasks, :task_defs, @@ -55,6 +58,8 @@ def init(project, is_retry) @student = project.student @project = project @learning_summary_report = project.learning_summary_report_path + @submission_date = project.portfolio_submission_date + @formatted_submission_date, @formatted_submission_time = format_submission_date(project) @files = project.portfolio_files(ensure_valid: true, force_ascii: is_retry) @base_path = project.portfolio_temp_path @image_path = Rails.root.join('public/assets/images') @@ -66,13 +71,36 @@ def init(project, is_retry) @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @is_retry = is_retry @include_pax = !is_retry - @work_id = "portfolio-#{project.id}-#{Time.now.to_i}-#{Process.pid}-#{Thread.current.object_id}#{'-retry' if is_retry}" + @work_id = FileHelper.sanitized_path( + "portfolio-#{Time.current.strftime('%Y%m%d-%H%M')}-#{project.unit.code}-#{project.student.username}-#{project.id}-#{Process.pid}#{'-retry' if is_retry}" + ) end def make_pdf logger.debug 'Running make_pdf: (portfolio)' generate_pdf(template: '/portfolio/portfolio_pdf') end + + private + + def format_submission_date(project) + return [nil, nil] if @submission_date.blank? + + campus_timezone = project.campus&.timezone.presence + + submission_time = + if campus_timezone.present? + @submission_date.in_time_zone(campus_timezone) + else + @submission_date.to_time.getlocal + end + + timezone_label = campus_timezone || ENV['TZ'].presence || submission_time.zone || Time.zone.name + [ + submission_time.strftime('%d %b %Y'), + "#{submission_time.strftime('%I:%M %p')} #{timezone_label}" + ] + end end # A custom error to capture the log message from the latex error @@ -87,9 +115,7 @@ def initialize(log_message) # Create the portfolio for this project def create_portfolio - self.compile_portfolio = false - save! - + logger.info "Creating portfolio for #{user.username} in #{unit.code}" begin pac = ProjectAppController.new pac.init(self, false) @@ -116,8 +142,13 @@ def create_portfolio logger.info "Created portfolio at #{portfolio_path} - #{log_details}" self.portfolio_production_date = Time.zone.now - save + self.compile_portfolio = false + save! + true rescue StandardError => e + self.compile_portfolio = false + save! + logger.error "Failed to convert portfolio to PDF - #{log_details} -\nError: #{e.message}" log_file = e.message.scan(%r{/.*\.log}).first @@ -138,7 +169,7 @@ def create_portfolio end end - raise LatexError.new(log_message), "Failed to convert portfolio to PDF - #{log_details}" + raise LatexError.new(log_message), "Failed to convert portfolio to PDF - #{id} - #{unit.code}" end end @@ -174,6 +205,19 @@ def portfolio_tasks end end + # Return the tasks that are currently being processed + def tasks_processing_pdf + # Get assigned tasks that should be included in the portfolio + tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation') + + # Select tasks that should have a PDF submission, but is currently being processed + tasks.select do |task| + !task.has_pdf && + task.processing_pdf? && + task.task_definition.upload_requirements.present? + end + end + # # Return the path to the student's learning summary report. # This returns nil if there is no learning summary report. @@ -226,18 +270,17 @@ def move_to_portfolio(file, name, kind) kind: kind, name: file[:filename] } + word_document = kind == 'document' && FileHelper.word_document?(file[:filename]) + if word_document + result[:name] = "#{File.basename(file[:filename], File.extname(file[:filename]))}.pdf" + end # copy up the learning summary report as first -- otherwise use files to determine idx if name == 'LearningSummaryReport' && kind == 'document' result[:idx] = 0 result[:name] = 'LearningSummaryReport.pdf' - - # set uses_draft_learning_summary to false, since we uploaded a new learning summary - self.uses_draft_learning_summary = false - save else - Dir.chdir(portfolio_tmp_dir) - files = Dir.glob('*') + files = Dir.glob('*', base: portfolio_tmp_dir) idx = files.map { |a_file| a_file.split('-').first.to_i }.max if idx.nil? || idx < 1 idx = 1 @@ -248,7 +291,29 @@ def move_to_portfolio(file, name, kind) end dest_file = portfolio_tmp_file_name(result) - FileUtils.cp file["tempfile"].path, File.join(portfolio_tmp_dir, dest_file) + dest_path = File.join(portfolio_tmp_dir, dest_file) + if word_document + Dir.mktmpdir('portfolio-document-', portfolio_tmp_dir) do |conversion_dir| + converted_path = File.join(conversion_dir, dest_file) + FileHelper.convert_word_document_to_pdf( + file['tempfile'].path, + converted_path, + work_id: FileHelper.gotenberg_work_id( + unit_code: unit.code, kind: 'portfolio', username: student.username, + label: name == 'LearningSummaryReport' ? 'lsr' : 'document' + ) + ) + File.rename(converted_path, dest_path) + end + else + FileUtils.cp file['tempfile'].path, dest_path + end + + if name == 'LearningSummaryReport' && kind == 'document' + self.uses_draft_learning_summary = false + save + end + result end @@ -259,8 +324,8 @@ def portfolio_files(ensure_valid: false, force_ascii: false) result = [] - Dir.chdir(portfolio_tmp_dir) - files = Dir.glob('*').select { |f| (f =~ /^\d{3}-(cover|document|code|image)/) == 0 } + files = Dir.glob('*', base: portfolio_tmp_dir) + .select { |f| (f =~ /^\d{3}-(cover|document|code|image|zip|archive)/) == 0 } files.each do |file| parts = file.split('-') idx = parts[0].to_i @@ -268,7 +333,7 @@ def portfolio_files(ensure_valid: false, force_ascii: false) name = parts.drop(2).join('-') result << { kind: kind, name: name, idx: idx } - FileHelper.ensure_utf8_code(file, force_ascii) if ensure_valid && kind == "code" + FileHelper.ensure_utf8_code(File.join(portfolio_tmp_dir, file), force_ascii) if ensure_valid && kind == "code" end result @@ -294,8 +359,8 @@ def remove_portfolio_file(idx, kind, name) end end - def portfolio_path - FileHelper.student_portfolio_path(self.unit, self.student.username, create: true) + def portfolio_path(create: true) + FileHelper.student_portfolio_path(self.unit, self.student.username, create: create) end def portfolio_exists? @@ -313,7 +378,9 @@ def portfolio_status end def portfolio_available - (File.exist? portfolio_path) && !compile_portfolio + # Checking must not create the directory -- this runs once per student in + # bulk reports and communication previews. + (File.exist? portfolio_path(create: false)) && !compile_portfolio end def remove_portfolio diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 65d1d481b6..80188516af 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -1,3 +1,6 @@ +require 'sidekiq/api' +require 'set' + class PortfolioEvidence include FileHelper @@ -17,15 +20,40 @@ def self.student_work_dir(type = nil, task = nil, create = true) FileHelper.student_work_dir(type, task, create) end - # Move all tasks to a folder with this process's id in "in_process" + # Move stale submissions to this PDF generator's processing folder. def self.move_to_pid_folder pid_folder = File.join(student_work_dir(:in_process), "pid_#{Process.pid}") + sidekiq_task_ids = sidekiq_owned_submission_ids + FileHelper.move_files(student_work_dir(:new), pid_folder, true, DateTime.now - 30.minutes) do |path| + !sidekiq_task_ids.include?(File.basename(path).to_i) + end - # Move everything in "new" to "pid" folder but retain the old "new" folder - FileHelper.move_files(student_work_dir(:new), pid_folder, true, DateTime.now - 30.minutes) pid_folder end + def self.sidekiq_owned_submission_ids + running_ids = Set.new + Sidekiq::Workers.new.each do |_process_id, _thread_id, work| + payload = work['payload'].is_a?(String) ? JSON.parse(work['payload']) : work['payload'] + running_ids.add(payload['args'].first.to_i) if payload['class'] == 'AcceptSubmissionJob' + end + + # A queued job only blocks fallback while Sidekiq can drain the default queue. + default_queue_running = Sidekiq::ProcessSet.new.any? do |process| + Array(process.queues).include?('default') && !process.stopping? + end + return running_ids unless default_queue_running + + Sidekiq::Queue.new('default').each do |job| + running_ids.add(job.args.first.to_i) if job.klass == 'AcceptSubmissionJob' + end + + running_ids + rescue StandardError => e + logger.warn "Could not fully inspect Sidekiq; allowing stale submission fallback: #{e.class}: #{e.message}" + running_ids + end + # # Process enqueued pdfs in each folder of the :new directory # into PDF files diff --git a/app/models/project.rb b/app/models/project.rb index 1444730f9b..9056cbd7df 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,6 +33,9 @@ class Project < ApplicationRecord has_many :session_activities, dependent: :destroy has_many :staff_notes, dependent: :destroy + has_many :engagements, dependent: :destroy, inverse_of: :project + has_many :engagement_projects, dependent: :destroy + has_many :shared_engagements, through: :engagement_projects, source: :engagement # Callbacks - methods called are private before_destroy :can_destroy? @@ -59,7 +62,9 @@ def self.permissions :make_submission, :get_submission, :change, - :reprocess_submission + :reprocess_submission, + :get_engagements, + :comment_engagement ] # What can tutors do with projects? tutor_role_permissions = [ @@ -74,7 +79,12 @@ def self.permissions :get_staff_note, :create_staff_note, :reprocess_submission, - :get_discussion_prompt + :get_discussion_prompt, + :get_engagements, + :create_engagement, + :edit_engagement, + :delete_engagement, + :comment_engagement ] # What can admins do with projects? @@ -82,7 +92,8 @@ def self.permissions :get, :get_submission, :reprocess_submission, - :get_discussion_prompt + :get_discussion_prompt, + :get_engagements ] # What can auditors do with projects? @@ -91,7 +102,8 @@ def self.permissions :get_submission, :get_staff_note, :reprocess_submission, - :get_discussion_prompt + :get_discussion_prompt, + :get_engagements ] # What can nil users do with projects? @@ -224,7 +236,7 @@ def tutors_and_tutorial def tutorial_enrolment_for_stream(tutorial_stream) tutorial_enrolments .joins(:tutorial) - .where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (tutorial_stream.present? ? tutorial_stream.id : nil)) + .where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (tutorial_stream.presence&.id)) .first end @@ -245,8 +257,8 @@ def tutor_for(task_definition) delegate :main_convenor_user, to: :unit def user_role(user) - if user == student then :student - elsif user.present? && unit.tutors.where(id: user.id).count != 0 then :tutor + if user.present? && unit.tutors.where(id: user.id).count != 0 then :tutor + elsif user == student then :student elsif user.present? && user.role.id == Role.admin_id then :admin elsif user.present? && user.role.id == Role.auditor_id then :auditor else nil @@ -261,16 +273,7 @@ def active? # Get a string representation of the Target Grade # def target_grade_desc - case target_grade - when 1 - 'Credit' - when 2 - 'Distinction' - when 3 - 'High Distinction' - else - 'Pass' - end + unit.grade_label(target_grade) end def reference_date @@ -278,16 +281,25 @@ def reference_date end def task_details_for_shallow_serializer(user) + teaching_breaks = unit.teaching_period&.breaks_for(campus) || [] + attention_audience = TaskComment.attention_audiences.fetch(user == student ? 'student' : 'staff') + similarity_stats = TaskSimilarity + .select('task_id', 'COUNT(*) AS similarity_count', 'SUM(flagged) AS flagged_count') + .group(:task_id) + .to_sql + tasks .joins(:task_status) - .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") - .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}") - .joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id') + .joins('LEFT JOIN tasks group_comment_tasks ON tasks.group_submission_id IS NOT NULL ' \ + 'AND group_comment_tasks.group_submission_id = tasks.group_submission_id') + .joins("LEFT JOIN comment_read_cursors crc ON crc.task_id = COALESCE(group_comment_tasks.id, tasks.id) AND crc.user_id = #{user.id.to_i}") + .joins("LEFT JOIN task_comments ON task_comments.task_id = COALESCE(group_comment_tasks.id, tasks.id) AND (task_comments.attention_audience IS NULL OR task_comments.attention_audience = #{attention_audience}) AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") + .joins("LEFT JOIN (#{similarity_stats}) task_similarity_stats ON task_similarity_stats.task_id = tasks.id") .select( - 'SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread', 'project_id', 'tasks.id as id', + "SUM(case when task_comments.user_id <> #{user.id.to_i} AND (crc.last_read_comment_id IS NULL OR task_comments.id > crc.last_read_comment_id) AND NOT task_comments.id is null then COALESCE(task_similarity_stats.similarity_count, 1) else 0 end) as number_unread", 'project_id', 'tasks.id as id', 'task_definition_id', 'task_statuses.id as status_id', 'completion_date', 'times_assessed', 'submission_date', 'tasks.grade as grade', 'quality_pts', 'include_in_portfolio', 'grade', - 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' + 'COALESCE(MAX(task_similarity_stats.flagged_count), 0) AS similar_to_count' ) .group( 'task_statuses.id', 'tasks.project_id', 'tasks.id', 'task_definition_id', 'status_id', @@ -296,6 +308,7 @@ def task_details_for_shallow_serializer(user) ) .map do |r| t = Task.find(r.id) + discuss_timeout_expiry_at = t.discuss_timeout_expiry_at(teaching_breaks: teaching_breaks) { id: r.id, status: TaskStatus.id_to_key(r.status_id), @@ -309,6 +322,9 @@ def task_details_for_shallow_serializer(user) extensions: t.extensions, scorm_extensions: t.scorm_extensions, due_date: t.due_date, + moved_to_discuss_at: t.moved_to_discuss_at, + discuss_timeout_expiry_at: discuss_timeout_expiry_at, + discuss_timeout_expiry_date: discuss_timeout_expiry_at&.to_date, submission_date: t.submission_date, completion_date: t.completion_date, target_start_date: t.target_start_date, @@ -361,7 +377,7 @@ def top_tasks # overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today } - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = overdue_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -377,7 +393,7 @@ def top_tasks # soon_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today && to_target.call(ts) < Time.zone.today + 7.days } - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = soon_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -392,7 +408,7 @@ def top_tasks # ahead_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today + 7.days } - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = ahead_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -506,7 +522,7 @@ def self.create_task_stats_from(total_task_counts, project_task_counts, target_g red_pct = ((project_task_counts.fail_count + project_task_counts.feedback_exceeded_count + project_task_counts.time_exceeded_count) / total_task_counts[target_grade]).signif(2) orange_pct = ((project_task_counts.redo_count + project_task_counts.need_help_count + project_task_counts.fix_and_resubmit_count) / total_task_counts[target_grade]).signif(2) - green_pct = ((project_task_counts.discuss_count + project_task_counts.demonstrate_count + project_task_counts.complete_count) / total_task_counts[target_grade]).signif(2) + green_pct = ((project_task_counts.discuss_count + project_task_counts.rediscuss_count + project_task_counts.demonstrate_count + project_task_counts.complete_count) / total_task_counts[target_grade]).signif(2) blue_pct = (project_task_counts.ready_for_feedback_count / total_task_counts[target_grade]).signif(2) grey_pct = (1 - red_pct - orange_pct - green_pct - blue_pct).signif(2) @@ -545,19 +561,14 @@ def revert_overdue_tasks # task_stats field def update_task_stats # generate SQL for columns that count the number of tasks per grade - count_by_grade = (GradeHelper::RANGE).map { |grade_id| "SUM(CASE WHEN target_grade <= #{grade_id} THEN 1 ELSE 0 END) AS count_#{grade_id}" } + count_by_grade = unit.grade_values.map { |grade_id| "SUM(CASE WHEN target_grade <= #{grade_id} THEN 1 ELSE 0 END) AS count_#{grade_id}" } # Get the count of the total number of tasks less than each target grade task_count = unit .task_definitions .select(*count_by_grade) # create columns for each grade .map do |r| # map to array - [ - r['count_0'].to_f || 0.0, - r['count_1'].to_f || 0.0, - r['count_2'].to_f || 0.0, - r['count_3'].to_f || 0.0 - ] + unit.grade_values.index_with { |grade_id| r["count_#{grade_id}"].to_f || 0.0 } end .first # there is only one row returned... @@ -697,14 +708,14 @@ def archive_submissions(out) FileUtils.rm_f(portfolio_path) if portfolio_available end - def add_staff_note(user, text, reply_to_id = nil) - text = text.strip - return nil if user.nil? || text.nil? || text.empty? + def add_staff_note(user, text, reply_to_id = nil, attachment: false) + text = text.to_s.strip + return nil if user.nil? || (text.empty? && !attachment) ln = staff_notes.last # don't add if duplicate note - return if ln && ln.user == user && ln.note == text + return if !attachment && ln && ln.user == user && ln.note == text note = StaffNote.create note.note = text diff --git a/app/models/session_activity.rb b/app/models/session_activity.rb index 22c135923d..18e6a55a65 100644 --- a/app/models/session_activity.rb +++ b/app/models/session_activity.rb @@ -4,6 +4,6 @@ class SessionActivity < ApplicationRecord belongs_to :task, optional: true belongs_to :task_definition, optional: true - VALID_ACTIONS = %w[inbox GET PUT assessing add-comment get-comments delete-comment get-comment-attachment mark-comment-unread get-submission-details get-submission-files].freeze + VALID_ACTIONS = %w[inbox GET PUT assessing add-comment edit-comment get-comments delete-comment get-comment-attachment mark-comment-unread get-submission-details get-submission-files].freeze validates :action, presence: true, inclusion: { in: VALID_ACTIONS } end diff --git a/app/models/similarity/unit_similarity_module.rb b/app/models/similarity/unit_similarity_module.rb index c003c36b56..efadccb263 100644 --- a/app/models/similarity/unit_similarity_module.rb +++ b/app/models/similarity/unit_similarity_module.rb @@ -18,82 +18,72 @@ def check_moss_similarity(force: false) # Get each task... return unless active - # need pwd to restore after cding into submission folder (so the files do not have full path) - pwd = FileUtils.pwd + logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" - begin - logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" + task_definitions.each do |td| + next if td.similarity_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? - task_definitions.each do |td| - next if td.similarity_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? + type_data = td.similarity_language.split + next if type_data.nil? || (type_data.length != 2) || (type_data[0] != 'moss') - type_data = td.similarity_language.split - next if type_data.nil? || (type_data.length != 2) || (type_data[0] != 'moss') + # Is there anything to check? + logger.debug "Checking plagiarism for #{td.name} (id=#{td.id})" + tasks = tasks_for_definition(td) + tasks_with_files = tasks.select(&:has_pdf) - # Is there anything to check? - logger.debug "Checking plagiarism for #{td.name} (id=#{td.id})" - tasks = tasks_for_definition(td) - tasks_with_files = tasks.select(&:has_pdf) + # Skip if not due yet + next if td.due_date > Time.zone.now - # Skip if not due yet - next if td.due_date > Time.zone.now + # Skip if no files changed + next unless tasks_with_files.count > 1 && + ( + tasks.where('tasks.file_uploaded_at > ?', last_plagarism_scan).select(&:has_pdf).count > 0 || + td.updated_at > last_plagarism_scan || + force + ) - # Skip if no files changed - next unless tasks_with_files.count > 1 && - ( - tasks.where('tasks.file_uploaded_at > ?', last_plagarism_scan).select(&:has_pdf).count > 0 || - td.updated_at > last_plagarism_scan || - force - ) + # There are new tasks, check these - # There are new tasks, check these + logger.debug 'Contacting MOSS for new checks' - logger.debug 'Contacting MOSS for new checks' + # Create the MossRuby object + moss_key = Doubtfire::Application.credentials.secret_key_moss + raise "No moss key set. Check ENV['DF_SECRET_KEY_MOSS'] first." if moss_key.nil? - # Create the MossRuby object - moss_key = Doubtfire::Application.credentials.secret_key_moss - raise "No moss key set. Check ENV['DF_SECRET_KEY_MOSS'] first." if moss_key.nil? + moss = MossRuby.new(moss_key) - moss = MossRuby.new(moss_key) + # Set options -- the options will already have these default values + moss.options[:max_matches] = 7 + moss.options[:directory_submission] = true + moss.options[:show_num_matches] = 500 + moss.options[:experimental_server] = false + moss.options[:comment] = '' + moss.options[:language] = type_data[1] - # Set options -- the options will already have these default values - moss.options[:max_matches] = 7 - moss.options[:directory_submission] = true - moss.options[:show_num_matches] = 500 - moss.options[:experimental_server] = false - moss.options[:comment] = '' - moss.options[:language] = type_data[1] + tmp_path = File.join(Dir.tmpdir, 'doubtfire', "check-#{id}-#{td.id}") - tmp_path = File.join(Dir.tmpdir, 'doubtfire', "check-#{id}-#{td.id}") + begin + # Create a file hash, with the files to be processed + to_check = MossRuby.empty_file_hash + add_done_files_for_plagiarism_check_of(td, tmp_path, to_check, tasks_with_files) - begin - # Create a file hash, with the files to be processed - to_check = MossRuby.empty_file_hash - add_done_files_for_plagiarism_check_of(td, tmp_path, to_check, tasks_with_files) + # Get server to process files + logger.debug 'Sending to MOSS...' + url = moss.check(to_check, ->(_) { print '.' }) - FileUtils.chdir(tmp_path) + logger.info "MOSS check for #{code} #{td.abbreviation} url: #{url}" - # Get server to process files - logger.debug 'Sending to MOSS...' - url = moss.check(to_check, ->(_) { print '.' }) - - logger.info "MOSS check for #{code} #{td.abbreviation} url: #{url}" - - td.plagiarism_report_url = url - td.plagiarism_updated = true - td.save - rescue StandardError => e - logger.error "Failed to check plagiarism for task #{td.name} (id=#{td.id}). Error: #{e.message}" - ensure - FileUtils.chdir(pwd) - FileUtils.rm_rf tmp_path - end + td.plagiarism_report_url = url + td.plagiarism_updated = true + td.save + rescue StandardError => e + logger.error "Failed to check plagiarism for task #{td.name} (id=#{td.id}). Error: #{e.message}" + ensure + FileUtils.rm_rf tmp_path end - self.last_plagarism_scan = Time.zone.now - save! - ensure - FileUtils.chdir(pwd) if FileUtils.pwd != pwd end + self.last_plagarism_scan = Time.zone.now + save! self end @@ -103,43 +93,42 @@ def check_jplag_similarity(force: false) # Get each task... return unless active - # need pwd to restore after cding into submission folder (so the files do not have full path) - pwd = FileUtils.pwd + completed_all_checks = true # making temp directory for unit - jplag root_work_dir = Rails.root.join("tmp", "jplag", "#{code}-#{id}") - unit_code = "#{code}-#{id}" - - begin - logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" - - task_definitions.each do |td| - next if td.similarity_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? - - # Is there anything to check? - logger.debug "Checking plagiarism for #{td.name} (id=#{td.id})" - tasks = tasks_for_definition(td) - tasks_with_files = tasks.select(&:has_pdf) - - # Skip if no files changed - next unless tasks_with_files.count > 1 && - ( - # NOTE: `last_plagarism_scan` is currently tracked for each Unit, not each Task Definition - tasks.where('tasks.file_uploaded_at > ?', last_plagarism_scan).select(&:has_pdf).count > 0 || - td.updated_at > last_plagarism_scan || - force - ) - - # Ensure work directory for the unit is created - FileUtils.mkdir_p(root_work_dir) - - # Init work directory for each task definition - tasks_dir = root_work_dir.join(td.id.to_s) - FileUtils.mkdir_p(tasks_dir) - - # There are new tasks, check these with JPLAG - run_jplag_on_done_files(td, tasks_dir, tasks_with_files, unit_code) - report_path = "#{Doubtfire::Application.config.jplag_report_dir}/#{unit_code}/#{td.abbreviation}-result.jplag" + + logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" + + task_definitions.each do |td| + next unless TaskDefinition::JPLAG_LANGUAGES.include?(td.similarity_language) + next unless td.upload_requirements&.any? { |upreq| upreq['type'] == 'code' && upreq['tii_check'] } + + # Is there anything to check? + logger.debug "Checking plagiarism for #{td.name} (id=#{td.id})" + tasks = tasks_for_definition(td) + tasks_with_files = tasks.select(&:has_pdf) + + # Skip if no files changed + next unless tasks_with_files.count > 1 && + ( + # NOTE: `last_plagarism_scan` is currently tracked for each Unit, not each Task Definition + tasks.where('tasks.file_uploaded_at > ?', last_plagarism_scan).select(&:has_pdf).count > 0 || + td.updated_at > last_plagarism_scan || + force + ) + + # Ensure work directory for the unit is created + FileUtils.mkdir_p(root_work_dir) + + # Init work directory for each task definition + tasks_dir = root_work_dir.join(td.id.to_s) + FileUtils.mkdir_p(tasks_dir) + + # There are new tasks, check these with JPLAG + report_path = FileHelper.task_jplag_report_path(self, td) + begin + run_jplag_on_done_files(td, tasks_dir, tasks_with_files, report_path) warn_pct = td.plagiarism_warn_pct || 50 logger.debug "Warn PCT: #{warn_pct}" @@ -149,11 +138,14 @@ def check_jplag_similarity(force: false) .delete_all process_jplag_plagiarism_report(report_path, warn_pct, td.group_set) + rescue StandardError => e + completed_all_checks = false + logger.error "Failed to check JPlag similarity for task #{td.name} (id=#{td.id}). Error: #{e.message}" end + end + if completed_all_checks self.last_plagarism_scan = Time.zone.now save! - ensure - FileUtils.chdir(pwd) if FileUtils.pwd != pwd end self @@ -230,29 +222,39 @@ def update_moss_plagiarism_stats # end # JPLAG Function - extracts "done" files for each task and packages them into a directory for JPLAG to run on - def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, unit_code) + def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, report_path) similarity_pct = task_definition.plagiarism_warn_pct return if similarity_pct.nil? + file_lang = task_definition.similarity_language + raise ArgumentError, "Unsupported JPlag language: #{file_lang.inspect}" unless TaskDefinition::JPLAG_LANGUAGES.include?(file_lang) + # Check if the directory exists and create it if it doesn't - results_dir = "/jplag/results/#{unit_code}" - system("docker exec -i jplag sh -c 'if [ ! -d \"#{results_dir}\" ]; then mkdir -p \"#{results_dir}\"; fi'") || raise('Failed to create JPlag results directory') + results_dir = File.dirname(report_path) + system('docker', 'exec', '-i', 'jplag', 'mkdir', '-p', results_dir) || raise('Failed to create JPlag results directory') # Remove existing result file if it exists - result_file = "#{results_dir}/#{task_definition.abbreviation}-result.jplag" - system("docker exec -i jplag sh -c 'if [ -f \"#{result_file}\" ]; then rm \"#{result_file}\"; fi'") || raise('Failed to remove previous JPlag report') + system('docker', 'exec', '-i', 'jplag', 'rm', '-f', report_path) || raise('Failed to remove previous JPlag report') # Extract task resources for base code use_base_code = false if task_definition.has_task_resources? && task_definition.use_resources_for_jplag_base_code use_base_code = true - path = task_definition.task_resources - - Zip::File.open(path) do |zip_file| - zip_file.each do |entry| - dest = File.join(tasks_dir, 'base', entry.name) - FileUtils.mkdir_p(File.dirname(dest)) - entry.extract(dest) { true } + linked_resource = task_definition.linked_task_resource + + if linked_resource && !task_definition.task_resource_zip?(linked_resource) + base_dir = File.join(tasks_dir, 'base') + FileUtils.mkdir_p(base_dir) + FileUtils.cp(linked_resource[:path], File.join(base_dir, linked_resource[:filename])) + else + path = linked_resource ? linked_resource[:path] : task_definition.task_resources + + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + dest = File.join(tasks_dir, 'base', entry.name) + FileUtils.mkdir_p(File.dirname(dest)) + entry.extract(dest) { true } + end end end end @@ -291,24 +293,40 @@ def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, unit_c logger.info "Starting JPLAG container to run on #{tasks_dir}" root_dir = Rails.root.to_s tasks_dir_split = tasks_dir.to_s.split(root_dir)[1] - file_lang = task_definition.similarity_language.to_s # Convert pct to decimal similarity_threshold = similarity_pct.to_f / 100 min_tokens = Doubtfire::Application.config.jplag_min_tokens.to_i # If empty, let JPlag set the default per-language - min_token_string = min_tokens <= 0 ? "" : "--min-tokens=#{min_tokens}" - - base_code_string = use_base_code ? "--base-code=#{tasks_dir_split}/base" : "" - skip_cluster_check = Doubtfire::Application.config.jplag_skip_cluster_check - skip_cluster_string = skip_cluster_check ? '--cluster-skip' : '' + + max_shown_comparisons = Doubtfire::Application.config.jplag_max_shown_comparisons + max_shown_comparisons = 2500 if max_shown_comparisons.nil? # Run JPLAG on the extracted files. JPlag container should already be in the /jplag/ workdir. - docker_command = "docker exec -i jplag java -jar jplag-jar-with-dependencies.jar --skip-version-check #{tasks_dir_split}/submissions #{base_code_string} -l #{file_lang} --similarity-threshold=#{similarity_threshold} #{min_token_string} #{skip_cluster_string} -M RUN -r #{results_dir}/#{task_definition.abbreviation}-result --overwrite" - logger.debug "Executing command: #{docker_command}" - system(docker_command) + docker_command = [ + 'docker', 'exec', '-i', 'jplag', + 'java', '-jar', 'jplag-jar-with-dependencies.jar', + '--skip-version-check', + "#{tasks_dir_split}/submissions" + ] + docker_command << "--base-code=#{tasks_dir_split}/base" if use_base_code + docker_command += [ + '-l', file_lang, + "--similarity-threshold=#{similarity_threshold}", + "--shown-comparisons=#{max_shown_comparisons}" + ] + docker_command << "--min-tokens=#{min_tokens}" if min_tokens.positive? + docker_command << '--cluster-skip' if skip_cluster_check + docker_command += [ + '-M', 'RUN', + '-r', report_path.delete_suffix('.jplag'), + '--overwrite' + ] + + logger.debug "Executing command: #{docker_command.inspect}" + system(*docker_command) || raise('Failed to run JPlag') # Delete the extracted code files from tmp tmp_dir = Rails.root.join("tmp/jplag") @@ -471,7 +489,7 @@ def add_done_files_for_plagiarism_check_of(task_definition, tmp_path, to_check, # extract files matching each pattern # -- each pattern - MossRuby.add_file(to_check, "**/#{pattern}") + MossRuby.add_file(to_check, File.join(tmp_path, '**', pattern)) end self diff --git a/app/models/staff_note.rb b/app/models/staff_note.rb index b0a84e86a9..889605f777 100644 --- a/app/models/staff_note.rb +++ b/app/models/staff_note.rb @@ -1,7 +1,73 @@ class StaffNote < ApplicationRecord + include MimeCheckHelpers + + ATTACHMENT_EXTENSIONS = { + 'image/jpeg' => '.jpg', + 'image/gif' => '.gif', + 'application/pdf' => '.pdf' + }.freeze + belongs_to :project belongs_to :user + validates :content_type, inclusion: { in: ATTACHMENT_EXTENSIONS.keys, allow_nil: true } + + after_destroy :delete_attachment + + def attachment_extension + ATTACHMENT_EXTENSIONS[content_type] + end + + def attachment? + content_type.present? && File.file?(attachment_path) + end + + def attachment_type + return nil if content_type.nil? + + content_type == 'application/pdf' ? 'pdf' : 'image' + end + + def attachment_path + attachment_path_for(attachment_extension) unless content_type.nil? + end + + def attachment_file_name + "staff-note-#{id}#{attachment_extension}" + end + + def add_attachment(file_upload, type) + self.content_type = if type == 'pdf' + 'application/pdf' + elsif mime_type(file_upload['tempfile'].path).start_with?('image/gif') + 'image/gif' + else + 'image/jpeg' + end + save! + + path = attachment_path + FileUtils.mkdir_p(File.dirname(path)) + + if type == 'pdf' + FileHelper.compress_pdf(file_upload['tempfile'].path) + FileUtils.mv(file_upload['tempfile'].path, path) + else + saved = FileHelper.compress_image_to_dest(file_upload['tempfile'].path, path) + raise 'Failed to save staff note image attachment' unless saved && File.file?(path) + end + end + + def delete_attachment + FileUtils.rm_f(attachment_path) if content_type.present? + end + + private + + def attachment_path_for(extension) + File.join(FileHelper.project_work_root(project), 'staff_note', "#{id}#{extension}") + end + # belongs_to :reply_to, class_name: 'StaffNote', optional: true, inverse_of: :replies # has_many :replies, class_name: 'StaffNote', dependent: :restrict_with_exception, inverse_of: :reply_to end diff --git a/app/models/submission_history.rb b/app/models/submission_history.rb new file mode 100644 index 0000000000..d1f1faecc9 --- /dev/null +++ b/app/models/submission_history.rb @@ -0,0 +1,204 @@ +require 'zip' +require 'stringio' + +class SubmissionHistory < ApplicationRecord + belongs_to :task, optional: false + has_one :overseer_assessment, dependent: :destroy + + validates :submission_timestamp, presence: true, uniqueness: { scope: :task_id } + + after_destroy :delete_associated_files + + def self.enabled_requirements(task) + task.upload_requirements.each_index.select do |index| + task.upload_requirements[index]['submission_history'] == true + end + end + + def self.create_archive!(task, submission_timestamp) + if exists?(task: task, submission_timestamp: submission_timestamp.to_s) + raise ActiveRecord::RecordNotUnique, 'Submission history already exists for this task and timestamp' + end + + enabled_indexes = enabled_requirements(task) + raise 'No upload requirements are enabled for submission history' if enabled_indexes.empty? + + source_path = FileHelper.zip_file_path_for_done_task(task) + raise "Submission file not found: #{source_path}" unless File.exist?(source_path) + + history = new(task: task, submission_timestamp: submission_timestamp.to_s) + FileUtils.mkdir_p(history.output_path) + + temporary_history = "#{history.archive_file_name}.tmp-#{SecureRandom.hex(8)}" + copied_files = 0 + archive_updated = false + begin + Zip::File.open(temporary_history, create: true) do |destination| + copy_archive_entries(history.archive_file_name, destination) if File.exist?(history.archive_file_name) + + Zip::File.open(source_path) do |source| + source.each do |entry| + next if entry.name_is_directory? + + file_name = entry.name.split('/').last + next unless file_name&.match?(/^\d{3}-(?:document|code|image|zip|archive)/) + upload_index = file_name.to_i + next unless enabled_indexes.include?(upload_index) + + preview_path = document_preview_path(task, upload_index) + if file_name.match?(/^\d{3}-document.*\.docx\z/i) && File.exist?(preview_path) + preview_entry_name = File.join(history.entry_prefix, entry.name.sub(/\.docx\z/i, '.pdf')) + destination.get_output_stream(preview_entry_name) do |output| + File.open(preview_path, 'rb') { |input| IO.copy_stream(input, output) } + end + else + destination.get_output_stream(File.join(history.entry_prefix, entry.name)) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + copied_files += 1 + end + end + end + + raise 'No selected submission files were found in the completed submission' if copied_files.zero? + + FileUtils.mv(temporary_history, history.archive_file_name) + archive_updated = true + system('chmod', 'o+w', history.output_path) + history.save! + history + ensure + FileUtils.rm_f(temporary_history) + history.delete_associated_files if archive_updated && !history.persisted? + if !history.persisted? && Dir.exist?(history.output_path) && Dir.empty?(history.output_path) + FileUtils.rm_rf(history.output_path) + end + end + end + + def output_path + FileHelper.task_submission_identifier_path(:done, task) + end + + def archive_file_name + File.join(output_path, 'history.zip') + end + + def entry_prefix + "#{FileHelper.sanitized_path(submission_timestamp.to_s)}/" + end + + def submission_entry_prefix + File.join(entry_prefix, task.id.to_s, '/') + end + + # Kept for Overseer compatibility; submissions are entries within this archive. + def submission_zip_file_name + archive_file_name + end + + def submission_zip_data + buffer = Zip::OutputStream.write_buffer do |output| + Zip::File.open(archive_file_name) do |archive| + submission_entries(archive).each do |entry| + output.put_next_entry(entry.name.delete_prefix(entry_prefix)) + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + + buffer.string + end + + def has_submission_files? # rubocop:disable Naming/PredicateName + return false unless File.exist?(archive_file_name) + + Zip::File.open(archive_file_name) { |archive| submission_entries(archive).any? } + rescue Zip::Error + false + end + + def delete_associated_files + return unless File.exist?(archive_file_name) + + temporary_history = "#{archive_file_name}.tmp-#{SecureRandom.hex(8)}" + + Zip::File.open(temporary_history, create: true) do |destination| + self.class.copy_archive_entries(archive_file_name, destination, excluding_prefix: entry_prefix) + end + + if Zip::File.open(temporary_history) { |archive| archive.entries.empty? } + FileUtils.rm_f(archive_file_name) + FileUtils.rm_f(temporary_history) + FileUtils.rm_rf(output_path) if Dir.empty?(output_path) + else + FileUtils.mv(temporary_history, archive_file_name) + end + ensure + FileUtils.rm_f(temporary_history) if temporary_history + end + + def submission_entries(archive) + archive.entries.reject(&:name_is_directory?).select do |entry| + entry.name.start_with?(submission_entry_prefix) + end + end + + def self.copy_archive_entries(source_path, destination, excluding_prefix: nil) + Zip::File.open(source_path) do |source| + source.each do |entry| + next if excluding_prefix && entry.name.start_with?(excluding_prefix) + + if entry.name_is_directory? + destination.mkdir(entry.name) unless destination.find_entry(entry.name) + else + destination.get_output_stream(entry.name) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + end + end + + def self.pending_marker_path(task) + File.join(FileHelper.task_submission_identifier_path(:pending, task), 'submission-history') + end + + def self.document_preview_dir(task) + File.join(FileHelper.task_submission_identifier_path(:pending, task), 'document-previews') + end + + def self.document_preview_path(task, upload_index) + File.join(document_preview_dir(task), "#{upload_index.to_s.rjust(3, '0')}-document.pdf") + end + + def self.stage_document_preview!(task, upload_index, source_path) + raise "Converted document preview was not found: #{source_path}" unless File.exist?(source_path) + + FileUtils.mkdir_p(document_preview_dir(task)) + FileUtils.cp(source_path, document_preview_path(task, upload_index)) + end + + def self.clear_document_previews(task) + FileUtils.rm_rf(document_preview_dir(task)) + end + + def self.mark_pending(task) + marker_path = pending_marker_path(task) + FileUtils.mkdir_p(File.dirname(marker_path)) + FileUtils.touch(marker_path) + end + + def self.clear_pending(task) + FileUtils.rm_f(pending_marker_path(task)) + clear_document_previews(task) + + pending_dir = FileHelper.task_submission_identifier_path(:pending, task) + FileUtils.rm_rf(pending_dir) if Dir.exist?(pending_dir) && Dir.empty?(pending_dir) + end + + def self.pending?(task) + File.exist?(pending_marker_path(task)) + end +end diff --git a/app/models/task.rb b/app/models/task.rb index ee3e76564c..3470f5a594 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,11 +1,24 @@ # frozen_string_literal: true require 'date' +require 'securerandom' class Task < ApplicationRecord include ApplicationHelper include GradeHelper + PREREQUISITE_STATUS_LEVELS = { + ready_for_feedback: 1, + assess_in_portfolio: 1, + discuss: 2, + rediscuss: 2, + attention_required: 0, + demonstrate: 2, + complete: 3 + }.freeze + + attr_accessor :discussion_confirmed_for_transition + # # Permissions around task data # @@ -129,11 +142,13 @@ def specific_permission_hash(role, perm_hash, _other) has_one :overflow_task_claim, dependent: :destroy has_many :comments, class_name: 'TaskComment', dependent: :destroy, inverse_of: :task + has_many :comment_read_cursors, dependent: :destroy, inverse_of: :task has_many :task_similarities, class_name: 'TaskSimilarity', dependent: :destroy, inverse_of: :task has_many :reverse_jplag_similarities, class_name: 'JplagTaskSimilarity', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' has_many :reverse_moss_similarities, class_name: 'MossTaskSimilarity', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' has_many :task_engagements, dependent: :destroy has_many :task_submissions, dependent: :destroy + has_many :submission_histories, dependent: :destroy has_many :overseer_assessments, dependent: :destroy has_many :tii_submissions, dependent: :destroy has_many :test_attempts, dependent: :destroy @@ -146,6 +161,7 @@ def specific_permission_hash(role, perm_hash, _other) delegate :target_date, to: :task_definition delegate :update_task_stats, to: :project + before_save :set_discuss_timeout_tracking, if: :will_save_change_to_task_status_id? after_update :update_task_stats, if: :saved_change_to_task_status_id? # TODO: consider moving to async task validates :task_definition_id, uniqueness: { scope: :project, @@ -169,7 +185,8 @@ def prevent_complete_if_assess_in_portfolio_only end def prevent_complete_if_requires_discussion - if task_definition&.requires_discussion && task_status == TaskStatus.complete && !has_discussed_in_class_comment? + if task_definition&.requires_discussion && task_status == TaskStatus.complete && + !has_discussed_in_class_comment? && !discussion_confirmed_for_transition errors.add(:task_status, "cannot be 'complete' until task has been discussed in class") end end @@ -221,8 +238,18 @@ def all_comments end def mark_comments_as_read(user, comments) + latest_comment_by_task = {} + comments.each do |comment| - comment.mark_as_read(user, unit) + next unless comment.requires_attention_for?(user) + next if comment.user_id == user.id + + current = latest_comment_by_task[comment.task_id] + latest_comment_by_task[comment.task_id] = comment if current.nil? || current.id < comment.id + end + + latest_comment_by_task.each_value do |comment| + comment.mark_as_read(user) end end @@ -236,16 +263,25 @@ def comments_for_user(user) TaskComment .joins('JOIN users AS authors ON authors.id = task_comments.user_id') .joins('JOIN users AS recipients ON recipients.id = task_comments.recipient_id') - .joins("LEFT JOIN comments_read_receipts u_crr ON u_crr.task_comment_id = task_comments.id AND u_crr.user_id = #{user.id}") - .joins("LEFT JOIN comments_read_receipts r_crr ON r_crr.task_comment_id = task_comments.id AND r_crr.user_id = recipients.id") + .joins( + "LEFT JOIN comment_read_cursors user_cursor " \ + "ON user_cursor.task_id = task_comments.task_id AND user_cursor.user_id = #{user.id.to_i}" + ) + .joins( + 'LEFT JOIN comment_read_cursors recipient_cursor ' \ + 'ON recipient_cursor.task_id = task_comments.task_id ' \ + 'AND recipient_cursor.user_id = recipients.id' + ) .where('task_comments.task_id = :task_id', task_id: self.id) .order('created_at ASC') .select( 'task_comments.id AS id', 'task_comments.comment AS comment', 'task_comments.content_type AS content_type', - "case when u_crr.created_at IS NULL then 1 else 0 end AS is_new", - 'r_crr.created_at AS recipient_read_time', + 'CASE WHEN user_cursor.last_read_comment_id IS NULL ' \ + 'OR task_comments.id > user_cursor.last_read_comment_id THEN 1 ELSE 0 END AS is_new', + 'CASE WHEN task_comments.id <= recipient_cursor.last_read_comment_id ' \ + 'THEN recipient_cursor.read_at ELSE NULL END AS recipient_read_time', 'task_comments.created_at AS created_at', 'authors.id AS author_id', 'authors.first_name AS author_first_name', @@ -259,6 +295,12 @@ def comments_for_user(user) ) end + def set_discuss_timeout_tracking + self.moved_to_discuss_at = task_status_id == TaskStatus.discuss.id ? Time.zone.now : nil + self.notified_discuss_warning_at = nil + self.notified_discuss_expiry_at = nil + end + def current_task_similarities task_similarities.where(dismissed: false) end @@ -408,16 +450,98 @@ def due_date return extension_date end + def local_due_date + if unit.allow_flexible_dates + return target_due_date if target_due_date.present? + + grade_target_date = task_definition.grade_target_date(project.target_grade) + return grade_target_date if grade_target_date.present? + end + + due_date + end + + def local_start_date + if unit.allow_flexible_dates + return target_start_date if target_start_date.present? + + grade_start_date = task_definition.grade_start_date(project.target_grade) + return grade_start_date if grade_start_date.present? + end + + return task_definition.start_date + extensions.weeks if extensions.negative? + + task_definition.start_date + end + + def days_awaiting_feedback(now_time = Time.zone.now) + return 0 if submission_date.blank? + + submission_time = submission_date.to_f + current_time = now_time.to_f + return 0 if current_time <= submission_time + + teaching_breaks = effective_teaching_breaks + paused_seconds = break_overlap_seconds(submission_time, current_time, teaching_breaks) + + ([0, current_time - submission_time - paused_seconds].max / 1.day).floor + end + + def discuss_timeout_elapsed_days(now_time = Time.zone.now, teaching_breaks: nil) + return 0 if moved_to_discuss_at.blank? + + discussion_time = moved_to_discuss_at.to_f + current_time = now_time.to_f + return 0 if current_time <= discussion_time + + teaching_breaks ||= effective_teaching_breaks + paused_seconds = break_overlap_seconds(discussion_time, current_time, teaching_breaks) + + ([0, current_time - discussion_time - paused_seconds].max / 1.day).floor + end + + def discuss_timeout_expiry_at(timeout_days = unit.discuss_timeout_expire_days, teaching_breaks: nil) + return nil if moved_to_discuss_at.blank? + + deadline = moved_to_discuss_at + timeout_days.days + teaching_breaks ||= effective_teaching_breaks + + teaching_breaks.sort_by(&:start_date).each do |teaching_break| + break_start = teaching_break.start_date + break_end = break_start + teaching_break.number_of_days.to_i.days + next if break_end <= moved_to_discuss_at || break_start >= deadline + + deadline += break_end - [break_start, moved_to_discuss_at].max + end + + deadline.end_of_day + end + + def discuss_timeout_expiry_date(timeout_days = unit.discuss_timeout_expire_days, teaching_breaks: nil) + discuss_timeout_expiry_at(timeout_days, teaching_breaks: teaching_breaks)&.to_date + end + + # Excludes any breaks that would otherwise "pause" feedback + def calendar_days_awaiting_feedback(now_time = Time.zone.now) + return 0 if submission_date.blank? + + [0, (now_time.to_date - submission_date.to_date).to_i].max + end + + def effective_teaching_breaks + unit&.teaching_period&.breaks_for(project&.campus) || [] + end + def complete? status == :complete end def discuss_or_demonstrate? - [:discuss, :demonstrate].include?(status) + [:discuss, :rediscuss, :demonstrate].include?(status) end def discuss? - status == :discuss + [:discuss, :rediscuss].include?(status) end def demonstrate? @@ -437,7 +561,30 @@ def ready_for_feedback? end def ready_or_complete? - [:complete, :discuss, :demonstrate, :ready_for_feedback, :assess_in_portfolio].include? status + [:complete, :discuss, :rediscuss, :demonstrate, :ready_for_feedback, :assess_in_portfolio].include? status + end + + def unmet_prerequisite_error + task_definition.task_prerequisites.each do |prerequisite| + prereq_td = prerequisite.prerequisite + minimum_status = TaskStatus.find(prerequisite.task_status_id) + prereq_task = project.task_for_task_definition(prereq_td) + + next if prereq_task.ready_or_complete? && + PREREQUISITE_STATUS_LEVELS[prereq_task.status] >= PREREQUISITE_STATUS_LEVELS[minimum_status.status_key] + + verb = case minimum_status + when TaskStatus.complete then 'completed' + when TaskStatus.discuss then 'discussed' + when TaskStatus.rediscuss then 'rediscussed' + when TaskStatus.demonstrate then 'demonstrated' + when TaskStatus.ready_for_feedback then 'submitted' + end + + return "Cannot submit this task until prerequisite '#{prereq_td.abbreviation}' has been #{verb}" + end + + nil end def submitted_status? @@ -484,6 +631,22 @@ def group_task? !group_submission.nil? || !task_definition.group_set.nil? end + def related_submission_histories + return submission_histories unless group_submission + + SubmissionHistory.where(task_id: group_submission.tasks.select(:id)) + end + + def student_participant_ids + return [project.user_id] if group_submission.nil? + + group_submission.projects.distinct.pluck(:user_id) + end + + def student_participant?(user) + user.present? && student_participant_ids.include?(user.id) + end + def active_overflow_task_claim claim = overflow_task_claim return nil unless claim @@ -507,6 +670,28 @@ def active_overflow_task_claim claim end + def transition_assignment_allowed?(by_user, role, system_transition) + return true if system_transition + + # Stream locking restricts who may assess the task, not who may submit it + if task_definition.lock_assessments_to_tutorial_stream && + !role.in?([:student, :group_member]) && + task_definition.tutorial_stream.present? + unit_role = unit.unit_role_for(by_user) + return false unless task_definition.tutorial_stream.tutorials.any? { |tutorial| tutorial.unit_role == unit_role } + end + + claim = active_overflow_task_claim + return true if claim.blank? + + unit_role = unit.unit_role_for(by_user) + unit_role.nil? || unit_role.id == claim.claimed_by_unit_role_id + end + + def transition_feedback_check_required?(check_feedback, system_transition) + check_feedback && !system_transition + end + def group return nil unless group_task? @@ -522,14 +707,18 @@ def ensured_group_submission group.create_submission self, '', group.projects.map { |proj| { project: proj, pct: 100 / group.projects.count } } end - def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: false, quality: 1) + def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: false, quality: 1, recursive_fix: false, + check_feedback: false, system_transition: false) # # Ensure that assessor is allowed to update the task in the indicated way # role = role_for(by_user) + role = :tutor if system_transition && by_user.present? return nil if role.nil? + return nil if [:student, :group_member].include?(role) && task_definition.staff_managed_task + # # Ensure that only staff can change from staff assigned status if # this is a restricted task @@ -541,20 +730,7 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # Protect closed states from student changes return nil if [:student, :group_member].include?(role) && task_submission_closed? - if task_definition.lock_assessments_to_tutorial_stream - unit_role = unit.unit_role_for(by_user) - tutorial_stream = task_definition.tutorial_stream - tutorials = tutorial_stream.tutorials - return nil unless tutorials.any? { |t| t.unit_role == unit_role } - end - - # Check to see if another tutor has claimed this task from overflow - if active_overflow_task_claim - unit_role = unit.unit_role_for(by_user) - if unit_role && unit_role.id != active_overflow_task_claim.claimed_by_unit_role_id - return nil - end - end + return nil unless transition_assignment_allowed?(by_user, role, system_transition) # # State transitions based upon the trigger # @@ -572,10 +748,28 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: else # Only tutors can perform these actions if role == :tutor - if status == TaskStatus.complete && task_definition.requires_discussion && !has_discussed_in_class_comment? + if status == TaskStatus.complete && task_definition.requires_discussion && + !has_discussed_in_class_comment? && !discussion_confirmed_for_transition return nil end + if status == TaskStatus.rediscuss && task_status != TaskStatus.discuss + return nil + end + + if transition_feedback_check_required?(check_feedback, system_transition) + if status == TaskStatus.complete && !has_manual_feedback_since_first_ready_for_feedback? + errors.add(:task_status, "cannot be moved to '#{status.name}' until feedback has been given") + return nil + end + + if [TaskStatus.fix_and_resubmit, TaskStatus.redo].include?(status) && + !has_recent_manual_feedback_from_tutor?(by_user) + errors.add(:task_status, "cannot be moved to '#{status.name}' until feedback has been given") + return nil + end + end + if task_definition.assess_in_portfolio_only # Block assess_in_portfolio_only tasks from being signed off as complete if status == TaskStatus.complete @@ -585,7 +779,7 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # Can only be graded if task_def is not assess_in_portfolio_only if task_definition.max_quality_pts > 0 case status - when TaskStatus.complete, TaskStatus.discuss, TaskStatus.demonstrate, TaskStatus.attention_required + when TaskStatus.complete, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate, TaskStatus.attention_required update(quality_pts: quality) end end @@ -594,7 +788,7 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: lc = comments.last # Prevent duplicate status comments during feedback unless lc && lc.user == by_user && lc.comment == status.name && (lc.content_type != 'status' || lc.task_status == status) - assess status, by_user + assess status, by_user, Time.zone.now, recursive_fix # Add a status comment for new assessments - only recorded on submitter's task in groups add_status_comment(by_user, status) @@ -616,10 +810,55 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: true end + def reuse_existing_submission_for_feedback(by_user) + related_tasks = group_submission ? group_submission.tasks.to_a : [self] + eligible = related_tasks.all? do |task| + role = task.role_for(by_user) + role.in?([:tutor, :admin]) && + task.task_status != TaskStatus.ready_for_feedback && + task.submission_date.present? && task.has_pdf && + task.transition_assignment_allowed?(by_user, role, false) + end + return nil unless eligible + + Task.transaction do + related_tasks.each do |task| + task.engage TaskStatus.ready_for_feedback + task.add_status_comment(by_user, TaskStatus.ready_for_feedback) + end + end + + true + end + def has_discussed_in_class_comment? comments.where(content_type: 'discussed_in_class').exists? end + def has_manual_feedback_since_first_ready_for_feedback? + first_ready_for_feedback_at = comments + .where(content_type: 'status', task_status_id: TaskStatus.ready_for_feedback.id) + .order(:created_at) + .pick(:created_at) + + feedback_comments = comments + .where(content_type: %w[text audio image pdf discussion]) + .where(user_id: unit.staff.select(:user_id)) + + feedback_comments = feedback_comments.where('created_at >= ?', first_ready_for_feedback_at) if first_ready_for_feedback_at + + feedback_comments.where.not("COALESCE(comment, '') LIKE ?", '**Automated Message:%').exists? + end + + def has_recent_manual_feedback_from_tutor?(tutor) + comments + .where(content_type: %w[text audio image pdf discussion]) + .where(user: tutor) + .where('created_at >= ?', 10.minutes.ago) + .where.not("COALESCE(comment, '') LIKE ?", '**Automated Message:%') + .exists? + end + def grade_desc grade_for(grade) end @@ -633,13 +872,9 @@ def grade_task(new_grade, ui = nil, grading_group = false) raise message end - grade_map = { - 'f' => -1, - 'p' => 0, - 'c' => 1, - 'd' => 2, - 'hd' => 3 - } + grade_map = unit.grade_definitions.to_h do |definition| + [definition['abbreviation'].downcase, definition['value']] + end if task_definition.is_graded if new_grade.nil? raise_error.call("No grade was supplied for a graded task (task id #{id})") @@ -651,13 +886,16 @@ def grade_task(new_grade, ui = nil, grading_group = false) if new_grade.is_a?(String) if grade_map.keys.include?(new_grade.downcase) # convert string representation to integer representation - new_grade = grade_map[new_grade] + new_grade = grade_map[new_grade.downcase] else - raise_error.call("New grade supplied to task is not a valid string - expects one of {f|p|c|d|hd} (task id #{id})") + raise_error.call("New grade supplied to task is not a valid abbreviation (task id #{id})") end end - unless new_grade.is_a?(Integer) && grade_map.values.include?(new_grade.to_i) - raise_error.call("New grade supplied to task is not a valid integer - expects one of {-1|0|1|2|3} (task id #{id})") + unless new_grade.is_a?(Integer) + raise_error.call("New grade supplied to task is not a valid integer (task id #{id})") + end + unless unit.assessment_grade_value?(new_grade) + raise_error.call("Grade is not enabled for this unit (task id #{id})") end # propagate new grade to all OTHER group members if group_task? && !grading_group @@ -674,7 +912,7 @@ def grade_task(new_grade, ui = nil, grading_group = false) end end - def assess(task_status, assessor, assess_date = Time.zone.now) + def assess(task_status, assessor, assess_date = Time.zone.now, recursive_fix = false) # Set the task's status to the assessment outcome status # and flag it as no longer awaiting signoff self.task_status = task_status @@ -697,7 +935,7 @@ def assess(task_status, assessor, assess_date = Time.zone.now) # Grant an extension on fix if due date is within 1 week case task_status - when TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.demonstrate + when TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate if to_same_day_anywhere_on_earth(due_date) < Time.zone.now + 7.days && can_apply_for_extension? && unit.extension_weeks_on_resubmit_request > 0 grant_extension(assessor, unit.extension_weeks_on_resubmit_request) end @@ -725,7 +963,7 @@ def assess(task_status, assessor, assess_date = Time.zone.now) end end - if task_status == TaskStatus.fix_and_resubmit + if task_status == TaskStatus.fix_and_resubmit && recursive_fix # Look for other submitted tasks from this student that has this task as a prerequisite # If they are ready for feedback, automatically assess them to fix and resubmit dependents = TaskPrerequisite.where(prerequisite_id: task_definition.id) @@ -738,9 +976,13 @@ def assess(task_status, assessor, assess_date = Time.zone.now) next unless task.task_status == TaskStatus.ready_for_feedback # Since we are calling this assess method again, we recursively check for more dependent tasks that need to be updated - task.assess(TaskStatus.fix_and_resubmit, assessor, assess_date) + task.assess(TaskStatus.fix_and_resubmit, assessor, assess_date, recursive_fix) task.add_status_comment(assessor, TaskStatus.fix_and_resubmit) - task.add_text_comment(assessor, "**Automated comment**: A prerequisite task was updated to Fix and Resubmit, so this task was updated as well. You may need to review and update the prerequisite before resubmitting.") + task.add_text_comment( + assessor, + "**Automated comment**: A prerequisite task was updated to Fix and Resubmit, so this task was updated as well. You may need to review and update the prerequisite before resubmitting.", + attention_audience: :student + ) end end @@ -840,7 +1082,7 @@ def weight task_definition.weighting.to_f end - def add_text_comment(user, text, reply_to_id = nil) + def add_text_comment(user, text, reply_to_id = nil, attention_audience: nil) text = text.strip return nil if user.nil? || text.nil? || text.empty? @@ -858,6 +1100,7 @@ def add_text_comment(user, text, reply_to_id = nil) comment.content_type = :text comment.recipient = user == project.student ? project.tutor_for(task_definition) : project.student comment.reply_to_id = reply_to_id + comment.attention_audience = attention_audience if attention_audience.present? comment.save! comment @@ -901,6 +1144,20 @@ def add_discussed_comment(current_user) discussed end + def add_discuss_timeout_comment(current_user, content_type, text) + return nil unless individual_task_or_submitter_of_group_task? + + comment = DiscussTimeoutComment.create + comment.task = self + comment.user = current_user + comment.comment = text + comment.content_type = content_type + comment.recipient = project.student + comment.save! + + comment + end + def add_checked_in_comment(current_user) discussed = TaskCheckedInComment.create discussed.task = self @@ -927,7 +1184,7 @@ def add_discussion_comment(user, prompts) raise "Error attaching uploaded file." unless discussion.add_prompt(prompt, index) end - discussion.mark_as_read(user, unit) + discussion.mark_as_read(user) logger.info(discussion) return discussion @@ -1062,6 +1319,8 @@ def has_done_file? # # Compress the done files for a student - includes cover page and work uploaded # + class ImageProcessingError < StandardError; end + def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: nil, rm_task_dir: true, rename_files: false) begin # Ensure that this task is the submitter task for a group_task... otherwise @@ -1076,13 +1335,16 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: image_files.each do |img| # Ensure all images in submissions are jpg dest_file = "#{task_dir}#{File.basename(img, ".*")}.jpg" - raise 'Failed to compress an image. Ensure all images are valid.' unless FileHelper.compress_image_to_dest("#{task_dir}#{img}", dest_file, true) + unless FileHelper.compress_image_to_dest("#{task_dir}#{img}", dest_file, true) + upload_name = upload_requirements[img.to_i]&.dig('name') || 'image' + raise ImageProcessingError, "The image for '#{upload_name}' could not be processed. Export it as a smaller JPEG or PNG and resubmit the task." + end # Cleanup unless the output was the same as the input FileUtils.rm("#{task_dir}#{img}") unless dest_file == "#{task_dir}#{img}" end - input_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(cover|document|code|image)/) == 0 } + input_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(cover|document|code|image|zip|archive)/) == 0 } if input_files.length != task_definition.number_of_uploaded_files logger.error "Error processing task #{log_details} - missing files expected #{task_definition.number_of_uploaded_files} got #{input_files.length}" @@ -1126,7 +1388,6 @@ def copy_done_to(path) def clear_in_process in_process_dir = student_work_dir(:in_process, false) if Dir.exist? in_process_dir - Dir.chdir(FileHelper.student_work_root) if FileUtils.pwd == in_process_dir FileUtils.rm_rf in_process_dir end @@ -1163,11 +1424,8 @@ def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) return false if in_process_dir.nil? if Dir.exist? in_process_dir - pwd = FileUtils.pwd - Dir.chdir(in_process_dir) # move all files to the enq dir - FileUtils.rm Dir.glob('*') - Dir.chdir(pwd) + FileUtils.rm Dir.glob('*', base: in_process_dir).map { |name| File.join(in_process_dir, name) } end # Zip new submission and store in done files (will remove from_dir) - ensure trailing / @@ -1205,23 +1463,15 @@ def move_files_on_abbreviation_change(old_abbreviation) end def __output_filename__(in_dir, idx, type) - pwd = FileUtils.pwd - Dir.chdir(in_dir) - begin - # Rename files with 000.type.* to 000-type-* - result = Dir.glob("#{idx.to_s.rjust(3, '0')}.#{type}.*").first + prefix = idx.to_s.rjust(3, '0') + # Rename files with 000.type.* to 000-type-* + result = Dir.glob("#{prefix}.#{type}.*", base: in_dir).first - if !result.nil? && File.exist?(result) - FileUtils.mv result, "#{idx.to_s.rjust(3, '0')}-#{type}#{File.extname(result)}" - end - result = Dir.glob("#{idx.to_s.rjust(3, '0')}-#{type}.*").first - ensure - Dir.chdir(pwd) + if !result.nil? && File.exist?(File.join(in_dir, result)) + FileUtils.mv File.join(in_dir, result), File.join(in_dir, "#{prefix}-#{type}#{File.extname(result)}") end - - return File.join(in_dir, result) unless result.nil? - - nil + result = Dir.glob("#{prefix}-#{type}.*", base: in_dir).first + File.join(in_dir, result) unless result.nil? end def in_process_files_for_task(is_retry) @@ -1266,20 +1516,29 @@ class TaskAppController < ApplicationController include LatexHelper attr_accessor :task + attr_accessor :submission_date attr_accessor :files attr_accessor :base_path attr_accessor :image_path attr_accessor :include_pax + attr_accessor :submitted_files_url def init(task, is_retry) @task = task + @submission_date = task.submission_date || task.file_uploaded_at || task.task_submissions.maximum(:submission_time) @files = task.in_process_files_for_task(is_retry) @base_path = task.student_work_dir(:in_process, false) @image_path = Rails.root.join('public/assets/images') @institution_name = Doubtfire::Application.config.institution[:name] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @include_pax = !is_retry - @work_id = "task-#{task.id}-#{Time.now.to_i}-#{Process.pid}-#{Thread.current.object_id}#{'-retry' if is_retry}" + @work_id = FileHelper.sanitized_path( + "task-#{Time.current.strftime('%Y%m%d-%H%M')}-#{task.unit.code}-#{task.project.student.username}-#{task.task_definition.abbreviation}-#{task.id}-#{Process.pid}#{'-retry' if is_retry}" + ) + host = Doubtfire::Application.config.institution[:host].to_s + host = "http://#{host}" unless host.match?(%r{\Ahttps?://}) + host = host.sub(%r{/*\z}, '') + @submitted_files_url = "#{host}/projects/#{task.project.id}/task_def_id/#{task.task_definition.id}/submission_files/download" end def make_pdf @@ -1387,12 +1646,14 @@ def initialize(log_message) def convert_submission_to_pdf(source_folder: FileHelper.student_work_dir(:new), log_to_stdout: true) logger.info "Converting task #{self.id} to pdf" - unless move_files_to_in_process(source_folder) - logger.error("Failed to move files for #{log_details} to in process") - return false - end - begin + unless move_files_to_in_process(source_folder) + logger.error("Failed to move files for #{log_details} to in process") + return false + end + + converted_word_documents = convert_word_documents_to_pdf + tac = TaskAppController.new tac.init(self, false) @@ -1430,7 +1691,7 @@ def convert_submission_to_pdf(source_folder: FileHelper.student_work_dir(:new), end end - raise LatexError.new(log_message), 'Failed to convert your submission to PDF. Check code files submitted for invalid characters, that documents are valid pdfs, and that images are valid.' + raise LatexError.new(log_message), 'Failed to convert your submission to PDF. Check code files submitted for invalid characters, that documents are valid PDFs or DOCX files, images are valid, and zip files are valid.' end end @@ -1451,11 +1712,22 @@ def convert_submission_to_pdf(source_folder: FileHelper.student_work_dir(:new), end end + stage_word_document_previews(converted_word_documents) save return true rescue => e + SubmissionHistory.clear_document_previews(self) trigger_transition trigger: 'fix', by_user: project.tutor_for(task_definition) - add_text_comment project.tutor_for(task_definition), "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{e.message}" + comment = if e.is_a?(ImageProcessingError) + "**Automated Comment**: #{e.message}" + else + "**Automated Comment**: Something went wrong with your submission. Check that code files do not contain invalid characters, documents are valid PDF or DOCX files, images are valid, and zip files are valid, then resubmit this task." + end + add_text_comment( + project.tutor_for(task_definition), + comment, + attention_audience: :student + ) raise e ensure # Ensure latex aux file is removed - if broken will cause issues for next submission in sidekiq @@ -1465,6 +1737,40 @@ def convert_submission_to_pdf(source_folder: FileHelper.student_work_dir(:new), end end + def convert_word_documents_to_pdf + in_process_dir = student_work_dir(:in_process, false) + return [] unless Dir.exist?(in_process_dir) + + converted_documents = [] + + Dir.glob(File.join(in_process_dir, '*-document.*')).each do |source_path| + next unless FileHelper.word_document?(source_path) + + upload_index = File.basename(source_path).to_i + destination_path = source_path.sub(/\.docx\z/i, '.pdf') + FileHelper.convert_word_document_to_pdf( + source_path, + destination_path, + work_id: FileHelper.gotenberg_work_id( + unit_code: unit.code, kind: 'task', username: project.student.username, label: task_definition.abbreviation + ) + ) + FileUtils.rm_f(source_path) + converted_documents << { upload_index: upload_index, path: destination_path } + end + + converted_documents + end + + def stage_word_document_previews(converted_documents) + converted_documents.each do |document| + requirement = upload_requirements[document[:upload_index]] + next unless requirement&.dig('submission_history') == true + + SubmissionHistory.stage_document_preview!(self, document[:upload_index], document[:path]) + end + end + # # The student has uploaded new work... # @@ -1494,7 +1800,7 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut reverse_jplag_similarities(&:destroy) reverse_moss_similarities(&:destroy) - save + save! end end @@ -1506,11 +1812,25 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut # Checks to make sure that the files match what we expect # def accept_submission(current_user, files, ui, contributions, trigger, alignments, accepted_tii_eula: false, test_submission: false) + if task_definition.staff_managed_task && [:student, :group_member].include?(role_for(current_user)) + ui.error!({ 'error' => 'Only teaching staff can upload a submission for this task.' }, 403) + end + + submission_lock_target.with_lock do # Ensure there is not a submission already in process if processing_pdf? ui.error!({ 'error' => 'A submission is already being processed. Please wait for the current submission process to complete.' }, 403) end + if SubmissionHistory.pending?(self) + ui.error!({ 'error' => 'Submission history is still being created. Please wait before submitting again.' }, 403) + end + + if !test_submission && (overseer_enabled? || task_definition.assessment_enabled) && + overseer_assessments.where(status: OverseerAssessment.statuses[:pre_queued]).exists? + ui.error!({ 'error' => 'A submission is already waiting for automated feedback. Please wait for the current Overseer job to complete before submitting again.' }, 403) + end + # Ensure all of the files are present if files.nil? || files.length != task_definition.number_of_uploaded_files ui.error!({ 'error' => 'Some files are missing from the submission upload' }, 403) @@ -1605,6 +1925,11 @@ def accept_submission(current_user, files, ui, contributions, trigger, alignment # Trigger processing of new submission - async AcceptSubmissionJob.perform_async(id, current_user.id, accepted_tii_eula, test_submission) + end + end + + def submission_lock_target + group_task? ? group : self end # The name that should be used for the uploaded file (based on index of upload requirements) @@ -1614,6 +1939,57 @@ def filename_for_upload(idx) "#{upload_requirements[idx]['name']}#{extension_for_upload(idx)}" end + # Keep the stored archive's numbered filenames for internal processing, but + # expose the upload requirement names when staff download its contents. + def download_filename_for(entry_name) + relative_path = entry_name.split('/', 2).last || entry_name + filename = File.basename(relative_path) + match = /\A(?\d{3})-(?document|code|image|zip|archive)(?\..+)\z/.match(filename) + return relative_path unless match + + requirement = upload_requirements[match[:index].to_i] + return relative_path unless requirement && requirement['type'] == match[:type] + + extension = match[:extension] + name = requirement['name'].to_s.strip + return relative_path if name.empty? + + name = name[0...-extension.length] if name.downcase.end_with?(extension.downcase) + renamed_file = "#{match[:index]}-#{FileHelper.sanitized_filename(name)}#{extension}" + directory = File.dirname(relative_path) + directory == '.' ? renamed_file : File.join(directory, renamed_file) + end + + def downloadable_submission_zip_path + source = zip_file_path_for_done_task + return source unless source && File.file?(source) + + result = FileHelper.tmp_file("submission-#{id}-named.zip") + if File.file?(result) && File.mtime(result) >= [File.mtime(source), task_definition.updated_at].max + return result + end + + partial_result = "#{result}.partial-#{SecureRandom.hex(8)}" + begin + Zip::OutputStream.open(partial_result) do |output_zip| + Zip::File.open(source) do |submission_zip| + submission_zip.each do |entry| + next if entry.name_is_directory? + + renamed_entry = entry.dup + renamed_entry.name = File.join(entry.name.split('/', 2).first, download_filename_for(entry.name)) + output_zip.copy_raw_entry(renamed_entry) + end + end + end + FileUtils.mv(partial_result, result) + ensure + FileUtils.rm_f(partial_result) + end + + result + end + def extension_for_upload(idx) filename = filename_in_zip(idx) return nil if filename.nil? @@ -1695,6 +2071,21 @@ def mark_as_moderated(moderation_type: :random_sample) private + def break_overlap_seconds(start_time, end_time, teaching_breaks) + teaching_breaks.sum do |teaching_break| + break_start = teaching_break.start_date.to_f + break_duration = teaching_break.number_of_days.to_i.days + break_end = break_start + break_duration + + next 0 unless break_start.finite? && break_duration.positive? + + overlap_start = [start_time, break_start].max + overlap_end = [end_time, break_end].min + + [0, overlap_end - overlap_start].max + end + end + def delete_associated_files if group_submission && group_submission.tasks.count <= 1 group_submission.destroy diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb new file mode 100644 index 0000000000..31ecd5a267 --- /dev/null +++ b/app/models/task_completion_snapshot.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require 'csv' +require 'set' +require 'zip' + +class TaskCompletionSnapshot < ApplicationRecord + include FileHelper + + belongs_to :unit + + validates :snapshot_timestamp, presence: true + validates :snapshot_timestamp, uniqueness: { scope: :unit_id } + + after_destroy :delete_snapshot_file + + def snapshot_file_path + return nil if unit.blank? + FileHelper.unit_task_status_snapshot_path(unit, create: true) + end + + def snapshot_contents + file_path = snapshot_file_path + return nil if file_path.blank? + if File.exist?(file_path) + return read_csv_from_zip(file_path, snapshot_timestamp) + end + nil + rescue Zip::Error + nil + end + + def snapshot_date + return nil if snapshot_timestamp.blank? + + snapshot_time.to_date + end + + def snapshot_time + return nil if snapshot_timestamp.blank? + + Time.zone.at(snapshot_timestamp.to_i) + end + + def load_stats + snapshot_contents = self.snapshot_contents + + return {} if snapshot_contents.blank? + + parse_csv_stats(snapshot_contents) + rescue CSV::MalformedCSVError + {} + end + + def load_target_grade_stats + csv_text = snapshot_contents + return {} if csv_text.blank? + + unit.grade_values.to_h do |target_grade| + [target_grade.to_s, parse_csv_stats(csv_text, target_grade: target_grade)] + end + rescue CSV::MalformedCSVError + {} + end + + # Count each CSV row once for the whole campus, even when the student belongs + # to multiple tutorial streams. Tutorial buckets remain available for filtering. + def load_status_population_stats + csv_text = snapshot_contents + return { 'all' => {}, 'target_grades' => {} } if csv_text.blank? + + stream_headers = unit.tutorial_streams.pluck(:abbreviation) + stream_headers = ['Tutorial'] if stream_headers.empty? + task_definitions = unit.task_definitions_by_grade + grade_labels = unit.grade_values.to_h { |grade| [unit.grade_label(grade), grade.to_s] } + campus_names = Campus.pluck(:abbreviation, :name).to_h + result = { 'all' => {}, 'target_grades' => {} } + seen = Set.new + + CSV.parse(csv_text, headers: true).each do |row| + student = row['Username'].presence || row['Student ID'].presence + campus_abbreviation = row['Campus'].to_s.strip + next if student.blank? || campus_abbreviation.blank? + + campus = campus_names[campus_abbreviation] || campus_abbreviation + tutorial_names = stream_headers.filter_map { |header| row[header].to_s.strip.presence }.uniq + grade = grade_labels[row['Target Grade'].to_s.strip] + scopes = [result['all']] + scopes << (result['target_grades'][grade] ||= {}) if grade + + scopes.each do |scope| + campus_stats = scope[campus] ||= {} + ['__all__', *tutorial_names].each do |tutorial| + task_stats = campus_stats[tutorial] ||= {} + task_definitions.each do |task_definition| + status_id = row[task_definition.abbreviation].to_s.strip.to_i + status = (TaskStatus.id_to_key(status_id) || :not_started).to_s + key = [scope.object_id, student, campus, tutorial, task_definition.abbreviation] + next unless seen.add?(key) + + counts = task_stats[task_definition.abbreviation] ||= {} + counts[status] = (counts[status] || 0) + 1 + end + end + end + end + + result + rescue CSV::MalformedCSVError + { 'all' => {}, 'target_grades' => {} } + end + + def load_student_counts + students = Set.new + campus_students = Hash.new { |hash, key| hash[key] = Set.new } + target_grade_students = Hash.new { |hash, key| hash[key] = Set.new } + target_grade_campus_students = Hash.new do |hash, key| + hash[key] = Hash.new { |campus_hash, campus| campus_hash[campus] = Set.new } + end + + CSV.parse(snapshot_contents.to_s, headers: true).each do |row| + student = row['Username'].presence || row['Student ID'].presence + next if student.blank? + + students.add(student) + + target_grade = unit.grade_values.find { |value| unit.grade_label(value) == row['Target Grade'].to_s.strip } + target_grade_students[target_grade].add(student) if target_grade + + campus_abbreviation = row['Campus'].to_s.strip + next if campus_abbreviation.blank? + + campus = Campus.find_by(abbreviation: campus_abbreviation)&.name || campus_abbreviation + campus_students[campus].add(student) + target_grade_campus_students[target_grade][campus].add(student) if target_grade + end + + { + 'student_count' => students.length, + 'campus_student_counts' => campus_students.transform_values(&:length), + 'target_grade_student_counts' => target_grade_students.transform_keys(&:to_s).transform_values(&:length), + 'target_grade_campus_student_counts' => target_grade_campus_students.transform_keys(&:to_s).transform_values do |campuses| + campuses.transform_values(&:length) + end + } + rescue CSV::MalformedCSVError + { + 'student_count' => 0, + 'campus_student_counts' => {}, + 'target_grade_student_counts' => {}, + 'target_grade_campus_student_counts' => {} + } + end + + def store_stats!(payload) + file_path = snapshot_file_path + raise 'Cannot store stats without a unit' if file_path.blank? + + FileUtils.mkdir_p(File.dirname(file_path)) + + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + raise 'Cannot store stats without a valid snapshot timestamp' if csv_filename.blank? + + tmp_path = "#{file_path}.tmp" + + # Read existing zip entries (if file exists) + existing_entries = {} + if File.exist?(file_path) + Zip::File.open(file_path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + existing_entries[entry.name] = entry.get_input_stream.read + end + end + end + + # Update or add the current snapshot entry + existing_entries[csv_filename] = payload.to_s + + # Write the zip file with all entries + Zip::OutputStream.open(tmp_path) do |zip| + existing_entries.each do |filename, content| + zip.put_next_entry(filename) + zip.write(content) + end + end + + FileUtils.mv(tmp_path, file_path) + ensure + FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path + end + + private + + def parse_csv_stats(csv_text, target_grade: nil) + csv = CSV.parse(csv_text, headers: true) + return {} if csv.empty? + + stream_headers = unit.tutorial_streams.pluck(:abbreviation) + stream_headers = ['Tutorial'] if stream_headers.empty? + task_definitions = unit.task_definitions_by_grade + + stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } + + csv.each do |row| + stored_target_grade = row['Target Grade'].to_s.strip + next if target_grade.present? && stored_target_grade.to_s != unit.grade_label(target_grade) + + campus_abbreviation = row['Campus'].to_s.strip + next if campus_abbreviation.blank? + + campus_name = Campus.find_by(abbreviation: campus_abbreviation)&.name || campus_abbreviation + + stream_headers.each do |stream_header| + tutorial_name = row[stream_header].to_s.strip + next if tutorial_name.blank? + + task_definitions.each do |task_definition| + status_value = row[task_definition.abbreviation].to_s.strip + status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started + stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 + end + end + end + + stats + end + + def read_csv_from_zip(zip_path, snapshot_timestamp) + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + Zip::File.open(zip_path) do |zip_file| + entry = zip_file.find_entry(csv_filename) + return nil if entry.nil? + + entry.get_input_stream.read + end + end + + def delete_snapshot_file + return if snapshot_timestamp.blank? + + file_path = snapshot_file_path + return if file_path.blank? || !File.exist?(file_path) + + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + return if csv_filename.blank? + + tmp_path = "#{file_path}.tmp" + + begin + # Read existing zip entries excluding the one we want to delete + remaining_entries = {} + Zip::File.open(file_path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + next if entry.name == csv_filename + remaining_entries[entry.name] = entry.get_input_stream.read + end + end + + if remaining_entries.empty? + # If no entries left, just delete the zip file + FileUtils.rm_f(file_path) + else + # Write the zip file with remaining entries + Zip::OutputStream.open(tmp_path) do |zip| + remaining_entries.each do |filename, content| + zip.put_next_entry(filename) + zip.write(content) + end + end + FileUtils.mv(tmp_path, file_path) + end + rescue StandardError => e + # If anything goes wrong with zip operations, just clean up and log + logger.error("Error managing snapshot zip file: #{e.message}") + FileUtils.rm_f(tmp_path) + end + end +end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 8d2fa7c5cd..da191b3cd5 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -1,6 +1,11 @@ require 'json' class TaskDefinition < ApplicationRecord + JPLAG_LANGUAGES = %w[ + java c cpp csharp python3 javascript typescript golang kotlin rlang rust swift + scala llvmir scheme emf emf-model scxml text multi + ].freeze + include ApplicationHelper include FileHelper include MimeCheckHelpers @@ -55,6 +60,8 @@ def self.permissions delegate :role_for, to: :unit + before_validation :normalize_upload_requirement_keys + before_destroy :ensure_not_used_as_prerequisite, prepend: true before_destroy :delete_associated_files after_update :move_files_on_abbreviation_change, if: :saved_change_to_abbreviation? @@ -90,15 +97,19 @@ def self.permissions serialize :upload_requirements, coder: JSON # Model validations/constraints - validates :name, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique + validates :name, allowed_characters: { type: :title }, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique validates :abbreviation, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique - validates :target_grade, inclusion: { in: GradeHelper::RANGE, message: '%{value} is not a valid target grade' } + validates :target_grade, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validate :target_grade_enabled_for_unit validates :max_quality_pts, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, message: 'must be between 0 and 100' } validate :upload_requirements, :check_upload_requirements_format + validate :submission_history_required_for_overseer validates :description, length: { maximum: 4095, allow_blank: true } + # Existing MOSS tasks can still be edited, but new language selections must match the JPlag UI. + validates :similarity_language, inclusion: { in: JPLAG_LANGUAGES }, allow_nil: true, if: :will_save_change_to_similarity_language? validate :ensure_no_submissions, if: :will_save_change_to_group_set_id? validate :unit_must_be_same @@ -113,36 +124,49 @@ def self.permissions include TaskDefinitionTiiModule include TaskDefinitionSimilarityModule - # def p_target_date - # due_date - # end + def grade_due_date_overrides + grade_due_dates.map do |override| + { + target_grade: override.target_grade, + target_due_date: override.target_due_date, + start_date: override.start_date + } + end + end - # Per-grade target date overrides + def content_link + return @content_link if defined?(@content_link) - def c_target_date - grade_due_dates.find { |g| g.target_grade == 1 }&.target_due_date + @content_link = unit.unit_content_links.find do |link| + link.context_type == 'task_definition' && link.context_key == abbreviation + end || unit.unit_content_links.find_by(context_type: 'task_definition', context_key: abbreviation) end - def d_target_date - grade_due_dates.find { |g| g.target_grade == 2 }&.target_due_date + def has_content_link? + content_link.present? end - def hd_target_date - grade_due_dates.find { |g| g.target_grade == 3 }&.target_due_date - end + def task_resource_link + return @task_resource_link if defined?(@task_resource_link) - # Per-grade start date overrides + @task_resource_link = unit.unit_content_links.find do |link| + link.context_type == 'task_definition_resource' && link.context_key == abbreviation + end || unit.unit_content_links.find_by( + context_type: 'task_definition_resource', + context_key: abbreviation + ) + end - def c_start_date - grade_due_dates.find { |g| g.target_grade == 1 }&.start_date + def has_task_resource_link? + task_resource_link.present? && task_resource_link.unit_content_site.file?(task_resource_link.route) end - def d_start_date - grade_due_dates.find { |g| g.target_grade == 2 }&.start_date + def grade_target_date(target_grade) + grade_due_dates.find { |g| g.target_grade == target_grade.to_i }&.target_due_date end - def hd_start_date - grade_due_dates.find { |g| g.target_grade == 3 }&.start_date + def grade_start_date(target_grade) + grade_due_dates.find { |g| g.target_grade == target_grade.to_i }&.start_date end def unit_must_be_same @@ -169,6 +193,8 @@ def cant_disable_aip_only_if_aip_tasks_exist def check_existing_prerequisites prereqs = TaskPrerequisite.where(task_definition_id: id) prereqs.each do |dp| + next if dp.prerequisite.nil? + if target_grade < dp.prerequisite.target_grade errors.add(:target_grade, "cannot be lower than prerequisite #{dp.prerequisite.abbreviation}'s target grade") end @@ -176,12 +202,22 @@ def check_existing_prerequisites dependents = TaskPrerequisite.where(prerequisite_id: id) dependents.each do |pr| + next if pr.task_definition.nil? + if target_grade > pr.task_definition.target_grade errors.add(:target_grade, "cannot exceed the target grade #{pr.task_definition.abbreviation} because this is a prerequisite") end end end + def ensure_not_used_as_prerequisite + return if destroyed_by_association&.name == :task_definitions + return unless TaskPrerequisite.exists?(prerequisite_id: id) + + errors.add(:base, "Cannot delete task definition while it is used as a prerequisite. Remove the prerequisite links first.") + throw :abort + end + # In the rollover process, copy this definition into another unit # Copy this task into the other unit def copy_to(other_unit) @@ -216,7 +252,7 @@ def copy_to(other_unit) FileUtils.cp(task_sheet, new_td.task_sheet()) end - if has_task_resources? + if has_uploaded_task_resources? # Copy the task resources, and trigger tii integration if needed new_td.add_task_resources(task_resources, copy: true) end @@ -226,10 +262,30 @@ def copy_to(other_unit) end new_td.save! + overseer_steps.find_each do |step| + new_td.overseer_steps.create!( + step.attributes.except('id', 'task_definition_id', 'created_at', 'updated_at') + ) + end + + grade_due_dates.each do |override| + new_td.grade_due_dates.create!( + target_grade: override.target_grade, + start_date: date_in_unit(override.start_date, other_unit), + target_due_date: date_in_unit(override.target_due_date, other_unit) + ) + end new_td end + # Map a date to the same week and day in another unit + def date_in_unit(date, other_unit) + return nil if date.nil? + + other_unit.date_for_week_and_day(unit.week_number(date), Date::ABBR_DAYNAMES[date.wday]) + end + def has_removed_group? saved_change_to_group_set_id? && group_set_id.nil? end @@ -278,6 +334,10 @@ def reset_overdue_tasks def move_files_on_abbreviation_change old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation + unit.unit_content_links + .where(context_type: %w[task_definition task_definition_resource], context_key: old_abbr) + .find_each { |link| link.update!(context_key: abbreviation) } + if File.exist? task_sheet_with_abbreviation(old_abbr, false) FileUtils.mv(task_sheet_with_abbreviation(old_abbr), task_sheet()) end @@ -335,8 +395,10 @@ def check_upload_requirements_format return end - # Check keys only contain key, type, name, tii_check, and tii_pct - unless req.keys.excluding('key', 'type', 'name', 'tii_check', 'tii_pct').empty? + req['type'] = 'zip' if req['type'] == 'archive' + + # Check keys only contain supported upload requirement settings + unless req.keys.excluding('key', 'type', 'name', 'tii_check', 'tii_pct', 'submission_history').empty? errors.add(:upload_requirements, "has additional values for item #{i + 1} --> #{req.keys.join(' ')}.") end @@ -345,8 +407,8 @@ def check_upload_requirements_format errors.add(:upload_requirements, "the name for item #{i + 1} does not seem to be a valid filename --> #{req['name']}.") end - # Check the type is either document or image or code - unless %w(document image code).include? req['type'] + # Check the type is either document, image, code, or zip + unless %w(document image code zip).include? req['type'] errors.add(:upload_requirements, "the type for item #{i + 1} is not valid --> #{req['type']}.") end @@ -360,10 +422,29 @@ def check_upload_requirements_format errors.add(:upload_requirements, "the tii_pct for item #{i + 1} is not a non-negative number --> #{req['tii_pct']}.") end + unless req['submission_history'].blank? || [true, false].include?(req['submission_history']) + errors.add(:upload_requirements, "the submission_history for item #{i + 1} is not a boolean --> #{req['submission_history']}.") + end + i += 1 end end + def normalize_upload_requirement_keys + return unless upload_requirements.is_a?(Array) + return unless upload_requirements.all? { |requirement| requirement.is_a?(Hash) && requirement.key?('key') } + + normalized_requirements = self.class.normalize_upload_requirement_keys(upload_requirements) + self.upload_requirements = normalized_requirements unless normalized_requirements == upload_requirements + end + + def submission_history_required_for_overseer + return unless assessment_enabled? + return if upload_requirements&.any? { |requirement| requirement['submission_history'] == true } + + errors.add(:upload_requirements, 'must include at least one file in submission history when Overseer is enabled') + end + def number_of_uploaded_files upload_requirements.length end @@ -385,11 +466,47 @@ def type_for_upload(idx) upload_requirements[idx]['type'] end - def self.to_csv(task_definitions) + def self.to_csv(unit, task_definitions) + grade_columns = grade_override_csv_columns(unit) + columns = required_csv_columns.dup + columns.insert(columns.index(:due_day) + 1, *grade_columns) + # overseer_steps goes last as its JSON is too large to read inline + columns << :overseer_steps + CSV.generate() do |csv| - csv << csv_columns + csv << columns task_definitions.each do |task_definition| - csv << task_definition.to_csv_row + values = required_csv_columns.zip(task_definition.to_csv_row).to_h + .merge(grade_columns.zip(task_definition.grade_override_csv_values).to_h) + .merge(overseer_steps: task_definition.overseer_steps_csv_value) + csv << columns.map { |col| values[col] } + end + end + end + + # Pass uses the task's own dates, so only higher grades have overrides + def self.override_grades(unit) + unit.grade_values.reject(&:zero?) + end + + # Keyed by abbreviation (eg. grade_hd_target_week) so columns are readable + def self.grade_csv_key(unit, grade) + unit.grade_abbreviation(grade).downcase.tr(' ', '_') + end + + def self.grade_override_csv_columns(unit) + override_grades(unit).flat_map do |grade| + key = grade_csv_key(unit, grade) + %w(start_week start_day target_week target_day).map { |col| :"grade_#{key}_#{col}" } + end + end + + def grade_override_csv_values + TaskDefinition.override_grades(unit).flat_map do |grade| + override = grade_due_dates.find { |g| g.target_grade == grade } + [override&.start_date, override&.target_due_date].flat_map do |date| + # '-' rather than blank so Excel doesn't overflow the previous cell into it + date.nil? ? %w(- -) : [unit.week_number(date), Date::ABBR_DAYNAMES[date.wday]] end end end @@ -506,11 +623,18 @@ def propogate_date_changes date_diff self.target_date += date_diff self.due_date += date_diff unless self.due_date.nil? self.save! + + grade_due_dates.each do |override| + override.update!( + start_date: override.start_date&.+(date_diff), + target_due_date: override.target_due_date&.+(date_diff) + ) + end end def to_csv_row TaskDefinition.csv_columns - .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements, :group_set, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts].include? col} + .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements, :group_set, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts, :overseer_steps].include? col} .map { |column| attributes[column.to_s] } + [ group_set.nil? ? "" : group_set.name, @@ -541,11 +665,42 @@ def to_csv_row # [ self['due_date'].nil? ? '' : due_date.strftime('%d-%m-%Y')] end + def overseer_steps_csv_value + overseer_steps.map do |step| + { + name: step.name, + description: step.description, + display_name: step.display_name, + display_description: step.display_description, + run_command: step.run_command, + timeout: step.timeout, + sort_order: step.sort_order, + step_type: step.step_type, + partial_output_diff: step.partial_output_diff, + stdin_input_file: step.stdin_input_file, + expected_output_file: step.expected_output_file, + feedback_message: step.feedback_message, + status_on_success: TaskStatus.find_by(id: step.status_on_success_id)&.status_key, + status_on_failure: TaskStatus.find_by(id: step.status_on_failure_id)&.status_key, + halt_on_success: step.halt_on_success, + halt_on_failure: step.halt_on_failure, + show_expected_output: step.show_expected_output, + show_stdin: step.show_stdin, + show_stdout: step.show_stdout, + enabled: step.enabled + } + end.to_json + end + def self.csv_columns [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, :is_graded, :plagiarism_warn_pct, :scorm_enabled, :scorm_allow_review, :scorm_bypass_test, :scorm_time_delay_enabled, :scorm_attempt_limit, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day, - :due_week, :due_day, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts] + :due_week, :due_day, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts, :overseer_steps] + end + + def self.required_csv_columns + csv_columns - [:overseer_steps] end def self.task_def_for_csv_row(unit, row) @@ -563,6 +718,9 @@ def self.task_def_for_csv_row(unit, row) due_date = unit.date_for_week_and_day row[:due_week].to_i, "#{row[:due_day]}".strip + grade_overrides, override_error = grade_overrides_for_csv_row(unit, row) + return [nil, false, "#{override_error} for #{abbreviation}"] if override_error + result = TaskDefinition.find_by(unit_id: unit.id, abbreviation: abbreviation) result = TaskDefinition.find_by(unit_id: unit.id, name: name) if result.nil? @@ -611,6 +769,7 @@ def self.task_def_for_csv_row(unit, row) end import_discussion_prompts_from_csv_row(result, row) + import_overseer_steps_from_csv_row(result, row) result.assess_in_portfolio_only = %w(Yes y Y yes true TRUE 1).include? "#{row[:assess_in_portfolio_only]}".strip @@ -631,9 +790,54 @@ def self.task_def_for_csv_row(unit, row) end end + result.apply_grade_overrides(grade_overrides) + [result, new_task, new_task ? "Added new task definition #{result.abbreviation}." : "Updated existing task #{result.abbreviation}"] end + def apply_grade_overrides(grade_overrides) + grade_overrides.each do |grade, dates| + override = grade_due_dates.find_or_initialize_by(target_grade: grade) + if dates.empty? + override.destroy if override.persisted? + else + override.update!(start_date: dates[:start_date], target_due_date: dates[:target_due_date]) + end + end + end + + # Reads grade__(start|target)_(week|day) columns into { grade => { start_date:, target_due_date: } }. + # Grades whose columns are all blank (or '-') map to {} so existing overrides are cleared. + def self.grade_overrides_for_csv_row(unit, row) + grades_by_key = override_grades(unit).index_by { |grade| grade_csv_key(unit, grade) } + grade_keys = row.headers.filter_map { |hdr| hdr.to_s[/\Agrade_(.+)_(?:start|target)_(?:week|day)\z/, 1] }.uniq + + overrides = {} + grade_keys.each do |grade_key| + dates = {} + { start_date: 'start', target_due_date: 'target' }.each do |key, prefix| + week, day = %w(week day).map { |part| row[:"grade_#{grade_key}_#{prefix}_#{part}"].to_s.strip.sub(/\A-\z/, '') } + next if week.empty? && day.empty? + + date = unit.date_for_week_and_day(week.to_i, day) unless week.empty? || day.empty? + return [nil, "Unable to determine grade #{grade_key.upcase} #{prefix} date -- need week number, and day short text eg. 'Wed'"] if date.nil? + + dates[key] = date + end + + grade = grades_by_key[grade_key] + if grade.nil? + return [nil, "Grade #{grade_key.upcase} cannot have date overrides in this unit"] if dates.any? + + next + end + + overrides[grade] = dates + end + + [overrides, nil] + end + def self.normalize_upload_requirement_keys(upload_requirements) return upload_requirements unless upload_requirements.is_a?(Array) @@ -658,11 +862,52 @@ def self.import_discussion_prompts_from_csv_row(task_definition, row) end end + def self.import_overseer_steps_from_csv_row(task_definition, row) + task_definition.overseer_steps.destroy_all + return if row[:overseer_steps].blank? + + JSON.parse(row[:overseer_steps]).each do |step| + OverseerStep.create!( + task_definition: task_definition, + name: step['name'], + description: step['description'], + display_name: step['display_name'], + display_description: step['display_description'], + run_command: step['run_command'], + timeout: step['timeout'], + sort_order: step['sort_order'], + step_type: step['step_type'], + partial_output_diff: step['partial_output_diff'], + stdin_input_file: step['stdin_input_file'], + expected_output_file: step['expected_output_file'], + feedback_message: step['feedback_message'], + status_on_success_id: status_id_from_csv(step['status_on_success']), + status_on_failure_id: status_id_from_csv(step['status_on_failure']), + halt_on_success: step['halt_on_success'], + halt_on_failure: step['halt_on_failure'], + show_expected_output: step['show_expected_output'], + show_stdin: step['show_stdin'], + show_stdout: step['show_stdout'], + enabled: step.key?('enabled') ? step['enabled'] : true + ) + end + end + + def self.status_id_from_csv(value) + return nil if value.blank? + + TaskStatus.status_for_name(value)&.id || TaskStatus.find_by(id: value.to_i)&.id + end + def is_group_task? !group_set.nil? end def has_task_resources? + has_task_resource_link? || has_uploaded_task_resources? + end + + def has_uploaded_task_resources? File.exist? task_resources(false) end @@ -739,10 +984,10 @@ def add_task_resources(file, copy: false) end def remove_task_resources() - if has_task_resources? + if has_uploaded_task_resources? FileUtils.rm task_resources - tii_group_attachments.destroy_all if tii_checks? + tii_group_attachments.destroy_all if tii_checks? && !has_task_resource_link? end end @@ -818,7 +1063,7 @@ def jplag_report end def related_tasks_with_files(consolidate_groups = true) - tasks_with_files = tasks.select(&:has_pdf) + tasks_with_files = tasks.joins(:project).where(projects: { enrolled: true }).select(&:has_pdf) if is_group_task? && consolidate_groups # group task so only select one member of each group @@ -840,12 +1085,18 @@ def related_tasks_with_files(consolidate_groups = true) # Read a file from the task definition resources. # - # @param filename [String] The name of the file to read from the zipfile. + # @param filename [String] The linked filename or path within the resource zip. # @return [String] The contents of the file, or nil if the file does not exist. def read_file_from_resources(filename) - return nil unless has_task_resources? + linked_resource = linked_task_resource + return nil unless linked_resource || has_uploaded_task_resources? - Zip::File.open(task_resources) do |zip_file| + if linked_resource && !task_resource_zip?(linked_resource) + return filename == linked_resource[:filename] ? File.binread(linked_resource[:path]) : nil + end + + resource_path = linked_resource ? linked_resource[:path] : task_resources + Zip::File.open(resource_path) do |zip_file| entry = zip_file.glob(filename).first return entry.get_input_stream.read if entry end @@ -853,8 +1104,24 @@ def read_file_from_resources(filename) nil end + def linked_task_resource + return unless has_task_resource_link? + + task_resource_link.unit_content_site.extract_file(task_resource_link.route) + end + + def task_resource_zip?(resource) + resource.present? && File.extname(resource[:filename]).casecmp('.zip').zero? + end + private + def target_grade_enabled_for_unit + return if unit.nil? || target_grade.nil? || unit.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end + def delete_associated_files() remove_task_sheet() remove_task_resources() diff --git a/app/models/task_status.rb b/app/models/task_status.rb index b4dbac8cd4..bc6eec9b99 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -72,6 +72,10 @@ def self.attention_required TaskStatus.find(14) end + def self.rediscuss + TaskStatus.find(15) + end + class << self # Provide access to the count from the database via a new db_count method alias_method :db_count, :count @@ -84,7 +88,7 @@ class << self # Keep this hard coded! Saves cache load time. # Important: count must equal the largest id in the database def self.count - 14 + 15 end def self.status_for_name(name) @@ -103,6 +107,8 @@ def self.status_for_name(name) TaskStatus.working_on_it when 'discuss', 'd' TaskStatus.discuss + when 'rediscuss', 're-discuss', 're discuss' + TaskStatus.rediscuss when 'demonstrate', 'demo' TaskStatus.demonstrate when 'ready for feedback', 'ready_for_feedback', 'ready to mark', 'ready_to_mark', 'rtm', 'rff' @@ -142,6 +148,7 @@ def self.id_to_key(id) when 12 then :time_exceeded when 13 then :assess_in_portfolio when 14 then :attention_required + when 15 then :rediscuss else :not_started end end @@ -161,6 +168,7 @@ def status_key return :time_exceeded if self == TaskStatus.time_exceeded return :assess_in_portfolio if self == TaskStatus.assess_in_portfolio return :attention_required if self == TaskStatus.attention_required + return :rediscuss if self == TaskStatus.rediscuss return :not_started end diff --git a/app/models/teaching_period.rb b/app/models/teaching_period.rb index eaf5d6b2c9..3cf78e94b5 100644 --- a/app/models/teaching_period.rb +++ b/app/models/teaching_period.rb @@ -13,11 +13,16 @@ class TeachingPeriod < ApplicationRecord inclusion: { in: 2000..2999, message: "%{value} is not a valid year" } validates :start_date, presence: true validates :end_date, presence: true + validates :last_teaching_date, presence: true validates :active_until, presence: true - validate :validate_end_date_after_start_date, :validate_active_until_after_end_date + before_validation :default_last_teaching_date, on: :create + before_validation :sync_last_teaching_date_with_end_date, on: :update + + validate :validate_end_date_after_start_date, :validate_last_teaching_date, :validate_active_until_after_end_date after_update :propogate_date_changes + after_update :refresh_communication_schedule_caches, if: :saved_change_to_teaching_dates? # Public methods @@ -25,11 +30,14 @@ def detailed_name "#{year} #{period}" end - def add_break(start_date, number_of_weeks) + def add_break(start_date, number_of_days, campus_ids = [], label = nil, pause_week_count = nil) break_in_teaching_period = Break.new break_in_teaching_period.start_date = start_date - break_in_teaching_period.number_of_weeks = number_of_weeks + break_in_teaching_period.number_of_days = number_of_days + break_in_teaching_period.label = label break_in_teaching_period.teaching_period = self + break_in_teaching_period.campus_ids = campus_ids || [] + break_in_teaching_period.pause_week_count = pause_week_count unless pause_week_count.nil? break_in_teaching_period.save! # add after save to ensure valid break @@ -38,33 +46,56 @@ def add_break(start_date, number_of_weeks) break_in_teaching_period end - def update_break(id, start_date, number_of_weeks) + def update_break(id, start_date, number_of_days, campus_ids = nil, label = nil, pause_week_count = nil) break_in_teaching_period = breaks.find(id) if start_date.present? break_in_teaching_period.start_date = start_date end - if number_of_weeks.present? - break_in_teaching_period.number_of_weeks = number_of_weeks + if number_of_days.present? + break_in_teaching_period.number_of_days = number_of_days end + break_in_teaching_period.label = label unless label.nil? + + break_in_teaching_period.campus_ids = campus_ids if campus_ids.present? || campus_ids == [] + + break_in_teaching_period.pause_week_count = pause_week_count unless pause_week_count.nil? + break_in_teaching_period.save! break_in_teaching_period end + def breaks_for(campus) + breaks.select { |teaching_break| teaching_break.applies_to?(campus) } + end + + # Teaching periods stay active past their end date so that end of semester + # marking can finish. + def active?(date = Time.zone.now) + active_until > date + end + + # The breaks that pause the week count for the teaching period. Breaks without + # this flag still extend deadlines, but the week number continues through them + # so that it stays aligned across campuses. + def week_pausing_breaks + breaks.select(&:pause_week_count?) + end + def week_number(date) # Calcualte date offset, add 2 so 0-week offset is week 1 not week 0 result = ((date - start_date) / 1.week).floor + 1 - for a_break in breaks.all do + for a_break in week_pausing_breaks do if date >= a_break.start_date # we are in or after the break, so calculated week needs to # be reduced by this break if date >= a_break.end_date # past the end of the break... - result -= a_break.number_of_weeks + result -= a_break.weeks_spanned elsif date == a_break.start_date # cant use standard calculation as this give 0 for this exact moment... result -= 1 if date >= a_break.first_monday @@ -94,11 +125,11 @@ def date_for_week(num) result = start_date + num.weeks # check breaks - for a_break in breaks do + for a_break in week_pausing_breaks do if result >= a_break.start_date # we are in or after the break, so calculated date is # extended by the break period - result += a_break.number_of_weeks.weeks + result += a_break.duration end end @@ -115,13 +146,16 @@ def date_for_week_and_day(week, day) start_day_num = start_date.wday - result = week_start + (day_num - start_day_num).days + day_offset = day_num - start_day_num + day_offset += 7 if day_offset.negative? + + result = week_start + day_offset.days - for a_break in breaks do + for a_break in week_pausing_breaks do if result >= a_break.start_date && result < a_break.end_date # we are in or after the break, so calculated date is # extended by the break period - result += a_break.number_of_weeks.weeks + result += a_break.duration end end @@ -132,6 +166,13 @@ def future_teaching_periods TeachingPeriod.where("start_date > :end_date", end_date: end_date) end + def refresh_communication_schedule_caches + CommunicationSetSchedule + .joins(communication_set: :unit) + .where(units: { teaching_period_id: id }) + .find_each(&:refresh_next_run_at!) + end + private def can_destroy? @@ -153,11 +194,33 @@ def validate_end_date_after_start_date end end + def default_last_teaching_date + self.last_teaching_date ||= end_date&.to_date + end + + def sync_last_teaching_date_with_end_date + return unless will_save_change_to_end_date? && !will_save_change_to_last_teaching_date? + return unless last_teaching_date == end_date_change_to_be_saved.first&.to_date + + self.last_teaching_date = end_date&.to_date + end + + def validate_last_teaching_date + return if start_date.blank? || end_date.blank? || last_teaching_date.blank? + return if last_teaching_date.between?(start_date.to_date, end_date.to_date) + + errors.add(:last_teaching_date, 'must be between the start and end dates') + end + def propogate_date_changes - return unless saved_change_to_start_date? || saved_change_to_end_date? + return unless saved_change_to_start_date? || saved_change_to_end_date? || saved_change_to_last_teaching_date? units.each do |u| - u.update(start_date: self.start_date, end_date: self.end_date) + u.update!(start_date: start_date, end_date: end_date, last_teaching_date: last_teaching_date) end end + + def saved_change_to_teaching_dates? + saved_change_to_start_date? || saved_change_to_end_date? + end end diff --git a/app/models/turn_it_in/task_definition_tii_module.rb b/app/models/turn_it_in/task_definition_tii_module.rb index 8e9d9e3097..0f7340b933 100644 --- a/app/models/turn_it_in/task_definition_tii_module.rb +++ b/app/models/turn_it_in/task_definition_tii_module.rb @@ -37,10 +37,21 @@ def send_group_attachments_to_tii return if tii_group_id.blank? return unless has_task_resources? + linked_resource = linked_task_resource + if linked_resource && !task_resource_zip?(linked_resource) + filename = linked_resource[:filename] + return unless filename.downcase.end_with?('.doc', '.docx') + return if filename.include?('__MACOSX') || File.size(linked_resource[:path]) < 50 + + TiiGroupAttachment.find_or_create_from_task_definition(self, filename) + return + end + count = 0 # loop through files in the task resources zip file - Zip::File.open(task_resources) do |zip_file| + resource_path = linked_resource ? linked_resource[:path] : task_resources + Zip::File.open(resource_path) do |zip_file| zip_file.each do |entry| next unless entry.file? next unless entry.name.end_with?('.doc', '.docx') diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 6c31fe19b3..20dacea9d6 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -17,6 +17,8 @@ class Tutorial < ApplicationRecord validates :abbreviation, uniqueness: { scope: :unit, message: 'must be unique within the unit' } + validates :duration_minutes, presence: true, numericality: { only_integer: true, greater_than: 0 } + # Make sure that unit in tutorial and tutorial stream are consistent validate :unit_must_be_same @@ -33,10 +35,15 @@ def self.default tutorial.meeting_day = 'Enter a regular meeting day.' tutorial.meeting_time = 'Enter a regular meeting time.' tutorial.meeting_location = 'Enter a location.' + tutorial.duration_minutes = 120 tutorial end + def duration_hours + duration_minutes.to_f / 60 + end + def self.find_by_user(user) Tutorial.joins(:tutor).where('user_id = :user_id', user_id: user.id) end diff --git a/app/models/unit.rb b/app/models/unit.rb index d31d84fb78..ef141845cb 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -6,13 +6,25 @@ require 'moss_ruby' require 'csv_helper' require 'grade_helper' +require 'securerandom' +require 'tmpdir' class Unit < ApplicationRecord + DEFAULT_GRADE_DEFINITIONS = [ + { 'id' => 'fail', 'value' => -1, 'label' => 'Fail', 'abbreviation' => 'F' }, + { 'id' => 'pass', 'value' => 0, 'label' => 'Pass', 'abbreviation' => 'P' }, + { 'id' => 'credit', 'value' => 1, 'label' => 'Credit', 'abbreviation' => 'C' }, + { 'id' => 'distinction', 'value' => 2, 'label' => 'Distinction', 'abbreviation' => 'D' }, + { 'id' => 'high-distinction', 'value' => 3, 'label' => 'High Distinction', 'abbreviation' => 'HD' } + ].freeze + include ApplicationHelper include FileHelper include MimeCheckHelpers include CsvHelper + has_one :lms_integration, dependent: :destroy + # # Permissions around unit data # @@ -52,8 +64,11 @@ def self.permissions :add_tutorial, :add_task_def, :provide_feedback, + :provide_bulk_feedback, + :view_staff_inbox, :change_project_enrolment, :download_stats, + :download_overflow_stats, :download_grades, :download_jplag_report, :rollover_unit, @@ -66,7 +81,12 @@ def self.permissions :get_tutor_times_summary, :get_marking_sessions, :upload_grades_csv, - :get_staff_notes + :upload_staff_notes_csv, + :get_staff_notes, + :capture_task_completion_snapshot, + :manage_unit_content, + :mannage_communications, + :delete_engagement ] # What can admin do with units? @@ -82,6 +102,7 @@ def self.permissions :add_tutorial, :add_task_def, :download_stats, + :download_overflow_stats, :download_unit_csv, :download_grades, :exceed_capacity, @@ -91,7 +112,11 @@ def self.permissions :grant_spec_con, :download_jplag_report, :get_marking_sessions, - :get_staff_notes + :get_staff_notes, + :upload_staff_notes_csv, + :get_tutor_times, + :manage_unit_content, + :mannage_communications, ] # What can auditors do with units? @@ -117,6 +142,8 @@ def self.permissions end def role_for(user) + return nil if user.nil? + if convenors.where('unit_roles.user_id=:id', id: user.id).count == 1 Role.convenor elsif tutors.where('unit_roles.user_id=:id', id: user.id).count == 1 @@ -144,6 +171,7 @@ def role_for(user) after_update :move_files_on_code_change, if: :saved_change_to_code? after_update :propogate_date_changes_to_tasks, if: :saved_change_to_start_date? after_update :update_overdue_tasks_aip, if: :saved_change_to_mark_late_submissions_as_assess_in_portfolio? + after_update :refresh_communication_schedule_caches, if: :saved_change_to_communication_schedule_inputs? # Model associations. # When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed. @@ -155,6 +183,12 @@ def role_for(user) has_many :unit_roles, dependent: :destroy, inverse_of: :unit has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit has_many :marking_sessions, dependent: :destroy + has_many :task_completion_snapshots, dependent: :destroy, inverse_of: :unit + has_many :communication_sets, class_name: 'CommunicationSet', dependent: :destroy + has_many :communication_rules, through: :communication_sets, class_name: 'CommunicationRule' + has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' + has_many :unit_content_sites, dependent: :destroy + has_many :unit_content_links, dependent: :destroy has_many :comments, through: :projects has_many :tasks, through: :projects @@ -178,7 +212,9 @@ def role_for(user) belongs_to :overseer_image, optional: true - validates :name, :description, :start_date, :end_date, presence: true + validates :name, :description, :start_date, :end_date, :last_teaching_date, presence: true + validates :name, allowed_characters: { type: :unit_name } + validates :code, allowed_characters: { type: :unit_code } validates :description, length: { maximum: 4095, allow_blank: true } @@ -194,9 +230,19 @@ def role_for(user) validates :feedback_overflow_threshold_days, numericality: { greater_than_or_equal_to: 0 } + validates :discuss_timeout_warning_days, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :discuss_timeout_expire_days, + numericality: { only_integer: true, greater_than_or_equal_to: 1 } + validate :warning_not_greater_than_overflow + validate :discuss_timeout_warning_before_expiry + + before_validation :default_last_teaching_date, on: :create + before_validation :sync_last_teaching_date_with_end_date, on: :update validate :validate_end_date_after_start_date + validate :validate_last_teaching_date validate :ensure_teaching_period_dates_match, if: :has_teaching_period? validate :ensure_main_convenor_is_appropriate, if: :main_convenor_id_changed? @@ -205,6 +251,8 @@ def role_for(user) validate :autogen_date_within_unit_active_period, if: -> { start_date_changed? || end_date_changed? || teaching_period_id_changed? || portfolio_auto_generation_date_changed? } validate :cant_disable_aip_only_if_aip_tasks_exist + validate :grade_definitions_are_valid + validate :configured_grades_preserve_used_values, if: :will_save_change_to_grade_values? scope :current, -> { current_for_date(Time.zone.now) } scope :current_for_date, ->(date) { where('start_date <= ? AND end_date >= ?', date, date) } @@ -212,6 +260,13 @@ def role_for(user) scope :not_current_for_date, ->(date) { where('start_date > ? OR end_date < ?', date, date) } scope :set_active, -> { where('active = ?', true) } scope :set_inactive, -> { where('active = ?', false) } + # SQL equivalent of #within_teaching_dates? + scope :within_teaching_dates, lambda { |now = Time.zone.now| + set_active.where('units.end_date IS NULL OR units.end_date >= ?', now.to_date) + } + scope :within_last_teaching_date, lambda { |now = Time.zone.now| + set_active.where('units.last_teaching_date >= ?', now.to_date) + } include UnitTiiModule @@ -228,6 +283,132 @@ def warning_not_greater_than_overflow ) end + def discuss_timeout_warning_before_expiry + return unless discuss_timeout_enabled + return if discuss_timeout_warning_days < discuss_timeout_expire_days + + errors.add(:discuss_timeout_warning_days, 'must be less than the expiry days') + end + + # Active and not past the end date (inclusive), which is kept in sync with the teaching period's. + def within_teaching_dates?(now = Time.zone.now) + active? && (end_date.blank? || now.to_date <= end_date) + end + + def within_last_teaching_date?(now = Time.zone.now) + active? && now.to_date <= last_teaching_date + end + + def discuss_timeout_active? + discuss_timeout_enabled && within_last_teaching_date? + end + + def self.notify_discuss_timeouts! + within_last_teaching_date.find_each(&:notify_discuss_timeouts!) + end + + def notify_discuss_timeouts! + return 0 unless discuss_timeout_active? + + discuss_timeout_tasks.find_each.sum do |task| + notify_discuss_timeout_for(task) + end + end + + def discuss_timeout_tasks + tasks + .includes(:project, :task_definition) + .where(task_status_id: TaskStatus.discuss.id) + .where.not(moved_to_discuss_at: nil) + .where('moved_to_discuss_at <= ?', discuss_timeout_warning_days.days.ago) + end + + def notify_discuss_timeout_for(task, teaching_breaks: nil, now_time: Time.zone.now) + return 0 if task.moved_to_discuss_at.blank? + return 0 if now_time.to_date > last_teaching_date + + expiry_at = task.discuss_timeout_expiry_at(discuss_timeout_expire_days, teaching_breaks: teaching_breaks) + return 0 if expiry_at.to_date >= last_teaching_date + + actor = task.project.tutor_for(task.task_definition) || main_convenor&.user + return 0 if actor.blank? + + elapsed_days = task.discuss_timeout_elapsed_days(now_time, teaching_breaks: teaching_breaks) + if now_time.in_time_zone(expiry_at.time_zone).to_date > expiry_at.to_date + expire_discuss_timeout_task(task, actor) + elsif elapsed_days >= discuss_timeout_warning_days + warn_discuss_timeout_task(task, actor, teaching_breaks: teaching_breaks) + else + 0 + end + end + + def warn_discuss_timeout_task(task, actor, teaching_breaks: nil) + return 0 if task.notified_discuss_warning_at.present? + + expiry_date = discuss_timeout_expiry_date(task, teaching_breaks: teaching_breaks) + created_comment = false + Task.transaction do + comment = task.add_discuss_timeout_comment( + actor, + DiscussTimeoutComment.warning, + "You must discuss this task with your tutor on or before #{formatted_discuss_timeout_date(expiry_date)}. If it has not been discussed by then, it will move to Fix and Resubmit, and you will need to resubmit your work." + ) + raise ActiveRecord::Rollback if comment.blank? + + task.update!(notified_discuss_warning_at: Time.zone.now) + queue_discuss_timeout_email(task, actor, :approaching, expiry_date) + created_comment = true + end + + created_comment ? 1 : 0 + end + + def expire_discuss_timeout_task(task, actor) + return 0 if task.notified_discuss_expiry_at.present? + + created_comment = false + Task.transaction do + task.update!(notified_discuss_expiry_at: Time.zone.now) + unless task.trigger_transition(trigger: 'fix', by_user: actor, system_transition: true) + raise ActiveRecord::Rollback + end + + comment = task.add_discuss_timeout_comment( + actor, + DiscussTimeoutComment.expired, + "This task moved to Fix and Resubmit because it was not discussed by the deadline. Review any feedback and resubmit it when you are ready." + ) + unless comment + raise ActiveRecord::Rollback + end + + queue_discuss_timeout_email(task, actor, :missed) + created_comment = true + end + + created_comment ? 1 : 0 + end + + def discuss_timeout_expiry_date(task, teaching_breaks: nil) + task.discuss_timeout_expiry_date(discuss_timeout_expire_days, teaching_breaks: teaching_breaks) + end + + def formatted_discuss_timeout_date(date) + result = "the #{date.day.ordinalize} of #{Date::MONTHNAMES[date.month]}" + return result if date.year == Time.zone.today.year + + "#{result} #{date.year}" + end + + def queue_discuss_timeout_email(task, actor, type, expiry_date = nil) + return unless send_notifications + return unless task.project.enrolled + return unless task.project.student.receive_feedback_notifications + + SendDiscussTimeoutEmailJob.perform_async(task.id, actor.id, type.to_s, expiry_date&.iso8601) + end + def detailed_name "#{name} #{teaching_period.present? ? teaching_period.detailed_name : start_date.strftime('%Y-%m-%d')}" end @@ -236,6 +417,14 @@ def active_projects projects.where(enrolled: true) end + def refresh_communication_schedule_caches + communication_set_schedules.find_each(&:refresh_next_run_at!) + end + + def saved_change_to_communication_schedule_inputs? + saved_change_to_active? || saved_change_to_start_date? || saved_change_to_end_date? + end + def ordered_task_definitions task_definitions.order('start_date ASC, abbreviation ASC') end @@ -285,6 +474,7 @@ def teaching_period=(tp) if tp.present? self[:start_date] = tp.start_date self[:end_date] = tp.end_date + self[:last_teaching_date] = tp.last_teaching_date self[:teaching_period_id] = tp.id end super(tp) @@ -294,6 +484,58 @@ def has_teaching_period? self.teaching_period.present? end + def has_main_content_site? + unit_content_sites.exists?(is_main: true) + end + + def grade_values + grade_definitions.filter_map { |definition| definition['value'] unless definition['value'] == -1 } + end + + def grade_definitions + normalize_grade_definitions(self[:grade_values]) + end + + def grade_value?(value) + grade_values.include?(value.to_i) + end + + def assessment_grade_value?(value) + grade_definitions.any? { |definition| definition['value'] == value.to_i } + end + + def grade_definition(value) + return nil if value.nil? + + grade_definitions.find { |definition| definition['value'] == value.to_i } + end + + def grade_label(value) + grade_definition(value)&.fetch('label', nil) || GradeHelper.grade_for(value) + end + + def grade_abbreviation(value) + grade_definition(value)&.fetch('abbreviation', nil) || GradeHelper.short_grade_for(value) + end + + def grade_definitions=(definitions) + normalized = normalize_grade_definitions(definitions, sort: false) + fail_definition = normalized.find { |definition| definition['value'] == -1 } + target_definitions = normalized.reject { |definition| definition['value'] == -1 } + + fail_definition['value'] = -1 + target_definitions.each_with_index { |definition, index| definition['value'] = index } + self[:grade_values] = grade_values_for_column([fail_definition, *target_definitions]) + end + + def grade_values_for_column(definitions) + if self.class.type_for_attribute('grade_values').is_a?(ActiveRecord::Type::Json) + definitions + else + definitions.to_json + end + end + def ensure_teaching_period_dates_match if self[:start_date] != teaching_period.start_date errors.add(:start_date, "should match teaching period date") @@ -301,6 +543,9 @@ def ensure_teaching_period_dates_match if self[:end_date] != teaching_period.end_date errors.add(:end_date, "should match teaching period date") end + if last_teaching_date != teaching_period.last_teaching_date + errors.add(:last_teaching_date, "should match teaching period date") + end end def ensure_main_convenor_is_appropriate @@ -308,6 +553,7 @@ def ensure_main_convenor_is_appropriate errors.add(:main_convenor, "must be a staff member from unit") unless id == main_convenor.unit_id errors.add(:main_convenor, "must be configured to administer unit") unless main_convenor.is_convenor? + errors.add(:main_convenor, "cannot be observer only") if main_convenor.observer_only? errors.add(:main_convenor, "must be capable of administering units - ensure user has appropriate permissions (contact admin staff to update)") unless main_convenor_user.has_convenor_capability? end @@ -320,6 +566,79 @@ def cant_disable_aip_only_if_aip_tasks_exist end end + def grade_definitions_are_valid + definitions = grade_definitions + values = definitions.map { |definition| definition['value'] } + target_values = values.reject { |value| value == -1 } + + errors.add(:grade_definitions, 'must include a failure grade and at least one target grade') if definitions.length < 2 + errors.add(:grade_definitions, 'must include index -1 exactly once') unless values.count(-1) == 1 + errors.add(:grade_definitions, 'target grade indexes must be unique non-negative integers') unless target_values.all? { |value| value >= 0 } && target_values.uniq.length == target_values.length + errors.add(:grade_definitions, 'must use unique identifiers') unless definitions.map { |definition| definition['id'] }.uniq.length == definitions.length + errors.add(:grade_definitions, 'must use unique labels') unless definitions.map { |definition| definition['label'].downcase }.uniq.length == definitions.length + errors.add(:grade_definitions, 'must use unique abbreviations') unless definitions.map { |definition| definition['abbreviation'].downcase }.uniq.length == definitions.length + + definitions.each do |definition| + errors.add(:grade_definitions, 'labels must be present and no longer than 50 characters') unless definition['label'].present? && definition['label'].length <= 50 + errors.add(:grade_definitions, 'abbreviations must be present and no longer than 10 characters') unless definition['abbreviation'].present? && definition['abbreviation'].length <= 10 + end + end + + def configured_grades_preserve_used_values + used_values = task_definitions.distinct.pluck(:target_grade) + used_values |= projects.distinct.pluck(:target_grade, :submitted_grade).flatten.compact + used_values |= tasks.distinct.pluck(:grade).compact + used_values |= communication_rules.joins(:communication_conditions) + .pluck('communication_conditions.target_grade', 'communication_conditions.task_target_grade') + .flatten + .compact + used_values |= communication_rules.joins(:communication_actions) + .pluck('communication_actions.target_grade') + .compact + previous_definitions = normalize_grade_definitions(attribute_in_database('grade_values')) + current_by_id = grade_definitions.index_by { |definition| definition['id'] } + + changed_values = used_values.select do |value| + previous_definition = previous_definitions.find { |definition| definition['value'] == value } + current_definition = current_by_id[previous_definition&.fetch('id', nil)] + current_definition.nil? || current_definition['value'] != value + end + + errors.add(:grade_definitions, "cannot remove or reorder grades currently in use at indexes: #{changed_values.uniq.sort.join(', ')}") if changed_values.any? + end + + def normalize_grade_definitions(raw_definitions, sort: true) + raw_definitions = DEFAULT_GRADE_DEFINITIONS if raw_definitions.blank? + raw_definitions = JSON.parse(raw_definitions) if raw_definitions.is_a?(String) + + definitions = Array(raw_definitions).map do |definition| + definition = definition.to_h if definition.respond_to?(:to_h) + if definition.is_a?(Hash) + definition = definition.stringify_keys + { + 'id' => definition['id'].presence || SecureRandom.uuid, + 'value' => definition['value'].to_i, + 'label' => definition['label'].to_s.strip, + 'abbreviation' => definition['abbreviation'].to_s.strip.upcase + } + else + value = definition.to_i + default = DEFAULT_GRADE_DEFINITIONS.find { |item| item['value'] == value } + default&.dup || { + 'id' => "grade-#{value}", + 'value' => value, + 'label' => "Grade #{value}", + 'abbreviation' => "G#{value}" + } + end + end + + unless definitions.any? { |definition| definition['value'] == -1 } + definitions.unshift(DEFAULT_GRADE_DEFINITIONS.first.dup) + end + + sort ? definitions.sort_by { |definition| definition['value'] } : definitions + end def validate_end_date_after_start_date if end_date.present? && start_date.present? && end_date < start_date @@ -327,13 +646,31 @@ def validate_end_date_after_start_date end end + def default_last_teaching_date + self.last_teaching_date ||= teaching_period&.last_teaching_date || end_date&.to_date + end + + def sync_last_teaching_date_with_end_date + return unless will_save_change_to_end_date? && !will_save_change_to_last_teaching_date? + return unless last_teaching_date == end_date_change_to_be_saved.first&.to_date + + self.last_teaching_date = end_date&.to_date + end + + def validate_last_teaching_date + return if start_date.blank? || end_date.blank? || last_teaching_date.blank? + return if last_teaching_date.between?(start_date.to_date, end_date.to_date) + + errors.add(:last_teaching_date, 'must be between the start and end dates') + end + def autogen_date_within_unit_active_period if [start_date, end_date, portfolio_auto_generation_date].all?(&:present?) && !(start_date < portfolio_auto_generation_date && portfolio_auto_generation_date <= end_date) errors.add(:portfolio_auto_generation_date, "should be after unit start date and before unit end date") end end - def rollover(teaching_period, start_date, end_date, new_code) + def rollover(teaching_period, start_date, end_date, new_code, last_teaching_date = nil) new_unit = self.dup new_unit.code = new_code if new_code.present? @@ -344,6 +681,7 @@ def rollover(teaching_period, start_date, end_date, new_code) new_unit.teaching_period = nil new_unit.start_date = start_date new_unit.end_date = end_date + new_unit.last_teaching_date = last_teaching_date || end_date&.to_date end # Clear archived @@ -433,23 +771,35 @@ def rollover(teaching_period, start_date, end_date, new_code) end end + communication_sets.each do |communication_set| + communication_set.copy_to(new_unit) + end + + # Duplicate content sites and their links (grade, task sheet and task resource pages) + site_mapping = unit_content_sites.to_h { |site| [site.id, site.copy_to(new_unit)] } + unit_content_links.each do |link| + new_link = link.dup + new_link.unit = new_unit + new_link.unit_content_site = site_mapping.fetch(link.unit_content_site_id) + new_link.save! + end + # Now duplicate all feedback chips chip_mapping = {} outcome_mapping.each do |source_outcome, new_outcome| - source_outcome.feedback_chips.each do |chip| + source_outcome.feedback_chips.find_each do |chip| new_chip = chip.dup - new_outcome.feedback_chips << new_chip - new_chip.learning_outcome_id = new_outcome.id - new_chip.parent_chip_id = nil + new_chip.learning_outcome = new_outcome + new_chip.parent_chip = nil new_chip.save! - chip_mapping[chip] = new_chip + chip_mapping[chip.id] = new_chip end source_outcome.feedback_chips.where.not(parent_chip_id: nil).find_each do |old_chip| - child_chip = chip_mapping[old_chip] - parent_chip = chip_mapping[old_chip.parent_chip] - child_chip.update(parent_chip_id: parent_chip.id) + child_chip = chip_mapping.fetch(old_chip.id) + parent_chip = chip_mapping.fetch(old_chip.parent_chip_id) + child_chip.update!(parent_chip: parent_chip) end end @@ -1258,7 +1608,7 @@ def export_users_to_csv ).group( 'projects.id', 'student_id', 'username', 'first_name', 'nickname', 'last_name', 'email', 'campus_abbreviation' ).each do |row| - csv << ([ + csv << escape_spreadsheet_formulas([ code, row['campus_abbreviation'], row['username'], @@ -1548,15 +1898,19 @@ def date_for_week_and_day(week, day) start_day_num = start_date.wday - start_date + week.weeks + (day_num - start_day_num).days + start_date + (week - 1).weeks + (day_num - start_day_num).days end end def week_number(date) + return nil if date.nil? || start_date.nil? + if teaching_period.present? teaching_period.week_number(date) else - ((date - start_date) / 1.week).floor + 1 + target_date = date.to_date + unit_start_date = start_date.to_date + ((target_date - unit_start_date).to_i / 7).floor + 1 end end @@ -1573,23 +1927,28 @@ def import_tasks_from_csv(file) headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_').to_sym unless hdr.nil? }], converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| - next if row[0] =~ /^(Task Name)|(name)/ # Skip header + next if ['Task Name', 'name'].include?(row[0].to_s.strip) # Skip header rows begin - missing = missing_headers(row, TaskDefinition.csv_columns) + missing = missing_headers(row, TaskDefinition.required_csv_columns) if missing.count > 0 errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end + existing_task_definition = task_definitions.find_by(abbreviation: row[:abbreviation]&.strip) + existing_task_definition ||= task_definitions.find_by(name: row[:name]&.strip) + existing_task_definition&.task_prerequisites&.destroy_all + task_definition, new_task, message = TaskDefinition.task_def_for_csv_row(self, row) - prerequisites_by_task[task_definition.abbreviation] = JSON.parse(row[:task_prerequisites]) unless row[:task_prerequisites].nil? if task_definition.nil? errors << { row: row, message: message } next end + prerequisites_by_task[task_definition.abbreviation] = JSON.parse(row[:task_prerequisites]) unless row[:task_prerequisites].nil? + success << { row: row, message: message } rescue Exception => e errors << { row: row, message: e.message } @@ -1611,6 +1970,14 @@ def import_tasks_from_csv(file) prerequisite_td = task_definitions.find_by(abbreviation: abbreviation) task_status_id = prerequisite['task_status_id'].to_i + if prerequisite_td.nil? + errors << { + row: "TaskDef '#{task_abbreviation}' prerequisites: #{prerequisites_list}", + message: "Unable to find prerequisite task definition with abbreviation #{abbreviation}." + } + next + end + TaskPrerequisite.create!({ task_definition_id: td.id, prerequisite: prerequisite_td, @@ -1630,7 +1997,7 @@ def import_tasks_from_csv(file) end def task_definitions_csv - TaskDefinition.to_csv(task_definitions) + TaskDefinition.to_csv(self, task_definitions) end def task_definitions_by_grade @@ -1645,7 +2012,7 @@ def tutorial_stream_abbr def times_tasks_have_been_assessed CSV.generate() do |csv| # Add headers - csv << ([ + csv << [ 'Tutorial', 'Tutor', 'Student Username', @@ -1661,7 +2028,7 @@ def times_tasks_have_been_assessed 'demonstrate', 'ready_for_feedback', 'discussed_in_class' - ]) + ] tasks .joins("LEFT JOIN task_engagements ON task_engagements.task_id = tasks.id") @@ -1715,7 +2082,7 @@ def times_tasks_have_been_assessed def days_awaiting_feedback_by_tutorial_csv CSV.generate() do |csv| # Add headers - csv << ([ + csv << [ 'Tutorial', 'Tutor', 'Username', @@ -1723,64 +2090,80 @@ def days_awaiting_feedback_by_tutorial_csv 'Project ID', 'Task Definition', 'Task ID', - 'Days Awaiting Feedback' - ]) + 'Days Awaiting Feedback', + 'Days Awaiting Feedback (Incl. Breaks)' + ] # Add data - tasks - .joins(:task_definition) - .joins('INNER JOIN users ON users.id = projects.user_id') - .joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)") - .joins('LEFT JOIN tutorials ON tutorials.id = sq.tutorial_id') - .select( - 'users.username AS username', - 'users.first_name AS first_name', - 'users.last_name AS last_name', - 'tasks.id as task_id', - 'task_definitions.abbreviation as task_abbr', - 'tasks.project_id as project_id', - 'DATEDIFF(CURDATE(),submission_date) AS days_since_submission', - 'tutorial_id', - 'tutorials.unit_role_id as unit_role_id', - 'tutorials.abbreviation AS tutorial_abbreviation' - ) - .group('tasks.id', 'task_definitions.abbreviation', 'tasks.project_id', 'tutorial_id', 'unit_role_id', 'submission_date') - .order('unit_role_id', 'days_since_submission DESC') - .where(projects: { enrolled: true }) - .where(task_status: TaskStatus.ready_for_feedback) - .each do |row| - csv << ([ + rows = tasks + .includes(project: { unit: { teaching_period: :breaks } }) + .joins(:task_definition) + .joins('INNER JOIN users ON users.id = projects.user_id') + .joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)") + .joins('LEFT JOIN tutorials ON tutorials.id = sq.tutorial_id') + .select( + 'users.username AS username', + 'users.first_name AS first_name', + 'users.last_name AS last_name', + 'tasks.id as task_id', + 'task_definitions.abbreviation as task_abbr', + 'tasks.project_id as project_id', + 'tasks.submission_date', + 'tutorial_id', + 'tutorials.unit_role_id as unit_role_id', + 'tutorials.abbreviation AS tutorial_abbreviation' + ) + .group('tasks.id', 'task_definitions.abbreviation', 'tasks.project_id', 'tutorial_id', 'unit_role_id', 'tasks.submission_date') + .where(projects: { enrolled: true }) + .where(task_status: TaskStatus.ready_for_feedback) + .to_a + + unit_roles_by_id = UnitRole.includes(:user).where(id: rows.map(&:unit_role_id).compact.uniq).index_by(&:id) + + rows + .sort_by { |row| [row.unit_role_id || Float::INFINITY, -row.days_awaiting_feedback] } + .each do |row| + csv << [ row['tutorial_abbreviation'], - row['unit_role_id'].present? ? UnitRole.find(row['unit_role_id']).user.name : '', + row.unit_role_id.present? ? unit_roles_by_id[row.unit_role_id]&.user&.name.to_s : '', row['username'], "#{row['first_name']} #{row['last_name']}", row['project_id'], row['task_abbr'], row['task_id'], - row['days_since_submission'] - ]) + row.days_awaiting_feedback, + row.calendar_days_awaiting_feedback + ] end end end def task_completion_csv + task_completion_csv_generator() + end + + def task_completion_csv_generator(task_status_uses_id: false) task_def_by_grade = task_definitions_by_grade streams = tutorial_streams grp_sets = group_sets + base_headers = [ + 'Student ID', + 'Username', + 'Student Name', + ] + base_headers << 'Campus' + base_headers.push( + 'Target Grade', + 'Email', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + ) CSV.generate() do |csv| # Add header row - csv << ([ - 'Student ID', - 'Username', - 'Student Name', - 'Target Grade', - 'Email', - 'Portfolio', - 'Grade', - 'Rationale', - 'Assessor', - ] + + csv << (base_headers + (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['Tutorial']) + grp_sets.map(&:name) + task_def_by_grade.map do |task_definition| @@ -1795,18 +2178,25 @@ def task_completion_csv # Get the details to fetch for each task definition... td_select = task_def_by_grade.map do |td| result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + if task_status_uses_id + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" + else + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + end result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? result end.flatten + group_table_alias = 'task_completion_groups' + # Query across all projects, joined to task's via definitions to ensure all definitions are covered active_projects .joins( :unit, 'INNER JOIN users ON projects.user_id = users.id', + 'LEFT OUTER JOIN campuses ON campuses.id = projects.campus_id', 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', @@ -1814,24 +2204,28 @@ def task_completion_csv 'LEFT OUTER JOIN tasks ON tasks.task_definition_id = task_definitions.id AND projects.id = tasks.project_id', 'LEFT OUTER JOIN task_statuses ON tasks.task_status_id = task_statuses.id', 'LEFT OUTER JOIN group_memberships ON group_memberships.project_id = projects.id AND group_memberships.active = TRUE', - 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' + "LEFT OUTER JOIN #{Group.quoted_table_name} #{group_table_alias} ON #{group_table_alias}.id = group_memberships.group_id" ).select( 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'projects.assessor_id as project_assessor', - 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', + 'users.last_name as last_name', 'campuses.abbreviation as campus_abbreviation', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', *td_select, # Get tutorial for each stream in unit *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, # Get tutorial for case when no stream "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", - *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } + *grp_sets.map { |gs| "MAX(CASE WHEN #{group_table_alias}.group_set_id = #{gs.id} THEN #{group_table_alias}.name ELSE NULL END) AS grp_#{gs.id}" } ).group( - 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' + 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'campus_abbreviation', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' ).each do |row| - csv << ([ + student_details = [ row['student_id'], row['username'], "#{row['first_name']} #{row['last_name']}", - GradeHelper.grade_for(row['target_grade']), + ] + student_details << row['campus_abbreviation'] + + csv << (student_details + [ + grade_label(row['target_grade']), row['email'], row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], create: true)), row['grade'] > 0 ? row['grade'] : nil, @@ -1846,8 +2240,12 @@ def task_completion_csv end.flatten + grp_sets.map do |gs| row["grp_#{gs.id}"] end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] - result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? + if task_status_uses_id + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.id : row["status_#{td.id}"].to_i] + else + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] + end + result << grade_abbreviation(row["grade_#{td.id}"]) if td.is_graded? result << row["stars_#{td.id}"] if td.has_stars? result << row["people_#{td.id}"] if td.is_group_task? result @@ -1859,7 +2257,7 @@ def task_completion_csv def staff_notes_csv CSV.generate() do |csv| # Add headers - csv << ([ + csv << [ 'Student Username', 'Project ID', 'Student Name', @@ -1867,7 +2265,7 @@ def staff_notes_csv 'Created', 'Author Name', 'Author Username', - ]) + ] StaffNote.joins(project: :unit) .where(units: { id: id }) @@ -1886,6 +2284,97 @@ def staff_notes_csv end end + def import_staff_notes_from_csv(file, author_id, progress_callback: nil) + success = [] + errors = [] + ignored = [] + author = User.find(author_id) + + csv = CSV.new(File.read(file), headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) + unless csv.header_row? + errors << { row: [], message: "Header row missing" } + return { success: success, ignored: ignored, errors: errors } + end + + csv.shift + total_rows = csv.read.size + progress_callback&.call(message: "Importing staff notes", rows_processed: 0, total_rows: total_rows) + + CSV.foreach(file, headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).with_index(1) do |row, row_count| + progress_callback&.call(message: "Importing staff notes", rows_processed: row_count, total_rows: total_rows) + + begin + missing = missing_headers(row, ['comment']) + unless row.headers.include?('username') || row.headers.include?('login_id') + missing << 'username or login_id' + end + if missing.any? + errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } + next + end + + username = row['username']&.strip + login_id = row['login_id']&.strip + comment = row['comment']&.strip + + if username.blank? && login_id.blank? + errors << { row: row, message: "Username or login_id is required" } + next + end + + if comment.blank? + errors << { row: row, message: "Comment is required" } + next + end + + username_user = User.find_by(username: username.downcase) if username.present? + login_id_user = User.find_by(login_id: login_id) if login_id.present? + + if username.present? && username_user.nil? + errors << { row: row, message: "Could not find user with username #{username}" } + next + end + + if login_id.present? && login_id_user.nil? + errors << { row: row, message: "Could not find user with login_id #{login_id}" } + next + end + + if username_user && login_id_user && username_user != login_id_user + errors << { row: row, message: "Username and login_id do not match the same user" } + next + end + + student = username_user || login_id_user + project = projects.find_by(user: student) + if project.nil? + errors << { row: row, message: "Student is not enrolled in unit" } + next + end + + if project.staff_notes.last&.note == comment + ignored << { row: row, message: "Staff note already exists" } + next + end + + project.add_staff_note(author, comment) + success << { row: row, message: "Staff note added for #{student.username}" } + rescue Exception => e + errors << { row: row, message: e.message } + end + end + + { + success: success, + ignored: ignored, + errors: errors + } + end + def get_portfolio_zip_filename(current_user) filename = FileHelper.sanitized_filename("portfolios-#{code}-#{current_user.username}") "#{FileHelper.tmp_file(filename)}.zip" @@ -1900,27 +2389,52 @@ def get_portfolio_zip(current_user, progress_callback: nil) # All active projects with a compiled portfolio portfolio_projects = active_projects.select(&:portfolio_available) - progress_callback.call(message: "Initialising portfolio download", total_rows: portfolio_projects.count, rows_processed: portfolio_projects.count) if progress_callback + if File.exist?(portfolio_zip_name) + progress_callback&.call( + message: "Initialising portfolio download", + total_rows: portfolio_projects.count, + rows_processed: portfolio_projects.count + ) + return portfolio_zip_name + end - return portfolio_zip_name if File.exist?(portfolio_zip_name) + progress_callback&.call( + message: "Adding portfolios to archive", + total_rows: portfolio_projects.count, + rows_processed: 0 + ) - progress_callback.call(message: "Initialising portfolio download", total_rows: portfolio_projects.count, rows_processed: 0) if progress_callback - count = 0 + partial_result = "#{portfolio_zip_name}.partial-#{SecureRandom.hex(8)}" + processed_count = 0 - # Create a new zip - Zip::File.open(portfolio_zip_name, Zip::File::CREATE) do |zip| - portfolio_projects.each do |project| - count += 1 - progress_callback.call(message: "Compressing portfolios", rows_processed: count) if progress_callback + begin + Zip::OutputStream.open(partial_result) do |zip| + portfolio_projects.each do |project| + # Starting the next entry finalises the previous one. Report progress + # only after that finalisation, not merely after feeding it data. + dst_path = FileHelper.sanitized_path( + project.target_grade_desc.to_s, + "#{project.student.username}-portfolio (#{project.tutors_and_tutorial})" + ) + '.pdf' + zip.put_next_entry(dst_path) + + if processed_count.positive? + progress_callback&.call(message: "Adding portfolios to archive", rows_processed: processed_count) + end - # Add file to zip in grade folder - src_path = project.portfolio_path - dst_path = FileHelper.sanitized_path(project.target_grade_desc.to_s, "#{project.student.username}-portfolio (#{project.tutors_and_tutorial})") + '.pdf' + File.open(project.portfolio_path, 'rb') { |portfolio| IO.copy_stream(portfolio, zip) } + processed_count += 1 + end + end + + # Closing finalises the last PDF and writes the central directory. Publish + # the completed archive before reporting 100% to the job monitor. + FileUtils.mv(partial_result, portfolio_zip_name) + progress_callback&.call(message: "Adding portfolios to archive", rows_processed: processed_count) + ensure + FileUtils.rm_f(partial_result) + end - # copy into zip - zip.add(dst_path, src_path) - end # active_projects - end # zip portfolio_zip_name end @@ -1942,8 +2456,19 @@ def get_task_resources_zip end if td.has_task_resources? - dst_path = FileHelper.sanitized_filename(td.abbreviation.to_s) + '.zip' - zip.add(dst_path, td.task_resources) + linked_resource = td.linked_task_resource + + if linked_resource && !td.task_resource_zip?(linked_resource) + dst_path = File.join( + FileHelper.sanitized_filename(td.abbreviation.to_s), + FileHelper.sanitized_filename(linked_resource[:filename]) + ) + zip.add(dst_path, linked_resource[:path]) + else + dst_path = FileHelper.sanitized_filename(td.abbreviation.to_s) + '.zip' + resource_path = linked_resource ? linked_resource[:path] : td.task_resources + zip.add(dst_path, resource_path) + end end end end # zip @@ -1953,85 +2478,176 @@ def get_task_resources_zip # # Create a temp zip file with all submission PDFs for a task # + def task_submissions_pdf_zip_path(current_user, td) + FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-pdfs.zip") + end + + def task_submissions_cache_current?(path, td) + return false unless File.file?(path) + + latest_project_update = td.tasks.joins(:project).maximum('projects.updated_at') + File.mtime(path) >= [td.updated_at, latest_project_update].compact.max + end + def get_task_submissions_pdf_zip(current_user, td, progress_callback: nil) # Get a temp file path - result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-pdfs.zip") + result = task_submissions_pdf_zip_path(current_user, td) tasks_with_files = td.related_tasks_with_files - progress_callback.call(message: "Initialising submission pdfs download", total_rows: tasks_with_files.count, rows_processed: tasks_with_files.count) if progress_callback + if task_submissions_cache_current?(result, td) + progress_callback&.call( + message: "Initialising submission pdfs download", + total_rows: tasks_with_files.count, + rows_processed: tasks_with_files.count + ) + return result + end - return result if File.exist?(result) + zip_root_path = "#{td.abbreviation}-pdfs" + pdf_entries = tasks_with_files.each_with_object({}) do |task, entries| + path_part = if td.is_group_task? && task.group + task.group.name.to_s + else + task.student.username.to_s + end - progress_callback.call(message: "Initialising submission pdfs download", total_rows: tasks_with_files.count, rows_processed: 0) if progress_callback + entries[File.join(zip_root_path, "#{path_part}.pdf")] = task.final_pdf_path + end - count = 0 + total_pdfs = pdf_entries.count + progress_callback&.call( + message: "Adding submission PDFs to archive", + total_rows: total_pdfs, + rows_processed: 0 + ) - # Create a new zip - Zip::File.open(result, Zip::File::CREATE) do |zip| - Dir.mktmpdir do |dir| - # Extract all of the files... - tasks_with_files.each do |task| - count += 1 - progress_callback.call(message: "Compressing submission pdfs", rows_processed: count) if progress_callback - - path_part = if td.is_group_task? && task.group - task.group.name.to_s - else - task.student.username.to_s - end + partial_result = "#{result}.partial-#{SecureRandom.hex(8)}" + processed_count = 0 - FileUtils.cp task.final_pdf_path, File.join(dir, path_part.to_s) + '.pdf' - end # each task + begin + Zip::OutputStream.open(partial_result) do |zip| + pdf_entries.each do |zip_path, pdf_path| + # Starting the next entry finalises the previous one. Report progress + # only after that finalisation, not merely after feeding it data. + zip.put_next_entry(zip_path) + progress_callback&.call(message: "Adding submission PDFs to archive", rows_processed: processed_count) if processed_count.positive? + + File.open(pdf_path, 'rb') { |pdf| IO.copy_stream(pdf, zip) } + processed_count += 1 + end + end + + # Closing the output stream finalises the last entry and writes the + # central directory, so 100% now means the archive is genuinely ready. + FileUtils.mv(partial_result, result) + progress_callback&.call(message: "Adding submission PDFs to archive", rows_processed: processed_count) + ensure + FileUtils.rm_f(partial_result) + end - # Copy files into zip - zip_root_path = "#{td.abbreviation}-pdfs" - FileHelper.recursively_add_dir_to_zip(zip, dir, zip_root_path) - end # mktmpdir - end # zip result end # # Create a temp zip file with all submissions for a task # + def task_submissions_zip_path(current_user, td) + FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-named-files.zip") + end + def get_task_submissions_zip(current_user, td, progress_callback: nil) # Get a temp file path - result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-files.zip") + result = task_submissions_zip_path(current_user, td) tasks_with_files = td.related_tasks_with_files - progress_callback.call(message: "Initialising submission files download", total_rows: tasks_with_files.count, rows_processed: tasks_with_files.count) if progress_callback + submissions = tasks_with_files.each_with_object({}) do |task, entries| + path_part = if td.is_group_task? && task.group + task.group.name.to_s + else + task.student.username.to_s + end + + # Group tasks can expose the same underlying submission through multiple + # student tasks. Match the old extracted-directory behaviour by retaining + # one archive per output folder. + entries[path_part] = task + end - return result if File.exist?(result) - progress_callback.call(message: "Initialising submission files download", total_rows: tasks_with_files.count, rows_processed: 0) if progress_callback + missing_submissions = [] + submissions.reject! do |path_part, task| + missing = !File.file?(task.zip_file_path_for_done_task) + missing_submissions << "#{path_part} (task #{task.id})" if missing + missing + end + logger.warn "Skipping missing submission archives: #{missing_submissions.join(', ')}" if missing_submissions.any? + + cache_current = missing_submissions.empty? && task_submissions_cache_current?(result, td) && + submissions.values.all? do |task| + source = task.zip_file_path_for_done_task + File.file?(source) && File.mtime(result) >= File.mtime(source) + end + + if cache_current + progress_callback&.call( + message: "Initialising submission files download", + total_rows: submissions.count, + rows_processed: submissions.count + ) + return result + end - count = 0 + progress_callback&.call( + message: "Adding submission files to archive", + total_rows: submissions.count, + rows_processed: 0 + ) - # Create a new zip - Zip::File.open(result, Zip::File::CREATE) do |zip| - Dir.mktmpdir do |dir| - # Extract all of the files... - tasks_with_files.each do |task| - count += 1 - progress_callback.call(message: "Compressing submission files", rows_processed: count) if progress_callback - - path_part = if td.is_group_task? && task.group - task.group.name.to_s - else - task.student.username.to_s - end + zip_root_path = "#{td.abbreviation}-submissions" + partial_result = "#{result}.partial-#{SecureRandom.hex(8)}" + processed_count = 0 - task.extract_file_from_done(dir, '*', - ->(_task, to_path, name) { File.join(to_path.to_s, path_part, name.to_s) }) # call + begin + Zip::OutputStream.open(partial_result) do |output_zip| + submissions.each do |path_part, task| + Zip::File.open(task.zip_file_path_for_done_task) do |submission_zip| + submission_zip.each do |entry| + next if entry.name_is_directory? + + # Done archives store files below a task-id folder. The aggregate + # archive replaces that folder with the student or group name. + relative_path = task.download_filename_for(entry.name) + aggregate_entry = entry.dup + aggregate_entry.name = File.join(zip_root_path, path_part, relative_path) + output_zip.copy_raw_entry(aggregate_entry) + end + end - FileUtils.mv Dir.glob("#{dir}/#{path_part}/#{task.id}/*"), File.join(dir, path_part.to_s) - FileUtils.rm_r "#{dir}/#{path_part}/#{task.id}" if File.directory?("#{dir}/#{path_part}/#{task.id}") - end # each task + processed_count += 1 + if processed_count < submissions.count + progress_callback&.call( + message: "Adding submission files to archive", + rows_processed: processed_count + ) + end + end + + if missing_submissions.any? + output_zip.put_next_entry(File.join(zip_root_path, 'missing-submissions.txt')) + output_zip.write("Submission files were unavailable for:\n#{missing_submissions.join("\n")}\n") + end + end + + # ZIP entries are copied in their existing compressed form. Closing only + # writes headers and the central directory; publish before reporting 100%. + FileUtils.mv(partial_result, result) + progress_callback&.call( + message: "Adding submission files to archive", + rows_processed: processed_count + ) + ensure + FileUtils.rm_f(partial_result) + end - # Copy files into zip - zip_root_path = "#{td.abbreviation}-submissions" - FileHelper.recursively_add_dir_to_zip(zip, dir, zip_root_path) - end # mktmpdir - end # zip result end @@ -2094,6 +2710,8 @@ def tasks_as_hash(data) status: TaskStatus.id_to_key(t.status_id), completion_date: t.completion_date, submission_date: t.submission_date, + # Only the inbox query computes this -- other callers fall back to the raw date + waiting_since: t.has_attribute?('waiting_since') ? t.waiting_since : t.submission_date, times_assessed: t.times_assessed, grade: t.grade, quality_pts: t.quality_pts, @@ -2115,18 +2733,36 @@ def tutorial_enrolment_subquery # Return all tasks from the database for this unit and given user # def get_all_tasks_for(user, my_tutorials_only = false) + staff_attention = TaskComment.attention_audiences.fetch('staff') + unread_comment = '(COALESCE(crc.last_read_comment_id, 0) < task_comments.id ' \ + 'AND COALESCE(tutor_crc.last_read_comment_id, 0) < task_comments.id ' \ + "AND task_comments.user_id <> #{user.id.to_i})" + similarity_stats = TaskSimilarity + .select('task_id', 'COUNT(*) AS similarity_count', 'SUM(flagged) AS flagged_count') + .group(:task_id) + .to_sql result = student_tasks. joins(:task_status). joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)"). - joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment') AND (task_comments.content_type IS NULL OR (task_comments.content_type <> 'plan' AND task_comments.content_type <> 'discussed_in_class'))"). - joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}"). + joins("LEFT JOIN unit_roles task_tutor_roles ON task_tutor_roles.id = COALESCE(sq.unit_role_id, #{main_convenor_id.to_i})"). + joins("LEFT JOIN comment_read_cursors crc ON crc.task_id = tasks.id AND crc.user_id = #{user.id.to_i}"). + joins('LEFT JOIN comment_read_cursors tutor_crc ON tutor_crc.task_id = tasks.id ' \ + 'AND tutor_crc.user_id = task_tutor_roles.user_id'). + joins( + 'LEFT JOIN task_comments ON task_comments.task_id = tasks.id ' \ + "AND (task_comments.attention_audience IS NULL OR task_comments.attention_audience = #{staff_attention}) " \ + "AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment') " \ + "AND (task_comments.content_type IS NULL OR (task_comments.content_type <> 'plan' " \ + "AND task_comments.content_type <> 'discussed_in_class'))" + ). joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{user.id}"). - joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id'). + joins("LEFT JOIN (#{similarity_stats}) task_similarity_stats ON task_similarity_stats.task_id = tasks.id"). select( 'sq.tutorial_id AS tutorial_id', 'sq.tutorial_stream_id AS tutorial_stream_id', 'tasks.id', - "SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread", + "SUM(case when #{unread_comment} AND task_comments.id IS NOT NULL " \ + 'then COALESCE(task_similarity_stats.similarity_count, 1) else 0 end) as number_unread', 'COUNT(distinct task_pins.task_id) != 0 as pinned', "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", 'project_id', @@ -2139,7 +2775,7 @@ def get_all_tasks_for(user, my_tutorials_only = false) 'submission_date', 'tasks.grade as grade', 'quality_pts', - 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' + 'COALESCE(MAX(task_similarity_stats.flagged_count), 0) AS similar_to_count' ). group( 'sq.tutorial_id', @@ -2171,7 +2807,7 @@ def get_all_tasks_for(user, my_tutorials_only = false) # def tasks_awaiting_feedback(user) get_all_tasks_for(user) - .where('task_statuses.id IN (:ids)', ids: [TaskStatus.discuss, TaskStatus.attention_required, TaskStatus.redo, TaskStatus.demonstrate, TaskStatus.fix_and_resubmit]) + .where('task_statuses.id IN (:ids)', ids: [TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.attention_required, TaskStatus.redo, TaskStatus.demonstrate, TaskStatus.fix_and_resubmit]) .order('task_definition_id') end @@ -2182,14 +2818,23 @@ def tasks_awaiting_feedback(user) # - those that have the ready for feedback (rff) state, or # - where new student comments are > 0 # - # They are sorted by a task's "action_date". This defines the last - # time a task has been "actioned", either the submission date or latest - # student comment -- whichever is newer. + # Ready for feedback tasks are sorted by submission date. Tasks included + # because of unread comments are sorted by their oldest unread comment so + # follow-up comments do not reset how long the task has been waiting. # def tasks_for_task_inbox(user, my_students_only = false) + unread_comment = '(COALESCE(crc.last_read_comment_id, 0) < task_comments.id ' \ + 'AND COALESCE(tutor_crc.last_read_comment_id, 0) < task_comments.id ' \ + "AND task_comments.user_id <> #{user.id.to_i})" + oldest_unread_comment_at = "MIN(CASE WHEN #{unread_comment} " \ + 'AND task_comments.id IS NOT NULL THEN task_comments.created_at END)' + inbox_date = "CASE WHEN task_statuses.id = #{TaskStatus.ready_for_feedback.id} " \ + "THEN COALESCE(submission_date, #{oldest_unread_comment_at}) " \ + "ELSE COALESCE(#{oldest_unread_comment_at}, submission_date) END" get_all_tasks_for(user, my_students_only) - .having('task_statuses.id IN (:ids) OR COUNT(task_pins.task_id) > 0 OR SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) > 0', ids: [TaskStatus.ready_for_feedback, TaskStatus.need_help]) - .order('pinned DESC, submission_date ASC, MAX(task_comments.created_at) ASC, task_definition_id ASC') + .select(Arel.sql("#{inbox_date} AS waiting_since")) + .having("task_statuses.id IN (:ids) OR COUNT(task_pins.task_id) > 0 OR SUM(case when #{unread_comment} AND task_comments.id IS NOT NULL then COALESCE(task_similarity_stats.similarity_count, 1) else 0 end) > 0", ids: [TaskStatus.ready_for_feedback, TaskStatus.need_help]) + .order(Arel.sql("pinned DESC, #{inbox_date} ASC, task_definition_id ASC, tasks.id ASC")) end # @@ -2465,7 +3110,7 @@ def student_task_completion_stats result[:tutorial][t.id] = _calculate_task_completion_stats(data.select { |r| r[:tutorial_id] == t.id }) end - for i in GradeHelper::RANGE do + for i in grade_values do result[:grade][i] = _calculate_task_completion_stats(data.select { |r| r[:grade] == i }) end @@ -2474,9 +3119,9 @@ def student_task_completion_stats def student_grades_csv CSV.generate do |row| - row << %w(unit_code username student_id target_grade submitted_grade portfolio_production_date has_portfolio spec_con_days grade rationale assessor assessor_id) + row << %w(unit_code username student_id target_grade submitted_grade portfolio_submission_date portfolio_production_date has_portfolio spec_con_days grade rationale assessor assessor_id) active_projects.each do |project| - row << [project.unit.code, project.student.username, project.student.student_id, project.target_grade, project.submitted_grade, project.portfolio_production_date, project.portfolio_exists?, project.spec_con_days, project.grade, project.grade_rationale, project.assessor&.name, project.assessor&.id] + row << [project.unit.code, project.student.username, project.student.student_id, project.target_grade, project.submitted_grade, project.portfolio_submission_date, project.portfolio_production_date, project.portfolio_exists?, project.spec_con_days, project.grade, project.grade_rationale, project.assessor&.name, project.assessor&.id] end end end @@ -2500,6 +3145,43 @@ def tutor_assessment_csv end end + def overflow_task_claims_csv + CSV.generate do |csv| + csv << [ + 'Tutor who claimed', + 'Claiming Unit Role ID', + 'Original Tutor', + 'Student Username', + 'Student ID', + 'Task ID', + 'Task Definition', + 'Days Awaiting Feedback', + 'Timestamp' + ] + + OverflowTaskClaimLog + .where(unit_id: id) + .includes(:claimed_by_user, :original_tutor_user, :student_user, task: :task_definition) + .order(:claimed_at) + .each do |claim| + task = claim.task + student = claim.student_user + + csv << [ + claim.claimed_by_user&.name, + claim.claimed_by_unit_role_id, + claim.original_tutor_user&.name, + student&.username, + student&.student_id, + claim.task_id, + task&.task_definition&.abbreviation, + claim.days_awaiting_feedback, + claim.claimed_at, + ] + end + end + end + #---------------------------------------------------------------------------- # Task updates from offline download/upload #---------------------------------------------------------------------------- @@ -2688,15 +3370,28 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) end begin - task.trigger_transition(trigger: task_entry['status'], by_user: user, quality: task_entry['new quality'].to_i) # saves task - task.grade_task(task_entry['new grade']) # try to grade task if need be + requested_status = TaskStatus.status_for_name(task_entry['status'].to_s) + raise "Unable to update task status to '#{task_entry['status']}'." if requested_status.nil? - if task_entry['new comment'].blank? - success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } - else - task.add_text_comment user, task_entry['new comment'] - success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } - success << { row: {}, message: "Added comment to #{task.task_definition.abbreviation} for #{owner_text}" } + status_changed = false + comment_added = false + + unless task.task_status == requested_status + task.trigger_transition(trigger: task_entry['status'], by_user: user, quality: task_entry['new quality'].to_i) # saves task + task.grade_task(task_entry['new grade']) # try to grade task if need be + status_changed = true + end + + new_comment = task_entry['new comment'].to_s.strip + last_comment = task.comments.last&.comment.to_s.strip + if new_comment.present? && last_comment != new_comment + task.add_text_comment user, new_comment + comment_added = true + end + + if status_changed || comment_added + success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } if status_changed + success << { row: {}, message: "Added comment to #{task.task_definition.abbreviation} for #{owner_text}" } if comment_added end rescue Exception => e errors << { row: task_entry, message: e.message } @@ -2714,10 +3409,11 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) begin done.each do |project, tasks| logger.info "Checking feedback email for project #{project.id}" - if project.student.receive_feedback_notifications - logger.info "Emailing feedback notification to #{project.student.name}" - PortfolioEvidenceMailer.task_feedback_ready(project, tasks).deliver - end + next unless project.enrolled + next unless project.student.receive_feedback_notifications + + logger.info "Emailing feedback notification to #{project.student.name}" + PortfolioEvidenceMailer.task_feedback_ready(project, tasks).deliver end rescue => e logger.error "Failed to send emails from feedback submission. Rescued with error: #{e.message}" @@ -2751,18 +3447,12 @@ def upload_batch_task_zip_or_csv(user, file) update_task_status_from_csv(user, File.read(file["tempfile"].path), success, ignored, errors) else # files are extracted to a temp dir first - i = 0 - tmp_dir = File.join(Dir.tmpdir, 'doubtfire', 'batch', i.to_s) - - while Dir.exist? tmp_dir - i += 1 - tmp_dir = File.join(Dir.tmpdir, 'doubtfire', 'batch', i.to_s) - end + batch_tmp_root = File.join(Dir.tmpdir, 'doubtfire', 'batch') + FileUtils.mkdir_p(batch_tmp_root) + tmp_dir = Dir.mktmpdir('batch-', batch_tmp_root) logger.debug "Created temp directory for batch zip at #{tmp_dir}" - FileUtils.mkdir_p(tmp_dir) - Zip::File.open(file["tempfile"].path) do |zip| # Find the marking file within the directory tree marking_file = zip.glob('**/marks.csv').first @@ -2799,70 +3489,70 @@ def upload_batch_task_zip_or_csv(user, file) # Copy over the updated/marked files to the file system zip.each do |file| # Skip processing marking file - next if ['marks.csv', 'readme.txt'].include?(File.basename(file[:name])) + next if ['marks.csv', 'readme.txt'].include?(File.basename(file.name)) # Test filename pattern - if (/.*-\d+.pdf/i =~ File.basename(file[:name])) != 0 - if file[:name][-1] != '/' - ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } + if (/.*-\d+.pdf/i =~ File.basename(file.name)) != 0 + if file.name[-1] != '/' + ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } end next end - if (/\._.*/ =~ File.basename(file[:name])) == 0 - ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } + if (/\._.*/ =~ File.basename(file.name)) == 0 + ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } next end # Extract the id from the filename - task_id_from_filename = File.basename(file[:name], '.pdf').split('-').last + task_id_from_filename = File.basename(file.name, '.pdf').split('-').last task = Task.find_by(id: task_id_from_filename) if task.nil? - ignored << { row: "File #{file[:name]}", message: 'Unable to find associated task.' } + ignored << { row: "File #{file.name}", message: 'Unable to find associated task.' } next end # Ensure that this task's id is inside entry_data task_entry = entry_data.select { |t| t['task'] == task.task_definition.abbreviation.tr(',', '_') && t['username'] == task.project.user.username }.first if task_entry.nil? - # error!({"error" => "File #{file[:name]} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) - errors << { row: "File #{file[:name]}", message: "Task id #{task.id} not in marks.csv" } + # error!({"error" => "File #{file.name} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) + errors << { row: "File #{file.name}", message: "Task id #{task.id} not in marks.csv" } next end if task.unit != self - errors << { row: "File #{file[:name]}", message: 'This task does not relate to this unit.' } + errors << { row: "File #{file.name}", message: 'This task does not relate to this unit.' } next end # Can the user assess this task? unless AuthorisationHelpers.authorise? user, task, :put - errors << { row: "File #{file[:name]}", error: "You do not have permission to assess task with id #{task.id}" } + errors << { row: "File #{file.name}", error: "You do not have permission to assess task with id #{task.id}" } next end # Read into the task's final pdf path the new file - tmp_file = File.join(tmp_dir, File.basename(file[:name])) + tmp_file = File.join(tmp_dir, File.basename(file.name)) # get file out of zip... to tmp_file file.extract(tmp_file) { true } # copy tmp_file to dest - if FileHelper.copy_pdf(tmp_file, task.final_pdf_path) + destination_path = task.final_pdf_path(ignore_portfolio_evidence: true) + + if FileHelper.copy_pdf(tmp_file, destination_path) + task.update(portfolio_evidence: nil) if task.portfolio_evidence.present? if task.group.nil? - success << { row: "File #{file[:name]}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } + success << { row: "File #{file.name}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } else - success << { row: "File #{file[:name]}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } + success << { row: "File #{file.name}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } end FileUtils.rm tmp_file else - errors << { row: "File #{file[:name]}", message: 'The file does not appear to be a valid PDF.' } + errors << { row: "File #{file.name}", message: 'The file does not appear to be a valid PDF.' } next end end end - - # Remove the extract dir - FileUtils.rm_rf tmp_dir end { @@ -2870,10 +3560,334 @@ def upload_batch_task_zip_or_csv(user, file) ignored: ignored, errors: errors } + ensure + FileUtils.rm_rf(tmp_dir) if tmp_dir + end + + def batch_feedback_csv_required_headers + ['username', 'student id', 'status', 'comment'] + end + + def parse_batch_feedback_csv(csv_str, return_headers: false) + CSV.parse( + csv_str, + headers: true, + return_headers: return_headers, + header_converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')&.downcase }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }] + ) + end + + def find_project_for_batch_feedback_csv_entry(task_entry) + username = task_entry['username'].to_s.strip + student_id = task_entry['student id'].to_s.strip + + return [nil, 'Provide either Username or Student ID.'] if username.blank? && student_id.blank? + + username_project = if username.present? + projects.joins(:user).where('LOWER(users.username) = ?', username.downcase).first + end + student_id_project = if student_id.present? + projects.joins(:user).where(users: { student_id: student_id }).first + end + + if username.present? && username_project.nil? && student_id.blank? + return [nil, "Unable to find student with username '#{username}'."] + end + + if student_id.present? && student_id_project.nil? && username.blank? + return [nil, "Unable to find student with student ID '#{student_id}'."] + end + + if username_project.present? && student_id_project.present? && username_project != student_id_project + return [nil, "Username '#{username}' and student ID '#{student_id}' refer to different students."] + end + + project = username_project || student_id_project + return [nil, 'Unable to find student project for this row.'] if project.nil? + + [project, nil] + end + + def build_batch_feedback_task_rows(task_definition, csv_str, errors, zip: nil, progress_callback: nil) + task_rows = [] + + csv_str.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') + csv_str.tr!("\r", "\n") + csv_str.gsub!("\n\n", "\n") + + entries = parse_batch_feedback_csv(csv_str, return_headers: true) + total_rows = 0 + + entries.each do |task_entry| + next if task_entry.header_row? + next if task_entry.to_hash.values.all? { |value| value.to_s.strip.blank? } + + total_rows += 1 + end + + rows_processed = 0 + + progress_callback&.call(message: 'Validating batch feedback rows', total_rows: total_rows, rows_processed: 0) + + entries.each do |task_entry| + if task_entry.header_row? + batch_feedback_csv_required_headers.each do |expect_header| + unless task_entry.to_hash.keys.include?(expect_header) + errors << { row: task_entry, message: "Missing header '#{expect_header}', ensure first row has header information." } + return nil + end + end + next + end + + next if task_entry.to_hash.values.all? { |value| value.to_s.strip.blank? } + + rows_processed += 1 + progress_callback&.call(message: 'Validating batch feedback rows', rows_processed: rows_processed) + + project, project_error = find_project_for_batch_feedback_csv_entry(task_entry) + if project_error.present? + errors << { row: task_entry, message: project_error } + next + end + + task = project.task_for_task_definition(task_definition) + if task.nil? + errors << { row: task_entry, message: "Unable to find task for #{task_definition.abbreviation}" } + next + end + + if task.group_task? + errors << { row: task_entry, message: 'Batch feedback upload does not support group tasks.' } + next + end + + status = task_entry['status'].to_s.strip + if status.blank? + errors << { row: task_entry, message: 'Status cannot be blank.' } + next + end + + if TaskStatus.status_for_name(status).nil? + errors << { row: task_entry, message: "Unable to update task status to '#{status}'." } + next + end + + student_entries = zip.nil? ? [] : batch_feedback_entries_for_project(zip, project) + pdf_entries = zip.nil? ? [] : batch_feedback_named_pdf_entries_for_project(zip, project) + + if zip.present? + if student_entries.any? && pdf_entries.empty? + expected_identifier = batch_feedback_primary_identifier_for_project(project) + errors << { + row: task_entry, + message: "Expected a PDF named #{expected_identifier}.pdf inside #{expected_identifier}'s folder." + } + next + end + + if pdf_entries.length > 1 + expected_identifier = batch_feedback_primary_identifier_for_project(project) + errors << { + row: task_entry, + message: "Found multiple PDFs named #{expected_identifier}.pdf inside #{expected_identifier}'s folder." + } + next + end + end + + task_rows << { + task: task, + project: project, + task_entry: task_entry, + pdf_entry: pdf_entries.first + } + end + + task_rows + end + + def write_batch_feedback_csv_file(task_rows) + file = Tempfile.new(["batch_feedback_#{id}_", '.csv']) + file.write(build_batch_feedback_legacy_marks_csv(task_rows)) + file.rewind + file + end + + def upload_batch_feedback_csv(user, task_definition, file, progress_callback: nil) + success = [] + errors = [] + ignored = [] + + type = mime_type(file["tempfile"].path) + + unless mime_in_list?(file["tempfile"].path, ['text/', 'text/plain', 'text/csv', 'application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream']) + errors << { row: {}, message: "File given is not a csv or zip file - detected #{type}" } + return { + success: success, + ignored: ignored, + errors: errors + } + end + + if type.start_with?('text/', 'text/plain', 'text/csv') + task_rows = build_batch_feedback_task_rows( + task_definition, + File.read(file["tempfile"].path), + errors, + progress_callback: progress_callback + ) + + if task_rows.blank? + return { + success: success, + ignored: ignored, + errors: errors + } + end + + converted_csv = write_batch_feedback_csv_file(task_rows) + progress_callback&.call(message: 'Applying batch feedback updates', total_rows: task_rows.count, rows_processed: 0) + result = upload_batch_task_zip_or_csv(user, { 'tempfile' => converted_csv }) + result[:errors] = errors + result[:errors] + result + else + upload_batch_feedback_zip(user, task_definition, file, progress_callback: progress_callback) + end + ensure + converted_csv.close! if defined?(converted_csv) && converted_csv.present? + end + + def batch_feedback_primary_identifier_for_project(project) + project.user.student_id.to_s.strip.presence || project.user.username.to_s.strip + end + + def batch_feedback_identifiers_for_project(project) + [ + project.user.student_id.to_s.strip.presence, + project.user.username.to_s.strip.presence + ].compact.uniq + end + + def batch_feedback_entries_for_identifier(zip, identifier) + zip.select do |entry| + path_parts = entry.name.split('/').reject(&:blank?) + next false if path_parts.empty? + + if entry.name_is_directory? + path_parts.any? { |part| part.casecmp(identifier).zero? } + else + path_parts[0...-1].any? { |part| part.casecmp(identifier).zero? } + end + end + end + + def batch_feedback_entries_for_project(zip, project) + batch_feedback_identifiers_for_project(project).flat_map do |identifier| + batch_feedback_entries_for_identifier(zip, identifier) + end.uniq + end + + def batch_feedback_named_pdf_entries_for_identifier(zip, identifier) + batch_feedback_entries_for_identifier(zip, identifier).select do |entry| + next false if entry.name_is_directory? + next false unless File.extname(entry.name).casecmp('.pdf').zero? + + File.basename(entry.name, '.pdf').casecmp(identifier).zero? + end + end + + def batch_feedback_named_pdf_entries_for_project(zip, project) + batch_feedback_identifiers_for_project(project).flat_map do |identifier| + batch_feedback_named_pdf_entries_for_identifier(zip, identifier) + end.uniq + end + + def build_batch_feedback_legacy_marks_csv(task_rows) + CSV.generate do |csv| + csv << check_mark_csv_headers.split(',') + + task_rows.each do |task_row| + task = task_row[:task] + project = task_row[:project] + task_entry = task_row[:task_entry] + tutorial = project.tutorial_for(task.task_definition)&.abbreviation.to_s + + csv << [ + project.user.username, + project.user.name, + tutorial, + task.task_definition.abbreviation, + task_entry['status'].to_s.strip, + '', + '', + task_entry['comment'].to_s.strip + ] + end + end + end + + def upload_batch_feedback_zip(user, task_definition, file, progress_callback: nil) + success = [] + errors = [] + ignored = [] + repacked_zip = Tempfile.new(["batch_feedback_#{id}_", '.zip']) + + Zip::File.open(file["tempfile"].path) do |zip| + marking_file = zip.glob('**/marks.csv').first + if marking_file.nil? + errors << { row: {}, message: 'No marks.csv contained in zip.' } + return { + success: success, + ignored: ignored, + errors: errors + } + end + + csv_str = marking_file.get_input_stream.read + csv_str.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless csv_str.nil? + + task_rows = build_batch_feedback_task_rows(task_definition, csv_str, errors, zip: zip, progress_callback: progress_callback) + + if task_rows.blank? + return { + success: success, + ignored: ignored, + errors: errors + } + end + + Zip::File.open(repacked_zip.path, Zip::File::CREATE) do |output_zip| + output_zip.get_output_stream('marks.csv') do |f| + f.write(build_batch_feedback_legacy_marks_csv(task_rows)) + end + + progress_callback&.call(message: 'Preparing PDF replacements', total_rows: task_rows.count, rows_processed: 0) + + task_rows.each_with_index do |task_row, index| + next if task_row[:pdf_entry].nil? + + output_name = "#{task_row[:task].task_definition.abbreviation}-#{task_row[:task].id}.pdf" + output_zip.get_output_stream(output_name) do |f| + f.write(task_row[:pdf_entry].get_input_stream.read) + end + + progress_callback&.call(message: 'Preparing PDF replacements', rows_processed: index + 1) + end + end + end + + result = upload_batch_task_zip_or_csv(user, { 'tempfile' => repacked_zip }) + result[:errors] = errors + result[:errors] + result + ensure + repacked_zip.close! if defined?(repacked_zip) && repacked_zip.present? end def send_weekly_status_emails(summary_stats) return unless send_notifications + return unless within_last_teaching_date? summary_stats[:unit] = self summary_stats[:tutorials] = {} @@ -3026,6 +4040,15 @@ def move_files_to_archive FileUtils.mkdir_p(FileHelper.root_submission_history_dir(archived: true)) FileUtils.mv(original_submission_history_path, archive_submission_history_path) end + + # Move JPlag reports + archive_jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false, archived: :force) + original_jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false, archived: false) + + if File.exist?(original_jplag_report_path) && ! File.exist?(archive_jplag_report_path) + FileUtils.mkdir_p(FileHelper.root_jplag_report_dir(archived: true)) + FileUtils.mv(original_jplag_report_path, archive_jplag_report_path) + end end def get_tutor_times(start_date: nil, end_date: nil, timezone: nil, ignore_sessions_during_tutorials: false) @@ -3109,39 +4132,120 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se CSV.generate() do |csv| # Add headers - csv << ([ + csv << [ 'User ID', 'Tutor', 'Total Minutes', 'Assessments', 'Comments', - ]) + ] summary.each do |row| - csv << ([ + csv << [ row[:user_id].to_s, row[:tutor_name], row[:total_minutes].to_s, row[:assessments_made].to_s, row[:comments_made].to_s, - ]) + ] end end end + def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) + snapshot_payload = task_completion_csv_generator(task_status_uses_id: true) + + timestamp = snapshot_time.to_i.to_s + + task_completion_snapshots + .find_or_initialize_by(snapshot_timestamp: timestamp) + .tap do |snapshot| + snapshot.save! + snapshot.store_stats!(snapshot_payload) + refresh_task_completion_snapshot_stats!(snapshot) + end + end + + # Stats for every snapshot, read from the aggregated file so no CSV is parsed per request. + # Rebuilt only when that file is missing, unreadable, or no longer matches the snapshots on record. + def task_completion_snapshot_stats + path = FileHelper.unit_task_status_snapshot_stats_path(self, create: false) + cached = File.exist?(path) ? JSON.parse(Zlib::GzipReader.open(path, &:read)) : nil + + if cached.present? && + cached.all? { |entry| entry.key?('student_count') && entry.key?('campus_student_counts') && entry.key?('target_grade_student_counts') && entry.key?('target_grade_campus_student_counts') && entry.key?('target_grade_stats') && entry.key?('status_population_stats') } && + cached.map { |entry| entry['snapshot_timestamp'] }.sort == TaskCompletionSnapshot.where(unit_id: id).pluck(:snapshot_timestamp).sort + return cached + end + + refresh_task_completion_snapshot_stats! + rescue JSON::ParserError, Zlib::Error + refresh_task_completion_snapshot_stats! + end + + # Writes the aggregated file. Stats for an already captured snapshot never change, so passing the + # snapshot that just changed merges it in rather than re-parsing every other CSV. + def refresh_task_completion_snapshot_stats!(changed_snapshot = nil) + path = FileHelper.unit_task_status_snapshot_stats_path(self, create: true) + + entries = + if changed_snapshot.present? && File.exist?(path) + student_counts = changed_snapshot.load_student_counts + JSON.parse(Zlib::GzipReader.open(path, &:read)).reject { |entry| entry['snapshot_timestamp'] == changed_snapshot.snapshot_timestamp } + + [{ + 'snapshot_date' => changed_snapshot.snapshot_date&.iso8601, + 'snapshot_timestamp' => changed_snapshot.snapshot_timestamp, + 'stats' => changed_snapshot.load_stats, + 'target_grade_stats' => changed_snapshot.load_target_grade_stats, + 'status_population_stats' => changed_snapshot.load_status_population_stats, + 'student_count' => student_counts['student_count'], + 'campus_student_counts' => student_counts['campus_student_counts'], + 'target_grade_student_counts' => student_counts['target_grade_student_counts'], + 'target_grade_campus_student_counts' => student_counts['target_grade_campus_student_counts'] + }] + else + task_completion_snapshots.reload.map do |snapshot| + student_counts = snapshot.load_student_counts + { + 'snapshot_date' => snapshot.snapshot_date&.iso8601, + 'snapshot_timestamp' => snapshot.snapshot_timestamp, + 'stats' => snapshot.load_stats, + 'target_grade_stats' => snapshot.load_target_grade_stats, + 'status_population_stats' => snapshot.load_status_population_stats, + 'student_count' => student_counts['student_count'], + 'campus_student_counts' => student_counts['campus_student_counts'], + 'target_grade_student_counts' => student_counts['target_grade_student_counts'], + 'target_grade_campus_student_counts' => student_counts['target_grade_campus_student_counts'] + } + end + end + + entries = entries.sort_by { |entry| -entry['snapshot_timestamp'].to_i } + + tmp_path = "#{path}.tmp" + Zlib::GzipWriter.open(tmp_path) { |gz| gz.write(JSON.generate(entries)) } + FileUtils.mv(tmp_path, path) + + entries + rescue JSON::ParserError, Zlib::Error + # An unreadable file cannot be merged into; drop it so the retry does a full rebuild. + FileUtils.rm_f(path) + refresh_task_completion_snapshot_stats! + end + private def delete_associated_files unit_path = FileHelper.unit_dir(self, create: false) unit_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false) submission_history_path = FileHelper.unit_submission_history_dir(self) + jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false) FileUtils.rm_rf unit_path FileUtils.rm_rf unit_portfolio_path FileUtils.rm_rf submission_history_path - - FileUtils.cd FileHelper.student_work_dir + FileUtils.rm_rf jplag_report_path end def update_overdue_tasks_aip @@ -3178,6 +4282,26 @@ def move_files_on_code_change FileUtils.mv(old_dir, new_dir) unless File.exist?(new_dir) end + old_submission_history_dir = File.join( + FileHelper.root_submission_history_dir(archived: archived), + FileHelper.sanitized_path("#{saved_change_to_code[0]}-#{id}") + ) + + if File.exist? old_submission_history_dir + new_submission_history_dir = FileHelper.unit_submission_history_dir(self) + FileUtils.mv(old_submission_history_dir, new_submission_history_dir) unless File.exist? new_submission_history_dir + end + + old_jplag_report_dir = File.join( + FileHelper.root_jplag_report_dir(archived: archived), + FileHelper.sanitized_path("#{saved_change_to_code[0]}-#{id}") + ) + + if File.exist? old_jplag_report_dir + new_jplag_report_dir = FileHelper.unit_jplag_report_dir(self, create: false) + FileUtils.mv(old_jplag_report_dir, new_jplag_report_dir) unless File.exist? new_jplag_report_dir + end + # rubocop:disable Rails/SkipsModelValidations tasks.where('portfolio_evidence IS NOT NULL').update_all("portfolio_evidence = REPLACE(portfolio_evidence, '#{saved_change_to_code[0]}-#{id}', '#{code}-#{id}')") # rubocop:enable Rails/SkipsModelValidations diff --git a/app/models/unit_content_link.rb b/app/models/unit_content_link.rb new file mode 100644 index 0000000000..6115ca7676 --- /dev/null +++ b/app/models/unit_content_link.rb @@ -0,0 +1,27 @@ +class UnitContentLink < ApplicationRecord + CONTEXT_TYPES = %w[grade grade_overview task_definition task_definition_resource].freeze + + belongs_to :unit + belongs_to :unit_content_site + + validates :context_type, inclusion: { in: CONTEXT_TYPES } + validates :context_key, :route, presence: true + validates :context_key, uniqueness: { scope: [:unit_id, :context_type] } + validate :resource_path_must_exist + + before_validation :normalise_route + + private + + def normalise_route + self.route = "/#{route.to_s.gsub(%r{\A/+|/+\z}, '')}" + self.route = '/' if route.blank? + end + + def resource_path_must_exist + return unless context_type == 'task_definition_resource' + return if unit_content_site&.file?(route) + + errors.add(:route, 'must be a file in the selected content site') + end +end diff --git a/app/models/unit_content_site.rb b/app/models/unit_content_site.rb new file mode 100644 index 0000000000..4709c51064 --- /dev/null +++ b/app/models/unit_content_site.rb @@ -0,0 +1,443 @@ +require 'fileutils' +require 'digest/sha1' +require 'digest/sha2' +require 'cgi' +require 'pathname' +require 'securerandom' +require 'set' +require 'uri' +require 'zip' + +class UnitContentSite < ApplicationRecord + include FileHelper + + MAX_ARCHIVE_ENTRIES = 20_000 + MAX_EXTRACTED_BYTES = 2.gigabytes + MAX_ENTRY_BYTES = 256.megabytes + CONTENT_REWRITE_VERSION = 1 + + belongs_to :unit + has_many :unit_content_links, dependent: :destroy + + validates :name, :original_filename, :archive_path, presence: true + validates :content_version, presence: true, format: { with: /\A[0-9a-f]{64}\z/ } + validates :name, uniqueness: { scope: :unit_id, case_sensitive: false } + validates :root_dir, presence: true + + before_validation :refresh_content_version, if: :content_version_needs_refresh? + + after_destroy :delete_content_files + + def self.archive_dir_for(unit) + File.join(FileHelper.unit_dir(unit), 'content_sites') + end + + def self.store_upload!(unit, file, name: nil) + original_filename = file[:filename] || file[:name] || 'content.zip' + site_name = name.presence || File.basename(original_filename, '.*') + archive_dir = archive_dir_for(unit) + FileUtils.mkdir_p archive_dir + + site = unit.unit_content_sites.create!( + name: site_name, + original_filename: original_filename, + root_dir: '/', + is_main: unit.unit_content_sites.none?, + content_version: content_version_for(file[:tempfile].path, '/'), + archive_path: File.join( + archive_dir, + "#{SecureRandom.hex(8)}-#{FileHelper.sanitized_filename(original_filename)}" + ) + ) + + FileUtils.cp file[:tempfile].path, site.archive_path + site.extract_for_serving! + site + rescue StandardError + site&.destroy + raise + end + + def replace_upload!(file, root_dir: nil) + original_archive_path = archive_path + original_filename_before_replace = original_filename + original_root_dir = self.root_dir + replacement_original_filename = file[:filename] || file[:name] || original_filename + replacement_archive_path = File.join( + self.class.archive_dir_for(unit), + "#{SecureRandom.hex(8)}-#{FileHelper.sanitized_filename(replacement_original_filename)}" + ) + + FileUtils.cp file[:tempfile].path, replacement_archive_path + replacement_root_options = self.class.root_dir_options_for(replacement_archive_path) + replacement_root_dir = + root_dir.presence || + (replacement_root_options.include?(self.root_dir) ? self.root_dir : '/') + replacement_content_version = self.class.content_version_for( + replacement_archive_path, + replacement_root_dir + ) + + update!( + original_filename: replacement_original_filename, + archive_path: replacement_archive_path, + root_dir: replacement_root_dir, + content_version: replacement_content_version + ) + extract_for_serving! + FileUtils.rm_f original_archive_path if original_archive_path.present? + self + rescue StandardError + if persisted? && original_archive_path.present? + update!( + archive_path: original_archive_path, + original_filename: original_filename_before_replace, + root_dir: original_root_dir + ) + end + FileUtils.rm_f replacement_archive_path if replacement_archive_path.present? + raise + end + + def copy_to(other_unit) + archive_dir = self.class.archive_dir_for(other_unit) + FileUtils.mkdir_p archive_dir + + new_site = dup + new_site.unit = other_unit + new_site.archive_path = File.join( + archive_dir, + "#{SecureRandom.hex(8)}-#{FileHelper.sanitized_filename(original_filename)}" + ) + FileUtils.cp archive_path, new_site.archive_path + new_site.save! + new_site.extract_for_serving! + new_site + rescue StandardError + new_site&.persisted? ? new_site.destroy : FileUtils.rm_f(new_site&.archive_path.to_s) + raise + end + + def self.root_dir_options_for(archive_path) + root_dir_options_from_entries(archive_entries_for(archive_path)) + rescue Zip::Error + ['/'] + end + + def self.content_version_for(archive_path, root_dir) + archive_hash = Digest::SHA256.file(archive_path).hexdigest + normalized_root = root_dir.to_s.gsub(%r{\A/+|/+\z}, '') + Digest::SHA256.hexdigest("#{CONTENT_REWRITE_VERSION}:#{archive_hash}:#{normalized_root}") + end + + def self.archive_entries_for(archive_path) + entries = [] + + Zip::File.open(archive_path) do |zip| + zip.each do |entry| + entries << entry.name unless entry.directory? + end + end + + entries + end + + def self.root_dir_options_from_entries(entries) + dirs = entries.each_with_object(Set.new(['/'])) do |entry, paths| + parts = entry.split('/').reject(&:blank?) + next if parts.any? { |part| ignored_archive_path?(part) } + + parts[0...-1].each_index do |index| + paths << "/#{parts[0..index].join('/')}" + end + end + + dirs.to_a.sort + end + + def self.ignored_archive_path?(path) + path.start_with?('__MACOSX') || path == '.DS_Store' || path.start_with?('._') + end + + def root_dir_options + self.class.root_dir_options_for(archive_path) + end + + def file_paths + root_prefix = normalized_root_dir + + self.class.archive_entries_for(archive_path).filter_map do |entry| + parts = entry.split('/').reject(&:blank?) + next if parts.any? { |part| self.class.ignored_archive_path?(part) } + next unless root_prefix.blank? || entry.start_with?("#{root_prefix}/") + + relative_path = root_prefix.blank? ? entry : entry.delete_prefix("#{root_prefix}/") + "/#{relative_path}" if relative_path.present? + end.sort + rescue Zip::Error, Errno::ENOENT + [] + end + + def file?(path) + entry_name = archive_entry_name(path) + return false if entry_name.blank? || !File.exist?(archive_path) + + Zip::File.open(archive_path) do |zip| + entry = zip.find_entry(entry_name) + entry.present? && entry.file? + end + rescue Zip::Error + false + end + + def extract_file(path) + entry_name = archive_entry_name(path) + return nil if entry_name.blank? || !File.exist?(archive_path) + + Zip::File.open(archive_path) do |zip| + entry = zip.find_entry(entry_name) + return nil unless entry&.file? + + filename = File.basename(entry.name) + cache_key = Digest::SHA1.hexdigest("#{archive_path}:#{entry.name}:#{updated_at.to_f}") + extracted_path = FileHelper.tmp_file( + "unit-content-#{id}-#{cache_key}-#{FileHelper.sanitized_filename(filename)}" + ) + + unless File.exist?(extracted_path) && File.size(extracted_path) == entry.size + temporary_path = "#{extracted_path}.#{SecureRandom.hex(6)}.tmp" + entry.extract(temporary_path) { true } + FileUtils.mv(temporary_path, extracted_path) + end + + { path: extracted_path, filename: filename } + ensure + FileUtils.rm_f(temporary_path) if defined?(temporary_path) && temporary_path.present? + end + rescue Zip::Error + nil + end + + def served_dir + File.join(File.dirname(archive_path), 'served', id.to_s) + end + + def public_files_path + "/api/units/#{unit_id}/content/sites/#{id}/files/v/#{content_version}" + end + + def served_file_path(route) + relative_route = normalized_content_route(route) + return nil unless relative_route + + candidates = [relative_route, File.join(relative_route, 'index.html')].uniq + candidates.each do |candidate| + path = File.join(served_dir, candidate) + return path if File.file?(path) + end + + nil + end + + def extract_for_serving! + staging_dir = nil + backup_dir = nil + raise Errno::ENOENT, "Unit content archive not found: #{archive_path}" unless File.file?(archive_path) + + parent_dir = File.dirname(served_dir) + FileUtils.mkdir_p(parent_dir) + staging_dir = File.join(parent_dir, ".#{id}-#{SecureRandom.hex(8)}") + backup_dir = File.join(parent_dir, ".#{id}-backup-#{SecureRandom.hex(8)}") + FileUtils.mkdir_p(staging_dir) + + extract_archive_into!(staging_dir) + rewrite_extracted_content!(staging_dir) + + FileUtils.mv(served_dir, backup_dir) if File.exist?(served_dir) + FileUtils.mv(staging_dir, served_dir) + FileUtils.rm_rf(backup_dir) + served_dir + rescue StandardError + FileUtils.mv(backup_dir, served_dir) if backup_dir && File.exist?(backup_dir) && !File.exist?(served_dir) + raise + ensure + FileUtils.rm_rf(staging_dir) if staging_dir && File.exist?(staging_dir) + FileUtils.rm_rf(backup_dir) if backup_dir && File.exist?(backup_dir) && File.exist?(served_dir) + end + + private + + def content_version_needs_refresh? + return false unless File.file?(archive_path.to_s) + + content_version.blank? || will_save_change_to_archive_path? || will_save_change_to_root_dir? + end + + def refresh_content_version + self.content_version = self.class.content_version_for(archive_path, root_dir) + end + + def extract_archive_into!(destination) + entry_count = 0 + extracted_bytes = 0 + root_prefix = normalized_root_dir + + Zip::File.open(archive_path) do |zip| + zip.each do |entry| + next if entry.directory? + raise Zip::Error, 'Unit content archive contains a symbolic link' if entry.respond_to?(:symlink?) && entry.symlink? + + entry_count += 1 + raise Zip::Error, 'Unit content archive contains too many files' if entry_count > MAX_ARCHIVE_ENTRIES + raise Zip::Error, 'Unit content archive contains an oversized file' if entry.size > MAX_ENTRY_BYTES + + extracted_bytes += entry.size + raise Zip::Error, 'Unit content archive is too large when extracted' if extracted_bytes > MAX_EXTRACTED_BYTES + + entry_path = safe_archive_entry_path(entry.name) + next unless entry_path + next unless root_prefix.blank? || entry_path.start_with?("#{root_prefix}/") + + relative_path = root_prefix.blank? ? entry_path : entry_path.delete_prefix("#{root_prefix}/") + next if relative_path.blank? + + output_path = File.join(destination, relative_path) + FileUtils.mkdir_p(File.dirname(output_path)) + entry.get_input_stream do |input| + File.open(output_path, 'wb') { |output| IO.copy_stream(input, output) } + end + end + end + end + + def safe_archive_entry_path(entry_name) + normalized = entry_name.to_s.tr('\\', '/') + parts = normalized.split('/').reject(&:blank?) + raise Zip::Error, 'Unit content archive contains an unsafe path' if normalized.start_with?('/') || parts.blank? + raise Zip::Error, 'Unit content archive contains an unsafe path' if parts.any? { |part| ['.', '..'].include?(part) } + return nil if parts.any? { |part| self.class.ignored_archive_path?(part) } + + parts.join('/') + end + + def rewrite_extracted_content!(root) + Dir.glob(File.join(root, '**', '*'), File::FNM_DOTMATCH).each do |path| + next unless File.file?(path) + + relative_path = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s + contents = File.binread(path) + rewritten = rewrite_contents(contents, relative_path) + File.binwrite(path, rewritten) unless rewritten.equal?(contents) || rewritten == contents + end + end + + def rewrite_contents(contents, relative_path) + extension = File.extname(relative_path).downcase + + case extension + when '.html', '.htm' + rewrite_html(contents, relative_path) + when '.css' + rewrite_css(contents) + when '.js', '.mjs' + rewrite_javascript(contents) + else + contents + end + rescue Encoding::CompatibilityError, Encoding::InvalidByteSequenceError + contents + end + + def rewrite_html(contents, relative_path) + result = contents.dup.force_encoding(Encoding::UTF_8) + return contents unless result.valid_encoding? + + base_path = "#{public_files_path}/#{url_path(File.dirname(relative_path))}/".gsub(%r{/+}, '/') + base_path = "#{public_files_path}/" if File.dirname(relative_path) == '.' + base_tag = %() + + if result.match?(/]*>/i) + result.sub!(/(]*>)/i, "\\1#{base_tag}") + else + result.prepend(base_tag) + end + + result.gsub!( + %r{(<(?:a|iframe|img|link|script|source|video|audio)\b[^>]*?\b(?:href|poster|src)=)(["'])(/(?!/)[^"']*)\2}i + ) do + "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}#{Regexp.last_match(2)}" + end + + result.gsub!(/\bsrcset=(["'])([^"']+)\1/i) do + quote = Regexp.last_match(1) + srcset = Regexp.last_match(2).split(',').map do |item| + reference, descriptor = item.strip.split(/\s+/, 2) + reference = canonical_reference(reference) if reference&.start_with?('/') && !reference.start_with?('//') + [reference, descriptor].compact.join(' ') + end.join(', ') + "srcset=#{quote}#{srcset}#{quote}" + end + + result + end + + def rewrite_css(contents) + result = contents.dup.force_encoding(Encoding::UTF_8) + return contents unless result.valid_encoding? + + result.gsub(%r{url\((["']?)(/(?!/)[^"')]+)\1\)}i) do + quote = Regexp.last_match(1) + "url(#{quote}#{canonical_reference(Regexp.last_match(2))}#{quote})" + end + end + + def rewrite_javascript(contents) + result = contents.dup.force_encoding(Encoding::UTF_8) + return contents unless result.valid_encoding? + + result = result.gsub( + %r{\b((?:import|export)(?:\s*[^"']*?\s*from\s*)?\s*)(["'])(/(?!/)[^"']+)\2} + ) do + "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}#{Regexp.last_match(2)}" + end + result.gsub(%r{\b(import\s*\(\s*)(["'])(/(?!/)[^"']+)\2(\s*\))}) do + "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{canonical_reference(Regexp.last_match(3))}" \ + "#{Regexp.last_match(2)}#{Regexp.last_match(4)}" + end + end + + def canonical_reference(reference) + path, suffix = reference.split(/(?=[?#])/, 2) + "#{public_files_path}#{url_path(path)}#{suffix}" + end + + def url_path(path) + path.to_s.split('/').map { |part| URI::DEFAULT_PARSER.escape(part) }.join('/') + end + + def normalized_content_route(route) + decoded = URI::DEFAULT_PARSER.unescape(route.to_s).tr('\\', '/') + parts = decoded.split('/').reject(&:blank?) + return nil if parts.any? { |part| ['.', '..'].include?(part) } + + parts.join('/').presence || 'index.html' + rescue ArgumentError + nil + end + + def archive_entry_name(path) + relative_path = path.to_s.gsub(%r{\A/+|/+\z}, '') + return nil if relative_path.blank? + + [normalized_root_dir, relative_path].compact_blank.join('/') + end + + def normalized_root_dir + root_dir.to_s.gsub(%r{\A/+|/+\z}, '') + end + + def delete_content_files + FileUtils.rm_f archive_path if archive_path.present? + FileUtils.rm_rf served_dir if id.present? + end +end diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 765bf117f8..68fc0f5e09 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -38,7 +38,9 @@ def tasks_awaiting_feedback end def oldest_task_awaiting_feedback - tasks_awaiting_feedback.order("submission_date ASC").first + tasks_awaiting_feedback + .includes(project: { unit: { teaching_period: :breaks } }) + .max_by(&:days_awaiting_feedback) end # @@ -134,7 +136,7 @@ def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) data[:engagements] = all_engagements data[:total_staff_engagements] = all_engagements.count - data[:staff_engagements] = weekly_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]) + data[:staff_engagements] = weekly_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.rediscuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]) # Weekly task engagements for this tutorial data[:weekly_engagements_count] = weekly_engagements.count @@ -147,7 +149,11 @@ def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) .distinct if tutorial_tasks.count > 0 - data[:oldest_task_days] = (Time.zone.now - tutorial_tasks.order("submission_date ASC").first.submission_date.to_time).to_i / 1.day + oldest_task = tutorial_tasks + .includes(project: { unit: { teaching_period: :breaks } }) + .max_by(&:days_awaiting_feedback) + + data[:oldest_task_days] = oldest_task&.days_awaiting_feedback || 0 data[:tasks_awaiting_feedback_count] = tutorial_tasks.count else data[:oldest_task_days] = 0 @@ -258,7 +264,7 @@ def get_marking_sessions_csv(start_date: nil, end_date: nil, timezone: nil) CSV.generate do |csv| # Add headers - csv << ([ + csv << [ 'Start Date', 'Start Time', 'End Date', @@ -270,13 +276,13 @@ def get_marking_sessions_csv(start_date: nil, end_date: nil, timezone: nil) 'Comments Added', 'Assessments Made', 'During Tutorial' - ]) + ] result.each do |row| start_time = row[:start_time].in_time_zone(tz) end_time = row[:end_time].in_time_zone(tz) - csv << ([ + csv << [ start_time.strftime('%Y-%m-%d %A'), start_time.strftime('%H:%M'), end_time.strftime('%Y-%m-%d %A'), @@ -288,7 +294,7 @@ def get_marking_sessions_csv(start_date: nil, end_date: nil, timezone: nil) row[:comments_added], row[:assessments], row[:during_tutorial] ? 'TRUE' : 'FALSE' - ]) + ] end end diff --git a/app/models/user.rb b/app/models/user.rb index c360c140ff..96eb6e5afe 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -90,16 +90,39 @@ def authenticate?(data) end end + # Record a completed interactive sign in. Signing in is also an access event. + def record_sign_in! + now = Time.current + update!(last_sign_in_at: now, last_access_at: now) + end + + # Refresh-token exchanges provide a low-write indication that the user is + # still accessing OnTrack without updating the user on every API request. + def record_access! + update!(last_access_at: Time.current) + end + # # Force-generates a new authentication token, regardless of whether or not # it is actually expired # - def generate_authentication_token!(remember: false, expiry: Time.zone.now + 2.hours, token_type: :general, force_new: true) + def generate_authentication_token!(remember: false, expiry: nil, token_type: :general, force_new: true) # Ensure this user is saved... so it has an id self.save unless self.persisted? + expiry_duration = + if token_type.to_sym == :refresh_token + Doubtfire::Application.config.refresh_token_expiry + else + Doubtfire::Application.config.access_token_expiry + end + expiry ||= Time.zone.now + expiry_duration + + # Reuse tokens for up to 75% of their configured lifetime, then rotate early. + token_reuse_duration = expiry_duration * 0.75 + # Get a recent token, or create a new one token = self.auth_tokens.where(token_type: token_type).last unless force_new - if token.nil? || token.created_at <= Time.zone.now - 90.minutes + if token.nil? || token.auth_token_expiry <= Time.zone.now || token.created_at <= Time.zone.now - token_reuse_duration token = AuthToken.generate(self, remember, expiry, token_type) end @@ -120,6 +143,13 @@ def generate_scorm_authentication_token! generate_authentication_token!(token_type: :scorm) end + # + # Generate an authentication token for unit content asset retrieval + # + def generate_content_authentication_token! + generate_authentication_token!(token_type: :content) + end + # # Returns authentication of the user # @@ -143,7 +173,10 @@ def token_for_text?(a_token, token_type) belongs_to :role, optional: false # Foreign Key has_many :unit_roles, dependent: :destroy, inverse_of: :user has_many :projects, dependent: :restrict_with_exception, inverse_of: :user + has_many :engagements, dependent: :restrict_with_exception, inverse_of: :user + has_many :engagement_comments, dependent: :restrict_with_exception, inverse_of: :user has_many :auth_tokens, dependent: :destroy, inverse_of: :user + has_many :comment_read_cursors, dependent: :destroy, inverse_of: :user has_many :user_oauth_tokens, dependent: :destroy, inverse_of: :user has_many :user_oauth_states, dependent: :destroy, inverse_of: :user has_one :webcal, dependent: :destroy, inverse_of: :user @@ -152,8 +185,9 @@ def token_for_text?(a_token, token_type) has_many :marking_sessions, dependent: :destroy # Model validations/constraints - validates :first_name, presence: true - validates :last_name, presence: true + validates :first_name, presence: true, allowed_characters: { type: :first_name } + validates :last_name, presence: true, allowed_characters: { type: :last_name } + validates :nickname, allowed_characters: { type: :preferred_name }, allow_blank: true validates :role_id, presence: true validates :username, presence: true, uniqueness: { case_sensitive: false } validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i } @@ -360,8 +394,7 @@ def self.permissions :get_scorm_token, :get_feedback_chips, - :get_los, - :update_glos + :get_los ] # What can tutors do with users? @@ -372,8 +405,7 @@ def self.permissions :get_scorm_token, :get_feedback_chips, - :get_los, - :update_glos + :get_los ] # What can students do with users? @@ -478,7 +510,7 @@ def self.export_to_csv end end User.order('id').each do |user| - row << user.attributes.select { |attribute| exportables.include? attribute }.map do |key, value| + values = user.attributes.select { |attribute| exportables.include? attribute }.map do |key, value| # pass in a blank encrypted_password and the role name instead of just role_id if key == 'encrypted_password' '' @@ -488,6 +520,7 @@ def self.export_to_csv value end end + row << CsvHelper.escape_spreadsheet_formulas(values) end end end diff --git a/app/models/webcal.rb b/app/models/webcal.rb index 06ce4f3f61..d0ca3c3e3b 100644 --- a/app/models/webcal.rb +++ b/app/models/webcal.rb @@ -13,6 +13,20 @@ def self.valid_time_units %w(W D H M) end + def reminder_trigger + return nil unless reminder? + + trigger = + case reminder_unit + when 'W', 'D' + "-P#{reminder_time}#{reminder_unit}" + when 'H', 'M' + "-PT#{reminder_time}#{reminder_unit}" + end + + Icalendar::Values::Duration.new(trigger, 'RELATED' => 'START') + end + # # Represents the presence of `reminder_time` and `reminder_unit`. # @@ -28,7 +42,7 @@ def reminder? def task_definitions TaskDefinition .joins(:unit, unit: :projects) - .includes(:unit, unit: :projects) + .includes(:grade_due_dates, unit: :projects) .where( projects: { user_id: user_id, enrolled: true }, units: { active: true } @@ -71,8 +85,15 @@ def to_ical(task_defs = task_definitions) ical.publish ical.prodid = Doubtfire::Application.config.institution[:product_name] - # load all of the tasks... uses the preloaded project - tasks = Task.where(task_definition: task_defs, project: task_defs.map { |t| t.unit.projects.first }.uniq) + projects = Project + .includes(:unit) + .where(user_id: user_id, enrolled: true, unit_id: task_defs.map(&:unit_id).uniq) + .index_by(&:unit_id) + + tasks = Task + .includes(:project, task_definition: :grade_due_dates) + .where(task_definition: task_defs, project: projects.values) + .index_by(&:task_definition_id) # Add iCalendar events for the specified definition. task_defs.each do |td| @@ -83,7 +104,7 @@ def to_ical(task_defs = task_definitions) ev_date_format = '%Y%m%d' ev_reminders = reminder? - ev_reminder_trigger = "-PT#{reminder_time}#{reminder_unit}" + ev_reminder_trigger = reminder_trigger # Add event for start date, if the user opted in. if include_start_dates @@ -91,7 +112,8 @@ def to_ical(task_defs = task_definitions) ev.uid = "S-#{td.id}" ev.summary = event_name_for_task_definition(td, 'start') ev.status = 'CONFIRMED' - ev.dtstart = ev.dtend = Icalendar::Values::Date.new(td.start_date.strftime(ev_date_format)) + start_date = Webcal.start_date_for_task_definition(td, tasks[td.id], projects[td.unit_id]) + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(start_date.strftime(ev_date_format)) Webcal.add_metadata_to_ical_event(ev, td) @@ -110,7 +132,8 @@ def to_ical(task_defs = task_definitions) ev.uid = "E-#{td.id}" ev.summary = event_name_for_task_definition(td, 'end') ev.status = 'CONFIRMED' - ev.dtstart = ev.dtend = Icalendar::Values::Date.new(Webcal.end_date_for_task_definition(td, tasks).strftime(ev_date_format)) + end_date = Webcal.end_date_for_task_definition(td, tasks[td.id], projects[td.unit_id]) + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(end_date.strftime(ev_date_format)) Webcal.add_metadata_to_ical_event(ev, td) @@ -137,9 +160,27 @@ def to_ical(task_defs = task_definitions) # # Returns the target/extended date for the specified task definition. # - def self.end_date_for_task_definition(task_def, tasks) - task = tasks.select { |t| t.task_definition_id == task_def.id }.first - task.present? ? task.due_date : task_def.target_date + def self.end_date_for_task_definition(task_def, task = nil, project = nil) + return task.local_due_date if task.present? + + flexible_grade_date_for_task_definition(task_def, project, :target_date) || task_def.target_date + end + + # + # Returns the start date for the specified task definition. + # + def self.start_date_for_task_definition(task_def, task = nil, project = nil) + return task.local_start_date if task.present? + + flexible_grade_date_for_task_definition(task_def, project, :start_date) || task_def.start_date + end + + def self.flexible_grade_date_for_task_definition(task_def, project, date_type) + return nil unless project&.unit&.allow_flexible_dates + + return task_def.grade_target_date(project.target_grade) if date_type == :target_date + + task_def.grade_start_date(project.target_grade) end # diff --git a/app/services/batch_feedback_upload.rb b/app/services/batch_feedback_upload.rb new file mode 100644 index 0000000000..2741bc30c6 --- /dev/null +++ b/app/services/batch_feedback_upload.rb @@ -0,0 +1,193 @@ +# A batch feedback zip received in chunks, so slow connections never outlast a proxy's request timeout. +# +# Each chunk is stored as its own file named "-". A chunk is linked into place, which +# fails if the name exists, so a request retried while the original is still running can't store the +# same chunk twice. No locks are needed, which matters because file locks hang on some NFS mounts. +class BatchFeedbackUpload + CHUNK_SIZE = Doubtfire::Application.config.batch_feedback_chunk_size + EXPIRES_AFTER = 24.hours + ID_PATTERN = /\A\h{32}\z/ + CHUNK_NAME_PATTERN = /\A(\d{20})-(\h{64})\z/ + + class Error < StandardError; end + class NotFound < Error; end + + class OffsetMismatch < Error + attr_reader :offset + + def initialize(offset) + @offset = offset + super("Expected a chunk starting at byte #{offset}.") + end + end + + attr_reader :id, :unit_id, :task_definition_id, :user_id, :filename, :size + + def self.root + File.join(FileHelper.tmp_file_dir, 'batch-feedback-uploads') + end + + def self.create(unit:, task_definition:, user:, filename:, size:) + raise Error, 'Upload size must be greater than zero.' unless size.to_i.positive? + + upload = new( + id: SecureRandom.hex(16), + unit_id: unit.id, + task_definition_id: task_definition.id, + user_id: user.id, + filename: File.basename(filename.to_s), + size: size.to_i + ) + + FileUtils.mkdir_p(upload.chunks_dir) + File.write(upload.metadata_path, upload.metadata.to_json) + upload + end + + def self.find(id) + return nil unless id.to_s.match?(ID_PATTERN) + + path = File.join(root, id, 'upload.json') + return nil unless File.exist?(path) + + new(**JSON.parse(File.read(path)).symbolize_keys) + end + + def self.remove_expired + Dir.glob(File.join(root, '*')).each do |dir| + # Storing a chunk updates the chunks directory, not the upload's own directory + last_activity = [dir, File.join(dir, 'chunks')].select { |path| File.exist?(path) }.map { |path| File.mtime(path) }.max + FileUtils.rm_rf(dir) if last_activity < EXPIRES_AFTER.ago + end + end + + def initialize(id:, unit_id:, task_definition_id:, user_id:, filename:, size:) + @id = id + @unit_id = unit_id + @task_definition_id = task_definition_id + @user_id = user_id + @filename = filename + @size = size + end + + def dir + File.join(self.class.root, id) + end + + def metadata_path + File.join(dir, 'upload.json') + end + + def chunks_dir + File.join(dir, 'chunks') + end + + def metadata + { id: id, unit_id: unit_id, task_definition_id: task_definition_id, user_id: user_id, filename: filename, size: size } + end + + # The chunks received so far, in order from the start of the file. + def chunks + received_chunks.map { |chunk| chunk.slice(:size, :sha256) } + end + + def offset + received_chunks.sum { |chunk| chunk[:size] } + end + + def status + received = chunks + { id: id, offset: received.sum { |chunk| chunk[:size] }, size: size, chunk_size: CHUNK_SIZE, chunks: received } + end + + # Stores a chunk that starts at offset, after checking it arrived intact. + def append(offset, chunk_path, sha256) + sha256 = sha256.to_s.downcase + chunk_size = File.size(chunk_path) + raise Error, 'Chunk is empty.' if chunk_size.zero? + raise Error, "Chunk exceeds the #{CHUNK_SIZE / 1.megabyte}MB chunk limit." if chunk_size > CHUNK_SIZE + raise Error, 'Chunk does not match its checksum.' unless Digest::SHA256.file(chunk_path).hexdigest == sha256 + + received = self.offset + raise OffsetMismatch, received unless offset == received + raise Error, 'Chunk extends past the end of the upload.' if received + chunk_size > size + + # Copy beside the chunks first, as the request's tempfile may be on another filesystem + temp_path = File.join(chunks_dir, ".tmp-#{SecureRandom.hex(8)}") + FileUtils.cp(chunk_path, temp_path) + File.link(temp_path, File.join(chunks_dir, format('%020d-%s', offset, sha256))) + rescue Errno::EEXIST + # Another request stored this chunk first + raise OffsetMismatch, self.offset + rescue Errno::ENOENT + raise NotFound + ensure + FileUtils.rm_f(temp_path) if temp_path + end + + # Joins the chunks into the file the import job reads, then yields its path to enqueue the job. + # The chunks are kept if no job is enqueued, so the caller can complete the upload again later. + def complete! + received = received_chunks + total = received.sum { |chunk| chunk[:size] } + raise Error, "Upload is incomplete: received #{total} of #{size} bytes." unless total == size + + path = import_path + temp_path = "#{path}.tmp-#{SecureRandom.hex(8)}" + File.open(temp_path, 'wb') do |file| + received.each { |chunk| IO.copy_stream(chunk[:path], file) } + end + # Linking fails if another request already completed this upload + File.link(temp_path, path) + + job_id = yield path + if job_id.nil? + FileUtils.rm_f(path) + else + FileUtils.rm_rf(dir) + end + job_id + rescue Errno::EEXIST, Errno::ENOENT + raise NotFound + ensure + FileUtils.rm_f(temp_path) if temp_path + end + + def destroy + FileUtils.rm_rf(dir) + end + + private + + # The stored chunks that run on from each other from the start of the file. + def received_chunks + received = 0 + stored_chunks.filter_map do |chunk_offset, sha256, path| + raise Error, 'Upload has conflicting chunks, please start it again.' if chunk_offset < received + next if chunk_offset > received + + chunk_size = File.size(path) + received += chunk_size + { size: chunk_size, sha256: sha256, path: path } + end + end + + # [offset, sha256, path] for each stored chunk, sorted by offset. + def stored_chunks + Dir.children(chunks_dir).filter_map do |name| + match = CHUNK_NAME_PATTERN.match(name) + [match[1].to_i, match[2], File.join(chunks_dir, name)] if match + end.sort + rescue Errno::ENOENT + raise NotFound + end + + def import_path + import_dir = File.join(FileHelper.tmp_file_dir, 'batch-feedback') + FileUtils.mkdir_p(import_dir) + + extension = File.extname(filename) + extension = '.upload' unless extension.match?(/\A\.[a-z0-9]{1,10}\z/i) + File.join(import_dir, "batch-feedback-#{unit_id}-#{task_definition_id}-#{id}#{extension}") + end +end diff --git a/app/services/communication_transfer.rb b/app/services/communication_transfer.rb new file mode 100644 index 0000000000..760d04c155 --- /dev/null +++ b/app/services/communication_transfer.rb @@ -0,0 +1,200 @@ +# Moves communication sets and rules between units as a JSON document. +# +# References travel as abbreviations rather than database ids. One with no +# equivalent in the target unit is left nil instead of failing the import, so +# the rule keeps its conditions and actions; CommunicationRule#unresolved? then +# flags it and blocks the set from running until someone repoints it. +class CommunicationTransfer + class InvalidDocument < StandardError; end + + VERSION = 1 + SET_FORMAT = 'ontrack.communication_set'.freeze + RULE_FORMAT = 'ontrack.communication_rule'.freeze + + SCHEDULE_ATTRIBUTES = %w[ + name active anchor_week anchor_day hour minute timezone recurrence interval repeat_count until_at + ].freeze + RULE_ATTRIBUTES = %w[name operator position active send_log_to_convenors].freeze + CONDITION_ATTRIBUTES = %w[ + type operator target_grade task_statuses task_status_count task_target_grade + activity_days spec_con_days submitted_portfolio + ].freeze + ACTION_ATTRIBUTES = %w[type subject body plain_text email_tutors email_convenors target_grade].freeze + + # Abbreviated references. Campuses are shared between units; everything else + # is unit scoped. Group sets and groups are named rather than abbreviated, so + # they are matched separately. + SCOPES = { + task_definition: ->(unit) { unit.task_definitions }, + tutorial: ->(unit) { unit.tutorials }, + tutorial_stream: ->(unit) { unit.tutorial_streams }, + campus: ->(_unit) { Campus } + }.freeze + + class << self + def export_set(set) + envelope(SET_FORMAT, set.unit).merge( + 'set' => { + 'name' => set.name, + 'active' => set.active, + 'schedules' => set.communication_set_schedules.map { |schedule| attributes_of(schedule, SCHEDULE_ATTRIBUTES) }, + 'rules' => set.communication_rules.map { |rule| rule_document(rule) } + } + ) + end + + def export_rule(rule) + envelope(RULE_FORMAT, rule.unit).merge('rule' => rule_document(rule)) + end + + def import_set(document, unit) + document = expect!(document, SET_FORMAT)['set'] + raise InvalidDocument, 'Document is missing its communication set' if document.blank? + + ApplicationRecord.transaction do + set = unit.communication_sets.create!( + name: available_name(unit, document['name']), + active: document.fetch('active', true) + ) + + Array(document['schedules']).each do |schedule| + # Switched off on the way in: the schedule runner is the one place an + # active flag is honoured, so this is what stops a set mailing students + # before anyone has looked at it. + set.communication_set_schedules.create!(schedule.slice(*SCHEDULE_ATTRIBUTES).merge('active' => false)) + end + + Array(document['rules']).each_with_index { |rule, index| build_rule(set, rule, index) } + set + end + end + + def import_rule(document, set) + document = expect!(document, RULE_FORMAT)['rule'] + raise InvalidDocument, 'Document is missing its communication rule' if document.blank? + + ApplicationRecord.transaction { build_rule(set, document, set.communication_rules.count) } + end + + private + + def envelope(format, unit) + { + 'format' => format, + 'version' => VERSION, + 'exported_at' => Time.zone.now.iso8601, + 'source' => { 'unit_code' => unit.code, 'teaching_period' => unit.teaching_period&.detailed_name } + } + end + + def rule_document(rule) + attributes_of(rule, RULE_ATTRIBUTES).merge( + 'conditions' => rule.communication_conditions.map { |condition| record_document(condition, CONDITION_ATTRIBUTES) }, + 'actions' => rule.communication_actions.map { |action| record_document(action, ACTION_ATTRIBUTES) } + ) + end + + def record_document(record, attributes) + document = attributes_of(record, attributes) + reference = record.required_reference + target = reference && record.public_send(reference) + document['reference'] = reference_document(reference, target) if target + + document + end + + # A group name is only unique inside its group set, so a group travels with + # the name of the set it sits in. + def reference_document(reference, target) + case reference + when :group_set then { 'group_set' => target.name } + when :group then { 'group' => target.name, 'group_set' => target.group_set.name } + else { reference.to_s => target.abbreviation } + end + end + + def attributes_of(record, attributes) + attributes.index_with { |attribute| record.public_send(attribute) } + end + + def build_rule(set, document, position) + rule = set.communication_rules.create!(document.slice(*RULE_ATTRIBUTES).merge('position' => position)) + + Array(document['conditions']).each do |condition| + build_record(rule.communication_conditions, condition, CONDITION_ATTRIBUTES, rule) + end + + Array(document['actions']).each do |action| + build_record(rule.communication_actions, action, ACTION_ATTRIBUTES, rule) + end + + rule + end + + def build_record(association, document, attributes, rule) + record = association.new(document.slice(*attributes)) + reference = record.required_reference + + if reference.present? + record.public_send("#{reference}=", find_reference(reference, document['reference'] || {}, rule.unit)) + end + + record.save! + rescue ActiveRecord::RecordInvalid => e + # Not every failure here is a missing reference -- units enable different + # target grades, so a grade-based rule can be valid where it was written + # and invalid here. Name the rule rather than the bare model. + raise InvalidDocument, "Rule '#{rule.name}' cannot be used in this unit: #{e.record.errors.full_messages.join(', ')}" + end + + def find_reference(reference, document, unit) + case reference + when :group_set then find_group_set(document['group_set'], unit) + when :group then find_group(document['group'], document['group_set'], unit) + else + abbreviation = document[reference.to_s] + return nil if abbreviation.blank? + + SCOPES.fetch(reference).call(unit).find_by(abbreviation: abbreviation) + end + end + + def find_group_set(name, unit) + find_named(unit.group_sets, name) + end + + def find_group(name, group_set_name, unit) + group_set = find_group_set(group_set_name, unit) + return nil if group_set.nil? + + find_named(group_set.groups, name) + end + + # Names are nullable on both group sets and groups, so a blank one must not + # be allowed to match the first unnamed record. + def find_named(scope, name) + return nil if name.blank? + + scope.find_by(name: name) + end + + def expect!(document, format) + document = document.deep_stringify_keys if document.respond_to?(:deep_stringify_keys) + raise InvalidDocument, "Expected a #{format} document" unless document.is_a?(Hash) && document['format'] == format + raise InvalidDocument, "Unsupported document version #{document['version'].inspect}" unless document['version'] == VERSION + + document + end + + # Pasting a set back into the unit it came from is a fair way to duplicate + # it, so a clashing name is suffixed rather than rejected. + def available_name(unit, name) + name = name.presence || 'Communication set' + return name unless unit.communication_sets.exists?(name: name) + + counter = 2 + counter += 1 while unit.communication_sets.exists?(name: "#{name} (#{counter})") + "#{name} (#{counter})" + end + end +end diff --git a/app/services/engagement_tracker.rb b/app/services/engagement_tracker.rb new file mode 100644 index 0000000000..c94df16340 --- /dev/null +++ b/app/services/engagement_tracker.rb @@ -0,0 +1,103 @@ +class EngagementTracker + DEBOUNCE = 15.minutes + + def self.record_attendance(user:, project:, occurred_at: Time.zone.now) + return unless during_enrolled_tutorial?(project, occurred_at) + + record_engagement( + user: user, + project: project, + engagement_type: 'Attendance', + note: 'Attended tutorial and QR scanned.', + occurred_at: occurred_at + ) + end + + def self.record_class_discussion(user:, project:, task_status_updates:, occurred_at: Time.zone.now) + timing = during_enrolled_tutorial?(project, occurred_at) ? 'during tutorial' : 'outside of tutorial' + note = if task_status_updates.any? + status_update_note(timing, task_status_updates) + else + "Class discussion #{timing}; no task statuses were updated." + end + + record_engagement( + user: user, + project: project, + engagement_type: 'Discussion', + note: note, + occurred_at: occurred_at + ) do |recent_engagement| + merge_status_updates!(recent_engagement, timing, task_status_updates) + end + end + + def self.record_engagement(user:, project:, engagement_type:, note:, occurred_at:) + project.with_lock do + recent_engagement = recently_recorded(project, engagement_type, occurred_at) + if recent_engagement + yield recent_engagement if block_given? + return + end + + project.engagements.create!( + user: user, + engagement_type: engagement_type, + note: note, + occurred_at: occurred_at + ) + end + end + + def self.recently_recorded(project, engagement_type, occurred_at) + project.engagements + .where('LOWER(engagement_type) = ?', engagement_type.downcase) + .where(occurred_at: (occurred_at - DEBOUNCE)..occurred_at) + .order(occurred_at: :desc) + .first + end + + def self.status_update_note(timing, task_status_updates) + ["Updated task statuses #{timing}.", *status_update_sentences(task_status_updates)].join(' ') + end + + def self.status_update_sentences(task_status_updates) + task_status_updates.map do |update| + "#{update[:task]}: #{update[:from]} → #{update[:to]}." + end + end + + def self.merge_status_updates!(engagement, timing, task_status_updates) + updates = status_update_sentences(task_status_updates) + return if updates.empty? + + if engagement.note.include?('no task statuses were updated') + engagement.update!(note: status_update_note(timing, task_status_updates)) + return + end + + new_updates = updates.reject { |update| engagement.note.include?(update) } + engagement.update!(note: "#{engagement.note} #{new_updates.join(' ')}") if new_updates.any? + end + + def self.during_enrolled_tutorial?(project, occurred_at) + project.tutorial_enrolments.includes(tutorial: :campus).any? do |enrolment| + tutorial = enrolment.tutorial + timezone = Time.zone + timezone = ActiveSupport::TimeZone[tutorial.campus&.timezone] if tutorial.campus&.timezone.present? + local_time = occurred_at.in_time_zone(timezone) + + next false unless tutorial.meeting_day == local_time.strftime('%A') + + tutorial_start = timezone.parse("#{local_time.to_date} #{tutorial.meeting_time}") + local_time >= tutorial_start && local_time < tutorial_start + tutorial.duration_minutes.minutes + end + end + + private_class_method :record_engagement, + :recently_recorded, + :status_update_note, + :status_update_sentences, + :merge_status_updates!, + :during_enrolled_tutorial? +end diff --git a/app/services/lms_group_mapping_applier.rb b/app/services/lms_group_mapping_applier.rb new file mode 100644 index 0000000000..f9a4cdf69b --- /dev/null +++ b/app/services/lms_group_mapping_applier.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +# +# Applies a student's LMS group mappings to their OnTrack project: campus, tutorial enrolments and +# group membership, creating mapped groups and tutorials when a mapping allows it. +# +module LmsGroupMappingApplier + module_function + + def mapping_errors(mappings) + errors = [] + campus_mappings = mappings.select { |mapping| mapping.target_type == 'campus' } + errors << 'Student belongs to multiple mapped campuses' if campus_mappings.map(&:campus_id).uniq.length > 1 + + mappings.select { |mapping| mapping.target_type == 'tutorial' } + .group_by(&:tutorial_stream_id) + .each_value do |stream_mappings| + errors << 'Student belongs to multiple LMS groups mapped to the same tutorial stream' if stream_mappings.length > 1 + end + mappings.select { |mapping| mapping.target_type == 'group' } + .group_by(&:group_set_id) + .each_value do |group_mappings| + errors << 'Student belongs to multiple LMS groups mapped to the same group set' if group_mappings.length > 1 + end + + mappings.each do |mapping| + case mapping.target_type + when 'group' + if mapping.create_if_missing? + if mapping.tutorial.blank? == mapping.tutorial_stream.blank? + errors << 'Select an existing tutorial or a tutorial stream for the new group' + end + elsif mapping.group.blank? + errors << "Select an existing group in #{mapping.group_set.name}" + end + when 'tutorial' + errors << "Select an existing tutorial in #{mapping.tutorial_stream.name}" if mapping.tutorial.blank? + end + end + errors + end + + # Returns true when the project changed. + def apply(project, mappings) + ActiveRecord::Base.transaction do + changed = false + campus = mappings.find { |mapping| mapping.target_type == 'campus' }&.campus + if campus && project.campus_id != campus.id + project.update!(campus: campus) + changed = true + end + + mappings.select { |mapping| mapping.target_type == 'tutorial' }.each do |mapping| + tutorial = mapping.tutorial + if project.tutorial_for_stream(mapping.tutorial_stream)&.id != tutorial.id + project.enrol_in(tutorial) + changed = true + end + end + + mappings.select { |mapping| mapping.target_type == 'group' }.each do |mapping| + group = mapping.group + if mapping.create_if_missing? + group = mapping.group_set.groups.where('LOWER(name) = ?', mapping.lms_group_name.downcase).first + tutorial = group&.tutorial || mapping.tutorial + if tutorial.blank? + tutorial = mapping.tutorial_stream.tutorials.where( + unit: project.unit + ).where('LOWER(abbreviation) = ?', mapping.lms_group_name.downcase).first + tutorial ||= Tutorial.create!( + unit: project.unit, + tutorial_stream: mapping.tutorial_stream, + abbreviation: mapping.lms_group_name, + meeting_day: 'LMS', + meeting_time: '', + meeting_location: mapping.lms_group_name + ) + end + group ||= Group.create!( + group_set: mapping.group_set, + tutorial: tutorial, + name: mapping.lms_group_name + ) + if project.tutorial_for_stream(tutorial.tutorial_stream)&.id != tutorial.id + project.enrol_in(tutorial) + changed = true + end + end + if project.group_for_groupset(mapping.group_set)&.id != group.id + group.add_member(project) + changed = true + end + end + changed + end + end +end diff --git a/app/services/lms_integration_validator.rb b/app/services/lms_integration_validator.rb new file mode 100644 index 0000000000..868af852f9 --- /dev/null +++ b/app/services/lms_integration_validator.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# +# Checks saved group mappings and the selected assignment against the live LMS course, so imports +# never apply mappings for groups or assignments that were renamed or deleted in the LMS. +# +class LmsIntegrationValidator + class ValidationError < LtiCourseDataSource::Error + attr_reader :issues + + def initialize(issues) + @issues = issues + super("LMS integration requires review: #{issues.pluck(:message).join('; ')}") + end + end + + def initialize(integration) + @integration = integration + end + + def validate(groups:, assignments:, record_success: true) + groups = Array(groups).map(&:with_indifferent_access) + assignments = Array(assignments).map(&:with_indifferent_access) + group_issues, notices = group_mapping_issues(groups) + issues = group_issues + assignment_issues(assignments) + valid = issues.empty? + validated_at = valid ? Time.current : nil + + if record_success || !valid + @integration.update!(validated: valid, validated_at: validated_at) + end + + { + valid: valid, + validated_at: validated_at, + groups: groups.map { |group| group.slice(:id, :name, :idnumber) }, + assignments: assignments.map { |assignment| assignment.slice(:id, :name, :due_date) }, + issues: issues, + notices: notices + } + end + + def validate!(groups:, assignments:) + result = validate(groups: groups, assignments: assignments) + return result if result[:valid] + + raise ValidationError, result[:issues] + end + + private + + def group_mapping_issues(groups) + return [[], []] unless @integration.group_mapping_enabled? + + mappings = @integration.lms_group_mappings.includes( + :group_set, :group, :campus, :tutorial_stream, :tutorial + ).to_a + live_groups = groups.index_by { |group| group[:id].to_i } + mappings_by_id = mappings.group_by(&:lms_group_id) + issues = [] + notices = [] + + live_groups.each do |id, group| + group_mappings = mappings_by_id[id] + if group_mappings.blank? + issues << { + type: 'group_missing', lms_group_id: id, lms_group_name: group[:name], + message: 'This LMS group has no mapping.' + } + else + if group_mappings.many? + notices << { + type: 'group_duplicate', lms_group_id: id, lms_group_name: group[:name], + message: "This LMS group has #{group_mappings.length} mappings; all will be applied." + } + end + + group_mappings.each do |mapping| + if mapping.lms_group_name != group[:name] + issues << { + type: 'group_renamed', lms_group_id: id, lms_group_name: group[:name], + message: "LMS group name changed from “#{mapping.lms_group_name}” to “#{group[:name]}”." + } + elsif mapping.invalid? + issues << { + type: 'group_invalid', lms_group_id: id, lms_group_name: group[:name], + message: mapping.errors.full_messages.join(', ') + } + end + end + end + end + + mappings.each do |mapping| + next if live_groups.key?(mapping.lms_group_id) + + issues << { + type: 'group_deleted', lms_group_id: mapping.lms_group_id, + lms_group_name: mapping.lms_group_name, + message: 'This mapped LMS group no longer exists.' + } + end + + [issues, notices] + end + + def assignment_issues(assignments) + return [] unless @integration.fetch_extensions? + + if @integration.assignment_id.blank? || @integration.assignment_name.blank? + return [{ type: 'assignment_missing', message: 'Select an LMS assignment.' }] + end + + assignment = assignments.find { |item| item[:id].to_i == @integration.assignment_id } + if assignment.blank? + return [{ + type: 'assignment_deleted', assignment_id: @integration.assignment_id, + assignment_name: @integration.assignment_name, + message: "The LMS assignment “#{@integration.assignment_name}” no longer exists." + }] + end + return [] if assignment[:name] == @integration.assignment_name + + [{ + type: 'assignment_renamed', assignment_id: assignment[:id], + assignment_name: assignment[:name], + message: "The LMS assignment was renamed from “#{@integration.assignment_name}” to “#{assignment[:name]}”." + }] + end +end diff --git a/app/services/lti_course_data_source.rb b/app/services/lti_course_data_source.rb new file mode 100644 index 0000000000..afef390313 --- /dev/null +++ b/app/services/lti_course_data_source.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# +# Reads LMS course data through the LTI service. +# +# Names and Role Provisioning is the baseline and works with any LTI 1.3 platform. When the +# Moodle OnTrack course-data plugin is available, members gain student ids, enrolment state and +# groups, and assignments with extensions become available. +# +# Another source, such as a direct Moodle web service client, only needs to provide the same +# public methods and be returned from LmsIntegration.data_source_for. +# +class LtiCourseDataSource + class Error < StandardError; end + + def initialize(unit) + @unit = unit + @server = LtiServer.new(unit.id) + end + + def link + @link = @server.link unless defined?(@link) + @link + rescue LtiServer::Error => e + raise Error, e.message + end + + def linked? + link.present? + end + + def course_data_available? + link.present? && link['courseDataAvailable'] == true + end + + # Members of the LMS course, including staff. Each member keeps its Names and Roles fields so + # institution settings can decide how to employ or enrol them. + def members + require_link! + membership = @server.members + plugin_users = {} + group_ids_by_user = Hash.new { |hash, key| hash[key] = [] } + + data = optional_course_data(%w[users groups]) + if data + Array(data['users']).each { |user| plugin_users[user['id'].to_s] = user } + Array(data['groups']).each do |group| + Array(group['member_user_ids']).each { |user_id| group_ids_by_user[user_id.to_s] << group['id'].to_i } + end + end + + Array(membership['members']).map do |member| + user_id = member['user_id'].to_s + plugin_user = plugin_users[user_id] + # The plugin's username is the same LMS username, for platforms that leave it out of Names and Roles + lms_username = member['ext_user_username'].presence || plugin_user&.fetch('username', nil) + { + lms_user_id: user_id, + login_id: UserIdentity.lti_user_id_data(member.merge('ext_user_username' => lms_username))[:login_id], + email: member['email'].presence || plugin_user&.fetch('email', nil), + name: member['name'], + first_name: member['given_name'].presence || plugin_user&.fetch('first_name', nil), + last_name: member['family_name'].presence || plugin_user&.fetch('last_name', nil), + student_id: plugin_user&.fetch('idnumber', nil).presence, + lis_person_sourcedid: member['lis_person_sourcedid'].presence, + roles: Array(member['roles']), + active: member_active?(member, plugin_user), + group_ids: group_ids_by_user[user_id], + member: member + } + end + rescue LtiServer::Error => e + raise Error, e.message + end + + def course_data + snapshot = course_data_snapshot(%w[groups assignments]) + { course: course(snapshot), groups: groups(snapshot), assignments: assignments(snapshot) } + end + + def groups(snapshot = nil) + (snapshot || course_data_snapshot(%w[groups])).fetch('groups', []).map do |group| + { id: group['id'].to_i, name: group['name'], idnumber: group['idnumber'] } + end + end + + def assignments(snapshot = nil) + (snapshot || course_data_snapshot(%w[assignments])).fetch('assignments', []).map do |assignment| + { id: assignment['id'].to_i, name: assignment['name'], due_date: assignment['due_date'].to_i } + end + end + + def course(snapshot = nil) + require_link! + details = { + context_id: link['contextId'], + label: link['contextLabel'], + title: link['contextTitle'], + start_date: nil, + end_date: nil + } + data = snapshot || optional_course_data(%w[groups]) + return details unless data + + context = data['context'] || {} + details.merge( + label: context['label'].presence || details[:label], + title: context['title'].presence || details[:title], + start_date: context['start_date'].to_i.positive? ? context['start_date'].to_i : nil, + end_date: context['end_date'].to_i.positive? ? context['end_date'].to_i : nil + ) + rescue LtiServer::Error => e + raise Error, e.message + end + + # The assignment with its per-user extension dates, keyed by LMS user id. + def assignment_extensions(assignment_id) + data = course_data_snapshot(%w[users assignments], assignment_id: assignment_id) + assignment = Array(data['assignments']).find { |item| item['id'].to_i == assignment_id.to_i } + raise Error, 'The selected assignment was not found in the LMS course' if assignment.blank? + + users = Array(data['users']).index_by { |user| user['id'].to_s } + { + assignment: { id: assignment['id'].to_i, name: assignment['name'], due_date: assignment['due_date'].to_i }, + extensions: Array(assignment['extensions']).map do |extension| + user = users[extension['user_id'].to_s] + { + lms_user_id: extension['user_id'].to_s, + login_id: user&.fetch('username', nil), + email: user&.fetch('email', nil), + extension_due_date: extension['extension_due_date'].to_i + } + end + } + end + + private + + def require_link! + raise Error, 'This unit is not linked to an LMS course. Link it by launching OnTrack from the LMS.' unless linked? + end + + def require_course_data! + require_link! + return if course_data_available? + + raise Error, 'The Moodle OnTrack course-data plugin is not available for this course.' + end + + # Plugin data, or nil so callers fall back to Names and Roles when the plugin has been removed. + def optional_course_data(include) + return nil unless course_data_available? + + @server.course_data(include: include) + rescue LtiServer::Error => e + raise unless e.status == 422 + + link['courseDataAvailable'] = false + nil + end + + def course_data_snapshot(include, assignment_id: nil) + require_course_data! + @server.course_data(include: include, assignment_id: assignment_id) + rescue LtiServer::Error => e + link['courseDataAvailable'] = false if e.status == 422 + raise Error, e.message + end + + def member_active?(member, plugin_user) + return false unless member['status'].blank? || member['status'] == 'Active' + return true if plugin_user.nil? + + !plugin_user['suspended'] && !plugin_user['deleted'] && Array(plugin_user['enrolments']).any? { |enrolment| enrolment['active'] } + end +end diff --git a/app/services/lti_server.rb b/app/services/lti_server.rb new file mode 100644 index 0000000000..5f6141254e --- /dev/null +++ b/app/services/lti_server.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +# +# Server-to-server client for the LTI service's internal API. Every request is keyed by an +# OnTrack unit id, so callers must authorise the user for that unit before using it. +# +class LtiServer + class Error < StandardError + attr_reader :status + + def initialize(message, status: 502) + @status = status + super(message) + end + end + + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 120 + + def self.configured? + config = Doubtfire::Application.config + config.lti_internal_url.present? && config.lti_internal_key.present? + end + + # Raises when the LTI service or its database is unavailable + def self.check_health! + internal_request(:get, 'health') + end + + def self.internal_request(method, path, body = nil) + config = Doubtfire::Application.config + unless configured? + raise Error.new('The LTI service is not configured (LTI_INTERNAL_URL and LTI_INTERNAL_SYNC_KEY)', status: 503) + end + + uri = URI.parse("#{config.lti_internal_url.sub(%r{/+\z}, '')}/lti/api/internal/#{path}") + request = case method + when :get then Net::HTTP::Get.new(uri) + when :post then Net::HTTP::Post.new(uri) + when :delete then Net::HTTP::Delete.new(uri) + end + request['Accept'] = 'application/json' + request['X-Internal-Key'] = config.lti_internal_key + if body + request['Content-Type'] = 'application/json' + request.body = body.to_json + end + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', + open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http| + http.request(request) + end + + payload = response.body.present? ? JSON.parse(response.body) : nil + unless response.is_a?(Net::HTTPSuccess) + message = payload.is_a?(Hash) && payload['error'].is_a?(String) ? payload['error'] : "The LTI service responded #{response.code}" + raise Error.new(message, status: response.code.to_i) + end + + payload + rescue JSON::ParserError + raise Error, 'The LTI service returned an invalid response' + rescue URI::InvalidURIError + raise Error.new('LTI_INTERNAL_URL is invalid', status: 503) + rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e + raise Error.new("Unable to reach the LTI service: #{e.message}", status: 503) + end + + def initialize(unit_id) + @unit_id = Integer(unit_id) + end + + # Returns nil when the unit is not linked to an LMS course. + def link + request(:get, 'link') + rescue Error => e + raise unless e.status == 404 + + nil + end + + def unlink + request(:delete, 'link') + end + + def members + request(:get, 'members') + end + + def course_data(include: nil, assignment_id: nil) + body = {} + body[:include] = include if include.present? + body[:assignmentId] = assignment_id if assignment_id.present? + request(:post, 'course-data', body) + end + + def grade_line_item + request(:get, 'grade-line-item') + end + + def ensure_grade_line_item + request(:post, 'grade-line-item') + end + + def submit_scores(scores) + request(:post, 'scores', { scores: scores }) + end + + private + + def request(method, path, body = nil) + LtiServer.internal_request(method, "units/#{@unit_id}/#{path}", body) + end +end diff --git a/app/services/lti_service_addresses.rb b/app/services/lti_service_addresses.rb new file mode 100644 index 0000000000..10c871a6d1 --- /dev/null +++ b/app/services/lti_service_addresses.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'resolv' + +# +# Recognises the LTI service by the address a request connects from. Entries are IPs, CIDR ranges +# or hostnames, and hostnames are resolved on every check because container IPs change on restart. +# +module LtiServiceAddresses + module_function + + def allowed?(remote_addr, entries = Doubtfire::Application.config.lti_service_hosts) + address = normalise(remote_addr) + return false if address.nil? + + Array(entries).any? { |entry| addresses_for(entry).any? { |allowed| allowed.include?(address) } } + end + + def addresses_for(entry) + [IPAddr.new(entry)] + rescue IPAddr::Error + Resolv.getaddresses(entry).filter_map { |ip| normalise(ip) } + end + + def normalise(ip) + address = IPAddr.new(ip.to_s) + address.ipv4_mapped? ? address.native : address + rescue IPAddr::Error + nil + end +end diff --git a/app/services/session_tracker.rb b/app/services/session_tracker.rb index 441d1e5d75..b2c34fdd39 100644 --- a/app/services/session_tracker.rb +++ b/app/services/session_tracker.rb @@ -59,7 +59,7 @@ def self.find_or_create_session(user, unit, ip_address) tz = ActiveSupport::TimeZone[tutorial.campus&.timezone] if tutorial.campus&.timezone.present? tutorial_start = tz.parse("#{now.to_date} #{tutorial.meeting_time}") - tutorial_end = tutorial_start + 2.hours + tutorial_end = tutorial_start + tutorial.duration_minutes.minutes if now >= tutorial_start && now < tutorial_end is_during_tutorial = true diff --git a/app/services/user_identity.rb b/app/services/user_identity.rb new file mode 100644 index 0000000000..7057979aa0 --- /dev/null +++ b/app/services/user_identity.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# +# Finds OnTrack users by their institution login id, then username, then email. SAML sign in, +# LTI launches and LMS imports all share this lookup so a person always resolves to the same account. +# +module UserIdentity + module_function + + def user_id_data(login_id:, email:) + { + login_id: login_id.presence, + email: email, + username: email.to_s[/(.*)@/, 1] + } + end + + # Institution settings decide which LTI member field holds the institution login id + def lti_user_id_data(member) + settings = Doubtfire::Application.config.institution_settings + return settings.map_lti_member_to_user_id(member) if settings.respond_to?(:map_lti_member_to_user_id) + + user_id_data(login_id: member['ext_user_username'].presence || member['user_id'], email: member['email']) + end + + # Institution settings can reject login ids that are not institution ids, such as old LMS user ids + def institution_login_id(login_id) + return nil if login_id.blank? + + settings = Doubtfire::Application.config.institution_settings + return login_id unless settings.respond_to?(:institution_login_id?) + + settings.institution_login_id?(login_id) ? login_id : nil + end + + def find_user(login_id:, email:, username: nil) + login_id = institution_login_id(login_id) + username ||= email.to_s[/(.*)@/, 1] + (login_id.present? && User.find_by(login_id: login_id)) || + (username.present? && User.find_by(username: username)) || + (email.present? && User.find_by(email: email)) || + nil + end + + # True when the account already belongs to a different institution login id + def mismatch?(user, login_id) + incoming = institution_login_id(login_id) + stored = institution_login_id(user.login_id) + incoming.present? && stored.present? && !stored.casecmp?(incoming) + end + + # Logs a matched account that belongs to a different login id, and returns true when + # DF_ENFORCE_LOGIN_ID_MATCH says it must not be used. Also logs a changed email, which may need a + # manual username change. + def blocked?(user, login_id:, email:, source:) + if mismatch?(user, login_id) + log_identity_event('login_id_mismatch', user, login_id: login_id, email: email, source: source) + return Doubtfire::Application.config.enforce_login_id_match + end + + if email.present? && user.email.present? && !user.email.casecmp?(email) + log_identity_event('login_id_email_changed', user, login_id: login_id, email: email, source: source) + end + alert_username_changed(user, login_id: login_id, email: email, source: source) if username_changed?(user, login_id, email) + false + end + + # Same person by login id, but their email now derives a different username + def username_changed?(user, login_id, email) + incoming = institution_login_id(login_id) + username = email.to_s[/(.*)@/, 1] + incoming.present? && user.login_id.to_s.casecmp?(incoming) && + username.present? && user.username.present? && !user.username.casecmp?(username) + end + + # Emails the details to the error address and sends Sentry a message without user details, once a day per change + def alert_username_changed(user, login_id:, email:, source:) + username = email.to_s[/(.*)@/, 1] + return unless Rails.cache.write("user_identity/username_changed/#{user.id}/#{username.downcase}", true, expires_in: 1.day, unless_exist: true) + + Sentry.capture_message('User matched by login id has a changed username', level: :warning, extra: { source: source }) if defined?(Sentry) + + message = "User #{user.id} (#{user.username}) matched login id #{login_id} during #{source}, " \ + "but their email #{email} now gives the username #{username}. The username may need to be changed manually." + ErrorLogMailer.error_message('Username changed', message, StandardError.new(message))&.deliver_now + rescue StandardError => e + Rails.logger.error "Failed to send username change alert for user #{user.id}: #{e.message}" + end + + # Fills in a missing login id or username on a matched account, never replacing either. A stored + # login id the institution does not recognise counts as missing. + def link_identity(user, login_id:, username: nil) + return false if user.new_record? + + login_id = institution_login_id(login_id) + missing = {} + missing[:login_id] = login_id if login_id && institution_login_id(user.login_id).nil? + missing[:username] = username if username.present? && user.username.blank? + missing.any? && user.update(missing) + rescue ActiveRecord::RecordNotUnique + log_identity_event('login_id_taken', user, login_id: login_id, email: nil, source: 'link') + false + end + + def log_identity_event(event, user, login_id:, email:, source:) + Rails.logger.warn({ + event: event, + source: source, + user_id: user.id, + username: user.username, + stored_login_id: user.login_id, + incoming_login_id: login_id, + stored_email: user.email, + incoming_email: email + }.to_json) + end +end diff --git a/app/sidekiq/accept_overseer_job.rb b/app/sidekiq/accept_overseer_job.rb index 5f176e2dfd..bc8d06c4de 100644 --- a/app/sidekiq/accept_overseer_job.rb +++ b/app/sidekiq/accept_overseer_job.rb @@ -8,9 +8,9 @@ class AcceptOverseerJob include ApplicationHelper include FileHelper - sidekiq_options lock: :until_executed, - # TODO: should students be allowed to submit a new task submission when the previous overseer job has not started/completed? - lock_args_method: ->(args) { [args.first, 'overseer-assessment'] }, + sidekiq_options queue: 'default', + lock: :until_executed, + lock_args_method: ->(args) { [args.first, args.last, 'overseer-assessment'] }, on_conflict: :reject, retry: 1 @@ -36,12 +36,15 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment total_steps: active_overseer_steps.size ) - work_dir_name = "#{task.id}-#{overseer_assessment_id}" + work_dir_name = [ + Time.current.to_i, task.unit.code, 'overseer', task.project.student.username, + task_definition.abbreviation, task.id, overseer_assessment_id, SecureRandom.uuid + ].map { |part| part.to_s.gsub(/[^A-Za-z0-9_-]/, '_') }.join('-') work_dir = Rails.root.join("tmp", "overseer", work_dir_name) FileUtils.mkdir_p(work_dir) - extract_student_submission_files(task, submission, work_dir) + extract_student_submission_files(task, submission, work_dir, timestamp) extract_overseer_resource_files(assessment, work_dir) success_status = nil @@ -57,6 +60,11 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment assessment_pass = true + overseer_image = task_definition.overseer_image || + task.unit.overseer_image || + OverseerImage.find_by(tag: docker_image_name_tag) + ensure_docker_image_present(docker_image_name_tag, overseer_image) + active_overseer_steps.each do |step| result = run_overseer_step( step: step, @@ -96,13 +104,19 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment end else oa.update!(status: :failed) - unless failure_status.nil? + # preserve_status_on_failure = [TaskStatus.time_exceeded.id, TaskStatus.assess_in_portfolio.id].include?(task.task_status_id) + + unless failure_status.nil? # || preserve_status_on_failure # TODO: have an override status setting for the step? eg. if the task is overdue, let it remain overdue, otherwise use this task status task.update!(task_status: failure_status) task.add_status_comment(task.project.tutor_for(task.task_definition), failure_status) oa.update!(result_task_status: failure_status.status_key.to_s) end - task.add_text_comment(task.project.tutor_for(task.task_definition), "**Automated comment**: Some tests did not pass for this submission. Please review the Overseer report, verify your output, and resubmit.") + task.add_text_comment( + task.project.tutor_for(task.task_definition), + "**Automated comment**: Some tests did not pass for this submission. Please review the Overseer report, verify your output, and resubmit.", + attention_audience: :student + ) end FileUtils.rm_rf(work_dir) @@ -113,6 +127,18 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment raise e end + def ensure_docker_image_present(docker_image_name_tag, overseer_image) + _, _, inspect_status = Open3.capture3('docker', 'image', 'inspect', docker_image_name_tag) + return if inspect_status.success? + + raise "Docker image #{docker_image_name_tag} is not configured" if overseer_image.nil? + + overseer_image.pull_from_docker + return if overseer_image.success? + + raise "Unable to pull Docker image #{docker_image_name_tag}: #{overseer_image.pulled_image_text}" + end + def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, docker_image_name_tag:, overseer_assessment_id:) script_contents = step.run_command raise "Execution script is empty" if script_contents.blank? @@ -155,6 +181,7 @@ def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, do command = %( timeout #{timeout} docker run --rm -i \ + --pull never \ --cpus 1 \ --network none \ #{volume_mount} \ @@ -239,25 +266,32 @@ def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, do ) end - def extract_student_submission_files(task, submission, work_dir) - # Extract submission files, removing any parent folders - Zip::File.open(submission) do |zip_file| - zip_file.each do |entry| - next if entry.name_is_directory? + def extract_student_submission_files(task, submission, work_dir, timestamp) + # Submission files are stored directly under their timestamp in the task archive. + Zip::File.open(submission) do |history_zip| + prefix = "#{FileHelper.sanitized_path(timestamp.to_s)}/" + entries = history_zip.entries.reject(&:name_is_directory?).select { |entry| entry.name.start_with?(prefix) } + raise "Submission history entries not found for timestamp: #{timestamp}" if entries.empty? + + extract_submission_entries(task, entries, work_dir, prefix) + end + end - parts = entry.name.split('/')[1..] - next unless parts.length >= 1 + def extract_submission_entries(task, entries, work_dir, prefix) + # Extract submission files, removing any parent folders. + entries.each do |entry| + parts = entry.name.delete_prefix(prefix).split('/') + next unless parts.first == task.id.to_s && parts.length >= 2 - file_name = parts.first - index = file_name.to_i + file_name = parts.second + index = file_name.to_i - file = task.upload_requirements[index] - final_name = file['name'] + file = task.upload_requirements[index] + final_name = file['name'] - dest_path = File.join(work_dir, final_name) - FileUtils.mkdir_p(File.dirname(dest_path)) - zip_file.extract(entry, dest_path) { true } - end + dest_path = File.join(work_dir, final_name) + FileUtils.mkdir_p(File.dirname(dest_path)) + entry.extract(dest_path) { true } end end diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index 15a92d084e..a7db16770c 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -2,14 +2,13 @@ class AcceptSubmissionJob include Sidekiq::Job include LogHelper - def perform(task_id, user_id, accepted_tii_eula, test_submission) - begin - # Ensure cwd is valid... - FileUtils.cd(Rails.root) - rescue StandardError => e - logger.error e - end + sidekiq_options queue: 'default', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + def perform(task_id, user_id, accepted_tii_eula, test_submission) begin task = Task.find(task_id) user = User.find(user_id) @@ -21,7 +20,7 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) begin logger.info "Accepting submission for task #{task.id} by user #{user.id}" # Convert submission to PDF - task.convert_submission_to_pdf(log_to_stdout: true) + raise "Failed to convert submission to PDF for task #{task.id}" unless task.convert_submission_to_pdf(log_to_stdout: true) rescue StandardError => e logger.error e @@ -29,15 +28,25 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) if task.project.student.receive_task_notifications begin PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver - rescue StandardError => e - logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}" + rescue StandardError => mail_error + logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{mail_error.message}" end end begin # Notify system admin + if defined?(Sentry) + Sentry.capture_exception( + e, + extra: { + task_id: task.id, + task_definition_abbreviation: task.task_definition.abbreviation, + latex_log_message: e.respond_to?(:log_message) ? e.log_message.to_s.last(5000) : nil + } + ) + end mail = ErrorLogMailer.error_message('Accept Submission', "Failed to convert submission to PDF for task #{task.log_details}", e) - mail.deliver if mail.present? + mail.presence&.deliver rescue StandardError => e logger.error "Failed to send error log to admin" end @@ -60,19 +69,24 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) task.send_documents_to_tii(user, accepted_tii_eula: accepted_tii_eula) end - if task.overseer_enabled? || test_submission - overseer_assessment = OverseerAssessment.create_for(task, test_submission) - if overseer_assessment.present? - logger.info "Launching Overseer assessment for task_def_id: #{task.task_definition.id} task_id: #{task.id}" - - overseer_assessment.send_to_overseer(test_submission: test_submission) - - else - logger.info "Overseer assessment for task_def_id: #{task.task_definition.id} task_id: #{task.id} was not performed #{overseer_assessment.inspect}" - end + if SubmissionHistory.enabled_requirements(task).any? + submission_timestamp = Time.now.utc.to_i + SubmissionHistory.mark_pending(task) + CreateSubmissionHistoryJob.perform_async(task.id, submission_timestamp, test_submission) + elsif task.overseer_enabled? || test_submission + logger.error "Overseer assessment was not performed because task definition #{task.task_definition.id} has no submission history files configured" end rescue StandardError => e # to raise error message to avoid unnecessary retry logger.error e + if defined?(Sentry) + Sentry.capture_exception( + e, + extra: { + task_id: task&.id, + task_definition_abbreviation: task&.task_definition&.abbreviation + } + ) + end task.clear_in_process end end diff --git a/app/sidekiq/aggregate_task_completion_stats_job.rb b/app/sidekiq/aggregate_task_completion_stats_job.rb new file mode 100644 index 0000000000..80c3a77529 --- /dev/null +++ b/app/sidekiq/aggregate_task_completion_stats_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class AggregateTaskCompletionStatsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id = nil) + logger.info 'Starting task completion stats aggregation...' + + at(0) + total(1) + + if unit_id.present? + Unit.find(unit_id).capture_task_complete_stats_snapshot! + else + Unit.within_teaching_dates.find_each(&:capture_task_complete_stats_snapshot!) + end + + at(1) + logger.info 'Completed task completion stats aggregation!' + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/archive_old_units_job.rb b/app/sidekiq/archive_old_units_job.rb index 7ebb68e666..f4bca72efa 100644 --- a/app/sidekiq/archive_old_units_job.rb +++ b/app/sidekiq/archive_old_units_job.rb @@ -4,6 +4,8 @@ class ArchiveOldUnitsJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform return unless Doubtfire::Application.config.archive_units @@ -17,6 +19,7 @@ def perform rescue StandardError => e begin # Notify system admin + Sentry.capture_exception(e) if defined?(Sentry) mail = ErrorLogMailer.error_message('Archive Units', "Failed to move old units to archive", e) mail.deliver if mail.present? diff --git a/app/sidekiq/clear_access_tokens_job.rb b/app/sidekiq/clear_access_tokens_job.rb index b9a4524bb0..789d9c3e53 100644 --- a/app/sidekiq/clear_access_tokens_job.rb +++ b/app/sidekiq/clear_access_tokens_job.rb @@ -4,6 +4,8 @@ class ClearAccessTokensJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform UserOauthToken.destroy_old_tokens UserOauthState.destroy_old_states diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb new file mode 100644 index 0000000000..25c645f031 --- /dev/null +++ b/app/sidekiq/communication_rule_job.rb @@ -0,0 +1,41 @@ +class CommunicationRuleJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first, args.last, 'communication-rule'] }, + on_conflict: :reject, + retry: 1 + + def perform(rule_id) + logger.info "Starting communication rule job..." + + at(0) + total(1) + + rule = CommunicationRule.find(rule_id) + + projects = rule.communication_set.preview_projects_for_rule(rule) + store( + result: projects.map do |project| + { + username: project.user&.username, + student_id: project.user&.student_id, + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at, + last_viewed_at: project.last_viewed_at + } + end + ) + + logger.info "Completed communication job" + rescue StandardError => e + logger.error e + raise e + end + +end diff --git a/app/sidekiq/create_submission_history_job.rb b/app/sidekiq/create_submission_history_job.rb new file mode 100644 index 0000000000..1e1b172ce9 --- /dev/null +++ b/app/sidekiq/create_submission_history_job.rb @@ -0,0 +1,28 @@ +class CreateSubmissionHistoryJob + include Sidekiq::Job + include LogHelper + + sidekiq_options queue: 'default', + lock: :until_executed, + lock_args_method: ->(args) { [args.first, 'submission-history'] }, + on_conflict: :reject, + retry: false + + def perform(task_id, submission_timestamp, test_submission) + task = Task.find(task_id) + history = SubmissionHistory.create_archive!(task, submission_timestamp) + + return unless task.overseer_enabled? || test_submission + + assessment = OverseerAssessment.create_for(history, test_submission) + return if assessment.nil? + + assessment.update!(student_notified_at: Time.current) if test_submission + assessment.send_to_overseer(test_submission: test_submission) + rescue StandardError => e + logger.error e + Sentry.capture_exception(e, extra: { task_id: task_id }) if defined?(Sentry) + ensure + SubmissionHistory.clear_pending(task) if task + end +end diff --git a/app/sidekiq/d2l_post_grades_job.rb b/app/sidekiq/d2l_post_grades_job.rb index 10bbf80e59..8c1d857b53 100644 --- a/app/sidekiq/d2l_post_grades_job.rb +++ b/app/sidekiq/d2l_post_grades_job.rb @@ -4,7 +4,8 @@ class D2lPostGradesJob include Sidekiq::Job include LogHelper - sidekiq_options lock: :until_executed + sidekiq_options queue: 'parallel', + lock: :until_executed def perform(unit_id, user_id) unit = Unit.find(unit_id) diff --git a/app/sidekiq/download_marking_sessions_job.rb b/app/sidekiq/download_marking_sessions_job.rb index f06f5b5fd3..08fa92c42a 100644 --- a/app/sidekiq/download_marking_sessions_job.rb +++ b/app/sidekiq/download_marking_sessions_job.rb @@ -9,9 +9,7 @@ class DownloadMarkingSessionsJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_role_id, start_date, end_date, timezone) diff --git a/app/sidekiq/download_overflow_task_claims_csv_job.rb b/app/sidekiq/download_overflow_task_claims_csv_job.rb new file mode 100644 index 0000000000..3541708a09 --- /dev/null +++ b/app/sidekiq/download_overflow_task_claims_csv_job.rb @@ -0,0 +1,31 @@ +require 'csv' + +class DownloadOverflowTaskClaimsCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options queue: 'parallel', + retry: false + + def perform(unit_id) + logger.info "Starting overflow task claims csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.overflow_task_claims_csv + + store(result: csv) + + logger.info "Completed overflow task claims csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_portfolios_job.rb b/app/sidekiq/download_portfolios_job.rb index 378ded4d47..a6b3529e04 100644 --- a/app/sidekiq/download_portfolios_job.rb +++ b/app/sidekiq/download_portfolios_job.rb @@ -5,9 +5,10 @@ class DownloadPortfoliosJob include Sidekiq::Status::Worker include LogHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { args }, - on_conflict: :reject, + on_conflict: :log, retry: false def perform(current_user_id, unit_id) diff --git a/app/sidekiq/download_submission_files_job.rb b/app/sidekiq/download_submission_files_job.rb index 0c5ece014e..132d0e9560 100644 --- a/app/sidekiq/download_submission_files_job.rb +++ b/app/sidekiq/download_submission_files_job.rb @@ -5,9 +5,10 @@ class DownloadSubmissionFilesJob include Sidekiq::Status::Worker include LogHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { args }, - on_conflict: :reject, + on_conflict: :log, retry: false def perform(current_user_id, unit_id, task_definition_id) diff --git a/app/sidekiq/download_submission_pdfs_job.rb b/app/sidekiq/download_submission_pdfs_job.rb index 015dd3a5b1..0fdc066326 100644 --- a/app/sidekiq/download_submission_pdfs_job.rb +++ b/app/sidekiq/download_submission_pdfs_job.rb @@ -5,9 +5,10 @@ class DownloadSubmissionPdfsJob include Sidekiq::Status::Worker include LogHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { args }, - on_conflict: :reject, + on_conflict: :log, retry: false def perform(current_user_id, unit_id, task_definition_id) diff --git a/app/sidekiq/download_task_assessment_counts_csv_job.rb b/app/sidekiq/download_task_assessment_counts_csv_job.rb index a136168a1a..920aa02bbd 100644 --- a/app/sidekiq/download_task_assessment_counts_csv_job.rb +++ b/app/sidekiq/download_task_assessment_counts_csv_job.rb @@ -9,9 +9,7 @@ class DownloadTaskAssessmentCountsCsvJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_id) diff --git a/app/sidekiq/download_task_completion_csv_job.rb b/app/sidekiq/download_task_completion_csv_job.rb index 3ea9bc1516..e9efabb338 100644 --- a/app/sidekiq/download_task_completion_csv_job.rb +++ b/app/sidekiq/download_task_completion_csv_job.rb @@ -9,9 +9,7 @@ class DownloadTaskCompletionCsvJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_id) diff --git a/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb index 6be0876a40..b65a0d4058 100644 --- a/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb +++ b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb @@ -9,9 +9,7 @@ class DownloadTasksAwaitingFeedbackCsvJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_id) diff --git a/app/sidekiq/download_tutor_assessment_stats_job.rb b/app/sidekiq/download_tutor_assessment_stats_job.rb index 4a1f8a3728..f17d67d55c 100644 --- a/app/sidekiq/download_tutor_assessment_stats_job.rb +++ b/app/sidekiq/download_tutor_assessment_stats_job.rb @@ -9,9 +9,7 @@ class DownloadTutorAssessmentStatsJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_id) diff --git a/app/sidekiq/download_unit_tutor_times_summary_job.rb b/app/sidekiq/download_unit_tutor_times_summary_job.rb index 3bbda469f9..44ea64eb72 100644 --- a/app/sidekiq/download_unit_tutor_times_summary_job.rb +++ b/app/sidekiq/download_unit_tutor_times_summary_job.rb @@ -9,9 +9,7 @@ class DownloadUnitTutorTimesSummaryJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, - on_conflict: :reject, + sidekiq_options queue: 'parallel', retry: false def perform(unit_id, start_date, end_date, timezone, ignore_sessions_during_tutorials) diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb new file mode 100644 index 0000000000..2523efb2a3 --- /dev/null +++ b/app/sidekiq/execute_communication_set_job.rb @@ -0,0 +1,623 @@ +require 'csv' + +class ExecuteCommunicationSetJob + include Sidekiq::Job + include Sidekiq::Status::Worker + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args[0], args[1], 'communication-set'] }, + on_conflict: :reject, + retry: 1 + + def perform(communication_set_id, target_rule_id = nil) + communication_set = CommunicationSet.find(communication_set_id) + rules = communication_set.communication_rules.to_a + + # Refuse the whole set, not just the broken rules: allocation is a waterfall, + # so a rule that cannot be evaluated sends its students into a later rule and + # mails them the wrong thing. + unresolved_rules = rules.select(&:unresolved?) + if unresolved_rules.any? + raise "Communication set #{communication_set.id} cannot run: #{unresolved_rules.map(&:name).join(', ')} " \ + 'reference records that do not exist in this unit' + end + target_rule_id = target_rule_id&.to_i + + if target_rule_id.present? && rules.none? { |rule| rule.id == target_rule_id } + raise ActiveRecord::RecordNotFound, "CommunicationRule #{target_rule_id} not found in set #{communication_set_id}" + end + + eligible_projects = communication_set.eligible_projects + remaining_projects = eligible_projects.dup + executed_rules = [] + action_results = [] + + rules_to_process = + if target_rule_id.present? + cutoff_index = rules.index { |rule| rule.id == target_rule_id } + rules.first(cutoff_index + 1) + else + rules + end + + at(0) + total(rules_to_process.length.nonzero? || 1) + + rules_to_process.each_with_index do |rule, index| + matched_projects = rule.matching_projects(remaining_projects) + + executed_rules << { + rule_id: rule.id, + rule_name: rule.name, + matched_project_ids: matched_projects.map(&:id) + } + + should_execute_actions = target_rule_id.present? ? rule.id == target_rule_id : true + + if should_execute_actions + rule_action_results = rule.communication_actions.flat_map do |action| + execute_action(action, matched_projects, communication_set.unit, rule) + end + + action_results.concat(rule_action_results) + if rule.send_log_to_convenors? + action_results.concat( + send_action_log_to_convenors( + matched_projects, + communication_set.unit, + rule, + rule_action_results + ) + ) + end + end + + remaining_projects -= matched_projects + at(index + 1) + end + + store( + result: { + communication_set_id: communication_set.id, + target_rule_id: target_rule_id, + executed_rule_ids: executed_rules.map { |item| item[:rule_id] }, + remaining_project_ids: remaining_projects.map(&:id), + rules: executed_rules, + actions: action_results + } + ) + rescue StandardError => e + logger.error("ExecuteCommunicationSetJob failed: #{e.class} #{e.message}") + raise e + end + + private + + def execute_action(action, projects, unit, rule) + case action.type + when 'ChangeTargetGradeAction' + execute_change_target_grade_action(action, projects) + when 'EmailStudentAction' + execute_email_student_action(action, projects, unit, rule) + when 'EmailStaffAction' + execute_email_staff_action(action, projects, unit, rule) + when 'TaskCommentAction' + execute_task_comment_action(action, projects, unit, rule) + else + [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + reason: 'unsupported action type' + }] + end + end + + def execute_change_target_grade_action(action, projects) + projects.map do |project| + previous_target_grade = project.target_grade + + project.update!(target_grade: action.target_grade) + + { + action_id: action.id, + action_type: action.type, + status: 'updated', + project_id: project.id, + username: project.user&.username, + previous_target_grade: previous_target_grade, + target_grade: action.target_grade + } + end + end + + def execute_email_student_action(action, projects, unit, rule) + projects.filter_map do |project| + recipient = project.user + sender = sender_for(unit) + + if recipient&.email.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'student email missing' + } + end + + if sender.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'sender email missing' + } + end + + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + plain_text: action.plain_text, + recipient: recipient, + sender: sender_user_for(unit) + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: recipient.username, + recipient_email: recipient.email + } + end + end + + def execute_email_staff_action(action, projects, unit, rule) + projects.flat_map do |project| + sender = sender_for(unit) + + if sender.blank? + next [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'sender email missing' + }] + end + + staff_recipients_for(project, unit, action).map do |recipient| + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + plain_text: action.plain_text, + recipient: recipient, + sender: sender_user_for(unit) + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: project.user&.username, + recipient_email: recipient.email, + recipient_username: recipient.username + } + end + end + end + + def execute_task_comment_action(action, projects, unit, rule) + comment_author = sender_user_for(unit) + + if comment_author.blank? + return [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + reason: 'comment author missing' + }] + end + + comment_text_template = action.body.to_s.strip + + projects.map do |project| + task_definition = action.task_definition || unit.task_definitions.find_by(id: action.task_definition_id) + if task_definition.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'task definition missing' + } + end + + task = project.task_for_task_definition(task_definition) + rendered_comment = render_template(comment_text_template, project, unit, rule, projects.length) + + if rendered_comment.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'comment text missing' + } + end + + comment = task.add_text_comment(comment_author, rendered_comment, attention_audience: :student) + + if comment.nil? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'duplicate comment' + } + end + + { + action_id: action.id, + action_type: action.type, + status: 'commented', + project_id: project.id, + username: project.user&.username, + task_definition_id: task_definition.id, + task_definition_name: task_definition.name, + comment_id: comment.id + } + end + end + + def send_action_log_to_convenors(projects, unit, rule, prior_action_results) + sender = sender_for(unit) + + if sender.blank? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'sender email missing' + }] + end + + recipients = unit.convenors.includes(:user).map(&:user).select { |user| user&.email.present? }.uniq(&:id) + + if recipients.empty? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'convenor email missing' + }] + end + + csv_content = build_action_log_csv(rule, projects, prior_action_results) + csv_filename = "communication-rule-#{rule.id}-action-log.csv" + body = action_log_email_body(rule, prior_action_results) + subject = action_log_email_subject(unit, rule) + + recipients.map do |recipient| + CommunicationsMailer.action_log_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule, + csv_content: csv_content, + csv_filename: csv_filename, + affected_students_count: projects.length + ).deliver_now + + { + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'sent', + recipient_email: recipient.email, + recipient_username: recipient.username, + attachment_filename: csv_filename, + affected_students_count: projects.length + } + end + end + + def staff_recipients_for(project, unit, action) + recipients = [] + + if action.email_tutors + recipients.concat( + project.tutorial_enrolments.filter_map do |tutorial_enrolment| + tutorial_enrolment.tutorial&.unit_role&.user + end + ) + end + + if action.email_convenors + recipients.concat(unit.convenors.includes(:user).map(&:user)) + end + + recipients.select { |recipient| recipient&.email.present? }.uniq(&:id) + end + + def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = []) + return '' if template.blank? + + student = project&.user + convenor = unit.main_convenor_user + target_grade_value = target_grade_override.nil? ? project&.target_grade : target_grade_override + + replacements = { + '{{student.first_name}}' => student&.first_name.to_s, + '{{student.last_name}}' => student&.last_name.to_s, + '{{student.preferred_name}}' => (student&.nickname.presence || student&.first_name).to_s, + '{{student.full_name}}' => [student&.first_name, student&.last_name].compact.join(' '), + '{{student.username}}' => student&.username.to_s, + '{{student.student_id}}' => student&.student_id.to_s, + '{{convenor.preferred_name}}' => (convenor&.nickname&.strip.presence || convenor&.first_name).to_s, + '{{affected_students_count}}' => affected_students_count.to_s, + '{{unit.code}}' => unit.code.to_s, + '{{unit.name}}' => unit.name.to_s, + '{{rule.name}}' => rule.name.to_s, + '{{target_grade}}' => target_grade_name(target_grade_value, unit), + '{{conditions_summary}}' => conditions_summary(rule), + '{{actions_summary}}' => actions_summary(rule, action_results) + } + + replacements.reduce(template.dup) do |rendered, (token, value)| + rendered.gsub(token, value) + end + end + + def target_grade_name(value, unit = nil) + (unit&.grade_label(value) || GradeHelper.grade_for(value)).to_s + end + + def formatted_email(user) + return nil if user&.email.blank? + + %("#{user.name}" <#{user.email}>) + end + + def sender_for(unit) + formatted_email(sender_user_for(unit)) + end + + def sender_user_for(unit) + unit.main_convenor_user || unit.convenors.includes(:user).first&.user + end + + def conditions_summary(rule) + rule.communication_conditions.map do |condition| + "- #{human_condition_summary(condition)}" + end.join("\n") + end + + def actions_summary(rule, action_results) + return rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") if action_results.blank? + + rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") + end + + def build_action_log_csv(rule, projects, action_results) + action_order = rule.communication_actions.each_with_index.to_h { |action, index| [action.id, index] } + ordered_results = action_results.sort_by do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + + [ + student&.username.to_s, + action_order.fetch(result[:action_id], Float::INFINITY), + result[:recipient_email].to_s + ] + end + + CSV.generate(headers: true) do |csv| + csv << [ + 'student_username', + 'student_id', + 'student_name', + 'rule_name', + 'action_type', + 'status', + 'details', + # 'previous_target_grade', + # 'new_target_grade', + 'recipient_email', + 'executed_at' + ] + + ordered_results.each do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + details = if result[:status] == 'updated' + "Changed target grade from #{target_grade_name(result[:previous_target_grade], rule.unit)} to #{target_grade_name(result[:target_grade], rule.unit)}" + elsif result[:status] == 'commented' + task_definition = TaskDefinition.find_by(id: result[:task_definition_id]) + "Added comment to #{task_definition_label(task_definition)}" + elsif result[:recipient_email].present? + "Sent email to #{result[:recipient_email]}" + else + result[:reason].to_s + end + + csv << [ + student&.username, + student&.student_id, + student&.name, + rule.name, + result[:action_type], + result[:status], + details, + # target_grade_name(result[:previous_target_grade]), + # target_grade_name(result[:target_grade]), + result[:recipient_email], + Time.current.iso8601 + ] + end + end + end + + def action_log_email_subject(unit, rule) + "#{unit.code} #{rule.name} action log" + end + + def action_log_email_body(rule, action_results) + [ + action_log_conditions_intro(rule), + conditions_summary(rule), + 'The following actions have been applied to these students:', + actions_summary(rule, action_results) + ].join("\n") + end + + def action_log_conditions_intro(rule) + if rule.operator == 'or' + 'A scheduled rule has been run for students that match any of the following conditions:' + else + 'A scheduled rule has been run for students that match all of the following conditions:' + end + end + + def human_condition_summary(condition) + case condition.type + when 'TaskDefinitionStatusCondition' + predicate = condition.operator == 'not_equal_to' ? 'Not In' : 'In' + task = TaskDefinition.find_by(id: condition.task_definition_id) + task_label = if task + "Task #{task.abbreviation} #{task.name}" + else + "Task #{condition.task_definition_id}" + end + "Students that have #{task_label} #{predicate} [#{Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ')}]" + when 'TargetGradeCondition' + "Students with a Target Grade #{operator_label(condition.operator)} #{target_grade_name(condition.target_grade, condition.communication.unit)}" + when 'TaskStatusCountCondition' + grade_label = target_grade_name(condition.task_target_grade, condition.communication.unit) + statuses = Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ') + "Students that have #{operator_label(condition.operator)} #{condition.task_status_count} #{grade_label} tasks in [#{statuses}]" + when 'LoginStatusCondition' + relative_activity_summary(condition, 'signed in') + when 'UnitViewedStatusCondition' + relative_activity_summary(condition, 'viewed this unit') + when 'SpecConCondition' + "Students with Special Consideration Days #{operator_label(condition.operator)} #{condition.spec_con_days}" + when 'TutorialEnrolmentCondition' + tutorial = Tutorial.find_by(id: condition.tutorial_id) + tutorial_label = + if tutorial + [tutorial.abbreviation, tutorial.name].compact.join(' ') + else + "Tutorial #{condition.tutorial_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{tutorial_label}" + when 'TutorialStreamEnrolmentCondition' + tutorial_stream = TutorialStream.find_by(id: condition.tutorial_stream_id) + stream_label = + if tutorial_stream + [tutorial_stream.abbreviation, tutorial_stream.name].compact.join(' ') + else + "Tutorial Stream #{condition.tutorial_stream_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{stream_label}" + when 'CampusCondition' + campus = Campus.find_by(id: condition.campus_id) + campus_label = campus&.name || "Campus #{condition.campus_id}" + "Students #{enrolment_label(condition.operator).downcase} #{campus_label}" + else + "#{condition.type.to_s.underscore.humanize} #{condition.operator.to_s.humanize}" + end + end + + def relative_activity_summary(condition, activity) + duration = "#{condition.activity_days} #{'day'.pluralize(condition.activity_days)}" + + if condition.operator == 'more_than' + "Students who have not #{activity} for more than #{duration}" + else + "Students who #{activity} within the last #{duration}" + end + end + + def human_action_summary(action) + case action.type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + "Change Target Grade to #{target_grade_name(action.target_grade, action.communication_rule.unit)}" + when 'TaskCommentAction' + "Add comment to #{task_definition_label(action.task_definition)}" + else + human_action_type_name(action.type) + end + end + + def human_action_type_name(type) + case type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + 'Change target grade' + when 'TaskCommentAction' + 'Add task comment' + else + type.to_s.underscore.humanize + end + end + + def operator_label(operator) + case operator.to_s + when 'greater_than' + 'Greater Than' + when 'greater_than_or_equal_to' + 'Greater Than Or Equal To' + when 'less_than' + 'Less Than' + when 'less_than_or_equal_to' + 'Less Than Or Equal To' + when 'equal_to' + 'Equal To' + when 'not_equal_to' + 'Not Equal To' + else + operator.to_s.humanize + end + end + + def enrolment_label(operator) + operator.to_s == 'not_enrolled_in' ? 'Not Enrolled In' : 'Enrolled In' + end + + def task_definition_label(task_definition) + return 'Task' if task_definition.blank? + + "Task #{task_definition.abbreviation} #{task_definition.name}" + end +end diff --git a/app/sidekiq/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb new file mode 100644 index 0000000000..d1dab980ae --- /dev/null +++ b/app/sidekiq/execute_communication_set_schedule_job.rb @@ -0,0 +1,25 @@ +class ExecuteCommunicationSetScheduleJob + include Sidekiq::Job + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first, 'communication-set-schedule'] }, + on_conflict: :reject, + retry: 1 + + def perform(schedule_id) + schedule = CommunicationSetSchedule.find(schedule_id) + return unless schedule.active? + return unless schedule.unit.active? + + now = Time.zone.now + return unless schedule.due?(now) + + ExecuteCommunicationSetJob.perform_async(schedule.communication_set_id) + schedule.update!( + last_enqueued_at: now, + last_run_at: now, + next_run_at: schedule.next_occurrence_after(now + 1.second) + ) + end +end diff --git a/app/sidekiq/import_batch_feedback_job.rb b/app/sidekiq/import_batch_feedback_job.rb new file mode 100644 index 0000000000..5351979f41 --- /dev/null +++ b/app/sidekiq/import_batch_feedback_job.rb @@ -0,0 +1,40 @@ +class ImportBatchFeedbackJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { args.first(3) }, + on_conflict: :reject, + retry: false + + def perform(unit_id, assessor_id, task_definition_id, path_to_upload) + logger.info "Starting batch feedback import..." + + at(0, 'Preparing batch feedback import') + total(1) + + unit = Unit.find(unit_id) + task_definition = unit.task_definitions.find(task_definition_id) + assessor = User.find(assessor_id) + + file = File.open(path_to_upload, 'rb') + result = unit.upload_batch_feedback_csv( + assessor, + task_definition, + { 'tempfile' => file }, + progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) unless rows_processed.nil? + } + ) + + store(result: result.to_json) + + logger.info "Completed batch feedback import!" + ensure + file&.close if defined?(file) && file.present? + FileUtils.rm_f(path_to_upload) if path_to_upload.present? + end +end diff --git a/app/sidekiq/import_grades_csv_job.rb b/app/sidekiq/import_grades_csv_job.rb index ce9ddd560f..a6e84cf4e5 100644 --- a/app/sidekiq/import_grades_csv_job.rb +++ b/app/sidekiq/import_grades_csv_job.rb @@ -9,7 +9,8 @@ class ImportGradesCsvJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, retry: false diff --git a/app/sidekiq/import_lms_extensions_job.rb b/app/sidekiq/import_lms_extensions_job.rb new file mode 100644 index 0000000000..b38aeda4a5 --- /dev/null +++ b/app/sidekiq/import_lms_extensions_job.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# +# Sets special consideration days from extensions granted on the selected LMS assignment. +# +class ImportLmsExtensionsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, preview_only, scheduled) + total(2) + at(0, 'Validating LMS integration') + + unit = Unit.find(unit_id) + integration = unit.lms_integration + unless integration&.fetch_extensions? && integration.assignment_id.present? + raise LtiCourseDataSource::Error, 'Enable extension imports and select an LMS assignment first' + end + + source = integration.data_source + LmsIntegrationValidator.new(integration).validate!( + groups: integration.group_mapping_enabled? ? source.groups : [], + assignments: source.assignments + ) + + at(1, 'Fetching LMS extensions') + data = source.assignment_extensions(integration.assignment_id) + due_date = Time.zone.at(data[:assignment][:due_date]).to_date + extensions = data[:extensions].select { |extension| extension[:extension_due_date].positive? } + total(2 + extensions.length) + + result = { success: [], ignored: [], errors: [] } + extensions.each_with_index do |extension, index| + extension_date = Time.zone.at(extension[:extension_due_date]).to_date + row = { + lms_username: extension[:login_id], + email: extension[:email], + extension_date: extension_date.iso8601, + spec_con_days: nil + } + + begin + days = [(extension_date - due_date).to_i, 0].max + row[:spec_con_days] = days + + user = UserIdentity.find_user(login_id: extension[:login_id], email: extension[:email]) + if user && UserIdentity.blocked?(user, login_id: extension[:login_id], email: extension[:email], source: 'lms_extensions') + result[:errors] << { row: row, message: "#{user.username} is linked to a different login id" } + next + end + + project = user && unit.projects.find_by(user_id: user.id) + if project.blank? + result[:ignored] << { row: row, message: 'Student is not enrolled in OnTrack' } + next + end + + if project.spec_con_days == days + result[:ignored] << { row: row, message: 'Special consideration days are unchanged' } + else + project.update!(spec_con_days: days) unless preview_only + message = preview_only ? "Would update special consideration to #{days} days" : "Special consideration updated to #{days} days" + result[:success] << { row: row, message: message } + end + rescue StandardError => e + result[:errors] << { row: row, message: e.message } + ensure + at(2 + index + 1, 'Importing extensions') + end + end + + store(result: result.to_json) + integration&.auto_sync_succeeded! if scheduled + rescue StandardError => e + LmsIntegration.find_by(unit_id: unit_id)&.auto_sync_failed!(:extensions, e) if scheduled + raise + end +end diff --git a/app/sidekiq/import_lms_students_job.rb b/app/sidekiq/import_lms_students_job.rb new file mode 100644 index 0000000000..50b297b97b --- /dev/null +++ b/app/sidekiq/import_lms_students_job.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +# +# Imports LMS course members into a unit. Based on ImportStudentsLtiJob, but reads members through +# the unit's LMS data source so it can run from OnTrack or on a schedule without an LTI launch. +# +# The LMS is the source of truth: when withdrawing is enabled, enrolled students who are no longer +# active students in the LMS are withdrawn, and returning students are re-enrolled. +# +class ImportLmsStudentsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include LtiHelper + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, preview_only, withdraw_missing, scheduled) + at(0, 'Fetching LMS course members') + total(0) + + unit = Unit.find(unit_id) + integration = unit.lms_integration + source = LmsIntegration.data_source_for(unit) + mappings = student_mappings(integration, source) + members = source.members + settings = Doubtfire::Application.config.institution_settings + total(members.length) + + result = { success: [], ignored: [], errors: [] } + active_student_user_ids = Set.new + active_student_count = 0 + incomplete_member_count = 0 + + members.each_with_index do |lms_member, index| + at(index, 'Importing members') + member_mappings = lms_member[:group_ids].flat_map { |group_id| mappings.fetch(group_id, []) } + row = display_row(unit, lms_member, member_mappings) + + begin + member = lms_member[:member] + valid_member, missing = valid_lti_member?(member) + unless valid_member + incomplete_member_count += 1 + result[:ignored] << { row: row, message: "Missing required fields: #{missing.join(', ')}" } + next + end + + staff_role = settings.should_employ_lti_member(member) + enrol = settings.should_enrol_lti_member(member) + if staff_role.nil? && !enrol + result[:ignored] << { row: row, message: 'Not a student or staff member' } + next + end + + unless lms_member[:active] + result[:ignored] << { row: row, message: 'Inactive in the LMS' } + next + end + + user = UserIdentity.find_user(login_id: lms_member[:login_id], email: lms_member[:email]) + active_student_count += 1 if enrol + active_student_user_ids << user.id if enrol && user + + # Still counted as active above, so an account we cannot trust is never withdrawn + if user && UserIdentity.blocked?(user, login_id: lms_member[:login_id], email: lms_member[:email], source: 'lms_import') + result[:errors] << { row: row, message: "#{user.username} is linked to a different login id" } + next + end + + mapping_errors = enrol ? LmsGroupMappingApplier.mapping_errors(member_mappings) : [] + if preview_only + record_preview(result, unit, row, user, staff_role, enrol, mapping_errors) + else + user ||= create_user(settings, lms_member) + UserIdentity.link_identity(user, login_id: lms_member[:login_id]) + active_student_user_ids << user.id if enrol + import_member(result, unit, row, user, staff_role, enrol, member_mappings, mapping_errors) + end + rescue StandardError => e + result[:errors] << { row: row, message: e.message } + end + end + + if withdraw_missing + withdraw_missing_students(result, unit, active_student_user_ids, active_student_count, incomplete_member_count, preview_only) + end + + store(result: result.to_json) + integration&.auto_sync_succeeded! if scheduled + rescue StandardError => e + LmsIntegration.find_by(unit_id: unit_id)&.auto_sync_failed!(:students, e) if scheduled + raise + end + + private + + def student_mappings(integration, source) + return {} unless integration&.group_mapping_enabled? && source.course_data_available? + + at(0, 'Validating LMS group mappings') + LmsIntegrationValidator.new(integration).validate!( + groups: source.groups, + assignments: integration.fetch_extensions? ? source.assignments : [] + ) + integration.lms_group_mappings.includes(:group_set, :group, :campus, :tutorial_stream, :tutorial) + .group_by(&:lms_group_id) + rescue LtiCourseDataSource::Error + raise if source.course_data_available? + + {} + end + + def display_row(unit, lms_member, mappings) + { + unit_code: unit.code, + lms_username: lms_member[:login_id], + student_id: lms_member[:student_id], + lis_person_sourcedid: lms_member[:lis_person_sourcedid], + first_name: lms_member[:first_name], + last_name: lms_member[:last_name], + email: lms_member[:email], + lms_roles: lms_member[:roles].join(', '), + lms_groups: mappings.map(&:lms_group_name).uniq.join("\n"), + mapped_campus: mappings.filter_map { |mapping| mapping.campus&.name }.uniq.join(', '), + mapped_tutorial: mappings.select { |mapping| mapping.target_type == 'tutorial' }.map { |mapping| mapping.tutorial&.abbreviation || mapping.lms_group_name }.uniq.join(', '), + mapped_group: mappings.select { |mapping| mapping.target_type == 'group' }.map { |mapping| mapping.group&.name || mapping.lms_group_name }.uniq.join(', ') + } + end + + def create_user(settings, lms_member) + user_id_data = UserIdentity.user_id_data(login_id: lms_member[:login_id], email: lms_member[:email]) + user = User.create! do |new_user| + settings.update_user_from_lti_response(new_user, user_id_data, lms_member[:member]) + end + user.update!(student_id: lms_member[:student_id]) if lms_member[:student_id].present? && user.student_id.blank? + user + end + + def record_preview(result, unit, row, user, staff_role, enrol, mapping_errors) + messages = [] + messages << "Would add staff (#{staff_role.name})" if staff_role && (user.nil? || unit.unit_role_for(user).nil?) + + if enrol + project = user && unit.projects.find_by(user_id: user.id) + messages << if user.nil? + 'Would create user and enrol student' + elsif project.nil? + 'Would enrol student' + elsif !project.enrolled + 'Would re-enrol student' + end + end + messages.compact! + + if mapping_errors.any? + result[:errors] << { row: row, message: mapping_errors.join('; ') } + elsif messages.any? + result[:success] << { row: row, message: messages.join('; ') } + else + result[:ignored] << { row: row, message: 'No change' } + end + end + + def import_member(result, unit, row, user, staff_role, enrol, mappings, mapping_errors) + messages = [] + if staff_role && unit.unit_role_for(user).nil? + staff = unit.employ_staff(user, staff_role) + messages << "Added staff (#{staff_role.name})" if staff&.persisted? + end + + if enrol + project = unit.projects.find_by(user_id: user.id) + was_enrolled = project&.enrolled + project = unit.enrol_student(user, project&.campus) + messages << (was_enrolled.nil? ? 'Enrolled student' : 'Re-enrolled student') unless was_enrolled + + if mapping_errors.any? + result[:errors] << { row: row, message: ([messages.join('; ')].compact_blank + mapping_errors).join('; ') } + return + end + messages << 'LMS group mappings updated' if mappings.any? && LmsGroupMappingApplier.apply(project, mappings) + end + + if messages.any? + result[:success] << { row: row, message: messages.join('; ') } + else + result[:ignored] << { row: row, message: 'No change' } + end + end + + def withdraw_missing_students(result, unit, active_student_user_ids, active_student_count, incomplete_member_count, preview_only) + # Incomplete or empty member data usually means LMS privacy or link settings are wrong, so never treat it as students leaving. + if active_student_count.zero? + result[:errors] << { row: { unit_code: unit.code }, message: 'No active LMS students were found, so no students were withdrawn' } + return + end + if incomplete_member_count.positive? + result[:errors] << { + row: { unit_code: unit.code }, + message: "#{incomplete_member_count} LMS members were missing required details, so no students were withdrawn. Check the tool shares names and emails." + } + return + end + + unit.projects.where(enrolled: true).where.not(user_id: active_student_user_ids.to_a).includes(:user).find_each do |project| + row = nil + row = { + unit_code: unit.code, + username: project.user.username, + student_id: project.user.student_id, + first_name: project.user.first_name, + last_name: project.user.last_name, + email: project.user.email + } + if preview_only + result[:success] << { row: row, message: 'Would withdraw: not an active student in the LMS' } + else + project.update!(enrolled: false) + result[:success] << { row: row, message: 'Withdrawn: not an active student in the LMS' } + end + rescue StandardError => e + result[:errors] << { row: row, message: e.message } + end + end +end diff --git a/app/sidekiq/import_staff_notes_csv_job.rb b/app/sidekiq/import_staff_notes_csv_job.rb new file mode 100644 index 0000000000..42c03ce850 --- /dev/null +++ b/app/sidekiq/import_staff_notes_csv_job.rb @@ -0,0 +1,40 @@ +require 'csv' + +class ImportStaffNotesCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, author_id, path_to_csv) + logger.info "Starting staff note imports..." + + at(0) + + ensure_csv!(path_to_csv) + + unit = Unit.find(unit_id) + result = unit.import_staff_notes_from_csv(path_to_csv, author_id, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: result.to_json) + + FileUtils.rm(path_to_csv) + + logger.info "Completed staff note imports!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/import_students_csv_job.rb b/app/sidekiq/import_students_csv_job.rb index 3669c53dbf..8b2919b8dd 100644 --- a/app/sidekiq/import_students_csv_job.rb +++ b/app/sidekiq/import_students_csv_job.rb @@ -9,7 +9,8 @@ class ImportStudentsCsvJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, retry: false diff --git a/app/sidekiq/import_students_job.rb b/app/sidekiq/import_students_job.rb index 363a49d793..4887b1ff6a 100644 --- a/app/sidekiq/import_students_job.rb +++ b/app/sidekiq/import_students_job.rb @@ -9,7 +9,8 @@ class ImportStudentsJob include MimeCheckHelpers include CsvHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, retry: false diff --git a/app/sidekiq/import_students_lti_job.rb b/app/sidekiq/import_students_lti_job.rb index 18a6d3f9f4..f708e117ab 100644 --- a/app/sidekiq/import_students_lti_job.rb +++ b/app/sidekiq/import_students_lti_job.rb @@ -10,7 +10,8 @@ class ImportStudentsLtiJob include CsvHelper include LtiHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, retry: false @@ -37,30 +38,29 @@ def perform(unit_id, members) next end - user_id_data = { - login_id: member["user_id"], - email: member["email"], - username: member["email"][/(.*)@/, 1] - } + user_id_data = UserIdentity.lti_user_id_data(member) - user = User.find_by(login_id: user_id_data[:login_id]) || - User.find_by(username: user_id_data[:username]) || - User.find_by(email: user_id_data[:email]) || + user = UserIdentity.find_user(login_id: user_id_data[:login_id], email: user_id_data[:email], username: user_id_data[:username]) || User.create! do |new_user| - # Update new user with details from the SAML response + # Update new user with details from the LTI response Doubtfire::Application.config.institution_settings.update_user_from_lti_response( new_user, user_id_data, member ) end + if UserIdentity.blocked?(user, login_id: user_id_data[:login_id], email: user_id_data[:email], source: 'lti_bulk_enrol') + result[:errors] << { row: member, message: "#{user.username} is linked to a different login id" } + next + end + UserIdentity.link_identity(user, login_id: user_id_data[:login_id], username: user_id_data[:username]) if user.valid? unit_role = Doubtfire::Application.config.institution_settings.should_employ_lti_member(member) unless unit_role.nil? staff = unit.employ_staff(user, unit_role) if staff&.valid? - result[:success] << { row: member, message: "Successfully added staff (#{unit_role.name})" } + result[:success] << { row: member, message: "Added staff (#{unit_role.name})" } end end @@ -71,7 +71,7 @@ def perform(unit_id, members) project = unit.enrol_student(user, nil) if project.valid? - result[:success] << { row: member, message: "Successfully enrolled user" } + result[:success] << { row: member, message: "Enrolled student" } else result[:errors] << { row: member, message: "Failed to enrol student" } end diff --git a/app/sidekiq/login_docker_job.rb b/app/sidekiq/login_docker_job.rb index 9616c1f3c8..fb71c5e4d8 100644 --- a/app/sidekiq/login_docker_job.rb +++ b/app/sidekiq/login_docker_job.rb @@ -6,7 +6,8 @@ class LoginDockerJob include LogHelper include ApplicationHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { args }, on_conflict: :reject, retry: 1 diff --git a/app/sidekiq/notify_discuss_timeout_job.rb b/app/sidekiq/notify_discuss_timeout_job.rb new file mode 100644 index 0000000000..d137bfbb05 --- /dev/null +++ b/app/sidekiq/notify_discuss_timeout_job.rb @@ -0,0 +1,13 @@ +class NotifyDiscussTimeoutJob + include Sidekiq::Job + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(_args) { ['notify-discuss-timeout'] }, + on_conflict: :reject, + retry: 1 + + def perform + Unit.notify_discuss_timeouts! + end +end diff --git a/app/sidekiq/notify_tutor_notes_job.rb b/app/sidekiq/notify_tutor_notes_job.rb index 194c6b4f35..6db73fce4f 100644 --- a/app/sidekiq/notify_tutor_notes_job.rb +++ b/app/sidekiq/notify_tutor_notes_job.rb @@ -1,6 +1,8 @@ class NotifyTutorNotesJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform(tutor_note_id, recipient_user_id) tutor_note = TutorNote.find(tutor_note_id) recipient = User.find(recipient_user_id) diff --git a/app/sidekiq/poll_communication_set_schedules_job.rb b/app/sidekiq/poll_communication_set_schedules_job.rb new file mode 100644 index 0000000000..161283f34e --- /dev/null +++ b/app/sidekiq/poll_communication_set_schedules_job.rb @@ -0,0 +1,20 @@ +class PollCommunicationSetSchedulesJob + include Sidekiq::Job + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(_args) { ['poll-communication-set-schedules'] }, + on_conflict: :reject, + retry: 1 + + def perform + CommunicationSetSchedule + .includes(:communication_set) + .active + .with_active_unit + .due(Time.zone.now) + .find_each do |schedule| + ExecuteCommunicationSetScheduleJob.perform_async(schedule.id) + end + end +end diff --git a/app/sidekiq/pull_docker_image_job.rb b/app/sidekiq/pull_docker_image_job.rb index 5b1e9aa798..9198bc048a 100644 --- a/app/sidekiq/pull_docker_image_job.rb +++ b/app/sidekiq/pull_docker_image_job.rb @@ -5,7 +5,8 @@ class PullDockerImageJob include ApplicationHelper include FileHelper - sidekiq_options lock: :until_executed, + sidekiq_options queue: 'parallel', + lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, retry: false diff --git a/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb index de38dc9a80..9fc4d30229 100644 --- a/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb +++ b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb @@ -3,9 +3,13 @@ class RefreshModerationFeedbackTimestampsJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform ModeratedTask .where(state: %i[open waiting_for_new_feedback]) + .joins(task: { project: :unit }) + .where(units: { id: Unit.within_teaching_dates.select(:id) }) .includes(task: :comments) .find_each do |moderated_task| task = moderated_task.task diff --git a/app/sidekiq/send_discuss_timeout_email_job.rb b/app/sidekiq/send_discuss_timeout_email_job.rb new file mode 100644 index 0000000000..f9277ae000 --- /dev/null +++ b/app/sidekiq/send_discuss_timeout_email_job.rb @@ -0,0 +1,26 @@ +class SendDiscussTimeoutEmailJob + include Sidekiq::Job + + sidekiq_options queue: 'parallel', + retry: 5 + + def perform(task_id, sender_id, notification_type, expiry_date = nil) + task = Task.find_by(id: task_id) + sender = User.find_by(id: sender_id) + return if task.blank? || sender.blank? + return unless task.unit.discuss_timeout_active? + return unless task.unit.send_notifications + return unless task.project.student.receive_feedback_notifications + + mail = case notification_type + when 'approaching' + NotificationsMailer.discussion_deadline_approaching(task, sender, Date.iso8601(expiry_date)) + when 'missed' + NotificationsMailer.discussion_deadline_missed(task, sender) + else + raise ArgumentError, "Unknown discussion deadline notification type: #{notification_type}" + end + + mail.deliver_now + end +end diff --git a/app/sidekiq/sync_lms_grades_job.rb b/app/sidekiq/sync_lms_grades_job.rb new file mode 100644 index 0000000000..8fce6878ef --- /dev/null +++ b/app/sidekiq/sync_lms_grades_job.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# +# Sends each enrolled student's OnTrack grade to the LMS grade item created when the unit was linked. +# A preview matches LMS members to OnTrack students and reports the grades without sending them. +# +class SyncLmsGradesJob + include Sidekiq::Job + include Sidekiq::Status::Worker + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, preview_only) + total(2) + at(0, 'Fetching LMS course members') + + unit = Unit.find(unit_id) + integration = unit.lms_integration + skip_ungraded = integration.nil? || integration.skip_ungraded? + send_rationale = integration&.send_grade_rationale? || false + source = LmsIntegration.data_source_for(unit) + members = source.members + server = LtiServer.new(unit.id) + + result = { success: [], ignored: [], errors: [] } + scores = [] + rows_by_user_id = {} + matched_project_ids = Set.new + + members.each do |lms_member| + row = { lms_user_id: lms_member[:lms_user_id], lms_username: lms_member[:login_id], email: lms_member[:email], name: lms_member[:name] } + next unless Doubtfire::Application.config.institution_settings.should_enrol_lti_member(lms_member[:member]) + + user = UserIdentity.find_user(login_id: lms_member[:login_id], email: lms_member[:email]) + project = user && unit.projects.find_by(user_id: user.id, enrolled: true) + if user + row[:ontrack_username] = user.username + row[:ontrack_name] = user.name + end + + if user && UserIdentity.blocked?(user, login_id: lms_member[:login_id], email: lms_member[:email], source: 'lms_grade_sync') + matched_project_ids << project.id if project + result[:ignored] << { row: row, message: 'OnTrack account is linked to a different login id' } + next + end + + if project.nil? + result[:ignored] << { row: row, message: user ? 'Not enrolled in OnTrack' : 'No matching OnTrack user' } + elsif project.grade.nil? || (skip_ungraded && project.grade.zero?) + matched_project_ids << project.id + result[:ignored] << { row: row, message: 'Not graded yet' } + else + matched_project_ids << project.id + row[:grade] = project.grade + rows_by_user_id[lms_member[:lms_user_id]] = row + score = { userId: lms_member[:lms_user_id], scoreGiven: project.grade } + if send_rationale && project.grade_rationale.present? + score[:comment] = project.grade_rationale + row[:feedback] = project.grade_rationale + end + scores << score + end + end + + # Graded OnTrack students with no LMS member would otherwise be skipped silently + unmatched = unit.projects.where(enrolled: true).where.not(grade: nil).where.not(id: matched_project_ids.to_a) + unmatched = unmatched.where.not(grade: 0) if skip_ungraded + unmatched.includes(:user).find_each do |project| + row = { ontrack_username: project.user.username, ontrack_name: project.user.name, email: project.user.email, grade: project.grade } + result[:ignored] << { row: row, message: 'Not a student in the LMS course' } + end + + if preview_only + rows_by_user_id.each_value { |row| result[:success] << { row: row, message: "Will send: #{row[:grade]}%" } } + elsif scores.any? + at(1, "Sending #{scores.length} grades to the LMS") + Array(server.submit_scores(scores)['results']).each do |score_result| + row = rows_by_user_id[score_result['userId']] + if score_result['success'] + result[:success] << { row: row, message: "Grade synced: #{row[:grade]}%" } + else + result[:errors] << { row: row, message: score_result['error'] || 'Failed to submit score' } + end + end + end + + at(2, preview_only ? 'Grade sync preview complete' : 'Grade sync complete') + store(result: result.to_json) + end +end diff --git a/app/sidekiq/sync_lms_integrations_job.rb b/app/sidekiq/sync_lms_integrations_job.rb new file mode 100644 index 0000000000..a448700778 --- /dev/null +++ b/app/sidekiq/sync_lms_integrations_job.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class SyncLmsIntegrationsJob + include Sidekiq::Job + + sidekiq_options queue: 'parallel', + lock: :until_executed, + lock_args_method: ->(_args) { ['sync-lms-integrations'] }, + on_conflict: :reject, + retry: 1 + + def perform + return unless Doubtfire::Application.config.lti_enabled && LtiServer.configured? + + today = Time.zone.today + outage = lti_outage + + LmsIntegration.auto_syncing.includes(:unit).find_each do |integration| + syncs = due_syncs(integration, today, check_course_data: outage.nil?) + next if syncs.empty? + + if outage + integration.record_auto_sync_outage!(nil, outage) + else + ImportLmsStudentsJob.perform_async(integration.unit_id, false, integration.withdraw_missing_students, true) if syncs.include?(:students) + ImportLmsExtensionsJob.perform_async(integration.unit_id, false, true) if syncs.include?(:extensions) + end + rescue StandardError => e + logger.error "Failed to schedule the LMS sync for unit #{integration.unit_id}: #{e.message}" + integration.auto_sync_failed!(nil, e) + end + end + + private + + # Checked once so an outage sends one email to ops instead of failing every unit's sync + def lti_outage + LtiServer.check_health! + nil + rescue LtiServer::Error => e + logger.error "Skipping the scheduled LMS sync because the LTI service is unavailable: #{e.message}" + email_ops(e) + e + end + + def email_ops(error) + ErrorLogMailer.error_message('LMS sync skipped', 'The LTI service is unavailable, so the scheduled LMS sync was skipped.', error).deliver_now + rescue StandardError => e + logger.error "Failed to email the LMS sync outage: #{e.message}" + end + + def due_syncs(integration, today, check_course_data:) + unit = integration.unit + return [] unless unit.active? + + syncs = [] + if integration.auto_sync_students? && today.between?(unit.start_date.to_date, unit.end_date.to_date) && + mappings_ready?(integration, check_course_data) + syncs << :students + end + if integration.auto_sync_extensions? && integration.validated? && integration.fetch_extensions? && + integration.assignment_id.present? && today.between?(unit.start_date.to_date, unit.end_date.to_date + 14.days) + syncs << :extensions + end + syncs + end + + # Mappings are skipped when the plugin is unavailable, so they do not need validating + def mappings_ready?(integration, check_course_data) + !integration.group_mapping_enabled? || integration.validated? || (check_course_data && !course_data_available?(integration)) + end + + def course_data_available?(integration) + integration.data_source.course_data_available? + rescue LtiServer::Error, LtiCourseDataSource::Error + true + end +end diff --git a/app/sidekiq/tii_action_job.rb b/app/sidekiq/tii_action_job.rb index a81bca1b11..85106c22bf 100644 --- a/app/sidekiq/tii_action_job.rb +++ b/app/sidekiq/tii_action_job.rb @@ -1,6 +1,8 @@ class TiiActionJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform(id) action = TiiAction.find(id) diff --git a/app/sidekiq/tii_check_progress_job.rb b/app/sidekiq/tii_check_progress_job.rb index ca8084bee0..b6b2e22ac2 100644 --- a/app/sidekiq/tii_check_progress_job.rb +++ b/app/sidekiq/tii_check_progress_job.rb @@ -6,6 +6,8 @@ class TiiCheckProgressJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform return unless TurnItIn.enabled? diff --git a/app/sidekiq/tii_register_web_hook_job.rb b/app/sidekiq/tii_register_web_hook_job.rb index f61de7bea4..73bb7facc7 100644 --- a/app/sidekiq/tii_register_web_hook_job.rb +++ b/app/sidekiq/tii_register_web_hook_job.rb @@ -5,6 +5,8 @@ class TiiRegisterWebHookJob include Sidekiq::Job + sidekiq_options queue: 'parallel' + def perform return unless TurnItIn.enabled? diff --git a/app/validators/allowed_characters_validator.rb b/app/validators/allowed_characters_validator.rb new file mode 100644 index 0000000000..768e0fcc57 --- /dev/null +++ b/app/validators/allowed_characters_validator.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AllowedCharactersValidator < ActiveModel::EachValidator + ALPHANUMERIC_CHARACTER = /\A[\p{L}\p{M}\p{N}]\z/u + CHARACTER_WHITELISTS = { + first_name: " -()_/,,.'’", + last_name: " -()_/,,.'’", + preferred_name: " -()_/,,.'’", + unit_name: " -#()", + title: " -#()&/:_.'\"*+", + unit_code: "-#/_" + }.freeze + + def validate_each(record, attribute, value) + return if value.blank? + + type = options.fetch(:type) + whitelist = CHARACTER_WHITELISTS.fetch(type) + if type == :title && !alphanumeric?(value[0]) + record.errors.add(attribute, 'must begin with an alphanumeric character') + end + return if value.each_char.all? { |character| alphanumeric?(character) || whitelist.include?(character) } + + record.errors.add(attribute, 'contains unsupported characters') + end + + private + + def alphanumeric?(character) + character.match?(ALPHANUMERIC_CHARACTER) + end +end diff --git a/app/views/communications_mailer/action_log_email.html.erb b/app/views/communications_mailer/action_log_email.html.erb new file mode 100644 index 0000000000..7df5658bf1 --- /dev/null +++ b/app/views/communications_mailer/action_log_email.html.erb @@ -0,0 +1,69 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ +

Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>,

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ +

+ <%= @affected_students_count %> + students matched this rule and were affected by the configured actions. +

+ +

A full log of actions has been attached as a CSV.

+ +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team +

+ +
+ + diff --git a/app/views/communications_mailer/action_log_email.text.erb b/app/views/communications_mailer/action_log_email.text.erb new file mode 100644 index 0000000000..f980a817f3 --- /dev/null +++ b/app/views/communications_mailer/action_log_email.text.erb @@ -0,0 +1,18 @@ +Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>, + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +--- + +<%= @affected_students_count %> students matched this rule and were affected by the configured actions. + +A full log of actions has been attached as a CSV. + +Cheers, +The <%= @doubtfire_product_name %> Team + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/communications_mailer/communication_email.html.erb b/app/views/communications_mailer/communication_email.html.erb new file mode 100644 index 0000000000..7933f82475 --- /dev/null +++ b/app/views/communications_mailer/communication_email.html.erb @@ -0,0 +1,53 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ + diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb new file mode 100644 index 0000000000..5ad89c15fb --- /dev/null +++ b/app/views/communications_mailer/communication_email.text.erb @@ -0,0 +1,12 @@ +<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %> + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +<%# Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %> + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex index b051661808..fa21d6e190 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -27,6 +27,12 @@ } \usepackage[fencedCode,hashEnumerators,pipeTables,texMathDollars]{markdown} +\markdownSetup{rendererPrototypes={ + ulBeginTight={\begin{itemize}}, + ulEndTight={\end{itemize}}, + olBeginTight={\begin{enumerate}}, + olEndTight={\end{enumerate}} +}} \usepackage{luatextra} \defaultfontfeatures{Ligatures=TeX} @@ -38,6 +44,9 @@ \usepackage{pdfpages} \usepackage{epstopdf} \usepackage{amssymb} +% Notebook Markdown may use these comparison commands in inline maths. +\providecommand{\lt}{\mathrel{<}} +\providecommand{\gt}{\mathrel{>}} \usepackage{colortbl} \usepackage{lastpage} \usepackage{multirow} @@ -96,20 +105,22 @@ end \fancyfoot[R]{} } -\definecolor{complete}{rgb}{0.357, 0.718, 0.357} -\definecolor{rff}{rgb}{0.0,0.47,0.84} +\definecolor{complete}{HTML}{5BB75B} +\definecolor{rff}{HTML}{0079D8} -\definecolor{notstarted}{rgb}{0.8,0.8,0.8} -\definecolor{workingonit}{rgb}{0.922, 0.561, 0.024} -\definecolor{needhelp}{rgb}{0.643, 0.561, 0.808} -\definecolor{fix}{rgb}{0.949, 0.847, 0.361} -\definecolor{dnr}{rgb}{0.831, 0.42, 0.329} -\definecolor{redo}{rgb}{0.502, 0.251, 0} -\definecolor{discuss}{rgb}{0.192, 0.69, 0.835} -\definecolor{demo}{rgb}{0.259, 0.545, 0.792} -\definecolor{fail}{rgb}{0.851, 0.216, 0.075} -\definecolor{timeexceeded}{rgb}{0.851, 0.216, 0.075} -\definecolor{assessinportfolio}{rgb}{0.568, 0.721, 0.568} +\definecolor{notstarted}{HTML}{CCCCCC} +\definecolor{workingonit}{HTML}{EB8F06} +\definecolor{needhelp}{HTML}{A48FCE} +\definecolor{fix}{HTML}{F2D85C} +\definecolor{dnr}{HTML}{D46B54} +\definecolor{redo}{HTML}{804000} +\definecolor{discuss}{HTML}{31B0D5} +\definecolor{rediscuss}{HTML}{126352} +\definecolor{demo}{HTML}{428BCA} +\definecolor{fail}{HTML}{D93713} +\definecolor{timeexceeded}{HTML}{D93713} +\definecolor{assessinportfolio}{HTML}{F2D85C} +\definecolor{attentionrequired}{HTML}{F1814D} \begin{document} diff --git a/app/views/layouts/discussion_deadline_mailer.html.erb b/app/views/layouts/discussion_deadline_mailer.html.erb new file mode 100644 index 0000000000..36b8b3f822 --- /dev/null +++ b/app/views/layouts/discussion_deadline_mailer.html.erb @@ -0,0 +1,59 @@ + + + + + + +
+ <%= yield %> + +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> +

+
+
+ Unsubscribe | Generated with <%= @doubtfire_product_name %> +
+ + diff --git a/app/views/layouts/discussion_deadline_mailer.text.erb b/app/views/layouts/discussion_deadline_mailer.text.erb new file mode 100644 index 0000000000..37f0bddbd7 --- /dev/null +++ b/app/views/layouts/discussion_deadline_mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/layouts/jupynotex.py b/app/views/layouts/jupynotex.py index 525a424f48..db3e9ca18d 100644 --- a/app/views/layouts/jupynotex.py +++ b/app/views/layouts/jupynotex.py @@ -9,6 +9,7 @@ """ import base64 +import binascii import json import os import re @@ -17,6 +18,7 @@ import tempfile import textwrap import traceback +import urllib.parse # basic verbatim start/end @@ -27,6 +29,13 @@ MARKDOWN_BEGIN = [r"\begin{markdown}"] MARKDOWN_END = [r"\end{markdown}"+"\n"] +ATTACHMENT_MIMETYPES = ( + ('image/png', '.png'), + ('image/jpeg', '.jpg'), + ('image/gif', '.gif'), + ('image/bmp', '.bmp'), +) + # highlighers for different languages (block beginning and ending) HIGHLIGHTERS = { 'python': ([r'\begin{minted}[fontsize=\footnotesize,breaklines,breakanywhere,tabsize=4]{python}'], [r'\end{minted}']), @@ -60,6 +69,13 @@ def _validator_positive_int(value): return value +def _as_lines(value): + """Return notebook text stored as either a string or list of strings.""" + if isinstance(value, str): + return value.splitlines() + return value + + def _process_plain_text(lines, config_options=None): """Wrap a series of lines around a verbatim indication.""" if config_options is None: @@ -67,6 +83,7 @@ def _process_plain_text(lines, config_options=None): result = [] result.extend(VERBATIM_BEGIN) + lines = _as_lines(lines) for line in lines: line = line.strip() @@ -117,17 +134,18 @@ def process_plain_text(self, lines): def process_png(self, image_data): """Process a PNG: just save the received b64encoded data to a temp file.""" - _, fname = tempfile.mkstemp(suffix='.png') - with open(fname, 'wb') as fh: + descriptor, fname = tempfile.mkstemp(suffix='.png') + with os.fdopen(descriptor, 'wb') as fh: fh.write(base64.b64decode(image_data)) return fname def process_svg(self, image_data): """Process a SVG: save the data, transform to PDF, and then use that.""" - _, svg_fname = tempfile.mkstemp(suffix='.svg') - _, pdf_fname = tempfile.mkstemp(suffix='.pdf') + svg_descriptor, svg_fname = tempfile.mkstemp(suffix='.svg') + pdf_descriptor, pdf_fname = tempfile.mkstemp(suffix='.pdf') + os.close(pdf_descriptor) raw_svg = ''.join(image_data).encode('utf8') - with open(svg_fname, 'wb') as fh: + with os.fdopen(svg_descriptor, 'wb') as fh: fh.write(raw_svg) cmd = ['rsvg-convert', '--format=pdf', '--output={}'.format(pdf_fname), svg_fname] @@ -169,8 +187,8 @@ def __init__(self, path, config_options): with open(path, 'rt', encoding='utf8') as fh: nb_data = json.load(fh) - # get the languaje, to highlight - lang = nb_data['metadata']['language_info']['name'] + # get the language, when available, to highlight + lang = nb_data.get('metadata', {}).get('language_info', {}).get('name') self._highlight_delimiters = HIGHLIGHTERS.get(lang, HIGHLIGHTERS[None]) # get all cells @@ -184,9 +202,73 @@ def _validate_config(self, config): config[key] = new_value return config + def _save_markdown_attachment(self, mime_bundle): + """Save a supported attachment MIME bundle and return its temporary path.""" + for mimetype, suffix in ATTACHMENT_MIMETYPES: + if mimetype not in mime_bundle: + continue + + image_data = mime_bundle[mimetype] + if isinstance(image_data, list): + image_data = ''.join(image_data) + + descriptor, fname = tempfile.mkstemp(suffix=suffix) + try: + with os.fdopen(descriptor, 'wb') as fh: + fh.write(base64.b64decode(image_data)) + except Exception: + os.unlink(fname) + raise + return fname + + if 'image/svg+xml' in mime_bundle: + return ItemProcessor(self.cell_options, self.config_options).process_svg( + mime_bundle['image/svg+xml'] + ) + + return None + + def _process_markdown_attachments(self, content, source): + """Extract embedded notebook images and rewrite their attachment URLs.""" + source_text = '\n'.join(source) + + # Use the longest names first so one attachment name cannot partially + # replace another, for example image.png and my-image.png. + attachments = content.get('attachments') or {} + for name in sorted(attachments, key=len, reverse=True): + try: + attachment_path = self._save_markdown_attachment(attachments[name]) + except (OSError, TypeError, ValueError, binascii.Error): + attachment_path = None + + if not attachment_path: + continue + + # Notebook editors may leave spaces literal or URL-encode them. + references = { + f'attachment:{name}', + f'attachment:{urllib.parse.quote(name)}', + f'attachment:{urllib.parse.quote(name, safe="")}', + } + for reference in references: + source_text = source_text.replace(reference, attachment_path) + + # A missing, corrupt, or unsupported embedded image should not make the + # whole submission PDF fail. Keep a visible marker in its place. + source_text = re.sub( + r'!\[([^\]]*)\]\(attachment:([^)]+)\)', + lambda match: ( + f'**[Image attachment unavailable: ' + f'{match.group(1) or match.group(2)}]**' + ), + source_text, + ) + + return source_text.splitlines() + def _proc_src(self, content): """Process the source of a cell.""" - source = content['source'] + source = _as_lines(content['source']) result = [] if content['cell_type'] == 'code': begin, end = self._highlight_delimiters @@ -194,6 +276,7 @@ def _proc_src(self, content): result.extend(textwrap.fill(line[:1000] + ' [The rest of this line has been truncated by the system to improve readability.] ' * (len(line) > 1000), width=90, subsequent_indent=' ') for line in source) result.extend(end) elif content['cell_type'] == 'markdown': + source = self._process_markdown_attachments(content, source) result.extend(MARKDOWN_BEGIN) result.extend(line.replace('```markdown', '```md').strip() for line in source) result.extend(MARKDOWN_END) diff --git a/app/views/lms_integration_mailer/auto_sync_failed.html.erb b/app/views/lms_integration_mailer/auto_sync_failed.html.erb new file mode 100644 index 0000000000..ea31330901 --- /dev/null +++ b/app/views/lms_integration_mailer/auto_sync_failed.html.erb @@ -0,0 +1,114 @@ + + + + <%= @unit.code %>: Daily LMS <%= @sync_name %> failed and has been turned off + + + +
+

<%= @unit.name %> - Daily LMS Sync Turned Off

+

<%= @unit.code %>

+ +

Hi <%= @convenor.first_name %>,

+ + <% if @failing_since %> +

+ The daily LMS <%= @sync_name %> for <%= @unit.code %> has failed every night since + <%= @failing_since.strftime('%A %-d %B') %>, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS. +

+

+ This is usually an outage of the LMS or of <%= @doubtfire_product_name %>'s LMS connection rather than a problem with the unit's settings. + The last error was: <%= @error_message %> +

+ <% else %> +

+ The daily LMS <%= @sync_name %> for <%= @unit.code %> failed, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS until you review it. +

+ <% if @issues.any? %> +

+ The LMS integration no longer matches the LMS course: +

    + <% @issues.each do |issue| %> +
  • + <% if issue[:lms_group_name].present? %><%= issue[:lms_group_name] %>:<% end %> + <%= issue[:message] %> +
  • + <% end %> +
+

+ <% else %> +

The error was: <%= @error_message %>

+ <% end %> + <% end %> + +

+ These settings have been turned off: +

    + <% @turned_off.each do |setting| %> +
  • <%= setting %>
  • + <% end %> +
+

+ + <% if @failing_since %> +

+ Once the LMS is working again, run a preview import to check what will change, then turn these settings back on in the + unit's LMS tab. +

+ <% else %> +

Fix the integration in the unit's LMS tab, then turn these settings back on.

+ <% end %> + +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team +

+
+
+ Generated with <%= @doubtfire_product_name %> +
+ + diff --git a/app/views/lms_integration_mailer/auto_sync_failed.text.erb b/app/views/lms_integration_mailer/auto_sync_failed.text.erb new file mode 100644 index 0000000000..dd4b2138c1 --- /dev/null +++ b/app/views/lms_integration_mailer/auto_sync_failed.text.erb @@ -0,0 +1,33 @@ +Hi <%= @convenor.first_name %>, + +<% if @failing_since %> +The daily LMS <%= @sync_name %> for <%= @unit.code %> <%= @unit.name %> has failed every night since <%= @failing_since.strftime('%A %-d %B') %>, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS. + +This is usually an outage of the LMS or of <%= @doubtfire_product_name %>'s LMS connection rather than a problem with the unit's settings. The last error was: <%= @error_message %> +<% else %> +The daily LMS <%= @sync_name %> for <%= @unit.code %> <%= @unit.name %> failed, so <%= @doubtfire_product_name %> has stopped syncing the unit with the LMS until you review it. + +<% if @issues.any? %> +The LMS integration no longer matches the LMS course: +<% @issues.each do |issue| %> + * <%= [issue[:lms_group_name], issue[:message]].compact_blank.join(': ') %> +<% end %> +<% else %> +The error was: <%= @error_message %> +<% end %> +<% end %> + +These settings have been turned off: +<% @turned_off.each do |setting| %> + * <%= setting %> +<% end %> + +<% if @failing_since %> +Once the LMS is working again, run a preview import to check what will change, then turn these settings back on in the unit's LMS tab: +<% else %> +Fix the integration in the unit's LMS tab, then turn these settings back on: +<% end %> +<%= @lms_url %> + +Cheers, +The <%= @doubtfire_product_name %> Team diff --git a/app/views/notifications_mailer/discussion_deadline_approaching.html.erb b/app/views/notifications_mailer/discussion_deadline_approaching.html.erb new file mode 100644 index 0000000000..94573e7541 --- /dev/null +++ b/app/views/notifications_mailer/discussion_deadline_approaching.html.erb @@ -0,0 +1,16 @@ +

<%= @unit.name %> - Discussion Deadline Approaching

+

<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %>

+ +

Hi <%= @student.first_name %>,

+ +

+ You must discuss task <%= @task.task_definition.abbreviation %> with your tutor on or before + <%= @deadline %>. +

+ +

+ If it is not discussed by then, it will move to Fix and Resubmit, and you will + need to resubmit your work. +

+ +

View this task in <%= @doubtfire_product_name %>.

diff --git a/app/views/notifications_mailer/discussion_deadline_approaching.text.erb b/app/views/notifications_mailer/discussion_deadline_approaching.text.erb new file mode 100644 index 0000000000..05cd0bf61f --- /dev/null +++ b/app/views/notifications_mailer/discussion_deadline_approaching.text.erb @@ -0,0 +1,14 @@ +Hi <%= @student.first_name %>, + +You must discuss task <%= @task.task_definition.abbreviation %> with your tutor on or before <%= @deadline %>. + +If it is not discussed by then, it will move to Fix and Resubmit, and you will need to resubmit your work. + +View this task in <%= @doubtfire_product_name %>: <%= @task_url %> + +Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> + +--- + +Visit <%= @unsubscribe_url %> to unsubscribe from these notifications. diff --git a/app/views/notifications_mailer/discussion_deadline_missed.html.erb b/app/views/notifications_mailer/discussion_deadline_missed.html.erb new file mode 100644 index 0000000000..eade95411a --- /dev/null +++ b/app/views/notifications_mailer/discussion_deadline_missed.html.erb @@ -0,0 +1,13 @@ +

<%= @unit.name %> - Discussion Deadline Missed

+

<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %>

+ +

Hi <%= @student.first_name %>,

+ +

+ Task <%= @task.task_definition.abbreviation %> moved to + Fix and Resubmit because it was not discussed by the deadline. +

+ +

Review any feedback and resubmit it when you are ready.

+ +

View this task in <%= @doubtfire_product_name %>.

diff --git a/app/views/notifications_mailer/discussion_deadline_missed.text.erb b/app/views/notifications_mailer/discussion_deadline_missed.text.erb new file mode 100644 index 0000000000..bbc19c18a5 --- /dev/null +++ b/app/views/notifications_mailer/discussion_deadline_missed.text.erb @@ -0,0 +1,14 @@ +Hi <%= @student.first_name %>, + +<%= @task.task_definition.abbreviation %> - <%= @task.task_definition.name %> moved to Fix and Resubmit because it was not discussed by the deadline. + +Review any feedback and resubmit it when you are ready. + +View this task in <%= @doubtfire_product_name %>: <%= @task_url %> + +Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> + +--- + +Visit <%= @unsubscribe_url %> to unsubscribe from these notifications. diff --git a/app/views/notifications_mailer/weekly_student_summary.html.erb b/app/views/notifications_mailer/weekly_student_summary.html.erb index 3a0593d36e..6487df32f9 100644 --- a/app/views/notifications_mailer/weekly_student_summary.html.erb +++ b/app/views/notifications_mailer/weekly_student_summary.html.erb @@ -61,7 +61,7 @@ <% end %> <% if @project.tutorial_enrolments.blank? %> -

Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.

+

Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.

<% end %>

Here is what you should focus on right now.

diff --git a/app/views/notifications_mailer/weekly_student_summary.text.erb b/app/views/notifications_mailer/weekly_student_summary.text.erb index 808fc7c3bd..b4630e3046 100644 --- a/app/views/notifications_mailer/weekly_student_summary.text.erb +++ b/app/views/notifications_mailer/weekly_student_summary.text.erb @@ -15,7 +15,7 @@ Hope you had a good week! Here's a summary of what has happened in this unit ove <% if @project.tutorial_enrolments.blank? %> -Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: <%= @doubtfire_host %>/#/projects/<%= @project.id %>/tutorials +Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: <%= @doubtfire_host %>/projects/<%= @project.id %>/tutorials Here is what you should focus on right now. <% else %> diff --git a/app/views/portfolio/portfolio_pdf.pdf.erb b/app/views/portfolio/portfolio_pdf.pdf.erb index 64704249c1..f4a9afe006 100644 --- a/app/views/portfolio/portfolio_pdf.pdf.erb +++ b/app/views/portfolio/portfolio_pdf.pdf.erb @@ -13,10 +13,13 @@ def latex_color_for_status(task) when :need_help then 'needhelp' when :working_on_it then 'workingonit' when :discuss then 'discuss' + when :rediscuss then 'rediscuss' when :ready_for_feedback then 'rff' when :demonstrate then 'demo' when :fail then 'fail' when :assess_in_portfolio then 'assessinportfolio' + when :attention_required then 'attentionrequired' + else raise ArgumentError, "No portfolio PDF color for task status #{task.status}" end end end @@ -96,7 +99,12 @@ No Tutor % DATE SECTION %---------------------------------------------------------------------------------------- -{\large \today}\\[3cm] % Date, change the \today to a set date if you want to be precise +<% if @formatted_submission_date.present? %> +{\large <%= lesc @formatted_submission_date %>}\\[0.2cm] +{\large \textcolor{gray}{<%= lesc @formatted_submission_time %>}}\\[3cm] +<% else %> +\vspace{3cm} +<% end %> %---------------------------------------------------------------------------------------- % LOGO SECTION @@ -125,7 +133,7 @@ No Tutor \textbf{Task} & \textbf{Status} & \textbf{Times Assessed} \\ \hline <% @task_defs.each do |td| %> - <%= lesc td.name %> & + <%= lesc "#{td.abbreviation} #{td.name}" %> & <% task = task_for_def(td) if task.nil? %> @@ -233,7 +241,11 @@ end # if there are linked outcomes \section{<%= lesc task.task_definition.name %>} \label{sec_<%= lesc task.task_definition.id %>} +<% if task.task_definition.description.present? %> <%= lesc task.task_definition.description %> +<% else %> + \textit{no task description} +<% end %> \bigskip \\ <% diff --git a/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb new file mode 100644 index 0000000000..9cead91931 --- /dev/null +++ b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.html.erb @@ -0,0 +1,72 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+

Hi <%= @student.first_name %>,

+

+ Automated assessment did not pass for the tasks below. Please review the Overseer report in <%= @doubtfire_product_name %>, verify your output, and resubmit. +

+

+ Tasks to review: +

+

+

+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> +

+
+
+ Unsubscribe | Generated with <%= @doubtfire_product_name %> +
+ + diff --git a/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb new file mode 100644 index 0000000000..37248debfa --- /dev/null +++ b/app/views/portfolio_evidence_mailer/overseer_assessment_failed.text.erb @@ -0,0 +1,17 @@ +Hi <%= @student.first_name %>, + +Automated assessment did not pass for the tasks below. Please review the Overseer report in <%= @doubtfire_product_name %>, verify your output, and resubmit. + +Tasks to review: +<% @tasks.each do |task| %> + * <%= task.task_definition.abbreviation %> - <%= task.task_definition.name %> +<% end %> + +Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> + +--- + +Visit <%= @unsubscribe_url %> to unsubscribe from these notifications. + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb index 8857c89996..bcac7f75ab 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_failed.html.erb @@ -50,7 +50,7 @@

<% end %>

- There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again. + There was an error compiling your portfolio. This usually means that one of your task PDFs is corrupt. Login to <%= @doubtfire_product_name %> and check each of your task PDFs. Once you have checked that all of the PDFs are valid you can schedule your portfolio to compile again.

Cheers,
diff --git a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb index fa52bd5a0a..096d3e02c1 100644 --- a/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb +++ b/app/views/portfolio_evidence_mailer/portfolio_ready.html.erb @@ -50,7 +50,7 @@

<% end %>

- Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>. + Your portfolio has been compiled and can now be viewed and downloaded from <%= @doubtfire_product_name %>.

Cheers,
diff --git a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb index 362e6f86b6..5e5095810d 100644 --- a/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb +++ b/app/views/portfolio_evidence_mailer/task_feedback_ready.html.erb @@ -50,7 +50,7 @@ <% @tasks.each do |task| %>

  • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%> <%= task.is_last_comment_by?(@tutor) ? ' with comments' : ''%> diff --git a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb index 99d27c61e1..a1396f4bc1 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_failed.html.erb @@ -53,7 +53,7 @@ <% @tasks.each do |task| %>
  • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%>
  • diff --git a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb index 87810ec5f0..98a9497115 100644 --- a/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb +++ b/app/views/portfolio_evidence_mailer/task_pdf_ready_message.html.erb @@ -50,7 +50,7 @@ <% @tasks.each do |task| %>
  • <%= task.task_definition.abbreviation %> - - + <%=task.task_definition.name%>
  • diff --git a/app/views/shared/_file.pdf.erb b/app/views/shared/_file.pdf.erb index 036b4b2635..d97d945e7a 100644 --- a/app/views/shared/_file.pdf.erb +++ b/app/views/shared/_file.pdf.erb @@ -26,3 +26,9 @@ <% end # end for %> <% end %> + +<% if %w[zip archive].include?(file_type) %> +\begin{tcolorbox}[colback=blue!5!white,colframe=blue!75!black] + This zip file was uploaded with the portfolio. Zip contents are not expanded into this PDF. +\end{tcolorbox} +<% end %> diff --git a/app/views/task/task_pdf.pdf.erb b/app/views/task/task_pdf.pdf.erb index 44e8a83861..86c40c525e 100644 --- a/app/views/task/task_pdf.pdf.erb +++ b/app/views/task/task_pdf.pdf.erb @@ -31,7 +31,7 @@ <% else %> <%= lesc @task.student.first_name %> \textsc{<%= lesc @task.student.last_name %>}\\ <%= lesc @task.student.username %>\\ -<%= lesc @task.submission_date.nil? ? "" : @task.submission_date.localtime.strftime("%Y/%m/%d %H:%M") %> +<%= lesc @submission_date.nil? ? "" : @submission_date.localtime.strftime("%Y/%m/%d %H:%M") %> <% end %> \end{flushleft} \end{minipage} @@ -136,6 +136,45 @@ No Tutor % Supervisor's Name \includepdf[pages={<%= page_idx %>-<%= page_idx %>},fitpaper]{<%= file[:path] %>} <% end # end for %> <% end # end if %> + <% if %w[zip archive].include?(file[:type]) %> +<% archive_tree = FileHelper.zip_file_tree(file[:path], File.basename(file[:path])) %> +\begin{tcolorbox}[enhanced,breakable,colback=orange!8!white,colframe=orange!80!black,boxrule=0.9pt,left=4mm,right=4mm,top=3mm,bottom=3mm] +\begin{minipage}{0.12\textwidth} +\centering +{\Huge \faFileArchiveO} +\end{minipage} +\hfill +\begin{minipage}{0.82\textwidth} +\textbf{Zip submission file}\\[0.35em] +This zip file was uploaded with the submission. Zip contents are not expanded into this PDF.\\[0.65em] +\href{<%= @submitted_files_url %>}{\faDownload\ Download submitted files} {\footnotesize (Must be already authenticated in <%= lesc @doubtfire_product_name %>)} +\end{minipage} + +\tcblower +\textbf{Zip contents}\\[0.35em] +<% if archive_tree[:error] %> +The zip file tree could not be read while generating this PDF. Download the submitted files to inspect the contents. +<% elsif archive_tree[:lines].empty? %> +No readable file entries were found in this zip. +<% else %> +{\footnotesize\ttfamily +\begin{tabular}{@{}p{0.96\textwidth}@{}} +<% archive_tree[:lines].each do |line| %> +<% + indent = line[/\A */].length + content = line.lstrip + indent_width = indent * 0.45 +%> +\hspace*{<%= indent_width %>em}<%= raw lesc(content).to_s.gsub('↳', '\ensuremath{\hookrightarrow}') %>\\ +<% end %> +\end{tabular} +} +<% if archive_tree[:truncated] %> +\\[0.35em]\emph{Showing the first <%= archive_tree[:lines].length %> of <%= archive_tree[:tree_lines] %> tree lines. Download the submitted files to inspect everything.} +<% end %> +<% end %> +\end{tcolorbox} + <% end %> <% end # list of documents %> <% diff --git a/config/application.rb b/config/application.rb index fe280a2899..7a0d450d91 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,6 +5,7 @@ require 'csv' require 'yaml' require 'bunny-pub-sub/services_manager' +require_relative '../app/middleware/sentry_tunnel_middleware' # Precompile assets before deploying to production if defined?(Bundler) @@ -17,6 +18,8 @@ module Doubtfire # class Application < Rails::Application config.load_defaults 7.0 + config.credentials.content_path = Rails.root.join('config/credentials/credentials.yml.enc') + config.credentials.key_path = Rails.root.join('config/credentials/master.key') # Remove Action Mailbox and Active Storage routes - not used initializer(:remove_action_mailbox_and_activestorage_routes, after: :add_routing_paths) do |app| @@ -34,6 +37,16 @@ class Application < Rails::Application # are: database, ldap, aaf, or saml. It can be overridden using the DF_AUTH_METHOD # environment variable. config.auth_method = (ENV['DF_AUTH_METHOD'] || :database).to_sym + config.access_token_expiry = ENV.fetch('DF_ACCESS_TOKEN_EXPIRY_SECONDS', 2.hours.to_i).to_i.seconds + config.refresh_token_expiry = ENV.fetch('DF_REFRESH_TOKEN_EXPIRY_SECONDS', 1.week.to_i).to_i.seconds + auth_rate_limit_enabled = ENV.fetch('ONTRACK_AUTH_RATE_LIMIT_ENABLED', 'true').downcase + raise ArgumentError, 'ONTRACK_AUTH_RATE_LIMIT_ENABLED must be true, false, 1, or 0' unless %w[true false 1 0].include?(auth_rate_limit_enabled) + + config.auth_rate_limit_enabled = %w[true 1].include?(auth_rate_limit_enabled) + config.auth_rate_limit = Integer(ENV.fetch('ONTRACK_AUTH_RATE_LIMIT', 5)) + config.auth_rate_period_seconds = Integer(ENV.fetch('ONTRACK_AUTH_RATE_PERIOD_SECONDS', 20)) + raise ArgumentError, 'ONTRACK_AUTH_RATE_LIMIT must be positive' unless config.auth_rate_limit.positive? + raise ArgumentError, 'ONTRACK_AUTH_RATE_PERIOD_SECONDS must be positive' unless config.auth_rate_period_seconds.positive? # ==> Student work directory # File server location for storing student's work. Defaults to `student_work` @@ -41,6 +54,10 @@ class Application < Rails::Application # variable. config.student_work_dir = ENV['DF_STUDENT_WORK_DIR'] || Rails.root.join('student_work').to_s + # Shared secret used to authenticate Caddy's internal file-authorisation + # requests. A blank value keeps the internal endpoints disabled. + config.caddy_download_auth_secret = ENV.fetch('CADDY_DOWNLOAD_AUTH_SECRET', nil) + # ==> Archive directory # File server location for storing archived student work. Defaults to a subfolder of student work # Set using DF_ARCHIVE_DIR environment variable. @@ -52,9 +69,29 @@ class Application < Rails::Application # Period for which to keep units config.unit_archive_after_period = ENV.fetch('DF_UNIT_ARCHIVE_PERIOD', 2).to_f * 1.year + # Minimum time to wait before notifying a student about an unread failed overseer assessment + config.overseer_student_notification_grace_period = ENV.fetch('OVERSEER_STUDENT_NOTIFICATION_GRACE_PERIOD_MINUTES', 30).to_i.minutes + # Limit number of pdf generators to run at once config.pdfgen_max_processes = ENV['DF_MAX_PDF_GEN_PROCESSES'] || 2 + # Size of each chunk in a batch feedback upload; keep under the proxy's request body limit + config.batch_feedback_chunk_size = ENV.fetch('BATCH_FEEDBACK_CHUNK_SIZE_MB', 50).to_i.megabytes + + # Each Word document conversion runs a short-lived, network-isolated + # Gotenberg container. The image includes the conversion entrypoint. + config.gotenberg_image = ENV.fetch('GOTENBERG_IMAGE', nil) + + # Absolute host path to tmp/gotenberg. Production uses this to mount only + # the current conversion's work directory into its one-shot container. + config.gotenberg_workdir_volume_mount = ENV.fetch('GOTENBERG_WORKDIR_VOLUME_MOUNT', nil) + + # Development fallback matching Overseer. This exposes the fallback + # container's entire tmp/gotenberg mount to each conversion container. + config.gotenberg_fallback_volume_container = ENV.fetch('GOTENBERG_FALLBACK_VOLUME_CONTAINER', nil) + config.word_document_build_path = ENV.fetch('WORD_DOCUMENT_BUILD_PATH', '/gotenberg/word_document_build.sh') + config.word_document_conversion_timeout_seconds = ENV.fetch('WORD_DOCUMENT_CONVERSION_TIMEOUT_SECONDS', 120) + # Date range for auditors to view config.auditor_unit_access_years = ENV.fetch('DF_AUDITOR_UNIT_ACCESS_YEARS', 2).to_f * 1.year @@ -64,38 +101,102 @@ def self.fetch_boolean_env(name) %w'true 1'.include?(ENV.fetch(name, 'false').downcase) end + def self.fetch_credential_or_env(*credential_path, env_key:, default: nil) + credential_value = + if credential_path.length == 1 && credentials.respond_to?(credential_path.first) + credentials.public_send(credential_path.first) + else + credentials.dig(*credential_path) + end + + credential_value.nil? ? ENV.fetch(env_key, default) : credential_value + end + + def self.cors_origin_for(host) + host = host.to_s.strip + return host if host.start_with?('http://', 'https://') + + "https://#{host}" + end + + LTI_SECRET_MIN_BYTES = 32 + LTI_SECRET_PLACEHOLDERS = %w[your-secret-lti-shared-api-secret].freeze + + # Returns why the LTI shared secret is unsafe, or nil when it can be used + def self.lti_api_secret_problem(secret) + secret = secret.to_s + return 'is not set' if secret.blank? + return 'is still the example value' if LTI_SECRET_PLACEHOLDERS.include?(secret) + return "must be at least #{LTI_SECRET_MIN_BYTES} bytes" if secret.bytesize < LTI_SECRET_MIN_BYTES + + nil + end + # ==> Log to stdout config.log_to_stdout = Application.fetch_boolean_env('DF_LOG_TO_STDOUT') # Have rails report errors and log messages to the following email address where present config.email_errors_to = ENV.fetch('DF_EMAIL_ERRORS_TO', nil) - # ==> JPLAG report directory - # File server location for storing JPLAG reports. Defaults to `jplag/results` - # directory under root but is overridden using DF_JPLAG_REPORT_DIR environment - # variable. - config.jplag_report_dir = ENV['DF_JPLAG_REPORT_DIR'] || Rails.root.join('jplag/results').to_s + # Tunes the comparison sensitivity by adjusting the minimum token required to be + # counted as a matching section. A smaller value increases the sensitivity + # but might lead to more false-positives config.jplag_min_tokens = ENV.fetch('DF_JPLAG_MIN_TOKENS', -1) + + # Skips the cluster calculation config.jplag_skip_cluster_check = ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].present? && (ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].to_s.downcase == "true" || ENV['DF_JPLAG_SKIP_CLUSTER_CHECK'].to_i == 1) + # The maximum number of comparisons that will be shown in the generated report + # if set to -1 all comparisons will be shown + config.jplag_max_shown_comparisons = ENV.fetch('DF_JPLAG_MAX_SHOWN_COMPARISONS', 2500) + # ==> File size limits # Sets the global file size limit per upload requirement # Defaults to 10MB (10,000,000 bytes) config.max_file_size = ENV.fetch('DF_MAX_FILE_SIZE', 10_000_000) - # ==> Load credentials from env - credentials.secret_key_base = ENV.fetch('DF_SECRET_KEY_BASE', Rails.env.production? ? nil : '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97') - credentials.secret_key_attr = ENV.fetch('DF_SECRET_KEY_ATTR', Rails.env.production? ? nil : 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055') - credentials.secret_key_devise = ENV.fetch('DF_SECRET_KEY_DEVISE', Rails.env.production? ? nil : 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9') - credentials.secret_key_aaf = ENV.fetch('DF_SECRET_KEY_AAF', Rails.env.production? ? nil : 'secretsecret12345') - credentials.secret_key_moss = ENV.fetch('DF_SECRET_KEY_MOSS', nil) + # Max files inside an uploaded zip. If denied for "too many files", + # remove generated folders (e.g. node_modules/build) or raise this limit. + config.zip_entry_limit = ENV.fetch('DF_ZIP_ENTRY_LIMIT', 1_000) + + # Max zip compression ratio. If denied for "compression ratio is too high", + # check for repetitive/generated data before raising this zip-bomb guard. + config.zip_compression_ratio_limit = ENV.fetch('DF_ZIP_COMPRESSION_RATIO_LIMIT', 100) + + # Max expanded zip size as a multiple of DF_MAX_FILE_SIZE. + # Eg. If max file size is 10MB, the uncompressed size can be a maximum of 100MB + config.zip_uncompressed_size_multiplier = ENV.fetch('DF_ZIP_UNCOMPRESSED_SIZE_MULTIPLIER', 10) + + # Prefer encrypted Rails credentials, while keeping env vars as a safe fallback. + credentials.secret_key_base = Application.fetch_credential_or_env(:secret_key_base, env_key: 'DF_SECRET_KEY_BASE', default: Rails.env.production? ? nil : '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97') + credentials.secret_key_attr = Application.fetch_credential_or_env(:secret_key_attr, env_key: 'DF_SECRET_KEY_ATTR', default: Rails.env.production? ? nil : 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055') + credentials.secret_key_devise = Application.fetch_credential_or_env(:secret_key_devise, env_key: 'DF_SECRET_KEY_DEVISE', default: Rails.env.production? ? nil : 'f4e23c4388dc600e503a09ad057b8271d8fcf4c2cd6723b44f33db638e49075fe96bc545eed9110ded0c5df505625d4e1c838b718349eecf1d39270d0829d5b9') + credentials.secret_key_aaf = Application.fetch_credential_or_env(:aaf, :secret_key, env_key: 'DF_SECRET_KEY_AAF', default: Rails.env.production? ? nil : 'secretsecret12345') + credentials.secret_key_moss = Application.fetch_credential_or_env(:moss, :secret_key, env_key: 'DF_SECRET_KEY_MOSS') + + # ==> User identity + # Refuse accounts matched by username or email that already belong to a different login id. When off, these are only logged. + config.enforce_login_id_match = ENV['DF_ENFORCE_LOGIN_ID_MATCH'].to_s.downcase == 'true' # ==> LTI settings # If enabled, mounts the LTI routes and enables LTI authentication. config.lti_enabled = ENV.fetch('LTI_ENABLED', false).to_s.downcase == "true" # Shared secret between Ruby on Rails API and the LTI.js API # LTI.js will send signed JWT tokens using this secret - config.lti_api_secret = ENV.fetch('LTI_SHARED_API_SECRET', nil) + config.lti_api_secret = Application.fetch_credential_or_env(:lti, :shared_api_secret, env_key: 'LTI_SHARED_API_SECRET') + # Anyone holding this secret can sign in as any user through /api/auth/lti + if config.lti_enabled && (problem = Application.lti_api_secret_problem(config.lti_api_secret)) + raise "LTI_SHARED_API_SECRET #{problem}. Generate one with `openssl rand -hex 32`, or set LTI_ENABLED=false." + end + # Where the LTI service connects from: hostnames, IPs or CIDR ranges. Only these can call /api/auth/lti and /api/lti/* + config.lti_service_hosts = ENV.fetch('LTI_SERVICE_HOSTS', 'lti').split(',').map(&:strip).reject(&:empty?) + if config.lti_enabled && config.lti_service_hosts.empty? + raise 'LTI_SERVICE_HOSTS must list where the LTI service connects from when LTI_ENABLED is true, e.g. lti.' + end + + # Server-to-server access to the LTI service, used by the unit LMS tab and scheduled LMS syncs + config.lti_internal_url = ENV.fetch('LTI_INTERNAL_URL', nil) + config.lti_internal_key = Application.fetch_credential_or_env(:lti, :internal_sync_key, env_key: 'LTI_INTERNAL_SYNC_KEY') # ==> Moderation settings config.moderation_score_factor = Float(ENV.fetch('MODERATION_SCORE_FACTOR', 1.0)) @@ -106,7 +207,9 @@ def self.fetch_boolean_env(name) config.institution[:name] = ENV['DF_INSTITUTION_NAME'] if ENV['DF_INSTITUTION_NAME'] config.institution[:email_domain] = ENV['DF_INSTITUTION_EMAIL_DOMAIN'] if ENV['DF_INSTITUTION_EMAIL_DOMAIN'] config.institution[:host] = ENV['DF_INSTITUTION_HOST'] if ENV['DF_INSTITUTION_HOST'] - config.institution[:cookie_domain] = ENV.fetch('DF_COOKIE_DOMAIN', URI.parse(Doubtfire::Application.config.institution[:host]).host) + # Blank means host-only cookies, scoped to whichever origin set them. + config.institution[:cookie_domain] = + ENV.fetch('DF_COOKIE_DOMAIN', URI.parse(Doubtfire::Application.config.institution[:host]).host).presence config.institution[:product_name] = ENV['DF_INSTITUTION_PRODUCT_NAME'] if ENV['DF_INSTITUTION_PRODUCT_NAME'] config.institution[:has_logo] = (ENV['DF_INSTITUTION_HAS_LOGO'].to_s.downcase == "true" || ENV['DF_INSTITUTION_HAS_LOGO'].to_i == 1) if ENV['DF_INSTITUTION_HAS_LOGO'] @@ -117,6 +220,7 @@ def self.fetch_boolean_env(name) config.institution[:plagiarism] = ENV['DF_INSTITUTION_PLAGIARISM'] if ENV['DF_INSTITUTION_PLAGIARISM'] # Institution host becomes localhost in development config.institution[:host] ||= 'http://localhost:4200' if Rails.env.development? + config.institution[:settings] = ENV['DF_INSTITUTION_SETTINGS_RB'] if ENV['DF_INSTITUTION_SETTINGS_RB'] config.institution[:ffmpeg] = ENV['DF_FFMPEG_PATH'] || 'ffmpeg' @@ -232,6 +336,7 @@ def self.fetch_boolean_env(name) config.autoload_paths << Rails.root.join('app') << Rails.root.join('app/models/comments') << + Rails.root.join('app/models/communication') << Rails.root.join('app/models/turn_it_in') << Rails.root.join('app/models/similarity') << Rails.root.join('app/models/d2l') @@ -239,17 +344,38 @@ def self.fetch_boolean_env(name) config.eager_load_paths << Rails.root.join('app') << Rails.root.join('app/models/comments') << + Rails.root.join('app/models/communication') << Rails.root.join('app/models/turn_it_in') << Rails.root.join('app/models/similarity') << Rails.root.join('app/models/d2l') # CORS config + # Configure a strict allowlist. Override per environment via: + # CORS_ALLOWED_ORIGINS="http://localhost:4200,https://frontend.example.edu" + default_cors_origins = [ + 'http://localhost:4200', + Application.cors_origin_for(config.institution[:host]) + ].uniq + allowed_cors_origins = ENV.fetch('CORS_ALLOWED_ORIGINS', default_cors_origins.join(',')) + .split(',') + .map(&:strip) + .reject(&:empty?) + .uniq + + config.middleware.insert_before Rack::MethodOverride, SentryTunnelMiddleware + config.middleware.insert_before Warden::Manager, Rack::Cors do allow do - origins '*' - resource '*', headers: :any, methods: %i(get post put delete options) + origins do |source, _env| + allowed_cors_origins.include?(source) + end + + resource '*', + headers: :any, + methods: %i[get post put patch delete options head] end end + config.middleware.use Rack::Attack config.active_support.to_time_preserves_timezone = :zone @@ -269,14 +395,17 @@ def self.fetch_boolean_env(name) config.sm_instance = nil config.overseer_enabled = ENV['OVERSEER_ENABLED'].present? && ENV['OVERSEER_ENABLED'].to_s.downcase != "false" && ENV['OVERSEER_ENABLED'].to_i != 0 - if (config.overseer_enabled) - config.docker_config = { - DOCKER_REGISTRY_URL: ENV.fetch('DOCKER_REGISTRY_URL', nil), - DOCKER_PROXY_URL: ENV.fetch('DOCKER_PROXY_URL', nil), - DOCKER_TOKEN: ENV.fetch('DOCKER_TOKEN', nil), - DOCKER_USER: ENV.fetch('DOCKER_USER', nil) - } + # Enables endpoints to return available storage on the device hosting the API. + config.disk_space_endpoint_enabled = %w[true 1 yes].include?(ENV['DISK_SPACE_ENDPOINT_ENABLED']&.downcase) + + config.docker_config = { + DOCKER_REGISTRY_URL: ENV.fetch('DOCKER_REGISTRY_URL', nil), + DOCKER_PROXY_URL: ENV.fetch('DOCKER_PROXY_URL', nil), + DOCKER_TOKEN: ENV.fetch('DOCKER_TOKEN', nil), + DOCKER_USER: ENV.fetch('DOCKER_USER', nil) + } + if (config.overseer_enabled) # Path to a physical directory on the host used for mounting overseer task work directories. # # Example (macOS development): @@ -311,12 +440,12 @@ def self.fetch_boolean_env(name) raise 'Overseer configuration error: you must set either OVERSEER_WORKDIR_VOLUME_MOUNT or OVERSEER_FALLBACK_VOLUME_CONTAINER.' end - # Enables the endpoint to return how much available storage is left on the device the API is hosted on (often docker volume storage) - # Used to ensure enough space is available to pull new images for Overseer - config.disk_space_endpoint_enabled = %w[true 1 yes].include?(ENV['DISK_SPACE_ENDPOINT_ENABLED']&.downcase) - config.after_initialize do - if config.docker_config[:DOCKER_TOKEN] && config.docker_config[:DOCKER_PROXY_URL] + docker_login_configured = %i[DOCKER_TOKEN DOCKER_USER DOCKER_PROXY_URL].all? do |key| + config.docker_config[key].present? + end + + if docker_login_configured LoginDockerJob.perform_async end end diff --git a/config/database.yml b/config/database.yml index 06faaed683..81ebb08640 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,29 +1,33 @@ development: - adapter: <%= ENV['DF_DEV_DB_ADAPTER'] %> - database: <%= ENV['DF_DEV_DB_DATABASE'] %> - username: <%= ENV['DF_DEV_DB_USERNAME'] %> - password: <%= ENV['DF_DEV_DB_PASSWORD'] %> - host: <%= ENV['DF_DEV_DB_HOST'] %> + adapter: <%= Rails.application.credentials.dig(:database, :development, :adapter) || ENV['DF_DEV_DB_ADAPTER'] %> + database: <%= Rails.application.credentials.dig(:database, :development, :database) || ENV['DF_DEV_DB_DATABASE'] %> + username: <%= Rails.application.credentials.dig(:database, :development, :username) || ENV['DF_DEV_DB_USERNAME'] %> + password: <%= Rails.application.credentials.dig(:database, :development, :password) || ENV['DF_DEV_DB_PASSWORD'] %> + host: <%= Rails.application.credentials.dig(:database, :development, :host) || ENV['DF_DEV_DB_HOST'] %> min_messages: warning + encoding: utf8mb4 + collation: utf8mb4_general_ci test: - adapter: <%= ENV['DF_TEST_DB_ADAPTER'] %> - database: <%= ENV['DF_TEST_DB_DATABASE'] %> - username: <%= ENV['DF_TEST_DB_USERNAME'] %> - password: <%= ENV['DF_TEST_DB_PASSWORD'] %> - host: <%= ENV['DF_TEST_DB_HOST'] %> + adapter: <%= Rails.application.credentials.dig(:database, :test, :adapter) || ENV['DF_TEST_DB_ADAPTER'] %> + database: <%= Rails.application.credentials.dig(:database, :test, :database) || ENV['DF_TEST_DB_DATABASE'] %> + username: <%= Rails.application.credentials.dig(:database, :test, :username) || ENV['DF_TEST_DB_USERNAME'] %> + password: <%= Rails.application.credentials.dig(:database, :test, :password) || ENV['DF_TEST_DB_PASSWORD'] %> + host: <%= Rails.application.credentials.dig(:database, :test, :host) || ENV['DF_TEST_DB_HOST'] %> min_messages: warning - + encoding: utf8mb4 + collation: utf8mb4_general_ci + staging: - adapter: <%= ENV['DF_STAGING_DB_ADAPTER'] %> - host: <%= ENV['DF_STAGING_DB_HOST'] %> - database: <%= ENV['DF_STAGING_DB_DATABASE'] %> - username: <%= ENV['DF_STAGING_DB_USERNAME'] %> - password: <%= ENV['DF_STAGING_DB_PASSWORD'] %> + adapter: <%= Rails.application.credentials.dig(:database, :staging, :adapter) || ENV['DF_STAGING_DB_ADAPTER'] %> + host: <%= Rails.application.credentials.dig(:database, :staging, :host) || ENV['DF_STAGING_DB_HOST'] %> + database: <%= Rails.application.credentials.dig(:database, :staging, :database) || ENV['DF_STAGING_DB_DATABASE'] %> + username: <%= Rails.application.credentials.dig(:database, :staging, :username) || ENV['DF_STAGING_DB_USERNAME'] %> + password: <%= Rails.application.credentials.dig(:database, :staging, :password) || ENV['DF_STAGING_DB_PASSWORD'] %> production: - adapter: <%= ENV['DF_PRODUCTION_DB_ADAPTER'] %> - host: <%= ENV['DF_PRODUCTION_DB_HOST'] %> - database: <%= ENV['DF_PRODUCTION_DB_DATABASE'] %> - username: <%= ENV['DF_PRODUCTION_DB_USERNAME'] %> - password: <%= ENV['DF_PRODUCTION_DB_PASSWORD'] %> + adapter: <%= Rails.application.credentials.dig(:database, :production, :adapter) || ENV['DF_PRODUCTION_DB_ADAPTER'] %> + host: <%= Rails.application.credentials.dig(:database, :production, :host) || ENV['DF_PRODUCTION_DB_HOST'] %> + database: <%= Rails.application.credentials.dig(:database, :production, :database) || ENV['DF_PRODUCTION_DB_DATABASE'] %> + username: <%= Rails.application.credentials.dig(:database, :production, :username) || ENV['DF_PRODUCTION_DB_USERNAME'] %> + password: <%= Rails.application.credentials.dig(:database, :production, :password) || ENV['DF_PRODUCTION_DB_PASSWORD'] %> diff --git a/config/environments/development.rb b/config/environments/development.rb index 7dd6f8112f..d79c75c9a5 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -2,6 +2,11 @@ Doubtfire::Application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Accept requests for the configured institution host, so a local HTTPS + # development proxy can forward them. Rails otherwise allows only localhost. + institution_host = URI.parse(config.institution[:host].to_s).host + config.hosts << institution_host if institution_host.present? + # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. @@ -15,7 +20,9 @@ # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. - if ENV['CACHE'] == 'true' || Rails.root.join('tmp/caching-dev.txt').exist? + if ENV['CACHE'] == 'true' || + ENV['DF_REDIS_CACHE_URL'].present? || + Rails.root.join('tmp/caching-dev.txt').exist? skip_first = true ActiveSupport::Reloader.to_prepare do if skip_first @@ -106,7 +113,7 @@ # pdfgen log verbosity config.pdfgen_quiet = false - config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z' - config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_DETERMINISTIC_KEY'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx' - config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_PRIMARY_KEY'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5' + config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT', default: 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z') + config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY', default: 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx') + config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY', default: '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5') end diff --git a/config/environments/production.rb b/config/environments/production.rb index 37b429654a..f3b958c23d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -7,6 +7,20 @@ config.consider_all_requests_local = false config.action_controller.perform_caching = true + # Use Redis for the shared production cache when configured. Falling back to + # an in-process cache avoids FileStore races during concurrent expiry while + # keeping production bootable for deployments without Redis. + config.cache_store = if ENV['DF_REDIS_CACHE_URL'].present? + [:redis_cache_store, { + url: ENV.fetch('DF_REDIS_CACHE_URL'), + connect_timeout: 1, + read_timeout: 1, + write_timeout: 1 + }] + else + :memory_store + end + # Disable Rails's static asset server (Apache or nginx will already do this) config.serve_static_files = true @@ -50,7 +64,7 @@ config.action_mailer.smtp_settings[:authentication] = nil if %w[no_auth none].include?(config.action_mailer.smtp_settings[:authentication]) end - config.active_record.encryption.key_derivation_salt = ENV.fetch('DF_ENCRYPTION_KEY_DERIVATION_SALT', nil) - config.active_record.encryption.deterministic_key = ENV.fetch('DF_ENCRYPTION_DETERMINISTIC_KEY', nil) - config.active_record.encryption.primary_key = ENV.fetch('DF_ENCRYPTION_PRIMARY_KEY', nil) + config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT') + config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY') + config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY') end diff --git a/config/environments/test.rb b/config/environments/test.rb index 24fcb3d4cc..15e3882e9c 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -37,9 +37,9 @@ # Logging level (:debug, :info, :warn, :error, :fatal) config.log_level = :warn - config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z' - config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx' - config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5' + config.active_record.encryption.key_derivation_salt = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :key_derivation_salt, env_key: 'DF_ENCRYPTION_KEY_DERIVATION_SALT', default: 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z') + config.active_record.encryption.deterministic_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :deterministic_key, env_key: 'DF_ENCRYPTION_DETERMINISTIC_KEY', default: 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx') + config.active_record.encryption.primary_key = Doubtfire::Application.fetch_credential_or_env(:active_record_encryption, :primary_key, env_key: 'DF_ENCRYPTION_PRIMARY_KEY', default: '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5') # Set turn it in environment ENV.store('TCA_SIGNING_KEY', 'test') @@ -57,4 +57,7 @@ ENV.store('D2L_OAUTH_SITE_TOKEN_URL', 'core/connect/token') ENV.store('D2L_API_HOST', 'https://api.brightspace.com') ENV.store('D2L_API_VERSION', '1.47') + + # Rack::Test requests connect from 127.0.0.1 + config.lti_service_hosts = %w[127.0.0.1] end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 0000000000..9ea4caa6f0 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,39 @@ +module Rack + class Attack + # Production uses the configured Rails cache: Redis when available, + # otherwise an in-process memory store. + Rack::Attack.cache.store = if Rails.env.production? + Rails.cache + else + ActiveSupport::Cache::MemoryStore.new + end + + # Limit repeated login attempts against the API auth endpoint. + throttle('auth/ip', limit: Doubtfire::Application.config.auth_rate_limit, + period: Doubtfire::Application.config.auth_rate_period_seconds.seconds) do |req| + req.ip if Doubtfire::Application.config.auth_rate_limit_enabled && req.path.start_with?('/api/auth') && req.post? + end + + # Return a consistent API-friendly throttling response. + self.throttled_responder = lambda do |request| + rack_env = + if request.respond_to?(:env) + request.env + elsif request.respond_to?(:[]) + request + else + {} + end + + match_data = rack_env['rack.attack.match_data'] || {} + now = match_data[:epoch_time] || Time.now.to_i + retry_after = match_data[:period].to_i - (now % match_data[:period].to_i) if match_data[:period].to_i.positive? + + body = { error: 'Too many login attempts. Please try again shortly.' }.to_json + headers = { 'Content-Type' => 'application/json' } + headers['Retry-After'] = retry_after.to_s if retry_after + + [429, headers, [body]] + end + end +end diff --git a/config/initializers/rubyzip.rb b/config/initializers/rubyzip.rb new file mode 100644 index 0000000000..1ba51c5f68 --- /dev/null +++ b/config/initializers/rubyzip.rb @@ -0,0 +1,4 @@ +# Aggregate submission downloads can exceed the classic ZIP format's 4 GiB +# offset limit. Rubyzip leaves ZIP64 output disabled by default, which produces +# archives with wrapped local-header offsets once that limit is crossed. +Zip.write_zip64_support = true diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000000..9f5bc7212d --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,113 @@ +module OnTrackSentryRedaction + FILTERED = "[Filtered]".freeze + PII_KEY_PATTERN = /(^|_)(email|first_name|last_name|login_id|name|student_id|username|user_id)\z/i + RAILS_LATEX_TASK_WORK_DIR = %r{(tmp/rails-latex/task-\d{8}-\d{4}-)[^/\s]+?(-[^/\s-]+-\d+-\d+(?:-retry)?)(?=/|\s|$)} + RAILS_LATEX_PORTFOLIO_WORK_DIR = %r{(tmp/rails-latex/portfolio-\d{8}-\d{4}-)[^/\s]+?(-\d+-\d+(?:-retry)?)(?=/|\s|$)} + PORTFOLIO_FAILURE_DETAILS = /(Failed to convert portfolio to PDF - \d+ - )[^\n]+? \([^)]+\)(?=\s+\S+)/ + SCORM_PATH = %r{(/scorm/[^/?#]+/)[^/?#]+(/)[^/?#]+}i + + module_function + + def scrub_event(event) + scrub_request(event.request) if event.respond_to?(:request) + + event.message = scrub_string(event.message) if event.respond_to?(:message) && event.respond_to?(:message=) + event.user.replace(scrub_hash(event.user)) if event.respond_to?(:user) && event.user.respond_to?(:replace) + event.extra.replace(scrub_hash(event.extra)) if event.respond_to?(:extra) && event.extra.respond_to?(:replace) + scrub_contexts(event.contexts) if event.respond_to?(:contexts) + + scrub_exception(event.exception) if event.respond_to?(:exception) + scrub_breadcrumbs(event.breadcrumbs) if event.respond_to?(:breadcrumbs) + + event + end + + def scrub_request(request) + return unless request + + request.url = scrub_string(request.url) if request.respond_to?(:url) && request.respond_to?(:url=) + request.headers&.each_key do |key| + request.headers[key] = FILTERED if key.casecmp("Username").zero? + end + end + + def scrub_contexts(contexts) + sidekiq = contexts&.[](:sidekiq) + return unless sidekiq.respond_to?(:key?) + + # Job args are positional, so PII_KEY_PATTERN cannot tell which ones hold user details. + %w[args wrapped_args].each { |key| sidekiq[key] = FILTERED if sidekiq.key?(key) } + contexts[:sidekiq] = scrub_hash(sidekiq) + end + + def scrub_exception(exception) + exception_values = exception&.values + exception_values&.each do |value| + value.value = scrub_string(value.value) if value.respond_to?(:value) && value.respond_to?(:value=) + + next unless value.respond_to?(:stacktrace) + + value.stacktrace&.frames&.each do |frame| + %i[abs_path context_line filename function module pre_context post_context vars].each do |attribute| + next unless frame.respond_to?(attribute) && frame.respond_to?(:"#{attribute}=") + + frame.public_send(:"#{attribute}=", scrub_value(frame.public_send(attribute))) + end + end + end + end + + def scrub_breadcrumbs(breadcrumbs) + breadcrumbs&.each do |breadcrumb| + breadcrumb.message = scrub_string(breadcrumb.message) if breadcrumb.respond_to?(:message) && breadcrumb.respond_to?(:message=) + breadcrumb.data = scrub_hash(breadcrumb.data) if breadcrumb.respond_to?(:data) && breadcrumb.respond_to?(:data=) + end + end + + def scrub_value(value) + case value + when Hash + scrub_hash(value) + when Array + value.map { |item| scrub_value(item) } + when String + scrub_string(value) + else + value + end + end + + def scrub_hash(hash) + hash.each_with_object({}) do |(key, value), result| + key = key.to_s + result[key] = key.match?(PII_KEY_PATTERN) ? FILTERED : scrub_value(value) + end + end + + def scrub_string(value) + return value unless value.is_a?(String) + + value + .gsub(RAILS_LATEX_TASK_WORK_DIR, "\\1[username]\\2") + .gsub(RAILS_LATEX_PORTFOLIO_WORK_DIR, "\\1[username]\\2") + .gsub(PORTFOLIO_FAILURE_DETAILS, "\\1[Filtered] ([username])") + .gsub(SCORM_PATH, "\\1[username]\\2[Filtered]") + end +end + +if ENV["SENTRY_DSN"].present? + Sentry.init do |config| + config.dsn = ENV.fetch("SENTRY_DSN", nil) + # Rails log breadcrumbs are excluded because app logs can include user details. + config.breadcrumbs_logger = [:http_logger] + config.environment = ENV.fetch("SENTRY_ENVIRONMENT", Rails.env) + config.release = ENV["SENTRY_RELEASE"] if ENV["SENTRY_RELEASE"].present? + # Add data like request headers and IP for users, if applicable; + # see https://docs.sentry.io/platforms/ruby/data-management/data-collected/ for more info + config.send_default_pii = false + + config.before_send = lambda do |event, _hint| + OnTrackSentryRedaction.scrub_event(event) + end + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index dc00e55825..942c043b5d 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -14,6 +14,11 @@ Sidekiq::Status.configure_server_middleware(config, expiration: 30.minutes.to_i) Sidekiq::Status.configure_client_middleware(config, expiration: 30.minutes.to_i) + + config.on(:startup) do + schedule_file = Rails.root.join('config/schedule.yml') + Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(schedule_file)) if File.exist?(schedule_file) + end end Sidekiq.configure_client do |config| diff --git a/config/initializers/uri_credential_leak_mitigation.rb b/config/initializers/uri_credential_leak_mitigation.rb new file mode 100644 index 0000000000..18556a8827 --- /dev/null +++ b/config/initializers/uri_credential_leak_mitigation.rb @@ -0,0 +1,22 @@ +require 'uri' + +# Mitigates credential leakage when combining a credential-bearing base URI +# with a relative URI via URI#+ by stripping userinfo from the merged result. +module UriCredentialLeakMitigation + def +(other) + combined = super + other_uri = other.is_a?(URI::Generic) ? other : URI.parse(other.to_str) + + return combined if other_uri.absolute? + return combined unless combined.respond_to?(:user=) || combined.respond_to?(:password=) + + sanitized = combined.dup + sanitized.user = nil if sanitized.respond_to?(:user=) + sanitized.password = nil if sanitized.respond_to?(:password=) + sanitized + rescue URI::InvalidComponentError + combined + end +end + +URI::Generic.prepend(UriCredentialLeakMitigation) diff --git a/config/no_institution_setting.rb b/config/no_institution_setting.rb index e47b250583..bd575f334a 100644 --- a/config/no_institution_setting.rb +++ b/config/no_institution_setting.rb @@ -1,5 +1,16 @@ class InstitutionSettings + # Suggests how each LMS group should map into OnTrack when a convenor pre-fills group mappings. + def prefill_lms_group_mappings(_unit, groups) + groups.map do |group| + { + lms_group_id: group[:id], + lms_group_name: group[:name], + target_type: 'ignore' + } + end + end + # Used when importing students from a CSV into a unit. This is called to check if # the headers in the CSV match an institution system. If this returns true, the import # will be processed using the lambdas you specify in the import_settings. @@ -122,6 +133,21 @@ def update_user_from_saml_response(user, user_id_data, response) user end + # The LTI member field stored as the user's login_id, which should never change for a person + def map_lti_member_to_user_id(member) + email = member['email'] + { + login_id: member['ext_user_username'].presence || member['user_id'], + email: email, + username: email.to_s[/(.*)@/, 1] + } + end + + # Whether a stored login_id is a real institution id. Ids that are not are replaced on the next match. + def institution_login_id?(_login_id) + true + end + def update_user_from_lti_response(user, user_id_data, member) user.login_id = user_id_data[:login_id] user.email = user_id_data[:email] diff --git a/config/routes.rb b/config/routes.rb index ea52a79000..4996574c64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,26 @@ get 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions', to: 'task_downloads#index' get 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs', to: 'task_submission_pdfs#index' get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index' + post 'api/submission/unit/:id/portfolio/access', to: 'portfolio_download_authorizations#create' + post 'api/submission/unit/:id/task_definitions/:task_def_id/download_submissions/access', + to: 'task_submission_files_download_authorizations#create' + post 'api/submission/unit/:id/task_definitions/:task_def_id/student_pdfs/access', + to: 'task_submission_pdfs_download_authorizations#create' + get 'api/units/:unit_id/content/sites/:site_id/files', to: 'unit_content_download_authorizations#serve' + get 'api/units/:unit_id/content/sites/:site_id/files/*route', + to: 'unit_content_download_authorizations#serve', + format: false + + # Caddy-only authorisation subrequests. + # These authenticate the user, validates it originates from Caddy, then return + # the path of the file to serve using X-OnTrack-* headers. + # The file is then served via Caddy + get 'api/internal/downloads/submission', to: 'submission_download_authorizations#show' + get 'api/internal/downloads/portfolio', to: 'portfolio_download_authorizations#show' + get 'api/internal/downloads/task-submission-files', to: 'task_submission_files_download_authorizations#show' + get 'api/internal/downloads/task-submission-pdfs', to: 'task_submission_pdfs_download_authorizations#show' + get 'api/internal/downloads/pdf-file', to: 'pdf_file_download_authorizations#show' + get 'api/internal/downloads/unit-content', to: 'unit_content_download_authorizations#show' mount ApiRoot => '/' mount GrapeSwaggerRails::Engine => '/api/docs' diff --git a/config/schedule.yml b/config/schedule.yml index b0bd370f02..84a31d9061 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -3,19 +3,44 @@ register_webhooks: cron: "every day at 5" class: "TiiRegisterWebHookJob" + queue: parallel progress_turn_it_in_jobs: cron: "every 30 minutes" class: "TiiCheckProgressJob" + queue: parallel clean_up_auth_tokens: cron: "every 30 minutes" class: "ClearAccessTokensJob" + queue: parallel refresh_moderation_feedback_timestamps: cron: "every 60 minutes" class: "RefreshModerationFeedbackTimestampsJob" + queue: parallel + +aggregate_task_completion_stats: + cron: "every day at 11:55pm" + class: "AggregateTaskCompletionStatsJob" + queue: parallel + +poll_communication_set_schedules: + cron: "every 5 minutes" + class: "PollCommunicationSetSchedulesJob" + queue: parallel + +notify_discuss_timeout: + cron: "every day at 8am" + class: "NotifyDiscussTimeoutJob" + queue: parallel + +sync_lms_integrations: + cron: "every day at 3am" + class: "SyncLmsIntegrationsJob" + queue: parallel # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" +# queue: parallel diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 0515ae2186..29ef91a73f 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1 +1,10 @@ -:concurrency: 1 +# Four parallel workers plus one serial submission worker in the same process. +:concurrency: 4 +:queues: + - parallel + +:capsules: + :submission: + :queues: + - default + :concurrency: 1 diff --git a/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb b/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb new file mode 100644 index 0000000000..ba60168e01 --- /dev/null +++ b/db/migrate/20260327041457_add_enforce_feedback_before_discussed_in_class.rb @@ -0,0 +1,5 @@ +class AddEnforceFeedbackBeforeDiscussedInClass < ActiveRecord::Migration[8.0] + def change + add_column :units, :enforce_feedback_before_discussed_in_class, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb b/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb new file mode 100644 index 0000000000..a0b9be1695 --- /dev/null +++ b/db/migrate/20260604031804_add_student_notified_at_to_overseer_assessments.rb @@ -0,0 +1,7 @@ +class AddStudentNotifiedAtToOverseerAssessments < ActiveRecord::Migration[8.0] + def change + add_column :overseer_assessments, :student_notified_at, :datetime + add_index :overseer_assessments, [:status, :student_notified_at, :updated_at], + name: 'index_overseer_assessments_on_status_notified_updated' + end +end diff --git a/db/migrate/20260604070032_add_communications_feat.rb b/db/migrate/20260604070032_add_communications_feat.rb new file mode 100644 index 0000000000..b28e520cc4 --- /dev/null +++ b/db/migrate/20260604070032_add_communications_feat.rb @@ -0,0 +1,123 @@ +class AddCommunicationsFeat < ActiveRecord::Migration[8.0] + def change + create_table :communication_sets do |t| + t.references :unit, null: false + t.string :name + t.boolean :active, null: false, default: true + t.timestamps + end + + create_table :communication_rules do |t| + t.references :communication_set, null: false + + t.integer :position, null: false, default: 0 + t.string :name + # AND | OR + t.string :operator # AND | OR + + t.boolean :send_log_to_convenors, null: false, default: false + + t.boolean :active, null: false, default: true + + t.timestamps + end + + create_table :communication_conditions do |t| + t.string :type, null: false + t.references :communication, null: false + + # TargetGradeCondition + t.integer :target_grade + # t.string :target_grade_comparison # gt|gte|lt|lte|notequal|equal + + # TaskDefinitionStatusCondition + t.references :task_definition + t.json :task_statuses + # t.string :task_status_comparison # equal|notequal + + # LoginStatusCondition + t.datetime :last_sign_in_at + # t.string :last_sign_in_comparison # before|after + + # TutorialEnrolmentCondition + t.references :tutorial + # t.string :tutorial_comparison # enrolled|notenrolled + + # TutorialStreamEnrolmentCondition + t.references :tutorial_stream + # t.string :tutorial_stream_comparison # enrolled|notenrolled + + # CampusCondition + t.references :campus + # t.string :campus_comparison # enrolled|notenrolled + + # TaskStatusCountCondition + t.integer :task_status_count + t.integer :task_target_grade + + # SpecConDaysCondition + t.integer :spec_con_days + + t.string :operator, null: false + + t.timestamps + end + + create_table :communication_actions do |t| + t.string :type, null: false + t.references :communication_rule, null: false + + # EmailStudentAction / EmailStaffAction + t.string :subject + t.text :body + + # EmailStaffAction + t.boolean :email_tutors, null: false, default: false + t.boolean :email_convenors, null: false, default: false + + # ChangeTargetGradeAction + t.integer :target_grade + + # TaskCommentAction + t.references :task_definition, null: true + + t.timestamps + end + + create_table :communication_set_schedules do |t| + t.references :communication_set, null: false + t.string :name + t.boolean :active, null: false, default: true + + # Anchor the schedule to the unit's teaching calendar. The actual start + # datetime is resolved through Unit#date_for_week_and_day. + t.integer :anchor_week, null: false + t.string :anchor_day, null: false + t.integer :hour, null: false, default: 8 + t.integer :minute, null: false, default: 0 + t.string :timezone, null: false, default: "UTC" + + # Canonical recurrence settings to hydrate IceCube rules from. + t.string :recurrence, null: false, default: "none" + t.integer :interval, null: false, default: 1 + + # Optional limits for recurring schedules. + t.integer :repeat_count + t.datetime :until_at + + # Serialized payload to rebuild an IceCube schedule without guessing. + t.json :ice_cube_schedule + + # Derived state for the worker that enqueues due communication runs. + t.datetime :next_run_at + t.datetime :last_run_at + t.datetime :last_enqueued_at + + t.timestamps + end + + add_index :communication_actions, :type + add_index :communication_conditions, :type + add_index :communication_set_schedules, [:active, :next_run_at] + end +end diff --git a/db/migrate/20260610065531_create_overflow_task_claim_logs.rb b/db/migrate/20260610065531_create_overflow_task_claim_logs.rb new file mode 100644 index 0000000000..d02ea8762a --- /dev/null +++ b/db/migrate/20260610065531_create_overflow_task_claim_logs.rb @@ -0,0 +1,17 @@ +class CreateOverflowTaskClaimLogs < ActiveRecord::Migration[8.0] + def change + create_table :overflow_task_claim_logs do |t| + t.references :unit, null: false + t.references :task, null: false + t.references :claimed_by_unit_role, null: false + t.references :claimed_by_user, null: false + t.references :original_tutor_user + t.references :student_user, null: false + t.integer :days_awaiting_feedback, null: false + t.datetime :claimed_at, null: false + t.timestamps + end + + add_index :overflow_task_claim_logs, [:unit_id, :claimed_at] + end +end diff --git a/db/migrate/20260611053643_create_submission_histories.rb b/db/migrate/20260611053643_create_submission_histories.rb new file mode 100644 index 0000000000..dee99d13d8 --- /dev/null +++ b/db/migrate/20260611053643_create_submission_histories.rb @@ -0,0 +1,257 @@ +# rubocop:disable Rails/SkipsModelValidations +require 'zip' + +class CreateSubmissionHistories < ActiveRecord::Migration[8.0] + # Migration-local models provide direct access to the tables as they exist at + # this migration point, without using future application callbacks or validations. + class MigrationTaskDefinition < ApplicationRecord + self.table_name = 'task_definitions' + end + + class MigrationOverseerAssessment < ApplicationRecord + self.table_name = 'overseer_assessments' + end + + class MigrationSubmissionHistory < ApplicationRecord + self.table_name = 'submission_histories' + end + + class MigrationTask < ApplicationRecord + self.table_name = 'tasks' + belongs_to :project, class_name: 'CreateSubmissionHistories::MigrationProject' + end + + class MigrationProject < ApplicationRecord + self.table_name = 'projects' + belongs_to :unit, class_name: 'CreateSubmissionHistories::MigrationUnit' + belongs_to :user, class_name: 'CreateSubmissionHistories::MigrationUser' + + def student + user + end + end + + class MigrationUnit < ApplicationRecord + self.table_name = 'units' + end + + class MigrationUser < ApplicationRecord + self.table_name = 'users' + end + + # `up` applies the migration when moving the database to this version. + def up + # Store one completed submission-history archive per task and timestamp. + create_table :submission_histories do |t| + t.references :task, null: false + t.string :submission_timestamp, null: false + + t.timestamps + end + + add_index :submission_histories, + [:task_id, :submission_timestamp], + unique: true, + name: 'index_submission_histories_on_task_and_timestamp' + + # Enable submission history for existing Overseer task definitions. Preserve + # an explicit value if this key has already been added to a requirement. + MigrationTaskDefinition.reset_column_information + MigrationTaskDefinition.find_each do |task_definition| + requirements = JSON.parse(task_definition.upload_requirements.presence || '[]') + next unless requirements.is_a?(Array) + + requirements.each do |requirement| + next unless requirement.is_a?(Hash) + next if requirement.key?('submission_history') + + requirement['submission_history'] = task_definition.assessment_enabled? + end + + task_definition.update_columns(upload_requirements: requirements.to_json) + rescue JSON::ParserError + next + end + + # Add the association as nullable first because existing Overseer assessments + # do not have a submission-history row yet. + add_reference :overseer_assessments, :submission_history, index: false + + # Refresh Active Record's cached columns after creating the table and column. + MigrationOverseerAssessment.reset_column_information + MigrationSubmissionHistory.reset_column_information + + # Preserve existing Overseer archives by creating a history with the same + # task and timestamp, then linking the assessment to that history. + MigrationOverseerAssessment.find_each do |assessment| + history = MigrationSubmissionHistory.find_or_create_by!( + task_id: assessment.task_id, + submission_timestamp: assessment.submission_timestamp + ) + assessment.update_columns(submission_history_id: history.id) + end + + # Replace each task's timestamp directories with one history.zip. Every + # directory and filename is preserved inside the archive. + task_ids_by_unit = MigrationTask.left_joins(project: :unit) + .where(id: MigrationSubmissionHistory.select(:task_id)) + .order('units.code, units.id, tasks.id') + .pluck('units.id', 'units.code', 'tasks.id') + .group_by { |unit_id, unit_code, _task_id| [unit_id, unit_code] } + total_units = task_ids_by_unit.size + say "Archiving submission histories across #{total_units} units" + + task_ids_by_unit.each_with_index do |((unit_id, unit_code), task_rows), index| + unit_label = "#{unit_code.presence || 'unknown'} (ID #{unit_id || 'unknown'})" + say_with_time "Archiving submission histories for unit #{unit_label} (#{index + 1}/#{total_units} units, #{task_rows.size} tasks)" do + task_rows.each do |_, _, task_id| + task = MigrationTask.find_by(id: task_id) + archive_existing_history_directories(task) if task + end + end + say "Completed #{index + 1}/#{total_units} units (#{((index + 1) * 100.0 / total_units).round}%)" + end + + # Once all existing rows are linked, make the association required and + # ensure a submission history cannot belong to multiple Overseer assessments. + change_column_null :overseer_assessments, :submission_history_id, false + add_index :overseer_assessments, :submission_history_id, unique: true + end + + # `down` reverses `up` when rolling the database back from this version. + def down + # Restore timestamp directories before removing the records that identify + # which task archives need to be unpacked. + MigrationSubmissionHistory.distinct.pluck(:task_id).each do |task_id| + task = MigrationTask.find_by(id: task_id) + restore_history_directories(task) if task + end + + # Remove the dependent association before removing its referenced table. + remove_reference :overseer_assessments, :submission_history + + # Restore upload requirements to their shape before this migration. + MigrationTaskDefinition.find_each do |task_definition| + requirements = JSON.parse(task_definition.upload_requirements.presence || '[]') + next unless requirements.is_a?(Array) + + requirements.each do |requirement| + requirement.delete('submission_history') if requirement.is_a?(Hash) + end + + task_definition.update_columns(upload_requirements: requirements.to_json) + rescue JSON::ParserError + next + end + + # Submission-history rows are no longer needed after the association and + # upload-requirement configuration have been removed. + drop_table :submission_histories + end + + private + + def archive_existing_history_directories(task) + task_path = FileHelper.task_submission_identifier_path(:done, task) + return unless Dir.exist?(task_path) + + directories = Dir.children(task_path).filter_map do |name| + path = File.join(task_path, name) + [name, path] if File.directory?(path) + end + return if directories.empty? + + archive_path = File.join(task_path, 'history.zip') + temporary_path = "#{archive_path}.tmp-#{SecureRandom.hex(8)}" + + Zip::File.open(temporary_path, create: true) do |archive| + copy_zip_entries(archive_path, archive) if File.exist?(archive_path) + directories.each { |name, path| add_directory_to_zip(archive, name, path) } + end + + FileUtils.mv(temporary_path, archive_path) + directories.each { |directory| FileUtils.rm_rf(directory.last) } + ensure + FileUtils.rm_f(temporary_path) if temporary_path + end + + def restore_history_directories(task) + task_path = FileHelper.task_submission_identifier_path(:done, task) + archive_path = File.join(task_path, 'history.zip') + return unless File.exist?(archive_path) + + submission_entries = Hash.new { |entries, timestamp| entries[timestamp] = [] } + Zip::File.open(archive_path) do |archive| + archive.each do |entry| + next if entry.name_is_directory? + + timestamp, relative_path = entry.name.split('/', 2) + next unless relative_path + + if relative_path.start_with?("#{task.id}/") + submission_entries[timestamp] << [relative_path, entry.get_input_stream.read] + else + destination = File.join(task_path, timestamp, relative_path) + FileUtils.mkdir_p(File.dirname(destination)) + entry.extract(destination) { true } + end + end + end + + submission_entries.each do |timestamp, entries| + timestamp_path = File.join(task_path, timestamp) + FileUtils.mkdir_p(timestamp_path) + Zip::File.open(File.join(timestamp_path, 'submission.zip'), create: true) do |submission_zip| + entries.each do |relative_path, contents| + submission_zip.get_output_stream(relative_path) { |output| output.write(contents) } + end + end + end + + FileUtils.rm_f(archive_path) + end + + def add_directory_to_zip(archive, root_name, path) + archive.mkdir("#{root_name}/") unless archive.find_entry("#{root_name}/") + + Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).sort.each do |source| + next if ['.', '..'].include?(File.basename(source)) + + relative_path = File.join(root_name, source.delete_prefix("#{path}/")) + if File.directory?(source) + archive.mkdir("#{relative_path}/") unless archive.find_entry("#{relative_path}/") + elsif File.basename(source) == 'submission.zip' + add_submission_zip_to_history(archive, root_name, source) + else + archive.add(relative_path, source) + end + end + end + + def add_submission_zip_to_history(archive, root_name, submission_path) + Zip::File.open(submission_path) do |submission_zip| + submission_zip.each do |entry| + next if entry.name_is_directory? + + archive.get_output_stream(File.join(root_name, entry.name)) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + end + + def copy_zip_entries(source_path, destination) + Zip::File.open(source_path) do |source| + source.each do |entry| + if entry.name_is_directory? + destination.mkdir(entry.name) unless destination.find_entry(entry.name) + else + destination.get_output_stream(entry.name) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + end + end +end +# rubocop:enable Rails/SkipsModelValidations diff --git a/db/migrate/20260618001447_create_engagements.rb b/db/migrate/20260618001447_create_engagements.rb new file mode 100644 index 0000000000..c66c8a759f --- /dev/null +++ b/db/migrate/20260618001447_create_engagements.rb @@ -0,0 +1,27 @@ +class CreateEngagements < ActiveRecord::Migration[8.0] + def change + create_table :engagements do |t| + t.references :project, null: false, index: true + t.references :user, null: false, index: true + t.string :engagement_type, null: false + t.text :note, null: false + t.datetime :occurred_at, null: false + t.text :evidence_url + t.string :content_type + t.string :attachment_extension + + t.timestamps + end + + create_table :engagement_comments do |t| + t.references :engagement, null: false, index: true + t.references :user, null: false, index: true + t.references :reply_to, null: true, index: true + t.text :comment, null: false + + t.timestamps + end + + add_index :engagements, [:project_id, :occurred_at] + end +end diff --git a/db/migrate/20260618033139_add_grade_values_to_units.rb b/db/migrate/20260618033139_add_grade_values_to_units.rb new file mode 100644 index 0000000000..1cc8ca1b4c --- /dev/null +++ b/db/migrate/20260618033139_add_grade_values_to_units.rb @@ -0,0 +1,17 @@ +class AddGradeValuesToUnits < ActiveRecord::Migration[8.0] + DEFAULT_GRADE_VALUES = [0, 1, 2, 3].to_json + + def up + add_column :units, :grade_values, :json + execute "UPDATE units SET grade_values = #{connection.quote(DEFAULT_GRADE_VALUES)}" + + change_column_null :task_definition_grade_due_dates, :target_due_date, true + add_column :task_definition_grade_due_dates, :start_date, :datetime, null: true unless column_exists?(:task_definition_grade_due_dates, :start_date) + end + + def down + remove_column :task_definition_grade_due_dates, :start_date if column_exists?(:task_definition_grade_due_dates, :start_date) + change_column_null :task_definition_grade_due_dates, :target_due_date, false + remove_column :units, :grade_values + end +end diff --git a/db/migrate/20260625055323_create_task_completion_snapshots.rb b/db/migrate/20260625055323_create_task_completion_snapshots.rb new file mode 100644 index 0000000000..7b66ae68d4 --- /dev/null +++ b/db/migrate/20260625055323_create_task_completion_snapshots.rb @@ -0,0 +1,12 @@ +class CreateTaskCompletionSnapshots < ActiveRecord::Migration[8.0] + def change + create_table :task_completion_snapshots do |t| + t.references :unit, null: false + t.string :snapshot_timestamp, null: false + + t.timestamps + end + + add_index :task_completion_snapshots, [:unit_id, :snapshot_timestamp], unique: true + end +end diff --git a/db/migrate/20260709014859_add_rediscuss_status.rb b/db/migrate/20260709014859_add_rediscuss_status.rb new file mode 100644 index 0000000000..509144a88c --- /dev/null +++ b/db/migrate/20260709014859_add_rediscuss_status.rb @@ -0,0 +1,23 @@ +class AddRediscussStatus < ActiveRecord::Migration[8.0] + DESCRIPTION = "You attempted to discuss this task, but it was not adequate. " \ + "Brush up your knowledge and return for another discussion " \ + "to get the task signed off.".freeze + + def up + status = TaskStatus.find_by(id: 15) || TaskStatus.find_by(name: "Rediscuss") + + if status + status.update!(name: "Rediscuss", description: DESCRIPTION) + else + TaskStatus.create!(id: 15, name: "Rediscuss", description: DESCRIPTION) + end + + Rails.cache.delete("task_statuses/15") + end + + def down + status = TaskStatus.find_by(id: 15, name: "Rediscuss") + status&.destroy! + Rails.cache.delete("task_statuses/15") + end +end diff --git a/db/migrate/20260713232535_add_unit_content_management.rb b/db/migrate/20260713232535_add_unit_content_management.rb new file mode 100644 index 0000000000..076a54b8d6 --- /dev/null +++ b/db/migrate/20260713232535_add_unit_content_management.rb @@ -0,0 +1,31 @@ +class AddUnitContentManagement < ActiveRecord::Migration[8.0] + def change + create_table :unit_content_sites do |t| + t.references :unit, null: false + t.string :name, null: false + t.string :original_filename, null: false + t.string :archive_path, null: false + t.string :root_dir, null: false, default: '/' + t.boolean :is_main, null: false, default: false + + t.timestamps + end + + add_index :unit_content_sites, [:unit_id, :name], unique: true + + create_table :unit_content_links do |t| + t.references :unit, null: false + t.references :unit_content_site, null: false + t.string :context_type, null: false + t.string :context_key, null: false + t.string :route, null: false, default: '/' + + t.timestamps + end + + add_index :unit_content_links, + [:unit_id, :context_type, :context_key], + unique: true, + name: 'index_unit_content_links_on_context' + end +end diff --git a/db/migrate/20260722015317_add_activity_timestamps.rb b/db/migrate/20260722015317_add_activity_timestamps.rb new file mode 100644 index 0000000000..fe781cc5f8 --- /dev/null +++ b/db/migrate/20260722015317_add_activity_timestamps.rb @@ -0,0 +1,6 @@ +class AddActivityTimestamps < ActiveRecord::Migration[8.0] + def change + add_column :users, :last_access_at, :datetime + add_column :projects, :last_viewed_at, :datetime + end +end diff --git a/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb b/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb new file mode 100644 index 0000000000..14adb011b5 --- /dev/null +++ b/db/migrate/20260722030000_add_activity_days_to_communication_conditions.rb @@ -0,0 +1,5 @@ +class AddActivityDaysToCommunicationConditions < ActiveRecord::Migration[8.0] + def change + add_column :communication_conditions, :activity_days, :integer + end +end diff --git a/db/migrate/20260722065239_add_discuss_timeout.rb b/db/migrate/20260722065239_add_discuss_timeout.rb new file mode 100644 index 0000000000..413de251e5 --- /dev/null +++ b/db/migrate/20260722065239_add_discuss_timeout.rb @@ -0,0 +1,14 @@ +class AddDiscussTimeout < ActiveRecord::Migration[8.0] + def change + add_column :units, :discuss_timeout_enabled, :boolean, null: false, default: false + add_column :units, :discuss_timeout_warning_days, :integer, null: false, default: 7 + add_column :units, :discuss_timeout_expire_days, :integer, null: false, default: 14 + add_column :tasks, :moved_to_discuss_at, :datetime + add_column :tasks, :notified_discuss_warning_at, :datetime + add_column :tasks, :notified_discuss_expiry_at, :datetime + + add_index :tasks, [:task_status_id, :moved_to_discuss_at] + add_index :tasks, [:task_status_id, :notified_discuss_warning_at] + add_index :tasks, [:task_status_id, :notified_discuss_expiry_at] + end +end diff --git a/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb b/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb new file mode 100644 index 0000000000..7deb42a5c5 --- /dev/null +++ b/db/migrate/20260724015355_add_submitted_portfolio_to_communication_conditions.rb @@ -0,0 +1,5 @@ +class AddSubmittedPortfolioToCommunicationConditions < ActiveRecord::Migration[8.0] + def change + add_column :communication_conditions, :submitted_portfolio, :boolean + end +end diff --git a/db/migrate/20260728033837_create_engagement_projects.rb b/db/migrate/20260728033837_create_engagement_projects.rb new file mode 100644 index 0000000000..1a32df4b5f --- /dev/null +++ b/db/migrate/20260728033837_create_engagement_projects.rb @@ -0,0 +1,11 @@ +class CreateEngagementProjects < ActiveRecord::Migration[8.0] + def change + create_table :engagement_projects do |t| + t.references :engagement, null: false, index: true + t.references :project, null: false, index: true + t.timestamps + end + + add_index :engagement_projects, [:engagement_id, :project_id], unique: true + end +end diff --git a/db/migrate/20260812020302_add_campus_ids_to_breaks.rb b/db/migrate/20260812020302_add_campus_ids_to_breaks.rb new file mode 100644 index 0000000000..23072560a3 --- /dev/null +++ b/db/migrate/20260812020302_add_campus_ids_to_breaks.rb @@ -0,0 +1,5 @@ +class AddCampusIdsToBreaks < ActiveRecord::Migration[8.0] + def change + add_column :breaks, :campus_ids, :json + end +end diff --git a/db/migrate/20260817055309_add_tutorial_duration.rb b/db/migrate/20260817055309_add_tutorial_duration.rb new file mode 100644 index 0000000000..33af5374fd --- /dev/null +++ b/db/migrate/20260817055309_add_tutorial_duration.rb @@ -0,0 +1,5 @@ +class AddTutorialDuration < ActiveRecord::Migration[8.0] + def change + add_column :tutorials, :duration_minutes, :integer, default: 120, null: false + end +end diff --git a/db/migrate/20260820043724_add_label_and_days_to_breaks.rb b/db/migrate/20260820043724_add_label_and_days_to_breaks.rb new file mode 100644 index 0000000000..c84bc37e6f --- /dev/null +++ b/db/migrate/20260820043724_add_label_and_days_to_breaks.rb @@ -0,0 +1,21 @@ +class AddLabelAndDaysToBreaks < ActiveRecord::Migration[8.0] + def up + add_column :breaks, :label, :string + add_column :breaks, :number_of_days, :integer + + execute 'UPDATE breaks SET number_of_days = number_of_weeks * 7' + + change_column_null :breaks, :number_of_days, false + remove_column :breaks, :number_of_weeks + end + + def down + add_column :breaks, :number_of_weeks, :integer + + execute 'UPDATE breaks SET number_of_weeks = CEIL(number_of_days / 7)' + + change_column_null :breaks, :number_of_weeks, false + remove_column :breaks, :number_of_days + remove_column :breaks, :label + end +end diff --git a/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb b/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb new file mode 100644 index 0000000000..b1c1a82897 --- /dev/null +++ b/db/migrate/20260820054718_add_group_references_to_communication_conditions.rb @@ -0,0 +1,6 @@ +class AddGroupReferencesToCommunicationConditions < ActiveRecord::Migration[8.0] + def change + add_reference :communication_conditions, :group_set + add_reference :communication_conditions, :group + end +end diff --git a/db/migrate/20260820063351_create_comment_read_cursors.rb b/db/migrate/20260820063351_create_comment_read_cursors.rb new file mode 100644 index 0000000000..82cdf101a5 --- /dev/null +++ b/db/migrate/20260820063351_create_comment_read_cursors.rb @@ -0,0 +1,44 @@ +class CreateCommentReadCursors < ActiveRecord::Migration[8.0] + def up + create_cursor_table + backfill_cursors + end + + def down + drop_table :comment_read_cursors + end + + private + + def create_cursor_table + create_table :comment_read_cursors do |t| + t.references :task, null: false + t.references :user, null: false + t.references :last_read_comment, null: false + t.datetime :read_at, null: false + + t.timestamps + t.index [:task_id, :user_id], unique: true + end + end + + def backfill_cursors + say_with_time 'Backfilling comment read cursors' do + execute <<~SQL + INSERT INTO comment_read_cursors + (task_id, user_id, last_read_comment_id, read_at, created_at, updated_at) + SELECT + task_comments.task_id, + comments_read_receipts.user_id, + MAX(comments_read_receipts.task_comment_id), + MAX(comments_read_receipts.updated_at), + MIN(comments_read_receipts.created_at), + MAX(comments_read_receipts.updated_at) + FROM comments_read_receipts + INNER JOIN task_comments + ON task_comments.id = comments_read_receipts.task_comment_id + GROUP BY task_comments.task_id, comments_read_receipts.user_id + SQL + end + end +end diff --git a/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb b/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb new file mode 100644 index 0000000000..469a93c0eb --- /dev/null +++ b/db/migrate/20260820063400_add_attention_audience_to_task_comments.rb @@ -0,0 +1,5 @@ +class AddAttentionAudienceToTaskComments < ActiveRecord::Migration[8.0] + def change + add_column :task_comments, :attention_audience, :integer + end +end diff --git a/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb b/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb new file mode 100644 index 0000000000..1548288c03 --- /dev/null +++ b/db/migrate/20260902030307_add_pause_week_count_to_breaks.rb @@ -0,0 +1,15 @@ +class AddPauseWeekCountToBreaks < ActiveRecord::Migration[8.0] + def up + # Defaults to true to preserve the existing behaviour, where every break + # pauses the teaching period week count. + add_column :breaks, :pause_week_count, :boolean, default: true, null: false + + # Only whole week breaks can pause the week count - clear the flag on any + # break that is not a multiple of 7 days so existing records stay valid. + execute 'UPDATE breaks SET pause_week_count = FALSE WHERE number_of_days % 7 <> 0' + end + + def down + remove_column :breaks, :pause_week_count + end +end diff --git a/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb b/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb new file mode 100644 index 0000000000..4b4ca2111a --- /dev/null +++ b/db/migrate/20260909002511_add_content_version_to_unit_content_sites.rb @@ -0,0 +1,12 @@ +class AddContentVersionToUnitContentSites < ActiveRecord::Migration[8.0] + def up + return if column_exists?(:unit_content_sites, :content_version) + + add_column :unit_content_sites, :content_version, :string, limit: 64 + say 'Run `rails unit_content_sites:extract_all` to populate versions and extract archives.' + end + + def down + remove_column :unit_content_sites, :content_version if column_exists?(:unit_content_sites, :content_version) + end +end diff --git a/db/migrate/20260916003706_add_lms_integration_to_units.rb b/db/migrate/20260916003706_add_lms_integration_to_units.rb new file mode 100644 index 0000000000..e865397eed --- /dev/null +++ b/db/migrate/20260916003706_add_lms_integration_to_units.rb @@ -0,0 +1,44 @@ +class AddLmsIntegrationToUnits < ActiveRecord::Migration[8.0] + def change + create_table :lms_integrations do |t| + t.references :unit, null: false, index: { unique: true } + # Where course data comes from, so other sources such as a direct Moodle web service can be added later. + t.string :source, null: false, default: 'lti' + t.bigint :assignment_id + t.string :assignment_name + t.boolean :fetch_extensions, null: false, default: false + t.boolean :auto_sync_students, null: false, default: false + t.boolean :withdraw_missing_students, null: false, default: false + t.boolean :auto_sync_extensions, null: false, default: false + t.boolean :group_mapping_enabled, null: false, default: false + t.boolean :skip_ungraded, null: false, default: true + t.boolean :send_grade_rationale, null: false, default: false + t.boolean :validated, null: false, default: false + t.datetime :validated_at + # Set by the first failed scheduled sync and cleared by the next one that succeeds + t.datetime :auto_sync_failing_since + t.text :auto_sync_last_error + + t.timestamps + end + + create_table :lms_group_mappings do |t| + t.references :lms_integration, null: false + t.bigint :lms_group_id, null: false + t.string :lms_group_name, null: false + t.string :target_type, null: false + t.references :group_set + t.bigint :group_id, index: true + t.references :campus + t.references :tutorial_stream + t.references :tutorial + t.boolean :create_if_missing, null: false, default: false + + t.timestamps + end + + add_index :lms_group_mappings, + [:lms_integration_id, :lms_group_id], + name: 'index_lms_group_mappings_on_integration_and_group' + end +end diff --git a/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb b/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb new file mode 100644 index 0000000000..e370a5c179 --- /dev/null +++ b/db/migrate/20261015040755_add_plain_text_to_communication_actions.rb @@ -0,0 +1,5 @@ +class AddPlainTextToCommunicationActions < ActiveRecord::Migration[8.0] + def change + add_column :communication_actions, :plain_text, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb b/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb new file mode 100644 index 0000000000..ba592251bb --- /dev/null +++ b/db/migrate/20261023034838_add_last_teaching_date_to_teaching_periods_and_units.rb @@ -0,0 +1,17 @@ +class AddLastTeachingDateToTeachingPeriodsAndUnits < ActiveRecord::Migration[8.0] + def up + add_column :teaching_periods, :last_teaching_date, :date + add_column :units, :last_teaching_date, :date + + execute 'UPDATE teaching_periods SET last_teaching_date = DATE(end_date)' + execute 'UPDATE units SET last_teaching_date = DATE(end_date)' + + change_column_null :teaching_periods, :last_teaching_date, false + change_column_null :units, :last_teaching_date, false + end + + def down + remove_column :units, :last_teaching_date + remove_column :teaching_periods, :last_teaching_date + end +end diff --git a/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb b/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb new file mode 100644 index 0000000000..85bdfff67f --- /dev/null +++ b/db/migrate/20261026014153_add_staff_managed_task_to_task_definitions.rb @@ -0,0 +1,5 @@ +class AddStaffManagedTaskToTaskDefinitions < ActiveRecord::Migration[8.0] + def change + add_column :task_definitions, :staff_managed_task, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20261026053206_add_content_type_to_staff_notes.rb b/db/migrate/20261026053206_add_content_type_to_staff_notes.rb new file mode 100644 index 0000000000..aedbe7a38d --- /dev/null +++ b/db/migrate/20261026053206_add_content_type_to_staff_notes.rb @@ -0,0 +1,5 @@ +class AddContentTypeToStaffNotes < ActiveRecord::Migration[8.0] + def change + add_column :staff_notes, :content_type, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 73fc5ebd58..45c5eafb15 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_22_230239) do +ActiveRecord::Schema[8.0].define(version: 2026_10_26_053206) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -33,11 +33,15 @@ create_table "breaks", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.datetime "start_date", null: false - t.integer "number_of_weeks", null: false t.bigint "teaching_period_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "campus_ids", size: :long, collation: "utf8mb4_bin" + t.string "label" + t.integer "number_of_days", null: false + t.boolean "pause_week_count", default: true, null: false t.index ["teaching_period_id"], name: "index_breaks_on_teaching_period_id" + t.check_constraint "json_valid(`campus_ids`)", name: "campus_ids" end create_table "campuses", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| @@ -63,6 +67,19 @@ t.index ["tutor_id"], name: "index_chip_usages_on_tutor_id" end + create_table "comment_read_cursors", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "task_id", null: false + t.bigint "user_id", null: false + t.bigint "last_read_comment_id", null: false + t.datetime "read_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["last_read_comment_id"], name: "index_comment_read_cursors_on_last_read_comment_id" + t.index ["task_id", "user_id"], name: "index_comment_read_cursors_on_task_id_and_user_id", unique: true + t.index ["task_id"], name: "index_comment_read_cursors_on_task_id" + t.index ["user_id"], name: "index_comment_read_cursors_on_user_id" + end + create_table "comments_read_receipts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_comment_id", null: false t.bigint "user_id", null: false @@ -73,6 +90,99 @@ t.index ["user_id"], name: "index_comments_read_receipts_on_user_id" end + create_table "communication_actions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "type", null: false + t.bigint "communication_rule_id", null: false + t.string "subject" + t.text "body" + t.boolean "email_tutors", default: false, null: false + t.boolean "email_convenors", default: false, null: false + t.integer "target_grade" + t.bigint "task_definition_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "plain_text", default: false, null: false + t.index ["communication_rule_id"], name: "index_communication_actions_on_communication_rule_id" + t.index ["task_definition_id"], name: "index_communication_actions_on_task_definition_id" + t.index ["type"], name: "index_communication_actions_on_type" + end + + create_table "communication_conditions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "type", null: false + t.bigint "communication_id", null: false + t.integer "target_grade" + t.bigint "task_definition_id" + t.text "task_statuses", size: :long, collation: "utf8mb4_bin" + t.datetime "last_sign_in_at" + t.bigint "tutorial_id" + t.bigint "tutorial_stream_id" + t.bigint "campus_id" + t.integer "task_status_count" + t.integer "task_target_grade" + t.integer "spec_con_days" + t.string "operator", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "activity_days" + t.boolean "submitted_portfolio" + t.bigint "group_set_id" + t.bigint "group_id" + t.index ["campus_id"], name: "index_communication_conditions_on_campus_id" + t.index ["communication_id"], name: "index_communication_conditions_on_communication_id" + t.index ["group_id"], name: "index_communication_conditions_on_group_id" + t.index ["group_set_id"], name: "index_communication_conditions_on_group_set_id" + t.index ["task_definition_id"], name: "index_communication_conditions_on_task_definition_id" + t.index ["tutorial_id"], name: "index_communication_conditions_on_tutorial_id" + t.index ["tutorial_stream_id"], name: "index_communication_conditions_on_tutorial_stream_id" + t.index ["type"], name: "index_communication_conditions_on_type" + t.check_constraint "json_valid(`task_statuses`)", name: "task_statuses" + end + + create_table "communication_rules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "communication_set_id", null: false + t.integer "position", default: 0, null: false + t.string "name" + t.string "operator" + t.boolean "send_log_to_convenors", default: false, null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["communication_set_id"], name: "index_communication_rules_on_communication_set_id" + end + + create_table "communication_set_schedules", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "communication_set_id", null: false + t.string "name" + t.boolean "active", default: true, null: false + t.integer "anchor_week", null: false + t.string "anchor_day", null: false + t.integer "hour", default: 8, null: false + t.integer "minute", default: 0, null: false + t.string "timezone", default: "UTC", null: false + t.string "recurrence", default: "none", null: false + t.integer "interval", default: 1, null: false + t.integer "repeat_count" + t.datetime "until_at" + t.text "ice_cube_schedule", size: :long, collation: "utf8mb4_bin" + t.datetime "next_run_at" + t.datetime "last_run_at" + t.datetime "last_enqueued_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active", "next_run_at"], name: "index_communication_set_schedules_on_active_and_next_run_at" + t.index ["communication_set_id"], name: "index_communication_set_schedules_on_communication_set_id" + t.check_constraint "json_valid(`ice_cube_schedule`)", name: "ice_cube_schedule" + end + + create_table "communication_sets", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "name" + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id"], name: "index_communication_sets_on_unit_id" + end + create_table "d2l_assessment_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.string "org_unit_id" @@ -99,6 +209,44 @@ t.index ["task_definition_id"], name: "index_discussion_prompts_on_task_definition_id" end + create_table "engagement_comments", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "engagement_id", null: false + t.bigint "user_id", null: false + t.bigint "reply_to_id" + t.text "comment", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["engagement_id"], name: "index_engagement_comments_on_engagement_id" + t.index ["reply_to_id"], name: "index_engagement_comments_on_reply_to_id" + t.index ["user_id"], name: "index_engagement_comments_on_user_id" + end + + create_table "engagement_projects", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "engagement_id", null: false + t.bigint "project_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["engagement_id", "project_id"], name: "index_engagement_projects_on_engagement_id_and_project_id", unique: true + t.index ["engagement_id"], name: "index_engagement_projects_on_engagement_id" + t.index ["project_id"], name: "index_engagement_projects_on_project_id" + end + + create_table "engagements", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "project_id", null: false + t.bigint "user_id", null: false + t.string "engagement_type", null: false + t.text "note", null: false + t.datetime "occurred_at", null: false + t.text "evidence_url" + t.string "content_type" + t.string "attachment_extension" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id", "occurred_at"], name: "index_engagements_on_project_id_and_occurred_at" + t.index ["project_id"], name: "index_engagements_on_project_id" + t.index ["user_id"], name: "index_engagements_on_user_id" + end + create_table "feedback_chips", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "type" t.text "chip_text" @@ -186,6 +334,49 @@ t.index ["context_id", "context_type"], name: "index_learning_outcomes_on_context_id_and_context_type" end + create_table "lms_group_mappings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "lms_integration_id", null: false + t.bigint "lms_group_id", null: false + t.string "lms_group_name", null: false + t.string "target_type", null: false + t.bigint "group_set_id" + t.bigint "group_id" + t.bigint "campus_id" + t.bigint "tutorial_stream_id" + t.bigint "tutorial_id" + t.boolean "create_if_missing", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["campus_id"], name: "index_lms_group_mappings_on_campus_id" + t.index ["group_id"], name: "index_lms_group_mappings_on_group_id" + t.index ["group_set_id"], name: "index_lms_group_mappings_on_group_set_id" + t.index ["lms_integration_id", "lms_group_id"], name: "index_lms_group_mappings_on_integration_and_group" + t.index ["lms_integration_id"], name: "index_lms_group_mappings_on_lms_integration_id" + t.index ["tutorial_id"], name: "index_lms_group_mappings_on_tutorial_id" + t.index ["tutorial_stream_id"], name: "index_lms_group_mappings_on_tutorial_stream_id" + end + + create_table "lms_integrations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "source", default: "lti", null: false + t.bigint "assignment_id" + t.string "assignment_name" + t.boolean "fetch_extensions", default: false, null: false + t.boolean "auto_sync_students", default: false, null: false + t.boolean "withdraw_missing_students", default: false, null: false + t.boolean "auto_sync_extensions", default: false, null: false + t.boolean "group_mapping_enabled", default: false, null: false + t.boolean "skip_ungraded", default: true, null: false + t.boolean "send_grade_rationale", default: false, null: false + t.boolean "validated", default: false, null: false + t.datetime "validated_at" + t.datetime "auto_sync_failing_since" + t.text "auto_sync_last_error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id"], name: "index_lms_integrations_on_unit_id", unique: true + end + create_table "logins", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.datetime "timestamp" t.bigint "user_id" @@ -226,6 +417,26 @@ t.index ["task_id"], name: "index_moderated_tasks_on_task_id" end + create_table "overflow_task_claim_logs", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "task_id", null: false + t.bigint "claimed_by_unit_role_id", null: false + t.bigint "claimed_by_user_id", null: false + t.bigint "original_tutor_user_id" + t.bigint "student_user_id", null: false + t.integer "days_awaiting_feedback", null: false + t.datetime "claimed_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["claimed_by_unit_role_id"], name: "index_overflow_task_claim_logs_on_claimed_by_unit_role_id" + t.index ["claimed_by_user_id"], name: "index_overflow_task_claim_logs_on_claimed_by_user_id" + t.index ["original_tutor_user_id"], name: "index_overflow_task_claim_logs_on_original_tutor_user_id" + t.index ["student_user_id"], name: "index_overflow_task_claim_logs_on_student_user_id" + t.index ["task_id"], name: "index_overflow_task_claim_logs_on_task_id" + t.index ["unit_id", "claimed_at"], name: "index_overflow_task_claim_logs_on_unit_id_and_claimed_at" + t.index ["unit_id"], name: "index_overflow_task_claim_logs_on_unit_id" + end + create_table "overflow_task_claims", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_id", null: false t.bigint "claimed_by_unit_role_id", null: false @@ -242,6 +453,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "total_steps" + t.datetime "student_notified_at" + t.bigint "submission_history_id", null: false + t.index ["status", "student_notified_at", "updated_at"], name: "index_overseer_assessments_on_status_notified_updated" + t.index ["submission_history_id"], name: "index_overseer_assessments_on_submission_history_id", unique: true t.index ["task_id", "submission_timestamp"], name: "index_overseer_assessments_on_task_id_and_submission_timestamp", unique: true t.index ["task_id"], name: "index_overseer_assessments_on_task_id" end @@ -329,6 +544,7 @@ t.integer "spec_con_days", default: 0, null: false t.bigint "assessor_id" t.datetime "portfolio_submission_date" + t.datetime "last_viewed_at" t.index ["assessor_id"], name: "index_projects_on_assessor_id" t.index ["campus_id"], name: "index_projects_on_campus_id" t.index ["enrolled"], name: "index_projects_on_enrolled" @@ -367,12 +583,22 @@ t.bigint "reply_to_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "content_type" t.index ["project_id"], name: "index_staff_notes_on_project_id" t.index ["reply_to_id"], name: "index_staff_notes_on_reply_to_id" t.index ["staff_notes_id"], name: "index_staff_notes_on_staff_notes_id" t.index ["user_id"], name: "index_staff_notes_on_user_id" end + create_table "submission_histories", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "task_id", null: false + t.string "submission_timestamp", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["task_id", "submission_timestamp"], name: "index_submission_histories_on_task_and_timestamp", unique: true + t.index ["task_id"], name: "index_submission_histories_on_task_id" + end + create_table "task_comments", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_id", null: false t.bigint "user_id", null: false @@ -395,6 +621,7 @@ t.bigint "reply_to_id" t.bigint "commentable_id" t.string "commentable_type" + t.integer "attention_audience" t.index ["assessor_id"], name: "index_task_comments_on_assessor_id" t.index ["commentable_type", "commentable_id"], name: "index_task_comments_on_commentable_type_and_commentable_id" t.index ["discussion_comment_id"], name: "index_task_comments_on_discussion_comment_id" @@ -405,6 +632,15 @@ t.index ["user_id"], name: "index_task_comments_on_user_id" end + create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "snapshot_timestamp", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id", "snapshot_timestamp"], name: "idx_on_unit_id_snapshot_timestamp_e923c3ae10", unique: true + t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id" + end + create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_definition_id", null: false t.integer "target_grade", null: false @@ -450,6 +686,7 @@ t.boolean "use_resources_for_jplag_base_code", default: false, null: false t.boolean "lock_assessments_to_tutorial_stream", default: false, null: false t.boolean "requires_discussion", default: false, null: false + t.boolean "staff_managed_task", default: false, null: false t.index ["abbreviation", "unit_id"], name: "index_task_definitions_on_abbreviation_and_unit_id", unique: true t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id" t.index ["name", "unit_id"], name: "index_task_definitions_on_name_and_unit_id", unique: true @@ -546,10 +783,16 @@ t.datetime "target_start_date" t.datetime "target_due_date" t.datetime "last_tutor_feedback_at" + t.datetime "moved_to_discuss_at" + t.datetime "notified_discuss_warning_at" + t.datetime "notified_discuss_expiry_at" t.index ["group_submission_id"], name: "index_tasks_on_group_submission_id" t.index ["project_id", "task_definition_id"], name: "tasks_uniq_proj_task_def", unique: true t.index ["project_id"], name: "index_tasks_on_project_id" t.index ["task_definition_id"], name: "index_tasks_on_task_definition_id" + t.index ["task_status_id", "moved_to_discuss_at"], name: "index_tasks_on_task_status_id_and_moved_to_discuss_at" + t.index ["task_status_id", "notified_discuss_expiry_at"], name: "index_tasks_on_task_status_id_and_notified_discuss_expiry_at" + t.index ["task_status_id", "notified_discuss_warning_at"], name: "index_tasks_on_task_status_id_and_notified_discuss_warning_at" t.index ["task_status_id"], name: "index_tasks_on_task_status_id" end @@ -561,6 +804,7 @@ t.datetime "active_until", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.date "last_teaching_date", null: false t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true end @@ -689,6 +933,7 @@ t.integer "capacity", default: -1 t.bigint "campus_id" t.bigint "tutorial_stream_id" + t.integer "duration_minutes", default: 120, null: false t.index ["abbreviation", "unit_id"], name: "index_tutorials_on_abbreviation_and_unit_id", unique: true t.index ["campus_id"], name: "index_tutorials_on_campus_id" t.index ["tutorial_stream_id"], name: "index_tutorials_on_tutorial_stream_id" @@ -696,6 +941,33 @@ t.index ["unit_role_id"], name: "index_tutorials_on_unit_role_id" end + create_table "unit_content_links", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "unit_content_site_id", null: false + t.string "context_type", null: false + t.string "context_key", null: false + t.string "route", default: "/", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_content_site_id"], name: "index_unit_content_links_on_unit_content_site_id" + t.index ["unit_id", "context_type", "context_key"], name: "index_unit_content_links_on_context", unique: true + t.index ["unit_id"], name: "index_unit_content_links_on_unit_id" + end + + create_table "unit_content_sites", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.string "name", null: false + t.string "original_filename", null: false + t.string "archive_path", null: false + t.string "root_dir", default: "/", null: false + t.boolean "is_main", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "content_version", limit: 64 + t.index ["unit_id", "name"], name: "index_unit_content_sites_on_unit_id_and_name", unique: true + t.index ["unit_id"], name: "index_unit_content_sites_on_unit_id" + end + create_table "unit_roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "user_id" t.bigint "tutorial_id" @@ -743,10 +1015,17 @@ t.boolean "mark_late_submissions_as_assess_in_portfolio", default: false, null: false t.integer "feedback_warning_threshold_days", default: 5 t.integer "feedback_overflow_threshold_days", default: 7 + t.boolean "enforce_feedback_before_discussed_in_class", default: false, null: false + t.text "grade_values", size: :long, collation: "utf8mb4_bin" + t.boolean "discuss_timeout_enabled", default: false, null: false + t.integer "discuss_timeout_warning_days", default: 7, null: false + t.integer "discuss_timeout_expire_days", default: 14, null: false + t.date "last_teaching_date", null: false t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id" t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id" t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id" t.index ["teaching_period_id"], name: "index_units_on_teaching_period_id" + t.check_constraint "json_valid(`grade_values`)", name: "grade_values" end create_table "user_oauth_states", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| @@ -797,6 +1076,7 @@ t.string "tii_eula_version" t.datetime "tii_eula_date" t.boolean "tii_eula_version_confirmed", default: false, null: false + t.datetime "last_access_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["login_id"], name: "index_users_on_login_id", unique: true t.index ["role_id"], name: "index_users_on_role_id" diff --git a/gotenberg.Dockerfile b/gotenberg.Dockerfile new file mode 100644 index 0000000000..6a23edcbbc --- /dev/null +++ b/gotenberg.Dockerfile @@ -0,0 +1,12 @@ +FROM gotenberg/gotenberg:8-libreoffice + +USER root + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY lib/shell/word_document_build.sh /gotenberg/word_document_build.sh +RUN chmod 755 /gotenberg/word_document_build.sh + +USER gotenberg diff --git a/jplag.Dockerfile b/jplag.Dockerfile index 1fcf747ce6..52adf9da5d 100644 --- a/jplag.Dockerfile +++ b/jplag.Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.23.3 +FROM alpine:3.24.2 ENV JPLAG_VERSION=6.3.0 WORKDIR /jplag diff --git a/lib/helpers/database_populator.rb b/lib/helpers/database_populator.rb index 57911a98bc..3b48ef7495 100644 --- a/lib/helpers/database_populator.rb +++ b/lib/helpers/database_populator.rb @@ -64,6 +64,7 @@ def initialize(scale = :small) generate_global_learning_outcomes() generate_campuses generate_activity_types + generate_overseer_images end def generate_teaching_periods @@ -76,7 +77,7 @@ def generate_teaching_periods } tp = TeachingPeriod.create!(data) - tp.add_break Date.parse('2018-03-30'), 1 + tp.add_break Date.parse('2018-03-30'), 7 data = { period: 'T2', @@ -87,7 +88,7 @@ def generate_teaching_periods } tp = TeachingPeriod.create! data - tp.add_break Date.parse('2018-08-13'), 1 + tp.add_break Date.parse('2018-08-13'), 7 data = { period: 'T3', @@ -98,7 +99,7 @@ def generate_teaching_periods } tp = TeachingPeriod.create! data - tp.add_break Date.parse('2018-12-24'), 2 + tp.add_break Date.parse('2018-12-24'), 14 end def generate_global_learning_outcomes @@ -166,6 +167,18 @@ def generate_activity_types ActivityType.create! data end + def generate_overseer_images + echo_line "--> Generating overseer images" + + overseer_image = OverseerImage.create!( + name: 'Bash', + tag: 'bash:latest' + ) + + echo_line "---> Pulling overseer image #{overseer_image.tag}" + overseer_image.pull_from_docker + end + # # Generate some users. Pass in an optional filter(s) for: # Role.admin, Role.convenor, Role.tutor, Role.student diff --git a/lib/shell/latex_build.sh b/lib/shell/latex_build.sh index c853bdb20f..a729f11414 100644 --- a/lib/shell/latex_build.sh +++ b/lib/shell/latex_build.sh @@ -15,7 +15,12 @@ cd work lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex RESULT=$? if [ $RESULT -eq 0 ]; then - echo "Running lualatex a second time to remove temporary last page..." + echo "Running lualatex a second time to remove temporary last page and update references..." + lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex + RESULT=$? +fi +if [ $RESULT -eq 0 ]; then + echo "Running lualatex a third time to stabilise page references..." lualatex -shell-escape -interaction=batchmode -halt-on-error input.tex RESULT=$? fi diff --git a/lib/shell/send_overseer_notifications.sh b/lib/shell/send_overseer_notifications.sh new file mode 100755 index 0000000000..e5be6b305e --- /dev/null +++ b/lib/shell/send_overseer_notifications.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +#Get path to script +APP_PATH=`echo $0 | awk '{split($0,patharr,"/"); idx=1; while(patharr[idx+1] != "") { if (patharr[idx] != "/") {printf("%s/", patharr[idx]); idx++ }} }'` +APP_PATH=`cd "$APP_PATH"; pwd` + +ROOT_PATH=`cd "$APP_PATH"/../..; pwd` + +cd "$ROOT_PATH" + +DF_LOG_TO_STDOUT=true rails overseer_notifications:send_failed_assessment_notifications diff --git a/lib/shell/word_document_build.sh b/lib/shell/word_document_build.sh new file mode 100755 index 0000000000..f145b14861 --- /dev/null +++ b/lib/shell/word_document_build.sh @@ -0,0 +1,106 @@ +#!/bin/sh + +set -eu + +WORK_ID=${1:-} + +if [ -z "$WORK_ID" ]; then + echo "Usage: word_document_build.sh " >&2 + exit 2 +fi + +case "$WORK_ID" in + *[!A-Za-z0-9_-]*) + echo "Invalid Gotenberg work id" >&2 + exit 2 + ;; +esac + +WORK_DIR="/workdir/gotenberg/$WORK_ID" +INPUT_FILE="$WORK_DIR/input.docx" +OUTPUT_FILE="$WORK_DIR/output.pdf" +TEMP_OUTPUT="$WORK_DIR/output.pdf.tmp" +GOTENBERG_URL="http://localhost:3000" +GOTENBERG_LOG="/tmp/gotenberg.log" +GOTENBERG_PID= + +if [ ! -f "$INPUT_FILE" ]; then + echo "Word document input was not found" >&2 + exit 1 +fi + +cleanup() { + exit_status=$? + trap - EXIT INT TERM + + if [ -n "$GOTENBERG_PID" ] && kill -0 "$GOTENBERG_PID" 2>/dev/null; then + kill "$GOTENBERG_PID" 2>/dev/null || true + wait "$GOTENBERG_PID" 2>/dev/null || true + fi + + rm -f "$TEMP_OUTPUT" + exit "$exit_status" +} + +start_gotenberg() { + # Docker replaces the image's normal command with this script, so start the + # bundled API before making the local conversion request. + # Keep the startup banner out of stdout so failures show the real error + gotenberg --gotenberg-graceful-shutdown-duration=0s >"$GOTENBERG_LOG" 2>&1 & + GOTENBERG_PID=$! + + health_status=0 + curl \ + --fail \ + --silent \ + --show-error \ + --retry 30 \ + --retry-connrefused \ + --retry-delay 1 \ + --connect-timeout 1 \ + --max-time 30 \ + "$GOTENBERG_URL/health" \ + >/dev/null 2>&1 || health_status=$? + + if [ "$health_status" -ne 0 ]; then + echo "Gotenberg failed to start" >&2 + tail -n 20 "$GOTENBERG_LOG" >&2 || true + exit "$health_status" + fi +} + +convert_document() { + convert_status=0 + curl \ + --fail-with-body \ + --silent \ + --show-error \ + --connect-timeout 5 \ + --max-time "${WORD_DOCUMENT_CONVERSION_TIMEOUT_SECONDS:-120}" \ + --request POST \ + --form "files=@$INPUT_FILE" \ + --output "$TEMP_OUTPUT" \ + "$GOTENBERG_URL/forms/libreoffice/convert" || convert_status=$? + + if [ "$convert_status" -ne 0 ]; then + # --fail-with-body writes Gotenberg's error response to the output file + if [ -s "$TEMP_OUTPUT" ]; then + cat "$TEMP_OUTPUT" >&2 + echo >&2 + fi + grep '"level":"error"' "$GOTENBERG_LOG" >&2 || true + exit "$convert_status" + fi + + if [ ! -s "$TEMP_OUTPUT" ]; then + echo "Gotenberg did not produce a PDF" >&2 + exit 1 + fi + + mv "$TEMP_OUTPUT" "$OUTPUT_FILE" +} + +trap cleanup EXIT INT TERM + +start_gotenberg +convert_document diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index f558bd2a1f..5db26d07e7 100644 --- a/lib/tasks/generate_pdfs.rake +++ b/lib/tasks/generate_pdfs.rake @@ -140,6 +140,7 @@ namespace :submission do success = false begin # Notify system admin + Sentry.capture_exception(e) if defined?(Sentry) mail = ErrorLogMailer.error_message("Failed portfolio job: #{project.log_details}", "Failed to create portfolio for project #{project.log_details}", e) mail.deliver if mail.present? rescue StandardError => e diff --git a/lib/tasks/init.rake b/lib/tasks/init.rake index 2023e509a3..4137a32533 100644 --- a/lib/tasks/init.rake +++ b/lib/tasks/init.rake @@ -1,4 +1,39 @@ +require 'io/console' + namespace :db do + def prompt_for_admin_password + password = ENV.fetch('DF_INITIAL_ADMIN_PASSWORD', nil) + if password.present? + unless Devise.password_length.cover?(password.length) + raise "DF_INITIAL_ADMIN_PASSWORD must be #{Devise.password_length} characters long" + end + + return password + end + + console = IO.console + if console.nil? + raise 'An interactive terminal is required to set the initial admin password. ' \ + 'Set DF_INITIAL_ADMIN_PASSWORD for non-interactive setup.' + end + + loop do + password = console.getpass('Enter password for the initial admin account: ') + confirmation = console.getpass('Confirm password: ') + + raise 'Password entry was cancelled' if password.nil? || confirmation.nil? + + unless Devise.password_length.cover?(password.length) + puts "Password must be #{Devise.password_length} characters long." + next + end + + return password if password == confirmation + + puts 'Passwords do not match. Try again.' + end + end + # # Generate roles # @@ -42,7 +77,8 @@ namespace :db do Fail: "You did not successfully demonstrate the required learning in this task.", "Time Exceeded": "You did not submit or complete the task before the appropriate deadline.", "Assess in Portfolio": "This task will not be signed off as complete by your tutor, and will be marked directly in your portfolio.", - "Attention Required": "This task needs to be discussed with your tutor so that you can get back on track." + "Attention Required": "This task needs to be discussed with your tutor so that you can get back on track.", + Rediscuss: "You attempted to discuss this task, but it was not adequate. Brush up your knowledge and return for another discussion to get the task signed off." } statuses.each do |name, desc| print "." @@ -73,8 +109,9 @@ namespace :db do profile[:login_id] ||= username if AuthenticationHelpers.db_auth? - profile[:password] = 'password' - profile[:password_confirmation] = 'password' + password = Rails.env.production? ? prompt_for_admin_password : 'password' + profile[:password] = password + profile[:password_confirmation] = password end user = User.create!(profile) diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index f6ba8ebfcb..b4f0cbab37 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -1,6 +1,194 @@ require_all 'lib/helpers' +require 'sidekiq/api' +# rubocop:disable Metrics/BlockLength namespace :maintenance do + def sidekiq_job_present_in_workers_or_default_queue?(&matcher) + Sidekiq::Workers.new.each do |_process_id, _thread_id, work| + payload = work['payload'].is_a?(String) ? JSON.parse(work['payload']) : work['payload'] + + return true if matcher.call(payload['class'], payload['args']) + end + + # The submission jobs checked here all run on the default queue. + Sidekiq::Queue.new("default").each do |job| + return true if matcher.call(job.klass, job.args) + end + + false + end + + def accept_submission_job_matches_task?(job_class, job_args, task_id) + job_class == 'AcceptSubmissionJob' && job_args.first.to_i == task_id + end + + def accept_submission_job_present?(task_id) + sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args| + accept_submission_job_matches_task?(job_class, job_args, task_id) + end + end + + def accept_overseer_job_matches_assessment?(job_class, job_args, overseer_assessment_id) + job_class == 'AcceptOverseerJob' && job_args.last.to_i == overseer_assessment_id + end + + def accept_overseer_job_present?(overseer_assessment_id) + sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args| + accept_overseer_job_matches_assessment?(job_class, job_args, overseer_assessment_id) + end + end + + def create_submission_history_job_present?(task_id) + sidekiq_job_present_in_workers_or_default_queue? do |job_class, job_args| + job_class == 'CreateSubmissionHistoryJob' && job_args.first.to_i == task_id + end + end + + def notify_failed_submission(task, message) + if task.project.student.receive_task_notifications + begin + PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver_now + rescue StandardError => e + Rails.logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}" + end + end + + if defined?(Sentry) + Sentry.capture_message( + "Cleared abandoned in-process submission for task #{task.id}", + level: :info, + extra: { + task_id: task.id, + task_definition: task.task_definition.abbreviation, + username: task.project.user.username, + detail: message + } + ) + end + + begin + exception = StandardError.new(message) + mail = ErrorLogMailer.error_message('Accept Submission Cleanup', message, exception) + mail.deliver_now if mail.present? + rescue StandardError => e + Rails.logger.error "Failed to send error log to admin for task #{task.id}!\n#{e.message}" + end + end + + def mark_task_for_resubmission(task, _message) + tutor = task.project.tutor_for(task.task_definition) + + task.trigger_transition(trigger: 'fix', by_user: tutor) + task.add_text_comment( + tutor, + "**Automated Comment**: Something went wrong with compiling your submission. Please resubmit the task.", + attention_audience: :student + ) + rescue StandardError => e + Rails.logger.error "Failed to move task #{task.id} to fix and add automated comment!\n#{e.message}" + end + + def mark_task_for_overseer_resubmission(task) + tutor = task.project.tutor_for(task.task_definition) + + task.trigger_transition(trigger: 'fix', by_user: tutor) + task.add_text_comment( + tutor, + "**Automated Comment**: Something went wrong while running the automated tests for this submission. Please resubmit the task.", + attention_audience: :student + ) + rescue StandardError => e + Rails.logger.error "Failed to move task #{task.id} to fix and add Overseer automated comment!\n#{e.message}" + end + + def clear_abandoned_submissions! + in_process_path = FileHelper.student_work_dir(:in_process) + return unless Dir.exist?(in_process_path) + + abandoned_submission_timeout = 10.minutes + stale_before = abandoned_submission_timeout.ago + + Dir.foreach(in_process_path) do |entry| + next unless entry.match?(/^\d+$/) + + task_path = File.join(in_process_path, entry) + next unless File.directory?(task_path) + next unless File.mtime(task_path) < stale_before + + task = Task.includes(project: [:user, :unit]).find_by(id: entry.to_i) + next if task.nil? + + if accept_submission_job_present?(task.id) + Rails.logger.info "Skipping abandoned submission cleanup for task #{task.id} because AcceptSubmissionJob is still running or queued" + next + end + + message = "Abandoned in-process submission detected for task #{task.log_details}. The stale in-process folder was older than #{abandoned_submission_timeout / 1.minute} minutes with no running or queued AcceptSubmissionJob, has now been cleared, and the task requires resubmission." + Rails.logger.error message + + mark_task_for_resubmission(task, message) + task.clear_in_process + notify_failed_submission(task, message) + end + end + + def clear_abandoned_overseer_assessments! + abandoned_assessment_timeout = 10.minutes + stale_before = abandoned_assessment_timeout.ago + + OverseerAssessment + .pre_queued + .includes(task: [project: :user]) + .where('created_at < ?', stale_before) + .find_each do |assessment| + if accept_overseer_job_present?(assessment.id) + Rails.logger.info "Skipping abandoned OverseerAssessment cleanup for assessment #{assessment.id} because AcceptOverseerJob is still running or queued" + next + end + + task = assessment.task + message = "Abandoned OverseerAssessment detected for task #{task.log_details}. Assessment #{assessment.id} remained pre_queued for more than #{abandoned_assessment_timeout / 1.minute} minutes with no running or queued AcceptOverseerJob and has been marked failed." + Rails.logger.error message + + assessment.update!(status: :failed) + mark_task_for_overseer_resubmission(task) + + if defined?(Sentry) + Sentry.capture_message( + "Marked stale OverseerAssessment failed for task #{task.id}", + level: :warning, + extra: { + task_id: task.id, + overseer_assessment_id: assessment.id, + task_definition: task.task_definition.abbreviation, + username: task.project.user.username, + detail: message + } + ) + end + end + end + + def clear_abandoned_submission_history_markers! + stale_before = 10.minutes.ago + marker_pattern = File.join(FileHelper.root_submission_history_dir, '**', 'pending', '*', 'submission-history') + + Dir.glob(marker_pattern).each do |marker_path| + begin + marker_mtime = File.mtime(marker_path) + rescue Errno::ENOENT + next + end + next unless marker_mtime < stale_before + + task_id = File.basename(File.dirname(marker_path)).to_i + next if create_submission_history_job_present?(task_id) + + Rails.logger.error "Clearing abandoned submission history marker for task #{task_id}" + FileUtils.rm_f(marker_path) + end + end + desc 'Cleanup temporary files' task cleanup: [:environment] do path = FileHelper.tmp_file_dir @@ -31,6 +219,25 @@ namespace :maintenance do .find_each(&:destroy!) AuthToken.destroy_old_tokens + BatchFeedbackUpload.remove_expired + clear_abandoned_submissions! + clear_abandoned_submission_history_markers! + clear_abandoned_overseer_assessments! + end + + desc 'Clear abandoned in-process submission folders and notify affected users' + task clear_abandoned_submissions: [:environment] do + clear_abandoned_submissions! + end + + desc 'Clear abandoned pre-queued Overseer assessments and request resubmission' + task clear_abandoned_overseer_assessments: [:environment] do + clear_abandoned_overseer_assessments! + end + + desc 'Clear stale submission history markers with no queued or running job' + task clear_abandoned_submission_history_markers: [:environment] do + clear_abandoned_submission_history_markers! end desc 'Remove PDFs from old submissions and archive units' @@ -74,3 +281,4 @@ namespace :maintenance do `find #{FileHelper.root_portfolio_dir} -name "*pdf.old" -exec rm {} \;` end end +# rubocop:enable Metrics/BlockLength diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake new file mode 100644 index 0000000000..577b0f3a48 --- /dev/null +++ b/lib/tasks/notifications.rake @@ -0,0 +1,7 @@ +namespace :notifications do + desc 'Warn students about stale Discuss tasks and expire overdue Discuss tasks' + task notify_discuss_timeout: :environment do + count = Unit.notify_discuss_timeouts! + Rails.logger.info "Discuss timeout notification pass created #{count} comment(s)." + end +end diff --git a/lib/tasks/overseer_notifications.rake b/lib/tasks/overseer_notifications.rake new file mode 100644 index 0000000000..2b69ee3f9e --- /dev/null +++ b/lib/tasks/overseer_notifications.rake @@ -0,0 +1,28 @@ +namespace :overseer_notifications do + def notify_failed_overseer_assessments! + assessments = OverseerAssessment + .awaiting_student_failure_notification + .includes(task: [{ project: :unit }, :task_definition]) + + assessments.group_by(&:project).each do |project, project_assessments| + tasks = project_assessments.map(&:task).uniq + + begin + mail = PortfolioEvidenceMailer.overseer_assessment_failed(project, tasks) + next if mail.blank? + + mail.deliver_now + project_assessments.each do |assessment| + assessment.update!(student_notified_at: Time.current) + end + rescue StandardError => e + Rails.logger.error "Failed to send overseer assessment email for project #{project.id}!\n#{e.message}" + end + end + end + + desc 'Send overdue overseer assessment failure notifications to students' + task send_failed_assessment_notifications: [:environment] do + notify_failed_overseer_assessments! + end +end diff --git a/lib/tasks/unit_content_sites.rake b/lib/tasks/unit_content_sites.rake new file mode 100644 index 0000000000..0a8f1dffbb --- /dev/null +++ b/lib/tasks/unit_content_sites.rake @@ -0,0 +1,40 @@ +namespace :unit_content_sites do + desc 'Populate content versions and extract unit content archives for serving. Safe to re-run; FORCE=1 re-extracts all.' + task extract_all: :environment do + force = ENV['FORCE'].present? + extracted = 0 + up_to_date = 0 + failures = [] + + UnitContentSite.includes(:unit).find_each do |site| + unless site.archive_path.present? && File.file?(site.archive_path) + puts "site #{site.id}: archive missing, skipped" + next + end + + # The version is a hash of the archive and its root dir, so this also + # picks up archives that were replaced outside the app. + version = UnitContentSite.content_version_for(site.archive_path, site.root_dir) + + if site.content_version == version && File.directory?(site.served_dir) && !force + up_to_date += 1 + next + end + + # Set in memory first - the HTML rewrite builds versioned URLs from it - + # but only persist once the extraction succeeded. A failure then leaves the + # old version stored, so the next run retries instead of assuming success. + site.content_version = version + site.extract_for_serving! + site.save! + extracted += 1 + puts "site #{site.id}: extracted" + rescue StandardError => e + puts "site #{site.id}: failed - #{e.message}" + failures << site.id + end + + puts "#{extracted} extracted, #{up_to_date} already up to date, #{failures.length} failed. Nothing deleted." + abort "Failed: #{failures.join(', ')}" if failures.any? + end +end diff --git a/test/api/activity_types_api_test.rb b/test/api/activity_types_api_test.rb index ca05092413..8c08c1b7af 100644 --- a/test/api/activity_types_api_test.rb +++ b/test/api/activity_types_api_test.rb @@ -15,9 +15,9 @@ def test_get_all_activity_types assert_equal expected_data.count, last_response_body.count - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] - last_response_body.each do | data | + last_response_body.each do |data| expected_data = ActivityType.find(data['id']) assert_json_matches_model(expected_data, data, response_keys) end @@ -28,7 +28,7 @@ def test_get_all_activity_types def test_admin_can_post_activity_type # Admin user admin = FactoryBot.create(:user, :admin) - + # the number of teaching period before post no_activity_type = ActivityType.count @@ -42,12 +42,12 @@ def test_admin_can_post_activity_type # perform the POST post_json '/api/activity_types', data_to_post - - # check if the request get through + + # check if the request get through assert_equal 201, last_response.status # check if the details posted match as expected - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] activity_type = ActivityType.find(last_response_body['id']) assert_json_matches_model(activity_type, last_response_body, response_keys) @@ -56,14 +56,14 @@ def test_admin_can_post_activity_type assert_equal data_to_post[:activity_type]['abbreviation'], activity_type.abbreviation # check if one more activity type is created - assert_equal no_activity_type +1, ActivityType.count + assert_equal no_activity_type + 1, ActivityType.count end # 2: Convenor cannot create a new activity type def test_convenor_cannot_post_activity_type # Convenor user convenor = FactoryBot.create(:user, :convenor) - + # the number of teaching period before post no_activity_type = ActivityType.count @@ -74,11 +74,11 @@ def test_convenor_cannot_post_activity_type # auth_token and username added to header add_auth_header_for(user: convenor) - + # perform the POST post_json '/api/activity_types', data_to_post - - # check if the request get through + + # check if the request get through assert_equal 403, last_response.status # check if no more activity type is created @@ -89,7 +89,7 @@ def test_convenor_cannot_post_activity_type def test_tutor_cannot_post_activity_type # Tutor user tutor = FactoryBot.create(:user, :tutor) - + # the number of teaching period before post no_activity_type = ActivityType.count @@ -100,11 +100,11 @@ def test_tutor_cannot_post_activity_type # auth_token and username added to header add_auth_header_for(user: tutor) - + # perform the POST post_json '/api/activity_types', data_to_post - - # check if the request get through + + # check if the request get through assert_equal 403, last_response.status # check if no more activity type is created @@ -119,27 +119,27 @@ def test_admin_can_put_activity_types # The activity type to be replaced activity_type = FactoryBot.create(:activity_type) - - # Data to replace + + # Data to replace data_to_put = { activity_type: FactoryBot.build(:activity_type) } # auth_token and username added to header - add_auth_header_for(user:admin) + add_auth_header_for(user: admin) # Update activity_type with data_to_put put_json "/api/activity_types/#{activity_type.id}", data_to_put - - #check if the request get through + + # check if the request get through assert_equal 200, last_response.status # check if the details posted match as expected - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] activity_type_updated = activity_type.reload assert_json_matches_model(activity_type_updated, last_response_body, response_keys) - # check if the details in the replaced teaching period match as data set to replace + # check if the details in the replaced teaching period match as data set to replace assert_equal data_to_put[:activity_type]['name'], activity_type_updated.name assert_equal data_to_put[:activity_type]['abbreviation'], activity_type_updated.abbreviation end @@ -151,8 +151,8 @@ def test_convenor_cannot_put_activity_types # The activity type to be replaced activity_type = FactoryBot.create(:activity_type) - - # Data to replace + + # Data to replace data_to_put = { activity_type: FactoryBot.build(:activity_type) } @@ -162,8 +162,8 @@ def test_convenor_cannot_put_activity_types # Update activity_type with data_to_put put_json "/api/activity_types/#{activity_type.id}", data_to_put - - #check if the request get through + + # check if the request get through assert_equal 403, last_response.status end @@ -174,8 +174,8 @@ def test_tutor_cannot_put_activity_types # The activity type to be replaced activity_type = FactoryBot.create(:activity_type) - - # Data to replace + + # Data to replace data_to_put = { activity_type: FactoryBot.build(:activity_type) } @@ -185,8 +185,8 @@ def test_tutor_cannot_put_activity_types # Update activity_type with data_to_put put_json "/api/activity_types/#{activity_type.id}", data_to_put - - #check if the request get through + + # check if the request get through assert_equal 403, last_response.status end @@ -209,21 +209,21 @@ def test_student_cannot_post_activity_type post_json '/api/activity_types', data_to_post # Check if the post does not get through - assert_equal 403, last_response.status + assert_equal 403, last_response.status # Check if the number of activity type is the same as initially assert_equal ActivityType.count, number_of_activity_type - end + end def test_student_cannot_put_activity_type # A user with student role which does not have premision to put a activity type user = FactoryBot.create(:user, :student) - #Create a new activity type + # Create a new activity type activity_type = FactoryBot.create(:activity_type) # Number of Activity type before put new activity type - number_of_activity_type = ActivityType.count + number_of_activity_type = ActivityType.count # Create a dummy activity type data_to_put = { @@ -235,9 +235,9 @@ def test_student_cannot_put_activity_type # Perform PUT, but the student user does not have permissions to put it. put_json "/api/activity_types/#{activity_type.id}", data_to_put - + # Check if the put does not get through - assert_equal 403, last_response.status + assert_equal 403, last_response.status # Check if the number of activity type is the same as initially assert_equal ActivityType.count, number_of_activity_type @@ -246,42 +246,41 @@ def test_student_cannot_put_activity_type def test_delete_activity_type # Create a activity type activity_type = FactoryBot.create(:activity_type) - - #number of activity type before delete + + # number of activity type before delete number_of_ativity_type = ActivityType.count - # auth_token and username added to header add_auth_header_for(user: User.first) - + # perform the delete - delete_json "/api/activity_types/#{activity_type.id}" - + delete_json "/api/activity_types/#{activity_type.id}" + # Check if the delete get through assert_equal 200, last_response.status # Check delete if success - assert_equal ActivityType.count, number_of_ativity_type-1 + assert_equal ActivityType.count, number_of_ativity_type - 1 # Check that you can't find the deleted id - refute ActivityType.exists?(activity_type.id) + assert_not ActivityType.exists?(activity_type.id) end def test_student_cannot_delete_activity_type # A user with student role which does not have permision to delete a activity type user = FactoryBot.build(:user, :student) - + # create a activity type to delete - activity_type = FactoryBot.create (:activity_type) - + activity_type = FactoryBot.create(:activity_type) + # number of activity type before delete number_of_ativity_type = ActivityType.count - + # auth_token and username added to header add_auth_header_for(user: user) # perform the delete - delete_json "/api/activity_types/#{activity_type.id}" + delete_json "/api/activity_types/#{activity_type.id}" # check if the delete does not get through assert_equal 403, last_response.status @@ -290,6 +289,6 @@ def test_student_cannot_delete_activity_type assert_equal ActivityType.count, number_of_ativity_type # # Check that you still can find the deleted id - assert ActivityType.exists?(activity_type.id) + assert ActivityType.exists?(activity_type.id) end end diff --git a/test/api/auth_test.rb b/test/api/auth_test.rb index cc3f737601..b107a20e29 100644 --- a/test/api/auth_test.rb +++ b/test/api/auth_test.rb @@ -2,9 +2,18 @@ class AuthTest < ActiveSupport::TestCase include Rack::Test::Methods + include ActiveSupport::Testing::TimeHelpers include TestHelpers::AuthHelper include TestHelpers::JsonHelper + setup do + Rack::Attack.enabled = false + end + + teardown do + Rack::Attack.enabled = true + end + def app Rails.application end @@ -17,6 +26,45 @@ def app # --------------------------------------------------------------------------- # # POST tests + def test_auth_post_is_rate_limited + original_setting = Doubtfire::Application.config.auth_rate_limit_enabled + Doubtfire::Application.config.auth_rate_limit_enabled = true + Rack::Attack.cache.store.clear + Rack::Attack.enabled = true + + limit = Doubtfire::Application.config.auth_rate_limit + credentials = { username: 'aadmin123', password: 'password' } + + limit.times do + post_json '/api/auth.json', credentials + assert_equal 401, last_response.status + end + + post_json '/api/auth.json', credentials + assert_equal 429, last_response.status + assert_equal 'Too many login attempts. Please try again shortly.', last_response_body['error'] + assert_operator last_response.headers['Retry-After'].to_i, :>, 0 + ensure + Doubtfire::Application.config.auth_rate_limit_enabled = original_setting + Rack::Attack.cache.store.clear + end + + def test_auth_post_is_not_rate_limited_when_disabled + original_setting = Doubtfire::Application.config.auth_rate_limit_enabled + Doubtfire::Application.config.auth_rate_limit_enabled = false + Rack::Attack.cache.store.clear + Rack::Attack.enabled = true + + credentials = { username: 'aadmin123', password: 'password' } + (Doubtfire::Application.config.auth_rate_limit + 1).times do + post_json '/api/auth.json', credentials + assert_equal 401, last_response.status + end + ensure + Doubtfire::Application.config.auth_rate_limit_enabled = original_setting + Rack::Attack.cache.store.clear + end + # Test POST for new authentication token def test_auth_post data_to_post = { @@ -50,7 +98,7 @@ def test_auth_post assert_equal 'general', token.token_type # User has the token - count of matching tokens for that user is 1 - assert_equal 1, expected_auth.auth_tokens.select{|t| t.authentication_token == actual_auth['auth_token']}.count + assert_equal 1, expected_auth.auth_tokens.select { |t| t.authentication_token == actual_auth['auth_token'] }.count # Check we got a refresh token assert last_response.cookies['refresh_token'], 'Expect refresh token to be set' @@ -62,6 +110,52 @@ def test_auth_post assert_match(/username=#{User.first.username};/, last_response.cookies['username'].to_s, 'Expect username to be set') end + # Signing in again shortly before the refresh token expires must issue a new one, rather than + # handing back the old cookie that will expire within hours. + def test_auth_post_rotates_refresh_token_near_expiry + data_to_post = { username: 'aadmin', password: 'password', remember: true } + user = User.find_by!(username: 'aadmin') + lifetime = Doubtfire::Application.config.refresh_token_expiry + first_sign_in = Time.zone.parse('2026-07-21 10:00:00 UTC') + + first_token = travel_to(first_sign_in) do + post_json '/api/auth.json', data_to_post + user.auth_tokens.where(token_type: :refresh_token).last + end + + # Early in its life, the same refresh token is reused + travel_to(first_sign_in + 1.day) do + post_json '/api/auth.json', data_to_post + assert_equal 201, last_response.status + assert_match(/refresh_token=#{first_token.authentication_token};/, last_response.cookies['refresh_token'].to_s) + end + + # Hours before it expires, a new refresh token with a full lifetime is issued + near_expiry = first_token.auth_token_expiry - 6.hours + travel_to(near_expiry) do + post_json '/api/auth.json', data_to_post + assert_equal 201, last_response.status + + new_token = user.auth_tokens.where(token_type: :refresh_token).last + assert_not_equal first_token.authentication_token, new_token.authentication_token + assert_in_delta((near_expiry + lifetime).to_i, new_token.auth_token_expiry.to_i, 5) + assert_match(/refresh_token=#{new_token.authentication_token};/, last_response.cookies['refresh_token'].to_s) + end + end + + def test_auth_records_sign_in_and_access_time + user = User.find_by!(username: 'aadmin') + sign_in_time = Time.zone.parse('2026-07-21 10:00:00 UTC') + + travel_to sign_in_time do + post_json '/api/auth.json', username: user.username, password: 'password' + end + + assert_equal 201, last_response.status + assert_equal sign_in_time, user.reload.last_sign_in_at + assert_equal sign_in_time, user.last_access_at + end + def test_auth_no_remember data_to_post = { username: 'aadmin', @@ -88,8 +182,8 @@ def test_fail_username_auth actual_auth = last_response_body # Check response body doesn't return 'user' and 'auth_token' values - refute actual_auth.key?('user'), 'User not expected if auth fails' - refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' + assert_not actual_auth.key?('user'), 'User not expected if auth fails' + assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' # 401 response code means invalid username / password assert_equal 401, last_response.status @@ -108,12 +202,43 @@ def test_fail_password_auth actual_auth = last_response_body # Check response body doesn't return 'user' and 'auth_token' values - refute actual_auth.key?('user'), 'User not expected if auth fails' - refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' + assert_not actual_auth.key?('user'), 'User not expected if auth fails' + assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' assert actual_auth.key? 'error' end + # Blank passwords must still be authenticated rather than bypassing the + # password check because ActiveSupport considers them not present. + def test_fail_blank_password_auth + user = User.find_by!(username: 'aadmin') + original_token_count = user.auth_tokens.count + + ['', ' ', "\t\n"].each do |password| + post_json '/api/auth.json', username: user.username, password: password + actual_auth = last_response_body + + assert_equal 401, last_response.status, "Expected #{password.inspect} to be rejected" + assert_equal 'Invalid email or password.', actual_auth['error'] + assert_not actual_auth.key?('user') + assert_not actual_auth.key?('auth_token') + end + + assert_equal original_token_count, user.auth_tokens.reload.count + end + + def test_blank_password_does_not_create_unknown_user + username = "unknown-blank-password-user-#{SecureRandom.hex(8)}" + + ['', ' ', "\t\n"].each do |password| + post_json '/api/auth.json', username: username, password: password + + assert_equal 401, last_response.status, "Expected #{password.inspect} to be rejected" + assert_equal 'Invalid email or password.', last_response_body['error'] + assert_not User.exists?(username: username) + end + end + # Test auth with empty request body def test_fail_empty_request data_to_post = "" @@ -123,8 +248,8 @@ def test_fail_empty_request actual_auth = last_response_body # Check response body doesn't return 'user' and 'auth_token' values - refute actual_auth.key?('user'), 'User not expected if auth fails' - refute actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' + assert_not actual_auth.key?('user'), 'User not expected if auth fails' + assert_not actual_auth.key?('auth_token'), 'Auth token not expected if auth fails' # 400 response code means missing username and password assert_equal 400, last_response.status @@ -231,6 +356,22 @@ def test_refresh_token assert_equal last_response_body['auth_token'], new_new_token.authentication_token end + def test_refresh_token_updates_access_but_not_sign_in_time + user = FactoryBot.create(:user, last_sign_in_at: 1.day.ago, last_access_at: 1.hour.ago) + original_sign_in_time = user.last_sign_in_at + token = user.generate_authentication_token!(token_type: :refresh_token) + access_time = Time.zone.parse('2026-07-21 11:00:00 UTC') + + set_cookie "username=#{user.username}" + set_cookie "refresh_token=#{token.authentication_token}" + + travel_to(access_time) { post '/api/auth/access-token' } + + assert_equal 201, last_response.status + assert_equal original_sign_in_time, user.reload.last_sign_in_at + assert_equal access_time, user.last_access_at + end + def test_token_signout_works_with_multiple user = FactoryBot.create(:user) # Create 2 auth tokens diff --git a/test/api/batch_feedback_uploads_api_test.rb b/test/api/batch_feedback_uploads_api_test.rb new file mode 100644 index 0000000000..40623f0724 --- /dev/null +++ b/test/api/batch_feedback_uploads_api_test.rb @@ -0,0 +1,184 @@ +require 'test_helper' + +class BatchFeedbackUploadsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def setup + @unit = FactoryBot.create(:unit, with_students: false) + @task_definition = @unit.task_definitions.first + @convenor = @unit.main_convenor_user + ImportBatchFeedbackJob.clear + end + + def teardown + @upload_ids&.each { |id| BatchFeedbackUpload.find(id)&.destroy } + end + + def start_upload(size:, user: @convenor) + add_auth_header_for(user: user) + post '/api/submission/batch_feedback_uploads', { + unit_id: @unit.id, + task_definition_id: @task_definition.id, + filename: 'feedback.zip', + size: size + } + return unless last_response.status == 201 + + (@upload_ids ||= []) << last_response_body['id'] + last_response_body['id'] + end + + def send_chunk(id, offset, data, sha256: Digest::SHA256.hexdigest(data)) + chunk = Tempfile.new('chunk') + chunk.binmode + chunk.write(data) + chunk.rewind + patch "/api/submission/batch_feedback_uploads/#{id}", { + offset: offset, + sha256: sha256, + chunk: Rack::Test::UploadedFile.new(chunk.path, 'application/octet-stream') + } + ensure + chunk&.close! + end + + def test_upload_in_chunks_and_import + id = start_upload(size: 10) + assert_equal 0, last_response_body['offset'] + assert_equal BatchFeedbackUpload::CHUNK_SIZE, last_response_body['chunk_size'] + + send_chunk(id, 0, 'abcdef') + assert_equal 200, last_response.status + assert_equal 6, last_response_body['offset'] + + get "/api/submission/batch_feedback_uploads/#{id}" + assert_equal 6, last_response_body['offset'] + assert_equal [{ 'size' => 6, 'sha256' => Digest::SHA256.hexdigest('abcdef') }], last_response_body['chunks'] + + # A retried chunk is rejected and tells the client where to resume + send_chunk(id, 0, 'abcdef') + assert_equal 409, last_response.status + assert_equal 6, last_response_body['offset'] + + send_chunk(id, 6, 'ghij') + assert_equal 10, last_response_body['offset'] + + post "/api/submission/batch_feedback_uploads/#{id}/complete" + assert_equal 201, last_response.status, last_response.body + + assert_equal 1, ImportBatchFeedbackJob.jobs.count + unit_id, user_id, task_definition_id, path = ImportBatchFeedbackJob.jobs.first['args'] + assert_equal [@unit.id, @convenor.id, @task_definition.id], [unit_id, user_id, task_definition_id] + assert_equal 'abcdefghij', File.read(path) + assert_nil BatchFeedbackUpload.find(id) + ensure + FileUtils.rm_f(path) if path + end + + def test_cannot_complete_partial_upload + id = start_upload(size: 10) + send_chunk(id, 0, 'abc') + + post "/api/submission/batch_feedback_uploads/#{id}/complete" + assert_equal 422, last_response.status + assert_equal 0, ImportBatchFeedbackJob.jobs.count + assert_equal 3, BatchFeedbackUpload.find(id).offset + end + + def test_rejects_chunk_that_does_not_match_its_checksum + id = start_upload(size: 10) + + send_chunk(id, 0, 'abcdef', sha256: Digest::SHA256.hexdigest('abcdeX')) + assert_equal 422, last_response.status + assert_equal 0, BatchFeedbackUpload.find(id).offset + end + + def test_stores_a_chunk_once_when_two_requests_race + id = start_upload(size: 10) + send_chunk(id, 0, 'abc') + upload = BatchFeedbackUpload.find(id) + + # A retry that checked the offset before the original request stored its chunk + chunk = Tempfile.new('chunk') + chunk.write('abc') + chunk.flush + offset_calls = 0 + upload.define_singleton_method(:offset) { (offset_calls += 1) == 1 ? 0 : super() } + error = assert_raises(BatchFeedbackUpload::OffsetMismatch) do + upload.append(0, chunk.path, Digest::SHA256.hexdigest('abc')) + end + + assert_equal 3, error.offset + assert_equal [{ size: 3, sha256: Digest::SHA256.hexdigest('abc') }], upload.chunks + assert_equal 1, Dir.children(upload.chunks_dir).count + ensure + chunk&.close! + end + + def test_rejects_an_upload_with_conflicting_chunks + id = start_upload(size: 10) + send_chunk(id, 0, 'abc') + upload = BatchFeedbackUpload.find(id) + File.write(File.join(upload.chunks_dir, format('%020d-%s', 0, Digest::SHA256.hexdigest('xyz'))), 'xyz') + + get "/api/submission/batch_feedback_uploads/#{id}" + assert_equal 422, last_response.status + end + + def test_rejects_chunk_past_end_of_upload + id = start_upload(size: 4) + + send_chunk(id, 0, 'abcdef') + assert_equal 422, last_response.status + assert_equal 0, BatchFeedbackUpload.find(id).offset + end + + def test_only_the_uploader_can_use_an_upload + id = start_upload(size: 10) + + other_convenor = FactoryBot.create(:user, :convenor) + @unit.employ_staff(other_convenor, Role.convenor) + add_auth_header_for(user: other_convenor) + + get "/api/submission/batch_feedback_uploads/#{id}" + assert_equal 404, last_response.status + + send_chunk(id, 0, 'abc') + assert_equal 404, last_response.status + assert_equal 0, BatchFeedbackUpload.find(id).offset + end + + def test_student_cannot_start_upload + student = FactoryBot.create(:user, :student) + @unit.enrol_student(student, nil) + + start_upload(size: 10, user: student) + assert_equal 401, last_response.status + end + + def test_cancel_upload + id = start_upload(size: 10) + + delete "/api/submission/batch_feedback_uploads/#{id}" + assert_equal 204, last_response.status + assert_nil BatchFeedbackUpload.find(id) + end + + def test_remove_expired_uploads + stale_id = start_upload(size: 10) + fresh_id = start_upload(size: 10) + stale = BatchFeedbackUpload.find(stale_id) + FileUtils.touch([stale.dir, stale.chunks_dir], mtime: 2.days.ago.to_time) + + BatchFeedbackUpload.remove_expired + + assert_nil BatchFeedbackUpload.find(stale_id) + assert_not_nil BatchFeedbackUpload.find(fresh_id) + end +end diff --git a/test/api/breaks_api_test.rb b/test/api/breaks_api_test.rb index 97c03d2b6c..802662e257 100644 --- a/test/api/breaks_api_test.rb +++ b/test/api/breaks_api_test.rb @@ -5,18 +5,22 @@ class BreaksApiTest < ActiveSupport::TestCase include TestHelpers::AuthHelper include TestHelpers::JsonHelper - #POST TEST + # POST TEST def test_post_breaks teaching_period = FactoryBot.create(:teaching_period) + campus = FactoryBot.create(:campus) start = teaching_period.start_date + 4.weeks number_of_break = Break.count data_to_post = { start_date: start, - number_of_weeks: rand(1..3) + number_of_days: rand(1..3) * 7, + label: 'Mid semester break', + campus_ids: [campus.id], + pause_week_count: false } - add_auth_header_for user: User.first + add_auth_header_for user: FactoryBot.create(:user, :admin) # Perform the POST post "/api/teaching_periods/#{teaching_period.id}/breaks", data_to_post @@ -25,13 +29,16 @@ def test_post_breaks assert_equal 201, last_response.status # Check if the details posted match as expected - response_keys = %w(start_date number_of_weeks) + response_keys = %w[start_date number_of_days label campus_ids pause_week_count] the_break = Break.find(last_response_body['id']) assert_json_matches_model(the_break, last_response_body, response_keys) # check if the details in the newly created break match as the pre-set data assert_equal data_to_post[:start_date].to_date, the_break.start_date.to_date - assert_equal data_to_post[:number_of_weeks], the_break.number_of_weeks + assert_equal data_to_post[:number_of_days], the_break.number_of_days + assert_equal data_to_post[:label], the_break.label + assert_equal data_to_post[:campus_ids], the_break.campus_ids + assert_not the_break.pause_week_count # check if one more break is created assert_equal Break.count, number_of_break + 1 @@ -41,10 +48,10 @@ def test_post_breaks # Get breaks in a teaching period def test_get_breaks # Create teaching period - teaching_period = FactoryBot.create(:teaching_period) - teaching_period.add_break teaching_period.start_date + 1.week, 1 + teaching_period = FactoryBot.create(:teaching_period) + teaching_period.add_break teaching_period.start_date + 1.week, 7 - add_auth_header_for(user: User.first) + add_auth_header_for(user: FactoryBot.create(:user, :admin)) # Perform the GET get "/api/teaching_periods/#{teaching_period.id}/breaks" @@ -54,9 +61,9 @@ def test_get_breaks assert_equal expected_data.count, last_response_body.count assert expected_data.count > 0 - response_keys = %w(id start_date number_of_weeks) - test_keys = %w(id number_of_weeks) - last_response_body.each do | data | + response_keys = %w[id start_date number_of_days label campus_ids pause_week_count] + test_keys = %w[id number_of_days pause_week_count] + last_response_body.each do |data| the_break = Break.find(data['id']) assert_json_matches_model(the_break, data, test_keys) assert_equal the_break.start_date.strftime('%Y-%m-%d'), data['start_date'] @@ -66,11 +73,11 @@ def test_get_breaks def test_delete_breaks # Create teaching period - teaching_period = FactoryBot.create(:teaching_period) - teaching_period.add_break teaching_period.start_date + 1.week, 1 - teaching_period.add_break teaching_period.start_date + 3.week, 1 + teaching_period = FactoryBot.create(:teaching_period) + teaching_period.add_break teaching_period.start_date + 1.week, 7 + teaching_period.add_break teaching_period.start_date + 3.weeks, 7 - add_auth_header_for(user: User.first) + add_auth_header_for(user: FactoryBot.create(:user, :admin)) the_break = Break.last count = teaching_period.breaks.count @@ -87,4 +94,50 @@ def test_delete_breaks # Check if the actual data match as expected assert last_response_body end + + def test_post_break_defaults_to_pausing_the_week_count + teaching_period = FactoryBot.create(:teaching_period) + + add_auth_header_for user: FactoryBot.create(:user, :admin) + + post "/api/teaching_periods/#{teaching_period.id}/breaks", { + start_date: teaching_period.start_date + 4.weeks, + number_of_days: 7 + } + + assert_equal 201, last_response.status + assert last_response_body['pause_week_count'] + assert Break.find(last_response_body['id']).pause_week_count + end + + def test_post_break_cannot_pause_the_week_count_over_a_partial_week + teaching_period = FactoryBot.create(:teaching_period) + number_of_breaks = Break.count + + add_auth_header_for user: FactoryBot.create(:user, :admin) + + post "/api/teaching_periods/#{teaching_period.id}/breaks", { + start_date: teaching_period.start_date + 4.weeks, + number_of_days: 5, + pause_week_count: true + } + + assert_not_equal 201, last_response.status + assert_equal number_of_breaks, Break.count + end + + def test_put_break_updates_pause_week_count + teaching_period = FactoryBot.create(:teaching_period) + the_break = teaching_period.add_break(teaching_period.start_date + 4.weeks, 7) + + add_auth_header_for user: FactoryBot.create(:user, :admin) + + put "/api/teaching_periods/#{teaching_period.id}/breaks/#{the_break.id}", { + pause_week_count: false + } + + assert_equal 200, last_response.status + assert_not last_response_body['pause_week_count'] + assert_not the_break.reload.pause_week_count + end end diff --git a/test/api/campuses_test.rb b/test/api/campuses_test.rb index 638d490df3..6ca302e812 100644 --- a/test/api/campuses_test.rb +++ b/test/api/campuses_test.rb @@ -13,8 +13,8 @@ def test_get_all_campuses get '/api/campuses' expected_data = Campus.all assert_equal expected_data.count, last_response_body.count - response_keys = %w(name abbreviation) - last_response_body.each do | data | + response_keys = %w[name abbreviation] + last_response_body.each do |data| c = Campus.find(data['id']) assert_json_matches_model(c, data, response_keys) end @@ -23,7 +23,7 @@ def test_get_all_campuses def test_get_campuses_by_id campus = FactoryBot.create(:campus, mode: 'timetable') get "/api/campuses/#{campus.id}" - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] assert_json_matches_model(campus, last_response_body, response_keys) end @@ -37,7 +37,7 @@ def test_post_campuses post_json '/api/campuses', data_to_post assert_equal 201, last_response.status - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] campus = Campus.find(last_response_body['id']) assert_json_matches_model(campus, last_response_body, response_keys) assert_equal 'timetable', campus[:mode], last_response_body.inspect @@ -67,7 +67,7 @@ def test_put_campuses # Update campus with id = 1 put_json '/api/campuses/1', data_to_put assert_equal 200, last_response.status - response_keys = %w(name abbreviation) + response_keys = %w[name abbreviation] first_campus = Campus.first assert_json_matches_model(first_campus, last_response_body, response_keys) assert_equal 'timetable', first_campus[:mode] @@ -93,18 +93,18 @@ def test_delete_campuses # auth_token and username added to header add_auth_header_for(user: User.first) - #Perform the delete + # Perform the delete delete_json "/api/campuses/#{campus.id}" - #check the request went through + # check the request went through assert_equal 200, last_response.status - get '/api/campuses' #get number of campuses + get '/api/campuses' # get number of campuses # check if current number of campuses = original number of campuses assert_equal initial_num_of_campus - 1, last_response_body.count # check campus no longer exists - refute Campus.exists?(campus.id) + assert_not Campus.exists?(campus.id) end def test_student_delete_campus diff --git a/test/api/comments/comment_test.rb b/test/api/comments/comment_test.rb index 1e1fc558a1..bb96e1d305 100644 --- a/test/api/comments/comment_test.rb +++ b/test/api/comments/comment_test.rb @@ -2,6 +2,7 @@ class CommentTest < ActiveSupport::TestCase include Rack::Test::Methods + include ActiveSupport::Testing::TimeHelpers include TestHelpers::AuthHelper include TestHelpers::JsonHelper include TestHelpers::TestFileHelper @@ -33,8 +34,8 @@ def test_get_comments assert_equal 200, last_response.status, last_response_body assert_equal 3, last_response_body.length, last_response_body.inspect - keys = %w(id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time) - keys_test = %w(id comment reply_to_id) + keys = %w[id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time] + keys_test = %w[id comment reply_to_id] last_response_body.each do |resp| assert_json_limit_keys_to_exactly keys, resp @@ -49,14 +50,14 @@ def test_get_comments assert_equal 200, last_response.status, last_response_body assert_equal 3, last_response_body.length, last_response_body.inspect - keys = %w(id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time) - keys_test = %w(id comment reply_to_id) + keys = %w[id comment has_attachment type is_new reply_to_id author recipient created_at recipient_read_time] + keys_test = %w[id comment reply_to_id] last_response_body.each do |resp| assert_json_limit_keys_to_exactly keys, resp comment = TaskComment.find(resp['id']) assert_json_matches_model comment, resp, keys_test - refute resp['is_new'], resp.inspect + assert_not resp['is_new'], resp.inspect end task.add_text_comment(user, 'Response') @@ -93,7 +94,7 @@ def test_student_post_comment } # check each is the same - assert_json_matches_model expected_response, last_response_body, %w(comment has_attachment type is_new) + assert_json_matches_model expected_response, last_response_body, %w[comment has_attachment type is_new] assert_json_matches_model expected_response[:author], last_response_body['author'], ['id'] assert_json_matches_model expected_response[:recipient], last_response_body['recipient'], ['id'] end @@ -132,7 +133,7 @@ def test_replying_to_comments comment_data = { comment: 'Responding!', reply_to_id: TaskComment.last.id } post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment_data assert_equal 201, last_response.status - assert_json_matches_model expected_response, last_response_body, %w(comment type reply_to_id) + assert_json_matches_model expected_response, last_response_body, %w[comment type reply_to_id] expected_response = { 'comment' => 'Responding again!', @@ -152,7 +153,7 @@ def test_replying_to_comments assert_equal 201, last_response.status # check each is the same - assert_json_matches_model expected_response, last_response_body, %w(comment type reply_to_id) + assert_json_matches_model expected_response, last_response_body, %w[comment type reply_to_id] end def test_student_post_reply_to_invalid_comment @@ -221,6 +222,100 @@ def test_student_reply_to_themselve_in_different_task_same_project assert_equal 404, last_response.status end + def test_student_can_edit_own_comment_within_10_minutes + project = Project.first + user = project.student + task_definition = project.unit.task_definitions.first + + add_auth_header_for(user: user) + post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment: 'Original comment' + assert_equal 201, last_response.status + + comment_id = last_response_body['id'] + + travel_to 9.minutes.from_now do + put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment_id}", comment: 'Edited comment' + assert_equal 200, last_response.status, last_response.body + end + + assert_equal 'Edited comment', TaskComment.find(comment_id).read_attribute(:comment) + assert_equal 'Edited comment', last_response_body['comment'] + end + + def test_student_cannot_edit_own_comment_after_10_minutes + project = Project.first + user = project.student + task_definition = project.unit.task_definitions.first + + add_auth_header_for(user: user) + post_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments", comment: 'Original comment' + assert_equal 201, last_response.status + + comment_id = last_response_body['id'] + + travel_to 11.minutes.from_now do + put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment_id}", comment: 'Too late' + assert_equal 403, last_response.status, last_response.body + end + + assert_equal 'Original comment', TaskComment.find(comment_id).read_attribute(:comment) + end + + def test_student_cannot_edit_other_users_comment + project = Project.first + task_definition = project.unit.task_definitions.first + tutor = project.tutor_for(task_definition) + user = project.student + task = project.task_for_task_definition(task_definition) + comment = task.add_text_comment(tutor, 'Tutor comment') + + add_auth_header_for(user: user) + put_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}", comment: 'Edited by student' + + assert_equal 403, last_response.status, last_response.body + assert_equal 'Tutor comment', comment.reload.read_attribute(:comment) + end + + def test_special_task_comments_cannot_be_deleted + project = FactoryBot.create(:project) + task_definition = project.unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + tutor = project.tutor_for(task_definition) + student = project.student + + submission_history = FactoryBot.create(:submission_history, task: task) + overseer_assessment = FactoryBot.create( + :overseer_assessment, + task: task, + submission_history: submission_history, + submission_timestamp: submission_history.submission_timestamp, + status: :failed + ) + + protected_comments = [ + task.add_status_comment(student, TaskStatus.ready_for_feedback), + task.add_discussed_comment(tutor), + task.add_feedback_review_request_comment(student), + AssessmentComment.create!( + task: task, + user: tutor, + recipient: student, + comment: 'Automated tests failed', + commentable: overseer_assessment + ) + ] + + protected_comments.each do |comment| + add_auth_header_for(user: comment.user) + delete_json "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/comments/#{comment.id}" + assert_equal 403, last_response.status, "Expected #{comment.class.name} delete to be rejected" + end + + deleted_comment_types = protected_comments.reject { |comment| TaskComment.exists?(comment.id) }.map(&:content_type) + + assert_empty deleted_comment_types, "Expected protected comment types to remain after delete attempt: #{deleted_comment_types.join(', ')}" + end + def test_student_reply_to_other_student_in_same_group unit = FactoryBot.create :unit @@ -248,7 +343,7 @@ def test_student_reply_to_other_student_in_same_group due_date: Time.zone.now + 1.week, abbreviation: 'TaskSwitchIndGrp', restrict_status_updates: false, - upload_requirements: [ { 'key' => 'file0', 'name' => 'Shape Class', 'type' => 'code' } ], + upload_requirements: [{ 'key' => 'file0', 'name' => 'Shape Class', 'type' => 'code' }], plagiarism_warn_pct: 0.8, is_graded: false, max_quality_pts: 0, @@ -491,7 +586,7 @@ def test_read_receipts_for_task_status_comments due_date: Time.zone.now + 2.weeks, abbreviation: 'test_read_receipts_for_task_status_comments', restrict_status_updates: false, - upload_requirements: [ ], + upload_requirements: [], plagiarism_warn_pct: 0.8, is_graded: false, max_quality_pts: 0) @@ -511,8 +606,8 @@ def test_read_receipts_for_task_status_comments task = project.task_for_task_definition(td) assert_equal TaskStatus.ready_for_feedback, task.task_status - refute task.comments.last.new_for?(user) - refute task.comments.last.new_for?(project.tutor_for(td)) + assert_not task.comments.last.new_for?(user) + assert_not task.comments.last.new_for?(project.tutor_for(td)) td.destroy! end @@ -558,6 +653,7 @@ def test_project_plan_task_comments_dont_show_in_inbox inbox = unit.tasks_for_task_inbox(unit.tutors.first) assert_not_includes inbox.map(&:id), task_new.id, "Task should not be in tutors inbox" + assert task_new.comments.last.attention_none?, "Plan comment should not require attention" assert_not task_new.comments.last.new_for?(user), "Comment should be marked read by student" assert_not task_new.comments.last.new_for?(project.tutor_for(td)), "Comment should be read by tutor" diff --git a/test/api/comments/extension_test.rb b/test/api/comments/extension_test.rb index 7c707ab798..ad0de7c0d0 100644 --- a/test/api/comments/extension_test.rb +++ b/test/api/comments/extension_test.rb @@ -15,22 +15,22 @@ def test_extension_application user = project.student td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: project.tutorial_enrolments.first.tutorial.tutorial_stream, - name: 'status task change', - description: 'status task change test', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.day, - abbreviation: 'LESS1WEEKEXTTEST', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: project.tutorial_enrolments.first.tutorial.tutorial_stream, + name: 'status task change', + description: 'status task change test', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.day, + abbreviation: 'LESS1WEEKEXTTEST', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { weeks_requested: '1', @@ -44,7 +44,7 @@ def test_extension_application post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post response = last_response_body assert_equal 201, last_response.status - assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}." + assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}." # Request a 2 week extension on the day td.due_date = Time.zone.now + 2.weeks @@ -57,7 +57,7 @@ def test_extension_application post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post response = last_response_body assert_equal 201, last_response.status - assert response["weeks_requested"] == 2, "Error: Weeks requested weeks should be 2, found #{response["weeks_requested"]}." + assert response["weeks_requested"] == 2, "Error: Weeks requested weeks should be 2, found #{response['weeks_requested']}." # Add auth_token and username to header add_auth_header_for(user: user) @@ -68,7 +68,6 @@ def test_extension_application response = last_response_body assert_equal 403, last_response.status, "Error: Allowed too long of a request to be applied." - # Add auth_token and username to header add_auth_header_for(user: user) @@ -90,22 +89,22 @@ def test_extension_application other_tutor = unit.main_convenor_user td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'status task change', - description: 'status task change test', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.day, - abbreviation: 'LESS1WEEKEXTTEST', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'status task change', + description: 'status task change test', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.day, + abbreviation: 'LESS1WEEKEXTTEST', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! main_tutor = project.tutor_for(td) @@ -121,17 +120,17 @@ def test_extension_application post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post response = last_response_body assert_equal 201, last_response.status - assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}." + assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}." tc = TaskComment.find(response['id']) # Check it is not read by the main tutor - refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create" + assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create" assert tc.read_by?(user), "Error: Should be read by student on create" # Check that reading by main tutor does not read the task tc.read_by? main_tutor - refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it" + assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it" # Check it is read after grant by another user tc.assess_extension other_tutor, true @@ -148,22 +147,22 @@ def test_disallow_student_extensions other_tutor = unit.main_convenor_user td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'status task change', - description: 'status task change test', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.day, - abbreviation: 'LESS1WEEKEXTTEST', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'status task change', + description: 'status task change test', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.day, + abbreviation: 'LESS1WEEKEXTTEST', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! main_tutor = project.tutor_for(td) @@ -182,7 +181,7 @@ def test_disallow_student_extensions post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post response = last_response_body assert_equal 201, last_response.status - assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}." + assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}." tc = ExtensionComment.find(response['id']) @@ -197,22 +196,22 @@ def test_disallow_student_extensions def test_extension_on_resubmit unit = FactoryBot.create(:unit, extension_weeks_on_resubmit_request: 2) td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task past due - for revert', - description: 'Task past due', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now + 1.day, - due_date: Time.zone.now + 1.day + 3.weeks, - abbreviation: 'TaskPastDueForRevert', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task past due - for revert', + description: 'Task past due', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.day, + due_date: Time.zone.now + 1.day + 3.weeks, + abbreviation: 'TaskPastDueForRevert', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -245,7 +244,7 @@ def test_extension_on_resubmit end def test_extension_in_inbox - unit = FactoryBot.create(:unit, auto_apply_extension_before_deadline: false, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 2, staff_count: 2, task_count:0) + unit = FactoryBot.create(:unit, auto_apply_extension_before_deadline: false, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 2, staff_count: 2, task_count: 0) project = unit.projects.first user = project.student tutor = unit.main_convenor_user @@ -253,22 +252,22 @@ def test_extension_in_inbox assert project.enrolled td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'status task change', - description: 'status task change test', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 2.week, - abbreviation: 'LESS1WEEKEXTTEST', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'status task change', + description: 'status task change test', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 2.weeks, + abbreviation: 'LESS1WEEKEXTTEST', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -284,7 +283,7 @@ def test_extension_in_inbox post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", data_to_post response = last_response_body assert_equal 201, last_response.status - assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response["weeks_requested"]}." + assert response["weeks_requested"] == 1, "Error: Deadline less than a week, requested weeks should be 1, found #{response['weeks_requested']}." unit.reload task = unit.tasks.last @@ -308,27 +307,27 @@ def test_extension_in_inbox end def test_flexible_dates - unit = FactoryBot.create(:unit, student_count: 1, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 1, staff_count: 1, task_count:0) + unit = FactoryBot.create(:unit, student_count: 1, unenrolled_student_count: 0, part_enrolled_student_count: 0, inactive_student_count: 0, tutorials: 1, staff_count: 1, task_count: 0) project = unit.projects.first user = project.student td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Flexible Dates Test', - description: 'Flexible Dates Test', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now + 1.day, - due_date: Time.zone.now + 1.day + 3.weeks, - abbreviation: 'FLEXTEST', - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Flexible Dates Test', + description: 'Flexible Dates Test', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.day, + due_date: Time.zone.now + 1.day + 3.weeks, + abbreviation: 'FLEXTEST', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -354,7 +353,7 @@ def test_flexible_dates assert_equal(-1, task.extensions, "Error: Task should have -1 extensions set") # Check cannot make extension request - post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", {weeks_requested: 1, comment: "Test Extension Request"} + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_extension", { weeks_requested: 1, comment: "Test Extension Request" } assert_equal 403, last_response.status, last_response_body td.destroy! diff --git a/test/api/comments/scorm_extension_test.rb b/test/api/comments/scorm_extension_test.rb index f206b8f0f5..ddcd5221eb 100644 --- a/test/api/comments/scorm_extension_test.rb +++ b/test/api/comments/scorm_extension_test.rb @@ -40,6 +40,18 @@ def test_scorm_extension_request data_to_post = { comment: 'I need more attempts please' } + extension_count = ScormExtensionComment.count + + clear_auth_header + header 'username', user.username + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 419, last_response.status + assert_equal extension_count, ScormExtensionComment.count + + add_auth_header_for(user: user, auth_token: 'invalid') + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 419, last_response.status + assert_equal extension_count, ScormExtensionComment.count add_auth_header_for(user: user) @@ -113,12 +125,12 @@ def test_read_by_main_tutor tc = TaskComment.find(last_response_body["id"]) # Check it is not read by the main tutor - refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create" + assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor on create" assert tc.read_by?(user), "Error: Should be read by student on create" # Check that reading by main tutor does not read the task tc.read_by? main_tutor - refute tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it" + assert_not tc.read_by?(main_tutor), "Error: Should not be read by main tutor even when they read it" # Check it is read after grant by another user tc.assess_scorm_extension other_tutor, true @@ -215,6 +227,12 @@ def test_scorm_extension_assessment granted: true } + clear_auth_header + header 'username', main_tutor.username + put_json "/api/projects/#{project.id}/task_def_id/#{td.id}/assess_scorm_extension/#{tc.id}", data_to_put + assert_equal 419, last_response.status + assert_not tc.reload.assessed? + add_auth_header_for(user: user) # When the user is unauthorised diff --git a/test/api/comments/status_test.rb b/test/api/comments/status_test.rb index 9c2e729b12..02d5e55d5d 100644 --- a/test/api/comments/status_test.rb +++ b/test/api/comments/status_test.rb @@ -15,28 +15,28 @@ def test_status_comments unit = project.unit td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - abbreviation: 'test_status_comments', - name: 'test_status_comments', - description: 'test_status_comments', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now - 1.week, - due_date: Time.zone.now + 1.day, - restrict_status_updates: false, - upload_requirements: [ ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + abbreviation: 'test_status_comments', + name: 'test_status_comments', + description: 'test_status_comments', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now - 1.week, + due_date: Time.zone.now + 1.day, + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! tutor = project.tutor_for(td) data_to_post = { - trigger: 'ready_for_feedback', + trigger: 'ready_for_feedback' } # Add auth_token and username to header diff --git a/test/api/communication_rules_api_test.rb b/test/api/communication_rules_api_test.rb new file mode 100644 index 0000000000..ca24a0ae4a --- /dev/null +++ b/test/api/communication_rules_api_test.rb @@ -0,0 +1,327 @@ +require 'test_helper' + +class CommunicationRulesApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_create_and_update_relative_activity_condition + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Activity', active: true) + rule = communication_set.communication_rules.create!(name: 'Inactive students', operator: 'and', position: 0) + add_auth_header_for(user: admin) + + post_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions", + communication_condition: { + type: 'LoginStatusCondition', + operator: 'more_than', + activity_days: 7 + } + + assert_equal 201, last_response.status + condition_response = last_response_body + assert_equal 'LoginStatusCondition', condition_response['type'] + assert_equal 'more_than', condition_response['operator'] + assert_equal 7, condition_response['activity_days'] + + put_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions/#{condition_response['id']}", + communication_condition: { + type: 'UnitViewedStatusCondition', + operator: 'within_last', + activity_days: 3 + } + + assert_equal 200, last_response.status + condition_response = last_response_body + assert_equal 'UnitViewedStatusCondition', condition_response['type'] + assert_equal 'within_last', condition_response['operator'] + assert_equal 3, condition_response['activity_days'] + + viewed_at = 1.day.ago.change(usec: 0) + project = FactoryBot.create(:project, unit: unit, last_viewed_at: viewed_at) + project.user.update!(last_sign_in_at: 2.days.ago.change(usec: 0)) + + get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}" + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['eligible_student_count'] + assert_not last_response_body.key?('previews') + + get "/api/units/#{unit.id}/communication_rules/#{rule.id}/preview" + + assert_equal 200, last_response.status + assert_equal rule.id, last_response_body['rule_id'] + assert_equal 1, last_response_body['eligible_student_count'] + student = last_response_body['students'].first + assert_equal project.id, student['project_id'] + assert_equal project.user.username, student['username'] + assert_equal false, student['has_portfolio'] + assert_equal viewed_at.iso8601(3), Time.zone.parse(student['last_viewed_at']).iso8601(3) + assert_equal project.user.last_sign_in_at.iso8601(3), Time.zone.parse(student['last_sign_in_at']).iso8601(3) + end + + def test_rule_preview_ignores_earlier_rules_in_the_set + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Spec con', active: true) + first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0) + second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1) + + [first_rule, second_rule].each do |rule| + rule.communication_conditions.create!( + type: 'SpecConCondition', + operator: 'greater_than_or_equal_to', + spec_con_days: 3 + ) + end + + matching_project = FactoryBot.create(:project, unit: unit, spec_con_days: 4) + FactoryBot.create(:project, unit: unit, spec_con_days: 1) + add_auth_header_for(user: admin) + + # Both rules report the same student -- the second is no longer filtered by the first. + [first_rule, second_rule].each do |rule| + get "/api/units/#{unit.id}/communication_rules/#{rule.id}/preview" + + assert_equal 200, last_response.status + assert_equal 2, last_response_body['eligible_student_count'] + assert_equal([matching_project.id], last_response_body['students'].map { |student| student['project_id'] }) + end + end + + def test_rules_are_returned_ordered_by_position + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Ordering', active: true) + + # Created out of order on purpose -- id order must not decide the response order. + third = communication_set.communication_rules.create!(name: 'Third', operator: 'and', position: 2) + first = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0) + second = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1) + expected = [first.id, second.id, third.id] + add_auth_header_for(user: admin) + + get "/api/units/#{unit.id}/communication_sets" + assert_equal 200, last_response.status + assert_equal(expected, last_response_body.first['rules'].map { |rule| rule['id'] }) + + get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}" + assert_equal 200, last_response.status + assert_equal(expected, last_response_body['rules'].map { |rule| rule['id'] }) + + get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}/rules" + assert_equal 200, last_response.status + assert_equal(expected, last_response_body.map { |rule| rule['id'] }) + + # And the order follows position after a drag-and-drop style reorder. + put_json "/api/units/#{unit.id}/communication_rules/#{third.id}", communication_rule: { position: 0 } + put_json "/api/units/#{unit.id}/communication_rules/#{first.id}", communication_rule: { position: 1 } + put_json "/api/units/#{unit.id}/communication_rules/#{second.id}", communication_rule: { position: 2 } + + get "/api/units/#{unit.id}/communication_sets/#{communication_set.id}" + assert_equal 200, last_response.status + assert_equal([third.id, first.id, second.id], last_response_body['rules'].map { |rule| rule['id'] }) + end + + def test_update_rule_position_reorders_the_set + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Ordering', active: true) + first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0) + second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1) + add_auth_header_for(user: admin) + + put_json "/api/units/#{unit.id}/communication_rules/#{second_rule.id}", communication_rule: { position: 0 } + assert_equal 200, last_response.status + + put_json "/api/units/#{unit.id}/communication_rules/#{first_rule.id}", communication_rule: { position: 1 } + assert_equal 200, last_response.status + + assert_equal [second_rule.id, first_rule.id], communication_set.reload.communication_rules.map(&:id) + end + + def test_delete_rule_removes_it_and_resequences_remaining_rules + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Ordering', active: true) + first_rule = communication_set.communication_rules.create!(name: 'First', operator: 'and', position: 0) + second_rule = communication_set.communication_rules.create!(name: 'Second', operator: 'and', position: 1) + third_rule = communication_set.communication_rules.create!(name: 'Third', operator: 'and', position: 2) + add_auth_header_for(user: admin) + + delete "/api/units/#{unit.id}/communication_rules/#{second_rule.id}" + + assert_equal 204, last_response.status + assert_not CommunicationRule.exists?(second_rule.id) + assert_equal [[first_rule.id, 0], [third_rule.id, 1]], + communication_set.reload.communication_rules.pluck(:id, :position) + end + + def test_create_and_update_portfolio_submitted_condition + admin = FactoryBot.create(:user, :admin) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0) + communication_set = unit.communication_sets.create!(name: 'Portfolio', active: true) + rule = communication_set.communication_rules.create!(name: 'Portfolio submitted', operator: 'and', position: 0) + add_auth_header_for(user: admin) + + post_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions", + communication_condition: { + type: 'PortfolioSubmittedCondition', + operator: 'equal_to', + submitted_portfolio: true + } + + assert_equal 201, last_response.status + condition_response = last_response_body + assert_equal true, condition_response['submitted_portfolio'] + + put_json "/api/units/#{unit.id}/communication_rules/#{rule.id}/conditions/#{condition_response['id']}", + communication_condition: { + submitted_portfolio: false + } + + assert_equal 200, last_response.status + assert_equal false, last_response_body['submitted_portfolio'] + assert_equal false, rule.communication_conditions.find(condition_response['id']).submitted_portfolio + end + + def test_copying_a_set_and_importing_it_into_another_unit + admin = FactoryBot.create(:user, :admin) + source_unit = transferable_unit('T1.1') + target_unit = transferable_unit('T1.1') + communication_set = transferable_set(source_unit) + add_auth_header_for(user: admin) + + get "/api/units/#{source_unit.id}/communication_sets/#{communication_set.id}/export" + + assert_equal 200, last_response.status + document = last_response_body + + assert_equal 'ontrack.communication_set', document['format'] + + post_json "/api/units/#{target_unit.id}/communication_sets/import", document: document + + assert_equal 201, last_response.status + assert_equal true, last_response_body['executable'] + assert_equal(['Chase T1.1'], last_response_body['rules'].map { |rule| rule['name'] }) + end + + def test_importing_without_the_referenced_task_flags_the_rule_and_blocks_execution + admin = FactoryBot.create(:user, :admin) + source_unit = transferable_unit('T1.1') + target_unit = transferable_unit('Different') + communication_set = transferable_set(source_unit) + add_auth_header_for(user: admin) + + get "/api/units/#{source_unit.id}/communication_sets/#{communication_set.id}/export" + post_json "/api/units/#{target_unit.id}/communication_sets/import", document: last_response_body + + assert_equal 201, last_response.status + assert_equal false, last_response_body['executable'] + assert_equal([true], last_response_body['rules'].map { |rule| rule['unresolved'] }) + + imported_id = last_response_body['id'] + + post_json "/api/units/#{target_unit.id}/communication_sets/#{imported_id}/execute", {} + + assert_equal 409, last_response.status + assert_equal(['Chase T1.1'], last_response_body['unresolved_rules'].map { |rule| rule['name'] }) + end + + def test_repointing_a_condition_clears_the_unresolved_flag_it_returns + admin = FactoryBot.create(:user, :admin) + source_unit = transferable_unit('T1.1') + target_unit = transferable_unit('Different') + add_auth_header_for(user: admin) + + get "/api/units/#{source_unit.id}/communication_sets/#{transferable_set(source_unit).id}/export" + post_json "/api/units/#{target_unit.id}/communication_sets/import", document: last_response_body + + imported_id = last_response_body['id'] + rule = last_response_body['rules'].first + condition = rule['conditions'].first + + assert_equal true, condition['unresolved'] + + put_json "/api/units/#{target_unit.id}/communication_rules/#{rule['id']}/conditions/#{condition['id']}", + communication_condition: { + type: 'TaskDefinitionStatusCondition', + operator: 'equal_to', + task_definition_id: target_unit.task_definitions.first.id, + task_statuses: ['not_started'] + } + + assert_equal 200, last_response.status + assert_equal false, last_response_body['unresolved'] + + get "/api/units/#{target_unit.id}/communication_sets/#{imported_id}" + + assert_equal true, last_response_body['executable'] + assert_equal([false], last_response_body['rules'].map { |item| item['unresolved'] }) + end + + def test_importing_a_rule_document_as_a_set_is_rejected + admin = FactoryBot.create(:user, :admin) + unit = transferable_unit('T1.1') + rule = transferable_set(unit).communication_rules.first + add_auth_header_for(user: admin) + + get "/api/units/#{unit.id}/communication_rules/#{rule.id}/export" + + assert_equal 'ontrack.communication_rule', last_response_body['format'] + + post_json "/api/units/#{unit.id}/communication_sets/import", document: last_response_body + + assert_equal 400, last_response.status + assert_match 'ontrack.communication_set', last_response_body['error'] + end + + private + + def transferable_unit(abbreviation) + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 1, + campus_count: 1 + ) + + unit.task_definitions.create!( + name: "Task #{abbreviation}", + abbreviation: abbreviation, + description: 'Test task', + weighting: 1, + target_grade: 0, + start_date: unit.start_date, + target_date: unit.start_date + 1.week, + upload_requirements: [] + ) + + unit.reload + end + + def transferable_set(unit) + communication_set = unit.communication_sets.create!(name: 'Nudges', active: true) + rule = communication_set.communication_rules.create!(name: 'Chase T1.1', operator: 'and', position: 0) + + rule.communication_conditions.create!( + type: 'TaskDefinitionStatusCondition', + operator: 'equal_to', + task_definition: unit.task_definitions.first, + task_statuses: ['not_started'] + ) + + rule.communication_actions.create!(type: 'EmailStudentAction', subject: 'Get started', body: 'Hi') + + communication_set + end +end diff --git a/test/api/csv_test.rb b/test/api/csv_test.rb index 39845dcd38..74cd05849b 100644 --- a/test/api/csv_test.rb +++ b/test/api/csv_test.rb @@ -23,7 +23,7 @@ def test_download_csv_all_task_definitions_unit assert_equal 200, last_response.status # Check for file - assert_equal "attachment; filename=COS10001-Tasks.csv",last_response.headers["content-disposition"] + assert_equal "attachment; filename=COS10001-Tasks.csv", last_response.headers["content-disposition"] end def test_download_csv_all_task_definitions_unit_with_empty_unit_id @@ -39,10 +39,9 @@ def test_download_csv_all_task_definitions_unit_with_empty_unit_id assert_equal 404, last_response.status end - #3: Testing for unit ID error with incorrect user ID - #GET /api/csv/task_definitions + # 3: Testing for unit ID error with incorrect user ID + # GET /api/csv/task_definitions def test_download_csv_all_task_definitions_unit_with_incorrect_unit_id - unit_id_to_test = '999' # auth_token and username added to header @@ -55,10 +54,9 @@ def test_download_csv_all_task_definitions_unit_with_incorrect_unit_id assert_equal 404, last_response.status end - #4: Testing for unit ID error with string user ID - #GET /api/csv/task_definitions + # 4: Testing for unit ID error with string user ID + # GET /api/csv/task_definitions def test_download_csv_all_task_definitions_unit_with_string_unit_id - unit_id_to_test = 'string' # auth_token and username added to header @@ -71,10 +69,9 @@ def test_download_csv_all_task_definitions_unit_with_string_unit_id assert_equal 400, last_response.status end - #5: Testing for authentication failure with incorrect token - #GET /api/csv/task_definitions + # 5: Testing for authentication failure with incorrect token + # GET /api/csv/task_definitions def test_download_csv_all_task_definitions_unit_with_incorrect_auth_token - unit_id_to_test = 'string' # auth_token and username added to header @@ -87,17 +84,16 @@ def test_download_csv_all_task_definitions_unit_with_incorrect_auth_token assert_equal 419, last_response.status end - #6: Testing for authentication failure with empty token - #GET /api/csv/task_definitions + # 6: Testing for authentication failure with empty token + # GET /api/csv/task_definitions def test_download_csv_all_task_definitions_unit_with_empty_auth_token - unit_id_to_test = 'string' # auth_token and username added to header add_auth_header_for(user: User.first) # Overwrite header for empty auth_token - header 'auth_token','' + header 'auth_token', '' # perform the get get "/api/csv/task_definitions?unit_id=#{unit_id_to_test}" @@ -106,7 +102,7 @@ def test_download_csv_all_task_definitions_unit_with_empty_auth_token assert_equal 419, last_response.status end - #####--------------POST tests - Upload CSV of task definitions to the provided unit------------###### + # ####--------------POST tests - Upload CSV of task definitions to the provided unit------------###### # 7: Testing for CSV upload all task definitions for the given unit # POST /api/csv/task_definitions @@ -181,7 +177,7 @@ def test_csv_upload_normalises_bad_upload_requirement_file_keys # Ensure our test csv has unordered or duplicate file keys # This scenario can happen when moving upload requirements around from different tasks expected_upload_requirements_by_task = { - '1.1P' => '[{"key":"file1","name":"HelloWorld.pas","type":"code"},{"key":"file1","name":"Screenshot","type":"image"}]', + '1.1P' => '[{"key":"file1","name":"HelloWorld.cpp","type":"code"},{"key":"file1","name":"Screenshot","type":"image"}]', '1.2P' => '[{"key":"file0","name":"PictureDrawing.pas","type":"code"},{"key":"file5","name":"Screenshot","type":"image"}]', '1.3P' => '[{"key":"file0","name":"PictureDrawing.pas","type":"code"},{"key":"file3","name":"Screenshot","type":"image"},{"key":"file2","name":"Screenshot","type":"image"}]' } @@ -221,7 +217,6 @@ def test_csv_upload_normalises_bad_upload_requirement_file_keys # 8: Testing for CSV upload failure due to incorrect auth token # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_incorrect_auth_token - data_to_post = { unit_id: '1', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv') @@ -236,10 +231,9 @@ def test_csv_upload_all_task_definitions_unit_incorrect_auth_token assert_equal 419, last_response.status end - #9: Testing for CSV upload failure due to empty auth token - #POST /api/csv/task_definitions + # 9: Testing for CSV upload failure due to empty auth token + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_empty_auth_token - data_to_post = { unit_id: '1', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv') @@ -249,7 +243,7 @@ def test_csv_upload_all_task_definitions_unit_empty_auth_token add_auth_header_for(user: User.first) # Overwrite header for empty auth_token - header 'auth_token','' + header 'auth_token', '' # perform the POST post "/api/csv/task_definitions", data_to_post @@ -257,10 +251,9 @@ def test_csv_upload_all_task_definitions_unit_empty_auth_token assert_equal 419, last_response.status end - #10: Testing for CSV upload failure due to string unit ID - #POST /api/csv/task_definitions + # 10: Testing for CSV upload failure due to string unit ID + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_string_unit_id - data_to_post = { unit_id: 'string', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv') @@ -275,10 +268,9 @@ def test_csv_upload_all_task_definitions_unit_string_unit_id assert_equal 400, last_response.status end - #11: Testing for CSV upload failure due to empty unit ID - #POST /api/csv/task_definitions + # 11: Testing for CSV upload failure due to empty unit ID + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_empty_unit_id - data_to_post = { unit_id: '', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv') @@ -293,10 +285,9 @@ def test_csv_upload_all_task_definitions_unit_empty_unit_id assert_equal 404, last_response.status end - #12: Testing for CSV upload of xlsx file type - #POST /api/csv/task_definitions + # 12: Testing for CSV upload of xlsx file type + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_xlsx - unit = FactoryBot.create(:unit, with_students: false) data_to_post = { @@ -319,10 +310,9 @@ def test_csv_upload_all_task_definitions_unit_xlsx assert_equal tdc + 1, unit.task_definitions.count end - #13: Testing for CSV upload failure due to incorrect file type (PDF) - #POST /api/csv/task_definitions + # 13: Testing for CSV upload failure due to incorrect file type (PDF) + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_incorrect_file_pdf - data_to_post = { unit_id: '1', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.pdf') @@ -337,13 +327,12 @@ def test_csv_upload_all_task_definitions_unit_incorrect_file_pdf assert_equal 403, last_response.status end - #14: Testing for CSV upload failure due to no file - #POST /api/csv/task_definitions + # 14: Testing for CSV upload failure due to no file + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_no_file - data_to_post = { unit_id: '1', - file: '', + file: '' } # auth_token and username added to header @@ -355,10 +344,9 @@ def test_csv_upload_all_task_definitions_unit_no_file assert_equal 400, last_response.status end - #15: Testing for CSV upload failure due to non-existant unit id - #POST /api/csv/task_definitions + # 15: Testing for CSV upload failure due to non-existant unit id + # POST /api/csv/task_definitions def test_csv_upload_all_task_definitions_unit_incorrect_unit_id - data_to_post = { unit_id: '9999', file: upload_file_csv('test_files/csv_test_files/COS10001-Tasks.csv') @@ -373,12 +361,11 @@ def test_csv_upload_all_task_definitions_unit_incorrect_unit_id assert_equal 404, last_response.status end - #####--------------GET tests - Download CSV of all students in this unit------------###### + # ####--------------GET tests - Download CSV of all students in this unit------------###### - #16: Testing for CSV download of all the students in a unit - #GET /api/csv/units/{id} + # 16: Testing for CSV download of all the students in a unit + # GET /api/csv/units/{id} def test_download_csv_all_students_in_unit - unit_id_to_test = '1' # auth_token and username added to header @@ -391,13 +378,12 @@ def test_download_csv_all_students_in_unit assert_equal 200, last_response.status # Check for file - assert_equal "attachment; filename=COS10001-Students.csv",last_response.headers["content-disposition"] + assert_equal "attachment; filename=COS10001-Students.csv", last_response.headers["content-disposition"] end - #18: Testing for unit ID error with string unit ID - #GET /api/csv/units/{id} + # 18: Testing for unit ID error with string unit ID + # GET /api/csv/units/{id} def test_download_csv_all_students_in_unit_with_string_unit_id - unit_id_to_test = 'string' # auth_token and username added to header @@ -410,10 +396,9 @@ def test_download_csv_all_students_in_unit_with_string_unit_id assert_equal 404, last_response.status end - #19: Testing for unit ID error with incorrect (non-existant) user ID - #GET /api/csv/units/{id} + # 19: Testing for unit ID error with incorrect (non-existant) user ID + # GET /api/csv/units/{id} def test_download_csv_all_students_in_unit_with_incorrect_unit_id - unit_id_to_test = '999' # auth_token and username added to header @@ -426,10 +411,9 @@ def test_download_csv_all_students_in_unit_with_incorrect_unit_id assert_equal 404, last_response.status end - #20: Testing for authentication failure with incorrect token - #GET /api/csv/units/{id} + # 20: Testing for authentication failure with incorrect token + # GET /api/csv/units/{id} def test_download_csv_all_students_in_unit_with_incorrect_auth_token - unit_id_to_test = '1' # auth_token and username added to header @@ -442,17 +426,16 @@ def test_download_csv_all_students_in_unit_with_incorrect_auth_token assert_equal 419, last_response.status end - #21: Testing for authentication failure with empty token - #GET /api/csv/units/{id} + # 21: Testing for authentication failure with empty token + # GET /api/csv/units/{id} def test_download_csv_all_students_in_unit_with_empty_auth_token - unit_id_to_test = '1' # auth_token and username added to header add_auth_header_for(user: User.first) - #Override header for empty auth_token - header 'auth_token','' + # Override header for empty auth_token + header 'auth_token', '' # perform the get get "/api/csv/units/#{unit_id_to_test}" @@ -461,7 +444,7 @@ def test_download_csv_all_students_in_unit_with_empty_auth_token assert_equal 419, last_response.status end - #####--------------POST tests - Upload CSV of all the students in a unit------------###### + # ####--------------POST tests - Upload CSV of all the students in a unit------------###### # 22: Testing for CSV upload of all the students in a unit # POST /api/csv/units/{id} @@ -490,8 +473,8 @@ def test_csv_upload_all_students_in_unit end end - #23: Testing for CSV upload failure due to incorrect auth token - #POST /api/csv/units/{id} + # 23: Testing for CSV upload failure due to incorrect auth token + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_incorrect_auth_token unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) @@ -508,8 +491,8 @@ def test_csv_upload_all_students_in_unit_incorrect_auth_token assert_equal 419, last_response.status end - #24: Testing for CSV upload failure due to empty auth token - #POST /api/csv/units/{id} + # 24: Testing for CSV upload failure due to empty auth token + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_empty_auth_token unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) data_to_post = { @@ -521,7 +504,7 @@ def test_csv_upload_all_students_in_unit_empty_auth_token add_auth_header_for(user: User.first) # Override the header for empty auth_token - header 'auth_token','' + header 'auth_token', '' # perform the POST post "/api/csv/units/#{unit.id}", data_to_post @@ -529,8 +512,8 @@ def test_csv_upload_all_students_in_unit_empty_auth_token assert_equal 419, last_response.status end - #25: Testing for CSV upload failure due to string unit ID - #POST /api/csv/units/{id} + # 25: Testing for CSV upload failure due to string unit ID + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_string_unit_id data_to_post = { file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv') @@ -545,8 +528,8 @@ def test_csv_upload_all_students_in_unit_string_unit_id assert_equal 404, last_response.status end - #28: Testing for CSV upload failure due to incorrect file type (PDF) - #POST /api/csv/units/{id} + # 28: Testing for CSV upload failure due to incorrect file type (PDF) + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_incorrect_file_pdf unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) @@ -563,8 +546,8 @@ def test_csv_upload_all_students_in_unit_incorrect_file_pdf assert_equal 403, last_response.status end - #29: Testing for CSV upload failure due to no file - #POST /api/csv/units/{id} + # 29: Testing for CSV upload failure due to no file + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_no_file unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) @@ -581,10 +564,9 @@ def test_csv_upload_all_students_in_unit_no_file assert_equal 400, last_response.status end - #30: Testing for CSV upload failure due to non-existant unit id - #POST /api/csv/units/{id} + # 30: Testing for CSV upload failure due to non-existant unit id + # POST /api/csv/units/{id} def test_csv_upload_all_students_in_unit_incorrect_unit_id - unit_id_to_test = '999' data_to_post = { file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv') @@ -598,10 +580,10 @@ def test_csv_upload_all_students_in_unit_incorrect_unit_id assert_equal 404, last_response.status end - #####--------------POST tests - Upload CSV with the students to un-enrol from the unit------------###### + # ####--------------POST tests - Upload CSV with the students to un-enrol from the unit------------###### - #31: Testing for CSV upload of all the students in a unit - #POST /api/csv/units/{id}/withdraw + # 31: Testing for CSV upload of all the students in a unit + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) @@ -626,8 +608,8 @@ def test_csv_upload_students_un_enroll_in_unit assert_equal false, Project.where(user_id: user_id_check).last.enrolled end - #32: Testing for CSV upload failure due to incorrect auth token - #POST /api/csv/units/{id}/withdraw + # 32: Testing for CSV upload failure due to incorrect auth token + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_incorrect_auth_token unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) @@ -653,15 +635,13 @@ def test_csv_upload_students_un_enroll_in_unit_incorrect_auth_token assert_equal true, unit.projects.last.enrolled end - #33: Testing for CSV upload failure due to empty auth token - #POST /api/csv/units/{id}/withdraw + # 33: Testing for CSV upload failure due to empty auth token + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_empty_auth_token - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) response = unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' assert_equal 1, unit.projects.count, response - unit_id_to_test = '1' data_to_post = { file: upload_file_csv('test_files/csv_test_files/COS10001-Students.csv') @@ -670,8 +650,8 @@ def test_csv_upload_students_un_enroll_in_unit_empty_auth_token # auth_token and username added to header add_auth_header_for(user: User.first) - #Override header for empty auth_token - header 'auth_token','' + # Override header for empty auth_token + header 'auth_token', '' user_id_check = unit.projects.last.user_id @@ -685,10 +665,9 @@ def test_csv_upload_students_un_enroll_in_unit_empty_auth_token assert_equal true, unit.projects.last.enrolled end - #34: Testing for CSV upload failure due to string unit ID - #POST /api/csv/units/{id}/withdraw + # 34: Testing for CSV upload failure due to string unit ID + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_string_unit_id - # Adding user to withdraw from unit unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' @@ -714,10 +693,9 @@ def test_csv_upload_students_un_enroll_in_unit_string_unit_id unit.destroy end - #35: Testing for CSV upload failure due to empty unit ID - #POST /api/csv/units/{id}/withdraw + # 35: Testing for CSV upload failure due to empty unit ID + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_empty_unit_id - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' @@ -741,10 +719,9 @@ def test_csv_upload_students_un_enroll_in_unit_empty_unit_id assert_equal true, Project.where(user_id: user_id_check).last.enrolled end - #36: Testing for CSV uploadof XLSX - #POST /api/csv/units/{id}/withdraw + # 36: Testing for CSV uploadof XLSX + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_xlsx - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' @@ -768,14 +745,12 @@ def test_csv_upload_students_un_enroll_in_unit_xlsx assert_equal 1, last_response_body['success'].count, last_response_body end - #37: Testing for CSV upload failure due to incorrect file type (PDF) - #POST /api/csv/units/{id}/withdraw + # 37: Testing for CSV upload failure due to incorrect file type (PDF) + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_incorrect_file_pdf - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' - unit_id_to_test = unit.id data_to_post = { file: upload_file_csv('test_files/csv_test_files/COS10001-Students.pdf') @@ -799,7 +774,6 @@ def test_csv_upload_students_un_enroll_in_unit_incorrect_file_pdf # 38: Testing for CSV upload failure due to no file # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_no_file - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' @@ -823,14 +797,13 @@ def test_csv_upload_students_un_enroll_in_unit_no_file assert_equal true, Project.where(user_id: user_id_check).last.enrolled end - #39: Testing for CSV upload failure due to non-existant unit id - #POST /api/csv/units/{id}/withdraw + # 39: Testing for CSV upload failure due to non-existant unit id + # POST /api/csv/units/{id}/withdraw def test_csv_upload_students_un_enroll_in_unit_incorrect_unit_id - unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false, stream_count: 0) unit.import_users_from_csv test_file_path 'csv_test_files/COS10001-Students.csv' - # auth_token and username added to header + # auth_token and username added to header add_auth_header_for(user: User.first) unit_id_to_test = '999' @@ -878,13 +851,12 @@ def test_download_csv_all_student_tasks_in_unit end end - #41: Testing for unit ID error with empty user ID - #GET /api/csv/units/{id}/task_completion + # 41: Testing for unit ID error with empty user ID + # GET /api/csv/units/{id}/task_completion def test_download_csv_all_student_tasks_in_unit_with_empty_unit_id - unit_id_to_test = '' - # auth_token and username added to header + # auth_token and username added to header add_auth_header_for(user: User.first) # perform the get @@ -894,10 +866,9 @@ def test_download_csv_all_student_tasks_in_unit_with_empty_unit_id assert_equal 404, last_response.status end - #42: Testing for unit ID error with string user ID - #GET /api/csv/units/{id}/task_completion + # 42: Testing for unit ID error with string user ID + # GET /api/csv/units/{id}/task_completion def test_download_csv_all_student_tasks_in_unit_with_string_unit_id - unit_id_to_test = 'string' # auth_token and username added to header @@ -910,10 +881,9 @@ def test_download_csv_all_student_tasks_in_unit_with_string_unit_id assert_equal 404, last_response.status end - #43: Testing for unit ID error with incorrect (non-existant) user ID - #GET /api/csv/units/{id}/task_completion + # 43: Testing for unit ID error with incorrect (non-existant) user ID + # GET /api/csv/units/{id}/task_completion def test_download_csv_all_student_tasks_in_unit_with_incorrect_unit_id - unit_id_to_test = '999' # auth_token and username added to header @@ -926,10 +896,9 @@ def test_download_csv_all_student_tasks_in_unit_with_incorrect_unit_id assert_equal 404, last_response.status end - #44: Testing for authentication failure with incorrect token - #GET /api/csv/units/{id}/task_completion + # 44: Testing for authentication failure with incorrect token + # GET /api/csv/units/{id}/task_completion def test_download_csv_all_student_tasks_in_unit_with_incorrect_auth_token - unit_id_to_test = '1' # Add authentication token to header @@ -942,10 +911,9 @@ def test_download_csv_all_student_tasks_in_unit_with_incorrect_auth_token assert_equal 419, last_response.status end - #45: Testing for authentication failure with empty token - #GET /api/csv/units/{id}/task_completion + # 45: Testing for authentication failure with empty token + # GET /api/csv/units/{id}/task_completion def test_download_csv_all_student_tasks_in_unit_with_empty_auth_token - unit_id_to_test = '1' # Add authentication token to header @@ -989,10 +957,63 @@ def test_download_csv_stats_tutor_assessed end end - #47: Testing for unit ID error with empty user ID - #GET /api/csv/units/{id}/tutor_assessments - def test_download_csv_stats_tutor_assessed_with_empty_unit_id + # GET /api/csv/units/{id}/overflow_task_claims + def test_download_csv_overflow_task_claims + Sidekiq::Testing.inline! do + unit = create(:unit, student_count: 1, task_count: 1, stream_count: 0, tutorials: 1) + convenor = unit.main_convenor.user + tutor_user = create(:user, :tutor, first_name: 'ATutor', last_name: 'Tutor') + tutor_role = unit.employ_staff(tutor_user, Role.tutor) + tutorial = unit.tutorials.first + tutorial.update!(unit_role: tutor_role) + + project = unit.active_projects.first + student = project.student + student.update!(username: 'student-one', student_id: 's1234567') + + task_definition = unit.task_definitions.first + task_definition.update!(abbreviation: 'T1') + + task = project.task_for_task_definition(task_definition) + claimed_at = Time.zone.parse('2026-04-01 10:30:00 UTC') + create( + :overflow_task_claim_log, + unit_id: unit.id, + task_id: task.id, + claimed_by_unit_role_id: tutor_role.id, + claimed_by_user_id: tutor_user.id, + original_tutor_user_id: tutor_user.id, + student_user_id: student.id, + days_awaiting_feedback: 12, + claimed_at: claimed_at + ) + add_auth_header_for(user: convenor) + + get "/api/csv/units/#{unit.id}/overflow_task_claims" + + assert_equal 200, last_response.status + assert_equal unit.overflow_task_claims_csv, last_response_body['result'] + + rows = CSV.parse(last_response_body['result'], headers: true) + assert_equal 1, rows.length + assert_equal 'ATutor Tutor', rows[0]['Tutor who claimed'] + assert_equal tutor_role.id.to_s, rows[0]['Claiming Unit Role ID'] + assert_equal 'ATutor Tutor', rows[0]['Original Tutor'] + assert_equal 'student-one', rows[0]['Student Username'] + assert_equal 's1234567', rows[0]['Student ID'] + assert_equal task.id.to_s, rows[0]['Task ID'] + assert_equal 'T1', rows[0]['Task Definition'] + assert_equal '12', rows[0]['Days Awaiting Feedback'] + assert_equal claimed_at.to_s, rows[0]['Timestamp'] + assert_equal claimed_at, Time.zone.parse(rows[0]['Timestamp']) + Sidekiq::Testing.fake! + end + end + + # 47: Testing for unit ID error with empty user ID + # GET /api/csv/units/{id}/tutor_assessments + def test_download_csv_stats_tutor_assessed_with_empty_unit_id unit_id_to_test = '' # Add authentication token to header @@ -1005,10 +1026,9 @@ def test_download_csv_stats_tutor_assessed_with_empty_unit_id assert_equal 404, last_response.status end - #48: Testing for unit ID error with string user ID - #GET /api/csv/units/{id}/tutor_assessments + # 48: Testing for unit ID error with string user ID + # GET /api/csv/units/{id}/tutor_assessments def test_download_csv_stats_tutor_assessed_with_string_unit_id - unit_id_to_test = 'string' # Add authentication token to header @@ -1020,10 +1040,9 @@ def test_download_csv_stats_tutor_assessed_with_string_unit_id assert_equal 404, last_response.status end - #49: Testing for unit ID error with incorrect (non-existant) user ID - #GET /api/csv/units/{id}/tutor_assessments + # 49: Testing for unit ID error with incorrect (non-existant) user ID + # GET /api/csv/units/{id}/tutor_assessments def test_download_csv_stats_tutor_assessed_with_incorrect_unit_id - unit_id_to_test = '999' # Add authentication token to header @@ -1036,10 +1055,9 @@ def test_download_csv_stats_tutor_assessed_with_incorrect_unit_id assert_equal 404, last_response.status end - #50: Testing for authentication failure with incorrect token - #GET /api/csv/units/{id}/tutor_assessments + # 50: Testing for authentication failure with incorrect token + # GET /api/csv/units/{id}/tutor_assessments def test_download_csv_stats_tutor_assessed_with_incorrect_auth_token - unit_id_to_test = '1' # Add authentication token to header @@ -1052,17 +1070,16 @@ def test_download_csv_stats_tutor_assessed_with_incorrect_auth_token assert_equal 419, last_response.status end - #51: Testing for authentication failure with empty token - #GET /api/csv/units/{id}/tutor_assessments + # 51: Testing for authentication failure with empty token + # GET /api/csv/units/{id}/tutor_assessments def test_download_csv_stats_tutor_assessed_with_empty_auth_token - unit_id_to_test = '1' # Add authentication token to header add_auth_header_for(user: User.first) - #Override header for empty auth_token - header 'auth_token','' + # Override header for empty auth_token + header 'auth_token', '' # perform the get get "/api/csv/units/#{unit_id_to_test}/tutor_assessments" @@ -1071,12 +1088,11 @@ def test_download_csv_stats_tutor_assessed_with_empty_auth_token assert_equal 419, last_response.status end - #####--------------GET tests - Download CSV of all users------------###### + # ####--------------GET tests - Download CSV of all users------------###### - #52: Testing for CSV download of stats related to number of tasks assessed by each tutor - #GET /api/csv/users + # 52: Testing for CSV download of stats related to number of tasks assessed by each tutor + # GET /api/csv/users def test_download_csv_all_users - # Add authentication token to header add_auth_header_for(user: User.first) @@ -1087,13 +1103,12 @@ def test_download_csv_all_users assert_equal 200, last_response.status # Check for file - assert_equal "attachment; filename=doubtfire_users.csv",last_response.headers["content-disposition"] + assert_equal "attachment; filename=doubtfire_users.csv", last_response.headers["content-disposition"] end - #53: Testing for authentication failure with incorrect token - #GET /api/csv/users + # 53: Testing for authentication failure with incorrect token + # GET /api/csv/users def test_download_csv_all_users_with_incorrect_auth_token - # Add authentication token to header add_auth_header_for(username: 'aadmin', auth_token: 'wrong_token') @@ -1104,15 +1119,14 @@ def test_download_csv_all_users_with_incorrect_auth_token assert_equal 419, last_response.status end - #54: Testing for authentication failure with empty token - #GET /api/csv/users + # 54: Testing for authentication failure with empty token + # GET /api/csv/users def test_download_csv_all_users_with_empty_auth_token - # Add authentication token to header add_auth_header_for(user: User.first) - #Override header for empty auth_token - header 'auth_token','' + # Override header for empty auth_token + header 'auth_token', '' # perform the get get "/api/csv/users" @@ -1121,12 +1135,11 @@ def test_download_csv_all_users_with_empty_auth_token assert_equal 419, last_response.status end - #####--------------POST tests - Upload CSV of users------------###### + # ####--------------POST tests - Upload CSV of users------------###### - #55: Testing for CSV upload of users - #POST /api/csv/users + # 55: Testing for CSV upload of users + # POST /api/csv/users def test_csv_upload_users - data_to_post = { file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv') } @@ -1142,10 +1155,9 @@ def test_csv_upload_users assert_equal 'test.case@doubtfire.com', User.last.email end - #56: Testing for CSV upload failure due to incorrect auth token - #POST /api/csv/units/{id}/withdraw + # 56: Testing for CSV upload failure due to incorrect auth token + # POST /api/csv/units/{id}/withdraw def test_csv_upload_users_incorrect_auth_token - data_to_post = { file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv') } @@ -1160,19 +1172,18 @@ def test_csv_upload_users_incorrect_auth_token assert_equal 419, last_response.status end - #57: Testing for CSV upload failure due to empty auth token - #POST /api/csv/units/{id}/withdraw + # 57: Testing for CSV upload failure due to empty auth token + # POST /api/csv/units/{id}/withdraw def test_csv_upload_users_empty_auth_token - data_to_post = { - file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv'), + file: upload_file_csv('test_files/csv_test_files/doubtfire_users.csv') } # Add authentication token to header add_auth_header_for(user: User.first) - #Override header for empty auth_token - header 'auth_token','' + # Override header for empty auth_token + header 'auth_token', '' # perform the POST to withdraw user from the unit post "/api/csv/users", data_to_post @@ -1181,13 +1192,12 @@ def test_csv_upload_users_empty_auth_token assert_equal 419, last_response.status end - #58: Testing for CSV upload of XLSX - #POST /api/csv/units/{id}/withdraw + # 58: Testing for CSV upload of XLSX + # POST /api/csv/units/{id}/withdraw def test_csv_upload_users_xlsx - data_to_post = { file: upload_file_csv('test_files/csv_test_files/doubtfire_users.xlsx') - } + } # Add authentication token to header add_auth_header_for(user: User.first) @@ -1199,10 +1209,9 @@ def test_csv_upload_users_xlsx assert_equal 1, last_response_body['ignored'].count, last_response_body end - #59: Testing for CSV upload failure due to incorrect file type (PDF) - #POST /api/csv/units/{id}/withdraw + # 59: Testing for CSV upload failure due to incorrect file type (PDF) + # POST /api/csv/units/{id}/withdraw def test_csv_upload_users_incorrect_file_pdf - data_to_post = { file: upload_file_csv('test_files/csv_test_files/doubtfire_users.pdf') } @@ -1217,10 +1226,9 @@ def test_csv_upload_users_incorrect_file_pdf assert_equal 403, last_response.status end - #60: Testing for CSV upload failure due to no file - #POST /api/csv/units/{id}/withdraw + # 60: Testing for CSV upload failure due to no file + # POST /api/csv/units/{id}/withdraw def test_csv_upload_users_no_file - data_to_post = { file: '' } @@ -1243,20 +1251,20 @@ def test_upload_unit_feedback_chips ulos = [ FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO1'), - FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO2'), + FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: unit.id, abbreviation: 'ULO2') ] other_ulos = [ - FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: other_unit.id, abbreviation: 'ULO1'), + FactoryBot.create(:learning_outcome, context_type: 'Unit', context_id: other_unit.id, abbreviation: 'ULO1') ] td = FactoryBot.create(:task_definition, unit: unit, abbreviation: 'P1', outcome_count: 0) tlos = [ FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO1'), - FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO2'), + FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: td.id, abbreviation: 'TLO2') ] other_td = FactoryBot.create(:task_definition, unit: other_unit, abbreviation: 'P1', outcome_count: 0) other_tlos = [ FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO1'), - FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO2'), + FactoryBot.create(:learning_outcome, context_type: 'TaskDefinition', context_id: other_td.id, abbreviation: 'TLO2') ] admin = FactoryBot.create(:user, :admin) diff --git a/test/api/d2l_test.rb b/test/api/d2l_test.rb index 37eadc7ab3..2837702088 100644 --- a/test/api/d2l_test.rb +++ b/test/api/d2l_test.rb @@ -351,14 +351,14 @@ def test_post_grades headers: { 'Content-Type' => 'application/json;charset=UTF-8' } ) - assert_equal p1, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => p1.student.student_id, "UserName" => p1.student.username, "Email" => p1.student.email } ) - assert_equal p2, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => p2.student.username, "Email" => p2.student.email } ) - assert_equal p3, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => p3.student.email } ) - assert_nil D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => 'BLAH' } ) + assert_equal p1, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => p1.student.student_id, "UserName" => p1.student.username, "Email" => p1.student.email }) + assert_equal p2, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => p2.student.username, "Email" => p2.student.email }) + assert_equal p3, D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => p3.student.email }) + assert_nil D2lIntegration.find_project_for_d2l_user(unit, { "OrgDefinedId" => 'BLAH', "UserName" => 'BLEE', "Email" => 'BLAH' }) p1_put_request = stub_request(:put, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/98701/values/12345") .with( - body: {"{\"GradeObjectType\":1,\"PointsNumerator\":50}" => nil} + body: { "{\"GradeObjectType\":1,\"PointsNumerator\":50}" => nil } ).to_return( status: 200, headers: { @@ -370,7 +370,7 @@ def test_post_grades p2_put_request = stub_request(:put, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/98701/values/12346") .with( - body: { "{\"GradeObjectType\":1,\"PointsNumerator\":60}" => nil }, + body: { "{\"GradeObjectType\":1,\"PointsNumerator\":60}" => nil } ).to_return(status: 200, headers: {}) D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') diff --git a/test/api/engagements_api_test.rb b/test/api/engagements_api_test.rb new file mode 100644 index 0000000000..357c87339c --- /dev/null +++ b/test/api/engagements_api_test.rb @@ -0,0 +1,496 @@ +require 'test_helper' + +class EngagementsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include ActiveSupport::Testing::TimeHelpers + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + end + + def setup + @unit = FactoryBot.create(:unit, with_students: false) + @student = FactoryBot.create(:user, :student) + @project = @unit.enrol_student(@student, nil) + @tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(@tutor, Role.tutor) + @convenor = @unit.main_convenor_user + end + + def engagement_params(overrides = {}) + { + engagement_type: 'attendance', + note: 'Attended the weekly discussion.', + occurred_at: Time.zone.now.iso8601 + }.merge(overrides) + end + + def create_engagement(user: @tutor, overrides: {}) + add_auth_header_for(user: user) + post_json "/api/projects/#{@project.id}/engagements", engagement_params(overrides) + assert_equal 201, last_response.status, last_response.body + Engagement.find(last_response_body['id']) + end + + def test_tutor_can_create_and_student_can_read_engagements + later = create_engagement(overrides: { engagement_type: 'forum', occurred_at: 1.day.from_now.iso8601 }) + earlier = create_engagement(overrides: { engagement_type: 'negative', occurred_at: 1.day.ago.iso8601 }) + + add_auth_header_for(user: @student) + get "/api/projects/#{@project.id}/engagements" + + assert_equal 200, last_response.status + assert_equal [earlier.id, later.id], (last_response_body.map { |engagement| engagement['id'] }) + assert_equal 'negative', last_response_body.first['engagement_type'] + assert_equal @tutor.id, last_response_body.first.dig('user', 'id') + end + + def test_tutor_can_create_one_engagement_for_multiple_students + other_student = FactoryBot.create(:user, :student) + other_project = @unit.enrol_student(other_student, nil) + + engagement = create_engagement( + overrides: { project_ids: [@project.id, other_project.id] } + ) + + assert_equal [engagement.id], @project.engagements.pluck(:id) + assert_equal [engagement.id], other_project.shared_engagements.pluck(:id) + assert_equal [other_project.id], engagement.additional_projects.pluck(:id) + + add_auth_header_for(user: other_student) + get "/api/projects/#{other_project.id}/engagements/#{engagement.id}" + assert_equal [@student.id, other_student.id].sort, + last_response_body['students'].pluck('id').sort + end + + def test_records_automatic_class_discussion_with_tutorial_context + tutorial = @unit.tutorials.first + tutorial.campus.update!(timezone: 'UTC') + @project.enrol_in(tutorial) + tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00') + add_auth_header_for(user: @tutor) + task_definition = @unit.task_definitions.first + + assert_difference 'Engagement.count', 1 do + travel_to(Time.zone.parse('2026-07-20 10:30:00 UTC')) do + post_json( + "/api/projects/#{@project.id}/engagements/class_discussion", + { + task_status_updates: [ + { + task_definition_id: task_definition.id, + from_status: 'ready_for_feedback', + to_status: 'complete' + } + ] + } + ) + end + end + + assert_equal 201, last_response.status + engagement = @project.engagements.last + assert_equal 'Discussion', engagement.engagement_type + assert_equal( + "Updated task statuses during tutorial. #{task_definition.abbreviation}: Ready for Feedback → Complete.", + engagement.note + ) + assert_equal @tutor, engagement.user + end + + def test_uses_default_timezone_for_tutorial_without_campus + tutorial = @unit.tutorials.first + tutorial.update!(campus: nil, meeting_day: 'Monday', meeting_time: '10:00') + @project.enrol_in(tutorial) + add_auth_header_for(user: @tutor) + + travel_to(Time.zone.parse('2026-07-20 10:30:00')) do + post_json( + "/api/projects/#{@project.id}/engagements/class_discussion", + { task_status_updates: [] } + ) + end + + assert_equal 201, last_response.status + assert_equal( + 'Class discussion during tutorial; no task statuses were updated.', + @project.engagements.last.note + ) + end + + def test_records_class_discussion_without_status_updates_outside_tutorial + add_auth_header_for(user: @tutor) + + assert_difference 'Engagement.count', 1 do + post_json( + "/api/projects/#{@project.id}/engagements/class_discussion", + { task_status_updates: [] } + ) + end + + assert_equal 201, last_response.status + assert_equal( + 'Class discussion outside of tutorial; no task statuses were updated.', + @project.engagements.last.note + ) + end + + def test_debounces_automatic_class_discussion_engagements + add_auth_header_for(user: @tutor) + path = "/api/projects/#{@project.id}/engagements/class_discussion" + task_definitions = @unit.task_definitions.first(2) + + assert_difference 'Engagement.count', 1 do + post_json( + path, + { + task_status_updates: [ + { + task_definition_id: task_definitions.first.id, + from_status: 'ready_for_feedback', + to_status: 'complete' + } + ] + } + ) + post_json( + path, + { + task_status_updates: [ + { + task_definition_id: task_definitions.second.id, + from_status: 'discuss', + to_status: 'rediscuss' + } + ] + } + ) + end + + assert_equal false, last_response_body['recorded'] + note = @project.engagements.last.note + assert_includes note, "#{task_definitions.first.abbreviation}: Ready for Feedback → Complete." + assert_includes note, "#{task_definitions.second.abbreviation}: Discuss → Rediscuss." + end + + def test_student_cannot_record_automatic_class_discussion + add_auth_header_for(user: @student) + + assert_no_difference 'Engagement.count' do + post_json( + "/api/projects/#{@project.id}/engagements/class_discussion", + { task_status_updates: [] } + ) + end + + assert_equal 403, last_response.status + end + + def test_student_and_unrelated_tutor_cannot_create_engagements + add_auth_header_for(user: @student) + post_json "/api/projects/#{@project.id}/engagements", engagement_params + assert_equal 403, last_response.status + + unrelated_tutor = FactoryBot.create(:user, :tutor) + add_auth_header_for(user: unrelated_tutor) + post_json "/api/projects/#{@project.id}/engagements", engagement_params + assert_equal 403, last_response.status + end + + def test_only_author_with_current_teaching_access_can_edit + engagement = create_engagement + other_tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(other_tutor, Role.tutor) + + add_auth_header_for(user: other_tutor) + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}", + { note: 'Changed by another tutor.' } + ) + assert_equal 403, last_response.status + + add_auth_header_for(user: @tutor) + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}", + { note: 'Corrected note.', occurred_at: 2.days.ago.iso8601 } + ) + assert_equal 200, last_response.status + assert_equal 'Corrected note.', engagement.reload.note + + @unit.unit_role_for(@tutor).destroy! + assert Engagement.exists?(engagement.id) + + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}", + { note: 'Changed after role removal.' } + ) + assert_equal 403, last_response.status + assert_equal 'Corrected note.', engagement.reload.note + end + + def test_tutor_can_delete_their_own_engagement + engagement = create_engagement + + add_auth_header_for(user: @tutor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 200, last_response.status + assert_nil Engagement.find_by(id: engagement.id) + end + + def test_tutor_cannot_delete_another_staff_members_engagement + engagement = create_engagement(user: @convenor) + + add_auth_header_for(user: @tutor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 403, last_response.status + assert Engagement.exists?(engagement.id) + end + + def test_convenor_can_delete_any_engagement + engagement = create_engagement + + add_auth_header_for(user: @convenor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 200, last_response.status + assert_nil Engagement.find_by(id: engagement.id) + end + + def test_student_cannot_delete_an_engagement + engagement = create_engagement + + add_auth_header_for(user: @student) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 403, last_response.status + assert Engagement.exists?(engagement.id) + end + + def test_tutor_cannot_delete_an_engagement_after_the_delete_window + engagement = create_engagement + + travel_to (Engagement::DELETE_WINDOW + 1.minute).from_now do + add_auth_header_for(user: @tutor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 403, last_response.status + assert Engagement.exists?(engagement.id) + + # The convenor has a longer window, so they can still delete it + add_auth_header_for(user: @convenor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 200, last_response.status + assert_nil Engagement.find_by(id: engagement.id) + end + end + + def test_convenor_cannot_delete_an_engagement_after_the_convenor_delete_window + engagement = create_engagement + + travel_to (Engagement::CONVENOR_DELETE_WINDOW + 1.minute).from_now do + add_auth_header_for(user: @convenor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 403, last_response.status + end + + assert Engagement.exists?(engagement.id) + end + + def test_student_and_teaching_staff_can_comment + engagement = create_engagement + + add_auth_header_for(user: @student) + post_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments", + { comment: 'I have added the supporting context.' } + ) + assert_equal 201, last_response.status + assert_equal @student.id, last_response_body.dig('user', 'id') + + add_auth_header_for(user: @tutor) + post_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments", + { comment: 'Thanks, this clarifies the evidence.' } + ) + assert_equal 201, last_response.status + + unrelated_student = FactoryBot.create(:user, :student) + add_auth_header_for(user: unrelated_student) + post_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments", + { comment: 'Unrelated comment.' } + ) + assert_equal 403, last_response.status + + add_auth_header_for(user: @student) + get "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal 200, last_response.status + assert_equal 2, last_response_body['comments'].length + assert_equal( + ['I have added the supporting context.', 'Thanks, this clarifies the evidence.'], + last_response_body['comments'].map { |comment| comment['comment'] } + ) + end + + def test_comment_can_reply_to_comment_in_same_engagement + engagement = create_engagement + + add_auth_header_for(user: @student) + post_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments", + { comment: 'Original comment.' } + ) + assert_equal 201, last_response.status + original_comment_id = last_response_body['id'] + + add_auth_header_for(user: @tutor) + post_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments", + { comment: 'Reply comment.', reply_to_id: original_comment_id } + ) + assert_equal 201, last_response.status + assert_equal original_comment_id, last_response_body['reply_to_id'] + + get "/api/projects/#{@project.id}/engagements/#{engagement.id}" + assert_equal original_comment_id, last_response_body['comments'].last['reply_to_id'] + end + + def test_comment_cannot_reply_to_comment_in_another_engagement + engagement = create_engagement + other_engagement = create_engagement(overrides: { note: 'Another engagement.' }) + original_comment = engagement.engagement_comments.create!( + user: @student, + comment: 'Comment on the first engagement.' + ) + + add_auth_header_for(user: @tutor) + post_json( + "/api/projects/#{@project.id}/engagements/#{other_engagement.id}/comments", + { comment: 'Invalid reply.', reply_to_id: original_comment.id } + ) + assert_equal 404, last_response.status + end + + def test_comment_author_can_edit_within_ten_minutes + engagement = create_engagement + comment = engagement.engagement_comments.create!(user: @student, comment: 'Original comment.') + + add_auth_header_for(user: @student) + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}", + { comment: 'Updated comment.' } + ) + + assert_equal 200, last_response.status + assert_equal 'Updated comment.', comment.reload.comment + end + + def test_comment_cannot_be_edited_after_ten_minutes_or_by_another_user + engagement = create_engagement + comment = engagement.engagement_comments.create!(user: @student, comment: 'Original comment.') + + add_auth_header_for(user: @tutor) + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}", + { comment: 'Tutor edit.' } + ) + assert_equal 403, last_response.status + + comment.update_column(:created_at, 11.minutes.ago) + add_auth_header_for(user: @student) + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{comment.id}", + { comment: 'Late edit.' } + ) + assert_equal 403, last_response.status + assert_equal 'Original comment.', comment.reload.comment + end + + def test_comment_author_and_convenor_can_delete + engagement = create_engagement + student_comment = engagement.engagement_comments.create!( + user: @student, + comment: 'Student comment.' + ) + + add_auth_header_for(user: @tutor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{student_comment.id}" + assert_equal 403, last_response.status + + add_auth_header_for(user: @student) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{student_comment.id}" + assert_equal 200, last_response.status + assert_not EngagementComment.exists?(student_comment.id) + + tutor_comment = engagement.engagement_comments.create!(user: @tutor, comment: 'Tutor comment.') + add_auth_header_for(user: @convenor) + delete "/api/projects/#{@project.id}/engagements/#{engagement.id}/comments/#{tutor_comment.id}" + assert_equal 200, last_response.status + assert_not EngagementComment.exists?(tutor_comment.id) + end + + def test_rejects_file_and_url_together + add_auth_header_for(user: @tutor) + data = engagement_params( + evidence_url: 'https://example.com/evidence', + attachment: upload_file('test_files/submissions/boo.png', 'image/png') + ) + + post "/api/projects/#{@project.id}/engagements", data + + assert_equal 400, last_response.status + assert_equal 0, @project.engagements.count + end + + def test_image_attachment_can_be_retrieved_and_replaced_with_url + add_auth_header_for(user: @tutor) + data = engagement_params( + attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg') + ) + + post "/api/projects/#{@project.id}/engagements", data + assert_equal 201, last_response.status, last_response.body + + engagement = Engagement.find(last_response_body['id']) + assert engagement.attachment? + assert File.exist?(engagement.attachment_path) + + add_auth_header_for(user: @student) + get "/api/projects/#{@project.id}/engagements/#{engagement.id}/attachment" + assert_equal 200, last_response.status + + add_auth_header_for(user: @tutor) + old_attachment_path = engagement.attachment_path + put_json( + "/api/projects/#{@project.id}/engagements/#{engagement.id}", + { evidence_url: 'https://example.com/replacement' } + ) + assert_equal 200, last_response.status + + engagement.reload + assert_not engagement.attachment? + assert_not File.exist?(old_attachment_path) + assert_equal 'https://example.com/replacement', engagement.evidence_url + end + + def test_pdf_attachment_is_accepted_and_removed_with_project + add_auth_header_for(user: @tutor) + data = engagement_params( + attachment: upload_file('test_files/submissions/00_question.pdf', 'application/pdf') + ) + + post "/api/projects/#{@project.id}/engagements", data + assert_equal 201, last_response.status, last_response.body + + engagement = Engagement.find(last_response_body['id']) + assert_equal 'pdf', engagement.content_type + attachment_path = engagement.attachment_path + assert File.exist?(attachment_path) + + @project.tutorial_enrolments.destroy_all + @project.destroy! + + assert_nil Engagement.find_by(id: engagement.id) + assert_not File.exist?(attachment_path) + end +end diff --git a/test/api/feedback/feedback_chip_api_consolidated_test.rb b/test/api/feedback/feedback_chip_api_consolidated_test.rb index a0222bb92d..2ea1b88e14 100644 --- a/test/api/feedback/feedback_chip_api_consolidated_test.rb +++ b/test/api/feedback/feedback_chip_api_consolidated_test.rb @@ -112,7 +112,7 @@ def test_auth_for_create_edit_global_feedback_chips } data_to_put = { - chip_text: 'Updated text', + chip_text: 'Updated text' } FactoryBot.create(:feedback_group_chip, learning_outcome_id: learning_outcome.id) @@ -253,7 +253,6 @@ def test_auth_for_delete_feedback_chips auditor = FactoryBot.create(:user, :auditor) student = unit.students.first - learning_outcomes = [ { learning_outcome: unit.learning_outcomes.first, diff --git a/test/api/feedback/learning_outcome_api_test.rb b/test/api/feedback/learning_outcome_api_test.rb index 0dcf445623..8ad22e4075 100644 --- a/test/api/feedback/learning_outcome_api_test.rb +++ b/test/api/feedback/learning_outcome_api_test.rb @@ -43,13 +43,13 @@ def test_auth_for_create_edit_unit_learning_outcome data_to_post = { abbreviation: 'ULO9', short_description: 'learning outcome short description', - full_outcome_description: 'Long description of learning outcome', + full_outcome_description: 'Long description of learning outcome' } data_to_put = { abbreviation: 'NLO9', short_description: 'new learning outcome short description', - full_outcome_description: 'New long description of learning outcome', + full_outcome_description: 'New long description of learning outcome' } users_can.each do |user| @@ -100,7 +100,7 @@ def test_get_global_outcomes last_response_body.each do |outcome| model = LearningOutcome.find(outcome['id']) - assert_json_matches_model model, outcome, %w(id context_id context_type abbreviation short_description full_outcome_description) + assert_json_matches_model model, outcome, %w[id context_id context_type abbreviation short_description full_outcome_description] end end @@ -195,6 +195,29 @@ def test_update_global_outcome assert_json_matches_model learning_outcome, last_response_body, %w[id context_id context_type abbreviation short_description full_outcome_description] end + def test_tutors_and_convenors_cannot_manage_global_outcomes + learning_outcome = FactoryBot.create(:learning_outcome, context_id: nil, context_type: nil, + short_description: 'Original') + new_abbreviation = FactoryBot.build(:learning_outcome).abbreviation + new_outcome = { abbreviation: new_abbreviation, short_description: 'New outcome' } + + [FactoryBot.create(:user, :tutor), FactoryBot.create(:user, :convenor)].each do |user| + add_auth_header_for user: user + + post_json 'api/global/outcomes', new_outcome + assert_equal 403, last_response.status + assert_nil LearningOutcome.global_outcomes.find_by(abbreviation: new_abbreviation) + + put_json "api/global/outcomes/#{learning_outcome.id}", short_description: 'Changed' + assert_equal 403, last_response.status + assert_equal 'Original', learning_outcome.reload.short_description + + delete "api/global/outcomes/#{learning_outcome.id}" + assert_equal 403, last_response.status + assert LearningOutcome.exists?(learning_outcome.id) + end + end + def test_create_task_definition_learning_outcome task_definition = FactoryBot.create(:task_definition, outcome_count: 0) data_to_post = { @@ -404,6 +427,17 @@ def test_unit_rollover assert_equal chip.learning_outcome_id, new_td_lo.id, 'Each feedback chip should have the correct learning outcome id' end + copied_group_chip = new_td_lo.feedback_group_chips.find_by(chip_text: group_chip_td_lo.chip_text) + copied_template_chip = new_td_lo.feedback_template_chips.find_by(chip_text: template_chip_td_lo.chip_text) + + assert_not_nil copied_group_chip, 'The task learning outcome feedback group should be copied' + assert_not_nil copied_template_chip, 'The task learning outcome feedback template should be copied' + assert_equal copied_group_chip, copied_template_chip.parent_chip + assert_equal template_chip_td_lo.description, copied_template_chip.description + assert_equal template_chip_td_lo.task_status, copied_template_chip.task_status + assert_equal template_chip_td_lo.summary_text, copied_template_chip.summary_text + assert_equal template_chip_td_lo.comment_text, copied_template_chip.comment_text + new_link = LearningOutcomeLink.find_by(source_id: new_lo1.id, target_id: global_learning_outcome.id) assert_not_nil new_link, 'New learning outcome 1 should be linked to the global learning outcome' diff --git a/test/api/group_sets_api_test.rb b/test/api/group_sets_api_test.rb index 52d2b66939..09204e9762 100644 --- a/test/api/group_sets_api_test.rb +++ b/test/api/group_sets_api_test.rb @@ -116,8 +116,8 @@ def test_post_add_a_group_to_a_group_set_of_a_unit_with_authorization unit_id: new_unit.id, group_set_id: new_group_set.id, group: { - name:new_group.name, - tutorial_id:new_tutorial.id + name: new_group.name, + tutorial_id: new_tutorial.id } } @@ -128,14 +128,14 @@ def test_post_add_a_group_to_a_group_set_of_a_unit_with_authorization # check if the POST get through assert_equal 201, last_response.status - #check response - response_keys = %w(name tutorial_id group_set_id locked) + # check response + response_keys = %w[name tutorial_id group_set_id locked] responseGroup = Group.find(last_response_body['id']) assert_json_matches_model(responseGroup, last_response_body, response_keys) assert_equal new_unit.id, responseGroup.group_set.unit.id assert_equal new_group.name, responseGroup.name - assert_equal new_group_set.id,responseGroup.group_set.id - assert_equal new_tutorial.id,responseGroup.group_set.unit.tutorials.first.id + assert_equal new_group_set.id, responseGroup.group_set.id + assert_equal new_tutorial.id, responseGroup.group_set.unit.tutorials.first.id end def test_get_all_groups_in_unit_without_authorization @@ -165,8 +165,8 @@ def test_get_all_groups_in_unit_with_authorization assert_equal new_group.group_set.groups.count, last_response_body.count # Check response - response_keys = %w(id name) - last_response_body.each do | data | + response_keys = %w[id name] + last_response_body.each do |data| grp = Group.find(data['id']) assert_json_matches_model(grp, data, response_keys) end @@ -205,11 +205,11 @@ def test_get_groups_in_a_group_set_with_authorization get "/api/units/#{new_unit.id}/group_sets/#{new_group_set.id}/groups" # Check returning number of groups - assert_equal new_group_set.groups.all.count,last_response_body.count + assert_equal new_group_set.groups.all.count, last_response_body.count # Check response - response_keys = %w(id name) - last_response_body.each do | data | + response_keys = %w[id name] + last_response_body.each do |data| grp = Group.find(data['id']) assert_json_matches_model(grp, data, response_keys) end @@ -217,10 +217,9 @@ def test_get_groups_in_a_group_set_with_authorization end def test_groups_unlocked_upon_creation - unit = FactoryBot.create :unit unit.save! - group_set = GroupSet.create!({name: 'test_groups_unlocked_upon_creation', unit: unit}) + group_set = GroupSet.create!({ name: 'test_groups_unlocked_upon_creation', unit: unit }) group_set.save! # A group should be unlocked upon creation. @@ -228,8 +227,8 @@ def test_groups_unlocked_upon_creation group: { name: 'test_groups_unlocked_upon_creation', tutorial_id: unit.tutorials.first.id, - capacity_adjustment: 0, - }, + capacity_adjustment: 0 + } } add_auth_header_for(user: unit.main_convenor_user) post "/api/units/#{unit.id}/group_sets/#{group_set.id}/groups", data @@ -243,9 +242,9 @@ def test_groups_unlocked_upon_creation def test_groups_lockable_only_by_staff unit = FactoryBot.create :unit unit.save! - group_set = GroupSet.create!({name: 'test_groups_lockable_only_by_staff', unit: unit, allow_students_to_manage_groups: true }) + group_set = GroupSet.create!({ name: 'test_groups_lockable_only_by_staff', unit: unit, allow_students_to_manage_groups: true }) group_set.save! - group = Group.create!({group_set: group_set, name: 'test_groups_lockable_only_by_staff', tutorial: unit.tutorials.first }) + group = Group.create!({ group_set: group_set, name: 'test_groups_lockable_only_by_staff', tutorial: unit.tutorials.first }) group.save! group.add_member(unit.active_projects[0]) diff --git a/test/api/groups_api_test.rb b/test/api/groups_api_test.rb index 9c71389519..8a53134868 100644 --- a/test/api/groups_api_test.rb +++ b/test/api/groups_api_test.rb @@ -10,42 +10,42 @@ def app Rails.application end -# def test_get_groups -# # The GET we are testing -# unit_id = rand(1..Unit.all.length) + # def test_get_groups + # # The GET we are testing + # unit_id = rand(1..Unit.all.length) -# end + # end def test_group_submission_with_extensions unit = FactoryBot.create :unit - group_set = GroupSet.create!({name: 'test_group_submission_with_extensions', unit: unit}) + group_set = GroupSet.create!({ name: 'test_group_submission_with_extensions', unit: unit }) group_set.save! - group = Group.create!({group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first}) + group = Group.create!({ group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first }) group.add_member(unit.active_projects[0]) group.add_member(unit.active_projects[1]) group.add_member(unit.active_projects[2]) td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 1.week, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.week, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0, - group_set: group_set - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.week, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + group_set: group_set + }) assert td.save! data_to_post = { @@ -77,8 +77,8 @@ def test_group_submission_with_extensions group.reload group.projects.each do |proj| - task = proj.task_for_task_definition(td) - assert_equal TaskStatus.ready_for_feedback, task.task_status + task = proj.task_for_task_definition(td) + assert_equal TaskStatus.ready_for_feedback, task.task_status end # ensure groupset has groups to destroy @@ -91,34 +91,34 @@ def test_group_submission_with_extensions def test_comment_on_group_task_without_group unit = FactoryBot.create :unit - group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit}) + group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit }) group_set.save! td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 1.week, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.week, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0, - group_set: group_set - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.week, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + group_set: group_set + }) assert td.save! project = unit.projects.first comment_data = { comment: "Hello World" } - # Add username and auth_token to Header + # Add username and auth_token to Header add_auth_header_for(user: project.student) post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/comments", comment_data @@ -132,33 +132,33 @@ def test_comment_on_group_task_without_group def test_pdf_comment_on_group_task unit = FactoryBot.create :unit - group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit}) + group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit }) group_set.save! - group = Group.create!({group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first}) + group = Group.create!({ group_set: group_set, name: 'test_group_submission_with_extensions', tutorial: unit.tutorials.first }) group.add_member(unit.active_projects[0]) group.add_member(unit.active_projects[1]) group.add_member(unit.active_projects[2]) td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 1.week, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.week, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0, - group_set: group_set - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.week, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + group_set: group_set + }) assert td.save! project = unit.projects.first @@ -181,27 +181,27 @@ def test_pdf_comment_on_group_task def test_pdf_comment_on_group_task_without_group unit = FactoryBot.create :unit - group_set = GroupSet.create!({name: 'test_comment_without_group', unit: unit}) + group_set = GroupSet.create!({ name: 'test_comment_without_group', unit: unit }) group_set.save! td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: Time.zone.now - 1.week, - target_date: Time.zone.now - 1.day, - due_date: Time.zone.now + 1.week, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0, - group_set: group_set - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now - 1.day, + due_date: Time.zone.now + 1.week, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + group_set: group_set + }) assert td.save! project = unit.projects.first @@ -307,7 +307,7 @@ def test_create_group end def test_group_student_count - unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 2}] + unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 2 }] assert_equal 2, unit.groups.first.group_memberships.count # Add username and auth_token to Header @@ -320,7 +320,7 @@ def test_group_student_count end def test_group_switch_tutorial - unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}] + unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }] gs = unit.group_sets.first gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: true @@ -334,13 +334,13 @@ def test_group_switch_tutorial tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil - refute p1.enrolled_in? tutorial - refute p2.enrolled_in? tutorial + assert_not p1.enrolled_in? tutorial + assert_not p2.enrolled_in? tutorial # Add username and auth_token to Header add_auth_header_for(user: unit.main_convenor_user) - put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} } + put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } } assert 201, last_response.status @@ -355,7 +355,7 @@ def test_group_switch_tutorial end def test_group_switch_tutorial_no_student_management - unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}] + unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }] gs = unit.group_sets.first gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: false @@ -369,11 +369,11 @@ def test_group_switch_tutorial_no_student_management tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil - refute p1.enrolled_in? tutorial - refute p2.enrolled_in? tutorial + assert_not p1.enrolled_in? tutorial + assert_not p2.enrolled_in? tutorial add_auth_header_for(user: unit.main_convenor_user) - put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} } + put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } } assert 201, last_response.status @@ -388,7 +388,7 @@ def test_group_switch_tutorial_no_student_management end def test_group_switch_tutorial_unenrolled_students - unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}] + unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }] gs = unit.group_sets.first gs.update keep_groups_in_same_class: true, allow_students_to_manage_groups: false, capacity: 2 @@ -404,16 +404,16 @@ def test_group_switch_tutorial_unenrolled_students tutorial = FactoryBot.create :tutorial, unit: unit, campus: nil - refute p1.enrolled_in? tutorial - refute p2.enrolled_in? tutorial + assert_not p1.enrolled_in? tutorial + assert_not p2.enrolled_in? tutorial p2.update(enrolled: false) p2.reload - refute group1.at_capacity? + assert_not group1.at_capacity? add_auth_header_for(user: unit.main_convenor_user) - put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: {tutorial_id: tutorial.id} } + put "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}", { group: { tutorial_id: tutorial.id } } assert 201, last_response.status @@ -428,17 +428,17 @@ def test_group_switch_tutorial_unenrolled_students assert group1.valid?, group1.errors.full_messages assert p1.enrolled_in? tutorial - refute p2.enrolled_in? tutorial + assert_not p2.enrolled_in? tutorial # check we can reenrol the student - refute group1.at_capacity? + assert_not group1.at_capacity? assert p2.update(enrolled: true) - refute group1.at_capacity? # they are not in the right tutorial + assert_not group1.at_capacity? # they are not in the right tutorial assert_equal 1, group1.projects.count end def test_locked_groups - unit = FactoryBot.create :unit, group_sets: 1, groups: [{gs: 0, students: 0}], task_count: 0 + unit = FactoryBot.create :unit, group_sets: 1, groups: [{ gs: 0, students: 0 }], task_count: 0 gs = unit.group_sets.first group1 = gs.groups.first diff --git a/test/api/lms_integration_api_test.rb b/test/api/lms_integration_api_test.rb new file mode 100644 index 0000000000..e32eea446d --- /dev/null +++ b/test/api/lms_integration_api_test.rb @@ -0,0 +1,211 @@ +require 'test_helper' + +class LmsIntegrationApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + setup do + @original_lti_enabled = Doubtfire::Application.config.lti_enabled + @original_lti_internal_url = Doubtfire::Application.config.lti_internal_url + @original_lti_internal_key = Doubtfire::Application.config.lti_internal_key + Doubtfire::Application.config.lti_enabled = true + Doubtfire::Application.config.lti_internal_url = 'http://lti.test' + Doubtfire::Application.config.lti_internal_key = 'internal-test-key' + end + + teardown do + Doubtfire::Application.config.lti_enabled = @original_lti_enabled + Doubtfire::Application.config.lti_internal_url = @original_lti_internal_url + Doubtfire::Application.config.lti_internal_key = @original_lti_internal_key + end + + def test_convenor_can_retry_grade_line_item_setup + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit) + retry_request = stub_request(:post, internal_url(unit, 'grade-line-item')) + .with(headers: { 'X-Internal-Key' => 'internal-test-key' }) + .to_return( + status: 200, + body: grade_line_item_status(configured: true).to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + post "/api/units/#{unit.id}/lms/grade_line_item" + + assert_equal 201, last_response.status, last_response.inspect + assert last_response_body['configured'] + assert_requested retry_request + end + + def test_student_cannot_retry_grade_line_item_setup + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: FactoryBot.create(:user, :student)) + + post "/api/units/#{unit.id}/lms/grade_line_item" + + assert_equal 403, last_response.status, last_response.inspect + end + + def test_retry_grade_line_item_propagates_lti_error + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit) + stub_request(:post, internal_url(unit, 'grade-line-item')).to_return( + status: 422, + body: { error: 'Enable Assignment and Grade Services, relaunch OnTrack and retry.' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + post "/api/units/#{unit.id}/lms/grade_line_item" + + assert_equal 422, last_response.status, last_response.inspect + assert_equal 'Enable Assignment and Grade Services, relaunch OnTrack and retry.', + last_response_body['error'] + end + + def test_grade_sync_is_not_queued_without_a_configured_line_item + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit) + stub_request(:get, internal_url(unit, 'grade-line-item')).to_return( + status: 200, + body: grade_line_item_status(configured: false).to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + post "/api/units/#{unit.id}/lms/sync_grades" + + assert_equal 422, last_response.status, last_response.inspect + assert_equal 'Grade sync is not configured for this LMS course.', last_response_body['error'] + assert_empty SyncLmsGradesJob.jobs + end + + def test_settings_patch_only_changes_the_given_toggles + unit = FactoryBot.create(:unit, with_students: false) + integration = unit.create_lms_integration!(group_mapping_enabled: true, validated: true, validated_at: Time.zone.now) + integration.lms_group_mappings.create!(lms_group_id: 7, lms_group_name: 'Lab 1', target_type: 'ignore') + add_auth_header_for(user: unit.main_convenor_user) + + patch "/api/units/#{unit.id}/lms/settings", { auto_sync_students: true } + + assert_equal 200, last_response.status, last_response.inspect + integration.reload + assert integration.auto_sync_students? + assert integration.group_mapping_enabled? + assert integration.validated? + assert_equal 1, integration.lms_group_mappings.count + end + + def test_settings_patch_keeps_validation_when_extensions_turn_off + unit = FactoryBot.create(:unit, with_students: false) + integration = unit.create_lms_integration!(fetch_extensions: true, auto_sync_extensions: true, validated: true, validated_at: Time.zone.now) + add_auth_header_for(user: unit.main_convenor_user) + + patch "/api/units/#{unit.id}/lms/settings", { fetch_extensions: false } + + assert_equal 200, last_response.status, last_response.inspect + integration.reload + assert_not integration.fetch_extensions? + assert_not integration.auto_sync_extensions? + assert integration.validated? + end + + def test_settings_patch_invalidates_when_extensions_turn_on + unit = FactoryBot.create(:unit, with_students: false) + integration = unit.create_lms_integration!(validated: true, validated_at: Time.zone.now) + add_auth_header_for(user: unit.main_convenor_user) + + patch "/api/units/#{unit.id}/lms/settings", { fetch_extensions: true } + + assert_equal 200, last_response.status, last_response.inspect + assert_not integration.reload.validated? + end + + def test_settings_patch_saves_the_assignment_and_invalidates + unit = FactoryBot.create(:unit, with_students: false) + integration = unit.create_lms_integration!(fetch_extensions: true, assignment_id: 1, assignment_name: 'Old', validated: true, validated_at: Time.zone.now) + add_auth_header_for(user: unit.main_convenor_user) + + patch "/api/units/#{unit.id}/lms/settings", { assignment_id: 2, assignment_name: 'Portfolio' } + + assert_equal 200, last_response.status, last_response.inspect + integration.reload + assert_equal 2, integration.assignment_id + assert_equal 'Portfolio', integration.assignment_name + assert_not integration.validated? + end + + def test_student_cannot_patch_settings + unit = FactoryBot.create(:unit) + add_auth_header_for(user: unit.active_projects.first.student) + + patch "/api/units/#{unit.id}/lms/settings", { auto_sync_students: true } + + assert_equal 403, last_response.status, last_response.inspect + end + + def test_grade_sync_preview_is_queued_without_a_configured_line_item + unit = FactoryBot.create(:unit, with_students: false) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit) + + post "/api/units/#{unit.id}/lms/sync_grades", { preview_only: true } + + assert_equal 201, last_response.status, last_response.inspect + assert_equal([[unit.id, true]], SyncLmsGradesJob.jobs.map { |job| job['args'] }) + ensure + SyncLmsGradesJob.clear + end + + def test_student_import_needs_validated_mappings_when_the_plugin_is_available + unit = FactoryBot.create(:unit, with_students: false) + unit.create_lms_integration!(group_mapping_enabled: true) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit, course_data_available: true) + + post "/api/units/#{unit.id}/lms/import_students", { preview_only: true } + + assert_equal 422, last_response.status, last_response.inspect + assert_empty ImportLmsStudentsJob.jobs + end + + def test_student_import_ignores_unvalidated_mappings_without_the_plugin + unit = FactoryBot.create(:unit, with_students: false) + unit.create_lms_integration!(group_mapping_enabled: true) + add_auth_header_for(user: unit.main_convenor_user) + stub_link(unit, course_data_available: false) + + post "/api/units/#{unit.id}/lms/import_students", { preview_only: true } + + assert_equal 201, last_response.status, last_response.inspect + assert_equal 1, ImportLmsStudentsJob.jobs.length + ensure + ImportLmsStudentsJob.clear + end + + private + + def internal_url(unit, path) + "http://lti.test/lti/api/internal/units/#{unit.id}/#{path}" + end + + def stub_link(unit, course_data_available: false) + stub_request(:get, internal_url(unit, 'link')).to_return( + status: 200, + body: { linked: true, contextId: 'course-1', courseDataAvailable: course_data_available }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def grade_line_item_status(configured:) + return { configured: false, visibility: 'unknown' } unless configured + + { + configured: true, + visibility: 'unknown', + lineItem: { id: 'https://moodle.test/lineitem/1', label: 'OnTrack', scoreMaximum: 100 } + } + end +end diff --git a/test/api/lti_api_test.rb b/test/api/lti_api_test.rb index 607c30a3b8..4605f90b4c 100644 --- a/test/api/lti_api_test.rb +++ b/test/api/lti_api_test.rb @@ -7,6 +7,28 @@ class LtiApiTest < ActiveSupport::TestCase include TestHelpers::AuthHelper include TestHelpers::JsonHelper + setup do + Rack::Attack.enabled = false + end + + teardown do + Rack::Attack.enabled = true + end + + LTI_ROUTE_PURPOSES = { + '/api/auth/lti' => 'auth', + '/api/lti/link' => 'link', + '/api/lti/enrol' => 'enrol', + '/api/lti/enrol/bulk' => 'enrol_bulk', + '/api/lti/grades' => 'grades', + '/api/lti/app-handoff' => 'app_handoff' + }.freeze + + # A fresh token, as the LTI service signs one per request + def lti_token(purpose, exp: Time.now.to_i + 30, **claims) + JWT.encode({ purpose: purpose, exp: exp, jti: SecureRandom.uuid, **claims }, Doubtfire::Application.config.lti_api_secret, 'HS256') + end + def test_ensure_jwt_secret_is_valid # Simply validate that our ENV var is not nil secret_key = Doubtfire::Application.config.lti_api_secret @@ -19,7 +41,8 @@ def test_invalid_jwt "/api/lti/link", "/api/lti/enrol", "/api/lti/enrol/bulk", - "/api/lti/grades" + "/api/lti/grades", + "/api/lti/app-handoff" ] # Test tokens without jti or expiration @@ -69,8 +92,6 @@ def test_invalid_jwt end def test_invalid_unit - secret_key = Doubtfire::Application.config.lti_api_secret - routes_expecting_unit = [ "/api/lti/link", "/api/lti/enrol", @@ -78,39 +99,22 @@ def test_invalid_unit "/api/lti/grades" ] - payload_missing_unit_id = { - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - } - token_missing_unit_id = JWT.encode(payload_missing_unit_id, secret_key, 'HS256') - add_auth_header_for(user: FactoryBot.create(:user, :convenor)) - # Ensure we get a 401 error if unit_id is missing from the token routes_expecting_unit.each do |url| - post url, { ltik: token_missing_unit_id } + # Ensure we get a 400 error if unit_id is missing from the token + post url, { ltik: lti_token(LTI_ROUTE_PURPOSES[url]) } assert_equal 400, last_response.status, "Expected 400 from #{url} with missing unit_id #{last_response_body}" assert_equal "Invalid LTI token.", last_response_body['error'] - end - - # Ensure we get a 404 if the unit (id) does not exist - payload_invalid_unit_id = { - unit_id: 9_999_999, # Unit should not exist - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - } - token_invalid_unit_id = JWT.encode(payload_invalid_unit_id, secret_key, 'HS256') - routes_expecting_unit.each do |url| - post url, { ltik: token_invalid_unit_id } + # Ensure we get a 404 if the unit (id) does not exist + post url, { ltik: lti_token(LTI_ROUTE_PURPOSES[url], unit_id: 9_999_999) } assert_equal 404, last_response.status, "Expected 404 from #{url} with invalid unit #{last_response_body}" assert_equal "Unit does not exist.", last_response_body['error'] end end def test_lti_authentication - secret_key = Doubtfire::Application.config.lti_api_secret - username = 'user12345' member = { user_id: '3', @@ -119,16 +123,11 @@ def test_lti_authentication family_name: 'Last name 2', email: "#{username}@doubtfire.com", ext_user_username: 'student_test_lti3', + lis_person_sourcedid: 'student-source-12345', roles: ['Learner'] } - token = JWT.encode({ - member: member, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - # Retrieve the temporary auth token and username - post '/api/auth/lti', { ltik: token } + post '/api/auth/lti', { ltik: lti_token('auth', member: member) } assert_equal 201, last_response.status assert last_response_body.key?('username') assert last_response_body.key?('auth_token') @@ -152,6 +151,83 @@ def test_lti_authentication assert_equal member[:name], user.nickname assert_equal member[:email], user.email assert_equal username, user.username + assert_equal member[:lis_person_sourcedid], user.student_id + end + + def test_lti_launch_fills_only_a_missing_student_id + user = FactoryBot.create(:user, :student) + user.update!(student_id: nil) + member = launch_member(user, SecureRandom.uuid).merge(lis_person_sourcedid: 'student-source-67890') + + post '/api/auth/lti', { ltik: lti_token('auth', member: member) } + assert_equal 201, last_response.status + assert_equal 'student-source-67890', user.reload.student_id + + member[:lis_person_sourcedid] = 'student-source-changed' + post '/api/auth/lti', { ltik: lti_token('auth', member: member) } + assert_equal 201, last_response.status + assert_equal 'student-source-67890', user.reload.student_id + end + + def test_lti_launch_does_not_claim_another_users_student_id + existing_id = FactoryBot.create(:user, :student).student_id + user = FactoryBot.create(:user, :student) + user.update!(student_id: nil) + + post '/api/auth/lti', { + ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid).merge(lis_person_sourcedid: existing_id)) + } + + assert_equal 201, last_response.status + assert_nil user.reload.student_id + end + + def test_lti_launch_links_login_id_without_renaming_an_existing_user + user = FactoryBot.create(:user, :student) + username = user.username + login_id = SecureRandom.uuid + + post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, login_id)) } + assert_equal 201, last_response.status + assert_equal username, last_response_body['username'] + + user.reload + assert_equal login_id, user.login_id + assert_equal username, user.username + end + + def test_lti_launch_keeps_an_existing_login_id + login_id = SecureRandom.uuid + user = FactoryBot.create(:user, :student, login_id: login_id) + + post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid)) } + assert_equal 201, last_response.status + + assert_equal login_id, user.reload.login_id + end + + def test_lti_launch_refuses_a_different_login_id_when_enforced + user = FactoryBot.create(:user, :student, login_id: SecureRandom.uuid) + config = Doubtfire::Application.config + config.enforce_login_id_match = true + + post '/api/auth/lti', { ltik: lti_token('auth', member: launch_member(user, SecureRandom.uuid)) } + assert_equal 403, last_response.status + assert_nil last_response_body['auth_token'] + ensure + config.enforce_login_id_match = false + end + + def launch_member(user, login_id) + { + user_id: '31', + name: user.name, + given_name: user.first_name, + family_name: user.last_name, + email: user.email, + ext_user_username: login_id, + roles: ['Learner'] + } end def test_convenor_can_link_requested_unit @@ -183,39 +259,31 @@ def test_convenor_can_link_requested_unit assert_equal the_unit.id, unit.id end - payload = { - unit_id: unit.id, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - } - - secret_key = Doubtfire::Application.config.lti_api_secret - token = JWT.encode(payload, secret_key, 'HS256') - users_can = [ convenor, FactoryBot.create(:user, :admin) ] # Test to ensure convenor and admins can link the unit - users_can.each do |_user| - post '/api/lti/link', { ltik: token } + users_can.each do |user| + add_auth_header_for(user: user) + post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: user.email) } assert_equal 200, last_response.status, last_response_body end - # Test to ensure that convenors cant link a unit they can not already enrol students in - payload_invalid_unit = { - unit_id: Unit.first.id, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - } - - token_invalid_unit = JWT.encode(payload_invalid_unit, secret_key, 'HS256') - - post '/api/lti/link', { ltik: token_invalid_unit } + # Test to ensure that convenors cant link a unit they do not manage + add_auth_header_for(user: convenor) + post '/api/lti/link', { ltik: lti_token('link', unit_id: Unit.first.id, email: convenor.email) } assert_equal 403, last_response.status, last_response_body assert_equal "Not authorised to link this unit.", last_response_body['error'], last_response_body + # Tutoring the unit is not enough, even with a system convenor role + unit_tutor = FactoryBot.create(:user, :convenor) + unit.employ_staff(unit_tutor, Role.tutor) + add_auth_header_for(user: unit_tutor) + post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: unit_tutor.email) } + assert_equal 403, last_response.status, last_response_body + users_cant = [ FactoryBot.create(:user, :student), FactoryBot.create(:user, :tutor) @@ -224,12 +292,45 @@ def test_convenor_can_link_requested_unit # Ensure that students and tutors cant link the unit users_cant.each do |user| add_auth_header_for(user: user) - post '/api/lti/link', { ltik: token } + post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: user.email) } assert_equal 403, last_response.status, last_response_body end unit.destroy end + def test_lti_routes_reject_requests_not_from_the_lti_service + convenor = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: convenor) + token = lti_token('link', unit_id: 1, email: convenor.email) + + # X-Forwarded-For is what request.ip trusts from private peers, so it must not help + [{}, { 'HTTP_X_FORWARDED_FOR' => '127.0.0.1' }].each do |forwarded| + %w[/api/auth/lti /api/lti/link /api/lti/enrol /api/lti/enrol/bulk /api/lti/grades /api/lti/app-handoff].each do |url| + post url, { ltik: token }, { 'REMOTE_ADDR' => '172.20.0.9' }.merge(forwarded) + assert_equal 403, last_response.status, "#{url} #{forwarded} #{last_response_body}" + assert_equal 'Only the LTI service can make this request.', last_response_body['error'] + end + end + end + + def test_link_rejects_a_different_launch_user + convenor = FactoryBot.create(:user, :convenor) + unit = FactoryBot.create(:unit, with_students: false) + unit.employ_staff(convenor, Role.convenor) + add_auth_header_for(user: convenor) + + [FactoryBot.create(:user, :student).email, ''].each do |email| + post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: email) } + assert_equal 403, last_response.status, last_response_body + assert_equal 'This OnTrack session does not belong to the LMS user who launched OnTrack. Relaunch OnTrack from the LMS.', last_response_body['error'] + end + + post '/api/lti/link', { ltik: lti_token('link', unit_id: unit.id, email: convenor.email.upcase) } + assert_equal 200, last_response.status, last_response_body + ensure + unit&.destroy + end + def test_correct_roles_are_enrolled users = [ FactoryBot.create(:user, :student), @@ -251,42 +352,28 @@ def test_correct_roles_are_enrolled unit = FactoryBot.create(:unit, with_students: false) - payload = { - unit_id: unit.id, - member: { - user_id: '2', - name: 'Nickname', - given_name: 'First name', - family_name: 'Last name', - email: 'email@doubtfire.com', - ext_user_username: 'student_test_lti', - roles: ['Learner'] - }, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid + member = { + user_id: '2', + name: 'Nickname', + given_name: 'First name', + family_name: 'Last name', + email: 'email@doubtfire.com', + ext_user_username: 'student_test_lti', + roles: ['Learner'] } - secret_key = Doubtfire::Application.config.lti_api_secret - token = JWT.encode(payload, secret_key, 'HS256') - roles_cant_be_enrolled.each do |role| - payload[:member][:roles] = [role] - - token = JWT.encode(payload, secret_key, 'HS256') - - add_auth_header_for(user: users.sample) - post '/api/lti/enrol', { ltik: token } + user = users.sample + add_auth_header_for(user: user) + post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: user.email, member: member.merge(roles: [role])) } assert_equal 204, last_response.status end roles_can_be_enrolled.each do |role| - payload[:member][:roles] = [role] - - token = JWT.encode(payload, secret_key, 'HS256') - - add_auth_header_for(user: users.sample) # or whichever user you want as caller - post '/api/lti/enrol', { ltik: token } + user = users.sample + add_auth_header_for(user: user) + post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: user.email, member: member.merge(roles: [role])) } assert_equal 201, last_response.status id = last_response_body['id'] @@ -354,14 +441,9 @@ def test_enrol_students_bulk email: 'email5@doubtfire.com', ext_user_username: 'student_test_lti4' } - ], - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid + ] } - secret_key = Doubtfire::Application.config.lti_api_secret - token = JWT.encode(payload, secret_key, 'HS256') - convenor = FactoryBot.create(:user, :convenor) unit.employ_staff(convenor, Role.convenor) @@ -373,7 +455,7 @@ def test_enrol_students_bulk expected_ignore_count = 2 assert_equal expected_enrolled_projects_count + expected_error_count + expected_ignore_count, payload[:members].count - post '/api/lti/enrol/bulk', { ltik: token } + post '/api/lti/enrol/bulk', { ltik: lti_token('enrol_bulk', **payload) } assert_equal 201, last_response.status job = last_response_body @@ -386,13 +468,14 @@ def test_enrol_students_bulk assert_equal expected_success_count, results['success'].count, results assert_equal expected_error_count, results['errors'].count, results assert_equal expected_ignore_count, results['ignored'].count, results + assert_equal 'student_test_lti1', User.find_by(email: 'email1@doubtfire.com').login_id student = FactoryBot.create(:user, :student) unit.enrol_student(student, nil) # Ensure students cant access this route add_auth_header_for(user: student) - post '/api/lti/enrol/bulk', { ltik: token } + post '/api/lti/enrol/bulk', { ltik: lti_token('enrol_bulk', **payload) } assert_equal 403, last_response.status Sidekiq::Testing.fake! end @@ -460,34 +543,16 @@ def test_get_grades_for_members convenor ] - secret_key = Doubtfire::Application.config.lti_api_secret - - # Ensure we get an error if we dont pass student_emails fiel - token_missing_emails = JWT.encode({ - unit_id: unit.id, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - users_can.each do |user| add_auth_header_for(user: user) - post '/api/lti/grades', { ltik: token_missing_emails } + + # Ensure we get an error if we dont pass student_emails fiel + post '/api/lti/grades', { ltik: lti_token('grades', unit_id: unit.id) } assert_equal 400, last_response.status assert_equal "Student emails field does not exist.", last_response_body['error'] - end - - # Ensure we get an error if we dont pass in an array - - token_non_array = JWT.encode({ - unit_id: unit.id, - student_emails: "not-an-array", - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - users_can.each do |user| - add_auth_header_for(user: user) - post '/api/lti/grades', { ltik: token_non_array } + # Ensure we get an error if we dont pass in an array + post '/api/lti/grades', { ltik: lti_token('grades', unit_id: unit.id, student_emails: 'not-an-array') } assert_equal 400, last_response.status assert_equal "Student emails must be an array.", last_response_body['error'] end @@ -500,22 +565,18 @@ def test_get_grades_for_members student3.email, student4.email, student5.email - ], - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid + ] } - token = JWT.encode(payload, secret_key, 'HS256') - users_cant.each do |user| add_auth_header_for(user: user) - post '/api/lti/grades', { ltik: token } + post '/api/lti/grades', { ltik: lti_token('grades', **payload) } assert_equal 403, last_response.status end users_can.each do |user| add_auth_header_for(user: user) - post '/api/lti/grades', { ltik: token } + post '/api/lti/grades', { ltik: lti_token('grades', **payload) } assert_equal 201, last_response.status assert_equal unit.projects.count, last_response_body.count last_response_body.each do |email, grade| @@ -534,44 +595,15 @@ def test_valid_member_data convenor = FactoryBot.create(:user, :convenor) unit.employ_staff(convenor, Role.convenor) - secret_key = Doubtfire::Application.config.lti_api_secret - - token_missing_member = JWT.encode({ - unit_id: unit.id, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - - token_invalid_member_object = JWT.encode({ - unit_id: unit.id, - member: "not-a-hash", - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - - token_missing_member_fields = JWT.encode({ - unit_id: unit.id, - member: { - user_id: nil - }, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') - - token_member_invalid_email = JWT.encode({ - unit_id: unit.id, - member: { - user_id: '3', - name: 'Nickname 2', - given_name: 'First name 2', - family_name: 'Last name 2', - email: nil, - ext_user_username: 'student_test_lti3', - roles: ['Learner'] - }, - exp: Time.now.to_i + 30, - jti: SecureRandom.uuid - }, secret_key, 'HS256') + member_invalid_email = { + user_id: '3', + name: 'Nickname 2', + given_name: 'First name 2', + family_name: 'Last name 2', + email: nil, + ext_user_username: 'student_test_lti3', + roles: ['Learner'] + } add_auth_header_for(user: convenor) urls = [ @@ -580,21 +612,117 @@ def test_valid_member_data ] urls.each do |url| - post url, { ltik: token_missing_member } + purpose = LTI_ROUTE_PURPOSES[url] + + post url, { ltik: lti_token(purpose, unit_id: unit.id) } assert_equal 400, last_response.status assert last_response_body['error'].start_with?('Invalid LTI token.'), last_response_body['error'] - post url, { ltik: token_invalid_member_object } + post url, { ltik: lti_token(purpose, unit_id: unit.id, member: 'not-a-hash') } assert_equal 400, last_response.status assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error'] - post url, { ltik: token_missing_member_fields } + post url, { ltik: lti_token(purpose, unit_id: unit.id, member: { user_id: nil }) } assert_equal 400, last_response.status assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error'] - post url, { ltik: token_member_invalid_email } + post url, { ltik: lti_token(purpose, unit_id: unit.id, member: member_invalid_email) } assert_equal 400, last_response.status assert last_response_body['error'].start_with?('Missing required fields:'), last_response_body['error'] end end + + def test_app_handoff_issues_single_use_login_token + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + + post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email.upcase) } + assert_equal 201, last_response.status, last_response_body + assert_equal user.username, last_response_body['username'] + login_token = last_response_body['auth_token'] + + clear_auth_header + post '/api/auth', { username: user.username, auth_token: login_token } + assert_equal 201, last_response.status, last_response_body + assert_equal user.id, last_response_body['user']['id'] + + post '/api/auth', { username: user.username, auth_token: login_token } + assert_equal 419, last_response.status + end + + def test_app_handoff_requires_authentication + user = FactoryBot.create(:user, :student) + + post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email) } + assert_equal 419, last_response.status + end + + def test_app_handoff_rejects_other_lti_tokens + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + + post '/api/lti/app-handoff', { ltik: lti_token(nil, email: user.email) } + assert_equal 403, last_response.status + assert_equal 'Invalid LTI token.', last_response_body['error'] + end + + def test_app_handoff_rejects_a_different_launch_user + user = FactoryBot.create(:user, :student) + other_user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + + [other_user.email, ''].each do |email| + post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: email) } + assert_equal 403, last_response.status + assert_nil last_response_body['auth_token'] + end + end + + def test_lti_tokens_cannot_be_replayed + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + token = lti_token('app_handoff', email: user.email) + + post '/api/lti/app-handoff', { ltik: token } + assert_equal 201, last_response.status, last_response_body + + post '/api/lti/app-handoff', { ltik: token } + assert_equal 403, last_response.status + assert_equal 'Invalid LTI token.', last_response_body['error'] + end + + def test_lti_tokens_only_work_for_their_purpose + convenor = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: convenor) + member = { user_id: '9', name: 'Staff', given_name: 'Staff', family_name: 'Member', email: convenor.email, roles: ['Instructor'] } + + # e.g. an enrolment token carries a member, so it must not also work as a sign in + LTI_ROUTE_PURPOSES.each do |url, purpose| + (LTI_ROUTE_PURPOSES.values - [purpose]).each do |other_purpose| + post url, { ltik: lti_token(other_purpose, unit_id: 1, email: convenor.email, member: member) } + assert_equal 403, last_response.status, "#{other_purpose} token at #{url}: #{last_response_body}" + assert_equal 'Invalid LTI token.', last_response_body['error'] + end + end + end + + def test_lti_tokens_must_expire_within_a_minute + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + + post '/api/lti/app-handoff', { ltik: lti_token('app_handoff', email: user.email, exp: Time.now.to_i + 3600) } + assert_equal 403, last_response.status + assert_equal 'Invalid LTI token.', last_response_body['error'] + end + + def test_enrol_rejects_a_different_launch_user + unit = FactoryBot.create(:unit, with_students: false) + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + instructor = { user_id: '9', name: 'Staff', given_name: 'Staff', family_name: 'Member', email: 'staff@doubtfire.com', roles: ['Instructor'] } + + post '/api/lti/enrol', { ltik: lti_token('enrol', unit_id: unit.id, email: instructor[:email], member: instructor) } + assert_equal 403, last_response.status, last_response_body + assert_nil unit.unit_role_for(user), 'The signed-in user must not be employed from another launch' + end end diff --git a/test/api/marking_sessions_api_test.rb b/test/api/marking_sessions_api_test.rb index 7a0615f7db..f5079fd36d 100644 --- a/test/api/marking_sessions_api_test.rb +++ b/test/api/marking_sessions_api_test.rb @@ -223,6 +223,8 @@ def test_assesment_activities assert_equal project.id, last_activity.project.id assert_equal "delete-comment", last_activity.action + project.task_for_task_definition(td).add_text_comment(tutor, 'Manual tutor feedback') + # Test asessment put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' } assert_equal 200, last_response.status @@ -364,7 +366,7 @@ def test_marking_sessions_tutorial_split # unit = Unit.first project = unit.projects.first - task = project.tasks.first + task = project.task_for_task_definition(unit.task_definitions.first) while current_time <= end_time travel_to current_time do diff --git a/test/api/overseer_image_api_test.rb b/test/api/overseer_image_api_test.rb index 65025a8803..dff6571204 100644 --- a/test/api/overseer_image_api_test.rb +++ b/test/api/overseer_image_api_test.rb @@ -26,7 +26,7 @@ def test_get_all_overseer_images assert_equal 200, last_response.status assert_equal expected_data.count, last_response_body.count, last_response_body - response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date] + response_keys = %w[name tag pulled_image_text pulled_image_status] last_response_body.each do |data| expected_data = OverseerImage.find(data['id']) @@ -288,7 +288,7 @@ def test_non_admin_cannot_delete_overseer_image ] # create a overseer image to delete - overseer_image = FactoryBot.create (:overseer_image) + overseer_image = FactoryBot.create(:overseer_image) # number of overseer image before delete number_of_images = OverseerImage.count diff --git a/test/api/projects_api_test.rb b/test/api/projects_api_test.rb index 30407838e6..375e02853c 100644 --- a/test/api/projects_api_test.rb +++ b/test/api/projects_api_test.rb @@ -4,6 +4,7 @@ class ProjectsApiTest < ActiveSupport::TestCase include Rack::Test::Methods + include ActiveSupport::Testing::TimeHelpers include TestHelpers::AuthHelper include TestHelpers::JsonHelper include TestHelpers::TestFileHelper @@ -62,8 +63,8 @@ def test_projects_returns_correct_data assert_json_limit_keys_to_exactly keys, data - assert_json_matches_model(project, data, %w(campus_id target_grade campus_id)) - assert_json_matches_model(project.unit, data['unit'], %w(id code name active)) + assert_json_matches_model(project, data, %w[campus_id target_grade campus_id]) + assert_json_matches_model(project.unit, data['unit'], %w[id code name active]) assert_json_matches_model project, data, key_test end @@ -86,6 +87,103 @@ def test_get_project_response_is_correct assert_json_matches_model project, last_response_body, key_test end + def test_get_project_records_when_student_viewed_it + user = FactoryBot.create(:user, :student, enrol_in: 1) + project = user.projects.first + viewed_at = Time.zone.parse('2026-07-21 12:00:00 UTC') + add_auth_header_for(user: user) + + travel_to(viewed_at) { get "/api/projects/#{project.id}" } + + assert_equal 200, last_response.status + assert_equal viewed_at, project.reload.last_viewed_at + end + + def test_get_project_does_not_record_staff_view_as_student_view + project = FactoryBot.create(:project) + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + get "/api/projects/#{project.id}" + + assert_equal 200, last_response.status + assert_nil project.reload.last_viewed_at + end + + def test_get_project_can_record_attendance_during_enrolled_tutorial + project, tutor, tutorial = attendance_test_data + attended_at = Time.zone.parse('2026-07-20 10:30:00 UTC') + tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00') + add_auth_header_for(user: tutor) + + assert_difference 'Engagement.count', 1 do + travel_to(attended_at) { get "/api/projects/#{project.id}?record_attendance=true" } + end + + assert_equal 200, last_response.status + attendance = project.engagements.last + assert_equal tutor, attendance.user + assert_equal 'Attendance', attendance.engagement_type + assert_equal 'Attended tutorial and QR scanned.', attendance.note + assert_equal attended_at, attendance.occurred_at + end + + def test_get_project_does_not_record_attendance_outside_enrolled_tutorial + project, tutor, tutorial = attendance_test_data + tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00') + add_auth_header_for(user: tutor) + + assert_no_difference 'Engagement.count' do + travel_to(Time.zone.parse('2026-07-20 12:00:00 UTC')) do + get "/api/projects/#{project.id}?record_attendance=true" + end + end + + assert_equal 200, last_response.status + end + + def test_get_project_debounces_attendance_for_fifteen_minutes + project, tutor, tutorial = attendance_test_data + tutorial.update!(meeting_day: 'Monday', meeting_time: '10:00') + add_auth_header_for(user: tutor) + + assert_difference 'Engagement.count', 1 do + travel_to(Time.zone.parse('2026-07-20 10:30:00 UTC')) do + get "/api/projects/#{project.id}?record_attendance=true" + end + end + + assert_no_difference 'Engagement.count' do + travel_to(Time.zone.parse('2026-07-20 10:44:00 UTC')) do + get "/api/projects/#{project.id}?record_attendance=true" + end + end + + assert_difference 'Engagement.count', 1 do + travel_to(Time.zone.parse('2026-07-20 10:46:00 UTC')) do + get "/api/projects/#{project.id}?record_attendance=true" + end + end + end + + def test_get_project_rejects_attendance_trigger_without_unit_teaching_access + project, = attendance_test_data + add_auth_header_for(user: project.student) + + assert_no_difference 'Engagement.count' do + get "/api/projects/#{project.id}?record_attendance=true" + end + + assert_equal 403, last_response.status + + add_auth_header_for(user: FactoryBot.create(:user, :admin)) + assert_no_difference 'Engagement.count' do + get "/api/projects/#{project.id}?record_attendance=true" + end + + assert_equal 403, last_response.status + end + def test_projects_works_with_inactive_units user = FactoryBot.create(:user, :student, enrol_in: 2) Unit.last.update(active: false) @@ -107,8 +205,8 @@ def test_projects_works_with_inactive_units project = user.projects.find(data['id']) assert project.present?, data.inspect - assert_json_matches_model(project, data, %w(campus_id target_grade campus_id)) - assert_json_matches_model(project.unit, data['unit'], %w(code id name active)) + assert_json_matches_model(project, data, %w[campus_id target_grade campus_id]) + assert_json_matches_model(project.unit, data['unit'], %w[code id name active]) end end @@ -129,7 +227,7 @@ def test_submitted_grade_cant_change_after_submission assert_equal 200, last_response.status, last_response_body assert_equal user.projects.find(project.id).submitted_grade, 2 - keys = %w(campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary) + keys = %w[campus_id target_grade submitted_grade compile_portfolio portfolio_available uses_draft_learning_summary] assert_json_limit_keys_to_exactly keys, last_response_body assert_json_matches_model project, last_response_body, keys @@ -192,4 +290,19 @@ def test_download_portfolio ensure FileUtils.rm_f(project.portfolio_path) end + + private + + def attendance_test_data + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + tutorial = unit.tutorials.first + tutorial.campus.update!(timezone: 'UTC') + student = FactoryBot.create(:user, :student) + project = unit.enrol_student(student, tutorial.campus) + project.enrol_in(tutorial) + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + + [project, tutor, tutorial] + end end diff --git a/test/api/scorm_api_test.rb b/test/api/scorm_api_test.rb index 22287d1888..fb563ed4c9 100644 --- a/test/api/scorm_api_test.rb +++ b/test/api/scorm_api_test.rb @@ -96,10 +96,9 @@ def test_username_with_dot_fails_without_fix user.update!(username: "user.name") td = FactoryBot.create(:task_definition, - unit: unit, - tutorial_stream: unit.tutorial_streams.first, - scorm_enabled: true - ) + unit: unit, + tutorial_stream: unit.tutorial_streams.first, + scorm_enabled: true) td.add_scorm_data(test_file_path("numbas.zip"), copy: true) td.save! diff --git a/test/api/settings_test.rb b/test/api/settings_test.rb index 2a922d1c93..6eba5b78a0 100644 --- a/test/api/settings_test.rb +++ b/test/api/settings_test.rb @@ -2,47 +2,47 @@ require 'json' class SettingTest < ActiveSupport::TestCase - include Rack::Test::Methods - include TestHelpers::AuthHelper - include TestHelpers::JsonHelper - - def app - Rails.application - end - - # Get config details - def test_get_config_details - expected_product_name = Doubtfire::Application.config.institution[:product_name] - - # Perform the GET - get '/api/settings' - - # Set returned details - returned_mes = last_response_body['externalName'] - - # Check if the call succeeds - assert_equal 200, last_response.status - # Check returned details match as expected - assert_equal expected_product_name, returned_mes - end - - # Get privacy policy details - def test_get_privacy_policy_details - expected_privacy = Doubtfire::Application.config.institution[:privacy] - expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism] - - # Perform the GET - get '/api/settings/privacy' - - # Set two returned details - returned_privacy = last_response_body['privacy'] - returned_plagiarism = last_response_body['plagiarism'] - - # Check if the call succeeds - assert_equal 200, last_response.status - - # Check returned details match as expected - assert_equal expected_privacy, returned_privacy - assert_equal expected_plagiarism, returned_plagiarism - end + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + # Get config details + def test_get_config_details + expected_product_name = Doubtfire::Application.config.institution[:product_name] + + # Perform the GET + get '/api/settings' + + # Set returned details + returned_mes = last_response_body['externalName'] + + # Check if the call succeeds + assert_equal 200, last_response.status + # Check returned details match as expected + assert_equal expected_product_name, returned_mes + end + + # Get privacy policy details + def test_get_privacy_policy_details + expected_privacy = Doubtfire::Application.config.institution[:privacy] + expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism] + + # Perform the GET + get '/api/settings/privacy' + + # Set two returned details + returned_privacy = last_response_body['privacy'] + returned_plagiarism = last_response_body['plagiarism'] + + # Check if the call succeeds + assert_equal 200, last_response.status + + # Check returned details match as expected + assert_equal expected_privacy, returned_privacy + assert_equal expected_plagiarism, returned_plagiarism + end end diff --git a/test/api/staff_managed_task_api_test.rb b/test/api/staff_managed_task_api_test.rb new file mode 100644 index 0000000000..d6d2552719 --- /dev/null +++ b/test/api/staff_managed_task_api_test.rb @@ -0,0 +1,55 @@ +require 'test_helper' + +class StaffManagedTaskApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_convenor_can_enable_staff_managed_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + task_definition = unit.task_definitions.first + + assert_not task_definition.staff_managed_task + + add_auth_header_for(user: unit.main_convenor_user) + put_json "/api/units/#{unit.id}/task_definitions/#{task_definition.id}", + { task_def: { staff_managed_task: true } } + + assert_equal 200, last_response.status, last_response_body + assert task_definition.reload.staff_managed_task + assert_equal true, last_response_body['staff_managed_task'] + end + + def test_students_cannot_change_status_or_upload_to_staff_managed_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + task_definition = unit.task_definitions.first + task_definition.update!(staff_managed_task: true) + project = unit.active_projects.first + task = project.task_for_task_definition(task_definition) + task_url = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}" + original_status = task.task_status + + add_auth_header_for(user: project.student) + put task_url, { trigger: 'need_help' } + assert_equal 403, last_response.status, last_response_body + assert_equal original_status, task.reload.task_status + + post "#{task_url}/submission", {} + assert_equal 403, last_response.status, last_response_body + assert_nil task.reload.file_uploaded_at + + add_auth_header_for(user: unit.main_convenor_user) + put task_url, { trigger: 'working_on_it' } + assert_equal 200, last_response.status, last_response_body + assert_equal TaskStatus.working_on_it, task.reload.task_status + + add_auth_header_for(user: project.student) + put task_url, { trigger: 'not_started' } + assert_equal 403, last_response.status, last_response_body + assert_equal TaskStatus.working_on_it, task.reload.task_status + end +end diff --git a/test/api/staff_notes_api_test.rb b/test/api/staff_notes_api_test.rb index 300d30eb77..47a343d57a 100644 --- a/test/api/staff_notes_api_test.rb +++ b/test/api/staff_notes_api_test.rb @@ -93,6 +93,144 @@ def test_tutor_can_create_staff_notes assert_equal response_staff_note.id, second_staff_note.reply_to_id end + def test_tutor_can_post_and_view_image_attachment_but_student_cannot + unit = FactoryBot.create(:unit, code: 'COS10001') + student = FactoryBot.create(:user, :student) + project = unit.enrol_student(student, nil) + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + + add_auth_header_for(user: tutor) + post "/api/projects/#{project.id}/staff_notes", { + note: '', + attachment: upload_file('test_files/submissions/Deakin_Logo.jpeg', 'image/jpeg') + } + assert_equal 201, last_response.status, last_response.body + + response_note = last_response_body + note = StaffNote.find(response_note['id']) + assert_equal true, response_note['has_attachment'] + assert_equal 'image', response_note['attachment_type'] + assert_equal 'image/jpeg', note.content_type + assert File.file?(note.attachment_path) + attachment_path = note.attachment_path + + get "/api/projects/#{project.id}/staff_notes/#{note.id}/attachment" + assert_equal 200, last_response.status + assert_match(%r{image/}, last_response.headers['Content-Type']) + + add_auth_header_for(user: student) + get "/api/projects/#{project.id}/staff_notes/#{note.id}/attachment" + assert_equal 403, last_response.status + + add_auth_header_for(user: tutor) + delete "/api/projects/#{project.id}/staff_notes/#{note.id}" + assert_equal 200, last_response.status + assert_not File.exist?(attachment_path) + end + + def test_tutor_can_post_pdf_and_invalid_attachment_is_rejected + unit = FactoryBot.create(:unit, code: 'COS10001') + student = FactoryBot.create(:user, :student) + project = unit.enrol_student(student, nil) + tutor = FactoryBot.create(:user, :tutor) + unit.employ_staff(tutor, Role.tutor) + + add_auth_header_for(user: tutor) + post "/api/projects/#{project.id}/staff_notes", { + attachment: upload_file('test_files/submissions/valid.pdf', 'application/pdf') + } + assert_equal 201, last_response.status, last_response.body + assert_equal 'pdf', last_response_body['attachment_type'] + assert_equal true, last_response_body['has_attachment'] + assert_equal 'application/pdf', project.staff_notes.last.content_type + + post "/api/projects/#{project.id}/staff_notes", { + attachment: upload_file('test_files/csv_test_files/COS10001-Students.csv', 'text/csv') + } + assert_equal 400, last_response.status + assert_equal 1, project.staff_notes.count + + project.staff_notes.last.destroy! + end + + def test_only_convenors_and_admins_can_upload_staff_notes_csv + unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false) + admin_unit = FactoryBot.create(:unit, code: 'COS10002', with_students: false) + student = FactoryBot.create(:user, :student, login_id: 'staff-note-student') + tutor = FactoryBot.create(:user, :tutor) + convenor = FactoryBot.create(:user, :convenor) + admin = FactoryBot.create(:user, :admin) + unit.enrol_student(student, nil) + admin_unit.enrol_student(student, nil) + unit.employ_staff(tutor, Role.tutor) + unit.employ_staff(convenor, Role.convenor) + + csv_file = Tempfile.new(['staff-notes-permissions', '.csv']) + csv_file.write("username,comment\n#{student.username},Permission test note\n") + csv_file.rewind + + Sidekiq::Testing.inline! do + [[convenor, unit], [admin, admin_unit]].each do |user, upload_unit| + add_auth_header_for(user: user) + post "/api/csv/units/#{upload_unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv') + assert_equal 201, last_response.status, "#{user.role.name}: #{last_response.body}" + end + + [tutor, student].each do |user| + add_auth_header_for(user: user) + post "/api/csv/units/#{unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv') + assert_equal 403, last_response.status, user.role.name + end + ensure + Sidekiq::Testing.fake! + end + ensure + csv_file&.close + csv_file&.unlink + end + + def test_staff_notes_csv_imports_by_username_or_login_id + unit = FactoryBot.create(:unit, code: 'COS10001', with_students: false) + convenor = FactoryBot.create(:user, :convenor) + first_student = FactoryBot.create(:user, :student, login_id: 'staff-note-login-1') + second_student = FactoryBot.create(:user, :student, login_id: 'staff-note-login-2') + first_project = unit.enrol_student(first_student, nil) + second_project = unit.enrol_student(second_student, nil) + unit.employ_staff(convenor, Role.convenor) + + csv_file = Tempfile.new(['staff-notes-import', '.csv']) + csv_file.write(CSV.generate do |csv| + csv << %w[username login_id comment] + csv << [first_student.username, nil, 'Username note'] + csv << [nil, second_student.login_id, 'Login ID note'] + csv << [first_student.username, first_student.login_id, 'Duplicate note'] + csv << [first_student.username, first_student.login_id, 'Duplicate note'] + csv << [first_student.username, second_student.login_id, 'Mismatched identifiers note'] + end) + csv_file.rewind + + Sidekiq::Testing.inline! do + add_auth_header_for(user: convenor) + post "/api/csv/units/#{unit.id}/staff_notes", file: Rack::Test::UploadedFile.new(csv_file.path, 'text/csv') + + assert_equal 201, last_response.status, last_response.body + result = JSON.parse(last_response_body['result']) + assert_equal 3, result['success'].size + assert_equal 1, result['ignored'].size + assert_equal 1, result['errors'].size + assert_equal 'Staff note already exists', result['ignored'].first['message'] + assert_equal 'Username and login_id do not match the same user', result['errors'].first['message'] + assert_equal ['Username note', 'Duplicate note'], first_project.staff_notes.reload.pluck(:note) + assert_equal ['Login ID note'], second_project.staff_notes.reload.pluck(:note) + ensure + Sidekiq::Testing.fake! + end + ensure + csv_file&.close + csv_file&.unlink + end + def test_tutor_can_edit_staff_notes unit = FactoryBot.create(:unit, code: 'COS10001') diff --git a/test/api/statistics_api_test.rb b/test/api/statistics_api_test.rb new file mode 100644 index 0000000000..573f2d639e --- /dev/null +++ b/test/api/statistics_api_test.rb @@ -0,0 +1,45 @@ +require 'test_helper' + +class StatisticsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_get_statistics + now = Time.current + FactoryBot.create(:user, last_access_at: now - 2.minutes) + FactoryBot.create(:user, last_access_at: now - 10.minutes) + FactoryBot.create(:user, last_access_at: now - 45.minutes) + FactoryBot.create(:user, last_access_at: now - 12.hours) + FactoryBot.create(:user, last_access_at: now - 3.days) + + add_auth_header_for(user: FactoryBot.create(:user, :admin)) + get '/api/admin/statistics' + + assert_equal 200, last_response.status + assert_equal User.where(last_access_at: (now - 5.minutes)..).count, + last_response_body.dig('activeUsers', 'fiveMinutes') + assert_equal User.where(last_access_at: (now - 15.minutes)..).count, + last_response_body.dig('activeUsers', 'fifteenMinutes') + assert_equal User.where(last_access_at: (now - 30.minutes)..).count, + last_response_body.dig('activeUsers', 'thirtyMinutes') + assert_equal User.where(last_access_at: (now - 1.hour)..).count, + last_response_body.dig('activeUsers', 'oneHour') + assert_equal User.where(last_access_at: (now - 24.hours)..).count, + last_response_body.dig('activeUsers', 'twentyFourHours') + assert_equal User.where(last_access_at: (now - 7.days)..).count, + last_response_body.dig('activeUsers', 'sevenDays') + assert_equal User.count, last_response_body['totalUsers'] + end + + def test_get_statistics_rejects_students + add_auth_header_for(user: FactoryBot.create(:user, :student)) + get '/api/admin/statistics' + + assert_equal 403, last_response.status + end +end diff --git a/test/api/students_api_test.rb b/test/api/students_api_test.rb index 24c0cf5ff5..093f19ce04 100644 --- a/test/api/students_api_test.rb +++ b/test/api/students_api_test.rb @@ -20,16 +20,16 @@ def test_get_students_with_authorization get "/api/students/?unit_id=#{newUnit.id}" # check returning number of students - assert_equal newUnit.active_projects.all.count,last_response_body.count + assert_equal newUnit.active_projects.all.count, last_response_body.count # check the response - last_response_body.each do | data | + last_response_body.each do |data| pro = newUnit.active_projects.find(data['id']) std = pro.student - assert_equal data['student']['id'],std['id'] - assert_equal data['student']['first_name'],std['first_name'] - assert_equal data['student']['last_name'],std['last_name'] - assert_equal data['student']['email'],std['email'] + assert_equal data['student']['id'], std['id'] + assert_equal data['student']['first_name'], std['first_name'] + assert_equal data['student']['last_name'], std['last_name'] + assert_equal data['student']['email'], std['email'] end assert_equal 200, last_response.status end diff --git a/test/api/submission_history_api_test.rb b/test/api/submission_history_api_test.rb new file mode 100644 index 0000000000..0a99ac5ad5 --- /dev/null +++ b/test/api/submission_history_api_test.rb @@ -0,0 +1,62 @@ +require 'test_helper' + +class SubmissionHistoryApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_group_member_can_view_submission_history_and_overseer_assessment + setup_group_submission + history = FactoryBot.create(:submission_history, task: @tasks.first.reload) + assessment = FactoryBot.create( + :overseer_assessment, + task: @tasks.first, + submission_history: history, + submission_timestamp: history.submission_timestamp + ) + add_auth_header_for(user: @projects.second.student) + + get submission_histories_path(@projects.second) + + assert_equal 200, last_response.status, last_response.body + assert_equal [history.id], last_response_body.pluck('id') + + get overseer_assessments_path(@projects.second) + + assert_equal 200, last_response.status, last_response.body + assert_equal [assessment.id], last_response_body.pluck('id') + end + + private + + def setup_group_submission + unit = FactoryBot.create(:unit, student_count: 3, task_count: 1) + @projects = unit.active_projects.first(3) + @task_definition = unit.task_definitions.first + group_set = FactoryBot.create(:group_set, unit: unit) + @task_definition.update!(group_set: group_set) + @group = FactoryBot.create(:group, group_set: group_set, tutorial: unit.tutorials.first) + @tasks = @projects.first(2).map do |project| + @group.add_member(project) + project.task_for_task_definition(@task_definition) + end + @group.create_submission( + @tasks.first, + 'Group submission', + @tasks.map { |task| { project: task.project, pct: 50, pts: 3 } } + ) + @tasks + end + + def submission_histories_path(project) + "/api/projects/#{project.id}/task_def_id/#{@task_definition.id}/submission_histories" + end + + def overseer_assessments_path(project) + "/api/projects/#{project.id}/task_def_id/#{@task_definition.id}/submissions/timestamps" + end +end diff --git a/test/api/tasks_api_test.rb b/test/api/tasks_api_test.rb index 4d2f996016..6df05674b9 100644 --- a/test/api/tasks_api_test.rb +++ b/test/api/tasks_api_test.rb @@ -11,6 +11,52 @@ def app Rails.application end + def test_staff_can_request_feedback_using_existing_submission + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + task_definition = unit.task_definitions.first + task_definition.update!(upload_requirements: [{ 'key' => 'file0', 'name' => 'Work', 'type' => 'document' }]) + project = unit.active_projects.first + task = project.task_for_task_definition(task_definition) + submitted_at = 2.days.ago + task.submit(project.user, submitted_at) + task.update!(task_status: TaskStatus.assess_in_portfolio) + pdf_path = task.final_pdf_path + path = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}" + + add_auth_header_for(user: unit.main_convenor_user) + put path, trigger: 'ready_for_feedback', reuse_existing_submission: true + assert_equal 409, last_response.status + assert_equal TaskStatus.assess_in_portfolio, task.reload.task_status + + FileUtils.mkdir_p(File.dirname(pdf_path)) + FileUtils.cp(Rails.root.join('test_files/submissions/1.2P.pdf'), pdf_path) + + submission_count = task.task_submissions.count + original_submission_date = task.submission_date + original_pdf = File.binread(pdf_path) + add_auth_header_for(user: project.user) + put path, trigger: 'ready_for_feedback', reuse_existing_submission: true + assert_equal 403, last_response.status + assert_equal TaskStatus.assess_in_portfolio, task.reload.task_status + + add_auth_header_for(user: unit.main_convenor_user) + put path, trigger: 'ready_for_feedback', reuse_existing_submission: true + assert_equal 200, last_response.status, last_response_body + assert_equal TaskStatus.ready_for_feedback, task.reload.task_status + assert_equal original_submission_date, task.submission_date + assert_equal submission_count, task.task_submissions.count + assert_equal original_pdf, File.binread(pdf_path) + + task.update!(task_status: TaskStatus.redo) + put path, trigger: 'ready_for_feedback', reuse_existing_submission: true + assert_equal 200, last_response.status, last_response_body + assert_equal TaskStatus.ready_for_feedback, task.reload.task_status + assert_equal original_submission_date, task.submission_date + assert_equal submission_count, task.task_submissions.count + ensure + FileUtils.rm_f(pdf_path) if pdf_path + end + def test_task_get # The GET we are testing unit = FactoryBot.create(:unit, perform_submissions: true) @@ -564,96 +610,132 @@ def test_download_task_pdf end def test_cant_submit_until_prerequisites_submitted - Sidekiq::Testing.inline! do - # Create a unit and two task definitions - unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) - td1 = unit.task_definitions.first - td2 = unit.task_definitions.second - project = unit.active_projects.first + # Create a unit and two task definitions + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td1 = unit.task_definitions.first + td2 = unit.task_definitions.second + project = unit.active_projects.first - task = project.task_for_task_definition(td2) + task = project.task_for_task_definition(td2) - td1.update( - upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], - target_grade: 0, # Pass - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now + 1.week - ) + td1.update( + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + target_grade: 0, # Pass + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week + ) - td2.update( - upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], - target_grade: 3, # HD - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now + 1.week - ) + td2.update( + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + target_grade: 3, # HD + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week + ) - # Create a prerequisite on the second taskDef that adds the first taskDef as a prereq - prereq = TaskPrerequisite.create!( - task_definition: td2, # Before you can submit td2... - prerequisite: td1, # You need to submit td1 - task_status_id: TaskStatus.ready_for_feedback.id - ) + # Create a prerequisite on the second taskDef that adds the first taskDef as a prereq + prereq = TaskPrerequisite.create!( + task_definition: td2, # Before you can submit td2... + prerequisite: td1, # You need to submit td1 + task_status_id: TaskStatus.ready_for_feedback.id + ) - assert prereq.valid? + assert prereq.valid? - # Add username and auth_token to Header - add_auth_header_for(user: project.user) + # Add username and auth_token to Header + add_auth_header_for(user: project.user) - data_to_post = { - trigger: 'ready_for_feedback' - } + data_to_post = { + trigger: 'ready_for_feedback' + } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) + data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - # Attempt to make a submission that has an unsubmitted prerequisite - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal 409, last_response.status, last_response_body - task = project.task_for_task_definition(td2) - # Ensure the submission was denied - assert_equal TaskStatus.not_started, task.task_status - assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been submitted" + # Attempt to make a submission that has an unsubmitted prerequisite + post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post + assert_equal 409, last_response.status, last_response_body + task = project.task_for_task_definition(td2) + # Ensure the submission was denied + assert_equal TaskStatus.not_started, task.task_status + assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been submitted" - prereq.update(task_status_id: TaskStatus.discuss.id) - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal 409, last_response.status, last_response_body - task.reload - # Ensure the submission was denied - assert_equal TaskStatus.not_started, task.task_status - assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been discussed" + prereq.update(task_status_id: TaskStatus.discuss.id) + post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post + assert_equal 409, last_response.status, last_response_body + task.reload + # Ensure the submission was denied + assert_equal TaskStatus.not_started, task.task_status + assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been discussed" - prereq.update(task_status_id: TaskStatus.demonstrate.id) - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal 409, last_response.status, last_response_body - task.reload - # Ensure the submission was denied - assert_equal TaskStatus.not_started, task.task_status - assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been demonstrated" + prereq.update(task_status_id: TaskStatus.demonstrate.id) + post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post + assert_equal 409, last_response.status, last_response_body + task.reload + # Ensure the submission was denied + assert_equal TaskStatus.not_started, task.task_status + assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been demonstrated" - prereq.update(task_status_id: TaskStatus.complete.id) - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal 409, last_response.status, last_response_body - task.reload - # Ensure the submission was denied - assert_equal TaskStatus.not_started, task.task_status - assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been completed" + prereq.update(task_status_id: TaskStatus.complete.id) + post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post + assert_equal 409, last_response.status, last_response_body + task.reload + # Ensure the submission was denied + assert_equal TaskStatus.not_started, task.task_status + assert_equal last_response_body['error'], "Cannot submit this task until prerequisite '#{td1.abbreviation}' has been completed" - prereq.update(task_status_id: TaskStatus.ready_for_feedback.id) + prereq.update(task_status_id: TaskStatus.ready_for_feedback.id) - # Make a submission to the prerequsite task - post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body - task1 = project.task_for_task_definition(td1) - assert_equal TaskStatus.ready_for_feedback, task1.task_status + # Use a direct status change here to avoid waiting for submission processing. + task1 = project.task_for_task_definition(td1) + task1.submit(project.user) + assert_equal TaskStatus.ready_for_feedback, task1.task_status - # Re-attempt to make a submission (Prerequisite status is ready for feedback, expecting complete) - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body - task.reload - assert_equal TaskStatus.ready_for_feedback, task.task_status + # Re-attempt to make a submission (Prerequisite status is ready for feedback, expecting complete) + post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post + assert_equal 201, last_response.status, last_response_body + task.reload + assert_equal TaskStatus.ready_for_feedback, task.task_status - prereq.destroy - unit.destroy - end + prereq.destroy + unit.destroy + end + + def test_cant_set_upload_free_task_ready_for_feedback_until_prerequisites_met + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + prerequisite_definition, task_definition = unit.task_definitions.first(2) + prerequisite_definition.update!(target_grade: 0) + task_definition.update!(target_grade: 3, upload_requirements: []) + project = unit.active_projects.first + task = project.task_for_task_definition(task_definition) + prerequisite_task = project.task_for_task_definition(prerequisite_definition) + prerequisite = TaskPrerequisite.create!( + task_definition: task_definition, + prerequisite: prerequisite_definition, + task_status_id: TaskStatus.ready_for_feedback.id + ) + + add_auth_header_for(user: project.user) + path = "/api/projects/#{project.id}/task_def_id/#{task_definition.id}" + + put path, { trigger: 'ready_for_feedback' } + assert_equal 409, last_response.status + assert_equal "Cannot submit this task until prerequisite '#{prerequisite_definition.abbreviation}' has been submitted", + last_response_body['error'] + assert_equal TaskStatus.not_started, task.reload.task_status + + prerequisite.update!(task_status_id: TaskStatus.complete.id) + prerequisite_task.update!(task_status: TaskStatus.ready_for_feedback) + + put path, { trigger: 'ready_for_feedback' } + assert_equal 409, last_response.status + assert_equal "Cannot submit this task until prerequisite '#{prerequisite_definition.abbreviation}' has been completed", + last_response_body['error'] + assert_equal TaskStatus.not_started, task.reload.task_status + + prerequisite_task.update!(task_status: TaskStatus.complete) + + put path, { trigger: 'ready_for_feedback' } + assert_equal 200, last_response.status + assert_equal TaskStatus.ready_for_feedback, task.reload.task_status end def test_prerequisites_task_status @@ -678,13 +760,6 @@ def test_prerequisites_task_status project = unit.active_projects.first - # Add username and auth_token to Header - add_auth_header_for(user: project.user) - - data_to_post = { - trigger: 'ready_for_feedback' - } - # Create a prerequisite on the second taskDef that adds the first taskDef as a prereq prereq = TaskPrerequisite.create!( task_definition: td2, # Before you can submit td2... @@ -697,78 +772,53 @@ def test_prerequisites_task_status tests = [ { prerequisite_status: TaskStatus.ready_for_feedback, - required_status: TaskStatus.ready_for_feedback, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.ready_for_feedback }, { prerequisite_status: TaskStatus.discuss, - required_status: TaskStatus.discuss, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.discuss }, { prerequisite_status: TaskStatus.demonstrate, - required_status: TaskStatus.demonstrate, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.demonstrate }, { prerequisite_status: TaskStatus.discuss, - required_status: TaskStatus.demonstrate, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.demonstrate }, { prerequisite_status: TaskStatus.demonstrate, - required_status: TaskStatus.discuss, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.discuss }, { prerequisite_status: TaskStatus.complete, - required_status: TaskStatus.complete, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.complete }, { prerequisite_status: TaskStatus.complete, - required_status: TaskStatus.ready_for_feedback, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.ready_for_feedback }, { prerequisite_status: TaskStatus.discuss, - required_status: TaskStatus.ready_for_feedback, - expected_status: 201, - expected_error: nil + required_status: TaskStatus.ready_for_feedback } ] - Sidekiq::Testing.inline! do - prereq_task = project.task_for_task_definition(td1) - task = project.task_for_task_definition(td2) - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - - tests.each do |test| - prereq_task.update(task_status_id: test[:prerequisite_status].id) - task.update(task_status_id: TaskStatus.not_started.id) - - post "/api/projects/#{project.id}/task_def_id/#{td2.id}/submission", data_to_post - assert_equal test[:expected_status], last_response.status, last_response_body - task.reload - if test[:expected_status] == 201 - # Ensure submission was accepted - assert_equal TaskStatus.ready_for_feedback, task.task_status - else - # Ensure submission was denied - assert_equal TaskStatus.not_started, task.task_status - end - end + prereq_task = project.task_for_task_definition(td1) + task = project.task_for_task_definition(td2) - prereq.destroy - unit.destroy + tests.each do |test| + prereq.update!(task_status_id: test[:required_status].id) + prereq_task.update!(task_status_id: test[:prerequisite_status].id) + task.update!(task_status_id: TaskStatus.not_started.id, submission_date: nil) + + task.submit(project.user) + task.reload + assert_equal TaskStatus.ready_for_feedback, task.task_status end + + prereq.destroy + unit.destroy end def test_check_in_comment @@ -859,88 +909,141 @@ def test_requires_discussion_blocks_complete_until_discussed_comment_added assert_instance_of TaskDiscussedComment, discussed_comment assert_equal 'discussed_in_class', discussed_comment.content_type + task.add_text_comment(tutor, 'Manual tutor feedback') + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete' } assert_equal 200, last_response.status task.reload assert_equal TaskStatus.complete, task.task_status end + def test_complete_with_discussed_only_keeps_comment_when_transition_succeeds + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Discussion required task', + description: 'Task that requires discussion before complete', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'DiscussReqAtomicTask', + restrict_status_updates: false, + requires_discussion: true, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + + project = unit.active_projects.first + task = project.task_for_task_definition(td) + tutor = unit.tutors.first + + add_auth_header_for(user: tutor) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete', discussed: true } + assert_equal 403, last_response.status + task.reload + assert_not_equal TaskStatus.complete, task.task_status + assert_not task.has_discussed_in_class_comment? + + task.add_text_comment(tutor, 'Manual tutor feedback') + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { trigger: 'complete', discussed: true } + assert_equal 200, last_response.status + task.reload + assert_equal TaskStatus.complete, task.task_status + assert task.has_discussed_in_class_comment? + end + def test_require_comment_for_feedback_submission_assess_in_portfolio - Sidekiq::Testing.inline! do - unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) - td1 = unit.task_definitions.first - project = unit.active_projects.first + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + td1 = unit.task_definitions.first + project = unit.active_projects.first - task = project.task_for_task_definition(td1) + td1.update!( + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], + target_grade: 0, # Pass + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + assess_in_portfolio_only: false + ) - td1.update( - upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], - target_grade: 0, # Pass - start_date: Time.zone.now - 2.weeks, - target_date: Time.zone.now + 1.week, - assess_in_portfolio_only: false - ) + task = project.task_for_task_definition(td1) - add_auth_header_for(user: project.user) + add_auth_header_for(user: project.user) - # Make a submission where a comment isn't required - post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", - with_file('test_files/submissions/program.cs', 'application/json', { - trigger: 'ready_for_feedback' - }) - assert_equal 201, last_response.status, last_response_body - task.reload - assert_equal TaskStatus.ready_for_feedback, task.task_status + # Use a direct submit here so the test can focus on the comment requirement. + task.submit(project.user) + task.reload + assert_equal TaskStatus.ready_for_feedback, task.task_status - task.update(task_status: TaskStatus.not_started) + task.update!(task_status: TaskStatus.not_started, submission_date: nil) - td1.update(assess_in_portfolio_only: true) + td1.update(assess_in_portfolio_only: true) - # Make a submission where a comment is required - post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", - with_file('test_files/submissions/program.cs', 'application/json', { - trigger: 'ready_for_feedback' - }) - assert_equal 422, last_response.status, last_response_body - task.reload - assert_equal TaskStatus.not_started, task.task_status + # Make a submission where a comment is required + post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { + trigger: 'ready_for_feedback' + }) + assert_equal 422, last_response.status, last_response_body + task.reload + assert_equal TaskStatus.not_started, task.task_status - comment = 'I would like feedback with my code..' + comment = 'I would like feedback with my code..' - # Make a submission with comment - post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", - with_file('test_files/submissions/program.cs', 'application/json', { - trigger: 'ready_for_feedback', - comment: comment - }) - assert_equal 201, last_response.status, last_response_body - task.reload - assert_equal TaskStatus.ready_for_feedback, task.task_status + # Make a submission with comment + post "/api/projects/#{project.id}/task_def_id/#{td1.id}/submission", + with_file('test_files/submissions/program.cs', 'application/json', { + trigger: 'ready_for_feedback', + comment: comment + }) + assert_equal 201, last_response.status, last_response_body + task.reload + assert_equal TaskStatus.ready_for_feedback, task.task_status - status_comment = task.comments.last - text_comment = task.comments.second_to_last + status_comment = task.comments.last + text_comment = task.comments.second_to_last - assert_not status_comment.nil? - assert_not text_comment.nil? + assert_not status_comment.nil? + assert_not text_comment.nil? - assert_equal TaskStatus.ready_for_feedback.name, status_comment.comment - assert_equal comment, text_comment.comment - end + assert_equal TaskStatus.ready_for_feedback.name, status_comment.comment + assert_equal comment, text_comment.comment end def test_resubmission_doesnt_change_submission_date Sidekiq::Testing.inline! do - unit = FactoryBot.create(:unit, task_count: 2, student_count: 0) + unit = FactoryBot.create( + :unit, + with_students: false, + student_count: 0, + task_count: 0, + tutorials: 0, + stream_count: 0, + staff_count: 0, + campus_count: 0, + outcome_count: 0 + ) tutor = FactoryBot.create(:user, :tutor) unit_role = unit.employ_staff(tutor, Role.tutor) tutorial_stream = FactoryBot.create(:tutorial_stream, unit: unit) tutorial = FactoryBot.create(:tutorial, unit: unit, tutorial_stream: tutorial_stream, campus: nil, unit_role: unit_role) - - td = unit.task_definitions.first - - td.update!(due_date: Time.zone.today + 1.day, tutorial_stream: tutorial_stream) - assert_not td.nil? + td = FactoryBot.create( + :task_definition, + unit: unit, + tutorial_stream: tutorial_stream, + target_grade: 0, + outcome_count: 0 + ) + td.update!( + target_date: Time.zone.today + 1.week, + due_date: Time.zone.today + 1.week + ) student1 = FactoryBot.create(:user, :student) student2 = FactoryBot.create(:user, :student) @@ -951,111 +1054,82 @@ def test_resubmission_doesnt_change_submission_date project1.enrol_in(tutorial) project2.enrol_in(tutorial) + task1 = project1.task_for_task_definition(td) + task2 = project2.task_for_task_definition(td) + tasks = unit.tasks_for_task_inbox(tutor, false) assert tasks.to_a.empty? # Submit a task before the due date (student 1) - add_auth_header_for(user: student1) - data_to_post = { - trigger: 'ready_for_feedback' - } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body + task1.submit(student1) travel 10.minutes # Submit a task before the due date (student 2) - add_auth_header_for(user: student2) - data_to_post = { - trigger: 'ready_for_feedback' - } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - post "/api/projects/#{project2.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body + task2.submit(student2) tasks = unit.tasks_for_task_inbox(tutor, false) + assert_equal TaskStatus.ready_for_feedback, task1.task_status + assert_equal TaskStatus.ready_for_feedback, task2.task_status + assert_equal 2, tasks.to_a.count assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task" assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task" - task1 = project1.task_for_task_definition(td) - task2 = project2.task_for_task_definition(td) - - assert_equal TaskStatus.ready_for_feedback, task1.task_status - assert_equal TaskStatus.ready_for_feedback, task2.task_status - assert task2.submission_date > task1.submission_date + original_submission_date = task1.submission_date # Submit the task again, ensure the submission_date hasn't changed (student1) travel 10.minutes - # Submit a task before the due date (student 1) - add_auth_header_for(user: student1) - data_to_post = { - trigger: 'ready_for_feedback' - } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body + task1.submit(student1) tasks = unit.tasks_for_task_inbox(tutor, false) assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task" assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task" - task1 = project1.task_for_task_definition(td) - task2 = project2.task_for_task_definition(td) + task1.reload assert task2.submission_date > task1.submission_date - assert TaskStatus.ready_for_feedback, task1.task_status + assert_equal original_submission_date, task1.submission_date + assert_equal TaskStatus.ready_for_feedback, task1.task_status - # Submit the task again after the duedate, ensure the submission_date hasn't changed (student1) + # Submit the task again later, ensure the submission_date hasn't changed (student1) travel 2.days - # Submit a task before the due date (student 1) - add_auth_header_for(user: student1) - data_to_post = { - trigger: 'ready_for_feedback' - } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body + task1.submit(student1) tasks = unit.tasks_for_task_inbox(tutor, false) assert_equal project1.id, tasks.first.project.id, "First task in inbox should be project1's task" assert_equal project2.id, tasks.second.project.id, "Second task in inbox should be project2's task" - task1 = project1.task_for_task_definition(td) - task2 = project2.task_for_task_definition(td) + task1.reload assert task2.submission_date > task1.submission_date - assert TaskStatus.ready_for_feedback, task1.task_status + assert_equal original_submission_date, task1.submission_date + assert_equal TaskStatus.ready_for_feedback, task1.task_status task1.update(task_status_id: TaskStatus.fix_and_resubmit.id) # Submit the task again, now expecting submission date to update travel 10.minutes - # Submit a task before the due date (student 1) - add_auth_header_for(user: student1) - data_to_post = { - trigger: 'ready_for_feedback' - } - data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) - post "/api/projects/#{project1.id}/task_def_id/#{td.id}/submission", data_to_post - assert_equal 201, last_response.status, last_response_body + task1.submit(student1) + task1.reload + assert_equal TaskStatus.ready_for_feedback, task1.task_status tasks = unit.tasks_for_task_inbox(tutor, false) + assert_operator tasks.to_a.count, :>=, 2, "Expected 2 or more tasks in tutors inbox" assert_equal project2.id, tasks.first.project.id, "First task in inbox should be project1's task" assert_equal project1.id, tasks.second.project.id, "Second task in inbox should be project2's task" - task1 = project1.task_for_task_definition(td) - task2 = project2.task_for_task_definition(td) + task1.reload assert task1.submission_date > task2.submission_date + assert task1.submission_date > original_submission_date end end @@ -1110,14 +1184,93 @@ def test_task_target_date_permissions task.reload assert_equal new_start_date, task.target_start_date assert_equal new_end_date, task.target_due_date + assert task.comments.last.attention_none? put "/api/projects/#{project1.id}/reset_target_dates" assert_equal 200, last_response.status task.reload assert_nil task.target_start_date assert_nil task.target_due_date + assert task.comments.last.attention_none? unit.update!(allow_flexible_dates: false) end + def test_claim_overflow_task_creates_analytics_log + travel_to Time.zone.parse('2026-04-15 10:30:00 UTC') do + unit = create( + :unit, + student_count: 1, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 1, + stream_count: 0, + tutorials: 1 + ) + original_tutor = create(:user, :tutor) + original_tutor_role = unit.employ_staff(original_tutor, Role.tutor) + unit.tutorials.first.update!(unit_role: original_tutor_role) + + claiming_tutor = create(:user, :tutor) + claiming_role = unit.employ_staff(claiming_tutor, Role.tutor) + claiming_role.update!(can_mark_overflow_tasks: true) + + project = unit.active_projects.first + task_definition = unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + task.update!(submission_date: 12.days.ago) + + add_auth_header_for(user: claiming_tutor) + + assert_difference('OverflowTaskClaim.count', 1) do + assert_difference('OverflowTaskClaimLog.count', 1) do + post "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/claim_overflow_task" + end + end + + assert_equal 201, last_response.status + + claim_log = OverflowTaskClaimLog.order(:id).last + assert_equal unit, claim_log.unit + assert_equal task, claim_log.task + assert_equal claiming_role, claim_log.claimed_by_unit_role + assert_equal claiming_tutor, claim_log.claimed_by_user + assert_equal original_tutor, claim_log.original_tutor_user + assert_equal project.student, claim_log.student_user + assert_equal 12, claim_log.days_awaiting_feedback + assert_equal Time.zone.now, claim_log.claimed_at + end + end + + def test_student_cannot_grade_own_task + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Graded task', + description: 'Task graded by staff', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'GradedTask', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: true, + max_quality_pts: 0 + }) + + project = unit.active_projects.first + top_grade = unit.grade_definitions.map { |definition| definition['value'] }.max + + add_auth_header_for(user: project.student) + + put "/api/projects/#{project.id}/task_def_id/#{td.id}", { grade: top_grade } + + assert_equal 403, last_response.status, last_response_body + assert_nil Task.find_by(project: project, task_definition: td)&.grade + end + end diff --git a/test/api/teaching_period_api_test.rb b/test/api/teaching_period_api_test.rb index 14d9009292..2c73b10632 100644 --- a/test/api/teaching_period_api_test.rb +++ b/test/api/teaching_period_api_test.rb @@ -19,10 +19,10 @@ def test_get_teaching_periods assert_equal expected_data.count, last_response_body.count # What are the keys we expect in the data that match the model - so we can check these - response_keys = %w(start_date year period end_date active_until) + response_keys = %w[start_date year period end_date active_until] # Loop through all of the responses - last_response_body.each do | data | + last_response_body.each do |data| # Find the matching teaching period, by id from response tp = TeachingPeriod.find(data['id']) # Match json with object @@ -32,7 +32,7 @@ def test_get_teaching_periods # Get a teaching period's details def test_get_a_teaching_periods_details - #create a dummy variable + # create a dummy variable expected_tp = FactoryBot.create(:teaching_period) # perform the GET @@ -49,37 +49,78 @@ def test_get_a_teaching_periods_details assert_equal actual_tp['end_date'].to_date, expected_tp.end_date.to_date end + def test_get_teaching_period_details_with_units_without_user + tp = FactoryBot.create(:teaching_period) + unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) + + get "/api/teaching_periods/#{tp.id}" + + assert_equal 200, last_response.status + assert_equal([unit.id], last_response_body['units'].map { |u| u['id'] }) + assert_nil last_response_body['units'].first['my_role'] + end + + def test_get_teaching_period_details_ignores_username_without_token + tp = FactoryBot.create(:teaching_period) + unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) + + header 'username', unit.main_convenor_user.username + get "/api/teaching_periods/#{tp.id}" + + assert_equal 200, last_response.status + assert_nil last_response_body['units'].first['my_role'] + end + + def test_get_teaching_period_details_includes_role_when_authenticated + tp = FactoryBot.create(:teaching_period) + unit = FactoryBot.create(:unit, teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) + + add_auth_header_for(user: unit.main_convenor_user) + get "/api/teaching_periods/#{tp.id}" + + assert_equal 200, last_response.status + assert_equal 'Convenor', last_response_body['units'].first['my_role'] + end + # PUT tests # Update break from teaching period def test_update_break_from_teaching_period tp = TeachingPeriod.first to_update = tp.breaks.first + campus = FactoryBot.create(:campus) + new_start_date = to_update.start_date + 1.day # Add username and auth_token to Header - add_auth_header_for(user: User.first) + add_auth_header_for(user: FactoryBot.create(:user, :admin)) # The api call we are testing - put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_weeks: 5 } + put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { + start_date: new_start_date, + number_of_days: 35, + campus_ids: [campus.id] + } to_update.reload - assert_equal 5, to_update.number_of_weeks + assert_equal 35, to_update.number_of_days + assert_equal [campus.id], to_update.campus_ids + assert_equal new_start_date.to_date, to_update.start_date.to_date end def test_update_break_must_be_from_teaching_period tp = TeachingPeriod.first to_update = TeachingPeriod.last.breaks.first - num_weeks = to_update.number_of_weeks + num_days = to_update.number_of_days # Add username and auth_token to Header add_auth_header_for(user: User.first) # The api call we are testing - put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_weeks: num_weeks + 1 } + put_json "/api/teaching_periods/#{tp.id}/breaks/#{to_update.id}", { number_of_days: num_days + 7 } assert_equal 404, last_response.status to_update.reload - assert_equal num_weeks, to_update.number_of_weeks + assert_equal num_days, to_update.number_of_days end # Replace a teaching period @@ -88,8 +129,10 @@ def test_put_teaching_period tp = FactoryBot.create(:teaching_period) # data to replace + replacement = FactoryBot.build(:teaching_period) + replacement.last_teaching_date = replacement.end_date.to_date data_to_put = { - teaching_period: FactoryBot.build(:teaching_period) + teaching_period: replacement } # Add username and auth_token to Header @@ -99,20 +142,22 @@ def test_put_teaching_period put_json "/api/teaching_periods/#{tp.id}", data_to_put # check if the POST get through - assert_equal 200, last_response.status + assert_equal 200, last_response.status, last_response_body # check if the details posted match as expected - response_keys = %w(period year start_date end_date active_until) + response_keys = %w[period year start_date end_date active_until] tp_updated = tp.reload assert_json_matches_model(tp_updated, last_response_body, response_keys) + assert_equal tp_updated.last_teaching_date.iso8601, last_response_body['last_teaching_date'] # check if the details in the replaced teaching period match as data set to replace assert_equal data_to_put[:teaching_period]['period'], tp_updated.period assert_equal data_to_put[:teaching_period]['active_until'].to_date, tp_updated.active_until.to_date assert_equal data_to_put[:teaching_period]['start_date'].to_date, tp_updated.start_date.to_date assert_equal data_to_put[:teaching_period]['end_date'].to_date, tp_updated.end_date.to_date + assert_equal data_to_put[:teaching_period]['last_teaching_date'], tp_updated.last_teaching_date end - + # Put teaching period using unauthorised account def test_student_cannot_put_teaching_period # A user with student role which does not have permission to put a teaching period @@ -124,7 +169,7 @@ def test_student_cannot_put_teaching_period # Number of teaching period before put new teaching period number_of_tp = TeachingPeriod.count - # Create a dummy teaching period + # Create a dummy teaching period data_to_put = { teaching_period: FactoryBot.build(:teaching_period) } @@ -163,7 +208,7 @@ def test_post_teaching_period assert_equal 201, last_response.status # check if the details posted match as expected - response_keys = %w(period year start_date end_date active_until) + response_keys = %w[period year start_date end_date active_until] teaching_period = TeachingPeriod.find(last_response_body['id']) assert_json_matches_model(teaching_period, last_response_body, response_keys) @@ -181,7 +226,7 @@ def test_post_teaching_period # Delete a teaching period def test_delete_teaching_period # create a dummy teaching period - teaching_period = FactoryBot.create (:teaching_period) + teaching_period = FactoryBot.create(:teaching_period) id_of_tp = teaching_period.id # number of teaching periods before delete @@ -197,10 +242,10 @@ def test_delete_teaching_period assert_equal 200, last_response.status # Check if the number of teaching period reduces by 1 - assert_equal TeachingPeriod.count, number_of_tp -1 + assert_equal TeachingPeriod.count, number_of_tp - 1 # Check that you can't find the deleted id - refute TeachingPeriod.exists?(id_of_tp) + assert_not TeachingPeriod.exists?(id_of_tp) end # Delete a teaching period using unauthorised account @@ -209,7 +254,7 @@ def test_student_cannot_delete_teaching_period user = FactoryBot.build(:user, :student) # Teaching period to delete - teaching_period = FactoryBot.create (:teaching_period) + teaching_period = FactoryBot.create(:teaching_period) id_of_tp = teaching_period.id # Number of teaching periods before deletion diff --git a/test/api/tii/tii_action_api_test.rb b/test/api/tii/tii_action_api_test.rb index 8a34265e96..9226eba46d 100644 --- a/test/api/tii/tii_action_api_test.rb +++ b/test/api/tii/tii_action_api_test.rb @@ -159,7 +159,7 @@ def test_trigger_action_via_put TiiActionJob.clear add_auth_header_for(user: User.where(role: Role.admin).first) action = TiiActionUploadSubmission.last - put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' } + put_json "/api/tii_actions/#{action.id}", { action: 'retry' } assert_equal 200, last_response.status assert_equal 1, TiiActionJob.jobs.size @@ -174,13 +174,13 @@ def test_trigger_action_via_put_multiple # Ensure retry is false action.update(retry: false) - refute action.retry + assert_not action.retry - put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' } + put_json "/api/tii_actions/#{action.id}", { action: 'retry' } assert_equal 200, last_response.status # Ensure 2nd put does not cause action to be scheduled twice - put_json "/api/tii_actions/#{action.id}", { 'action': 'retry' } + put_json "/api/tii_actions/#{action.id}", { action: 'retry' } assert_equal 403, last_response.status assert_equal 1, TiiActionJob.jobs.size diff --git a/test/api/tii/tii_group_attachment_api_test.rb b/test/api/tii/tii_group_attachment_api_test.rb index b716db1a7e..41c18671c6 100644 --- a/test/api/tii/tii_group_attachment_api_test.rb +++ b/test/api/tii/tii_group_attachment_api_test.rb @@ -15,8 +15,7 @@ def app setup do @task_def = FactoryBot.create(:task_definition, - unit: FactoryBot.create(:unit, with_students: false, task_count: 0) - ) + unit: FactoryBot.create(:unit, with_students: false, task_count: 0)) @unit = @task_def.unit @@ -34,6 +33,26 @@ def app ) end + def test_group_attachments_require_authentication + clear_auth_header + path = "/api/units/#{@unit.id}/task_definitions/#{@task_def.id}/tii_group_attachments" + attachment = @task_def.tii_group_attachments.first + + get path + assert_equal 419, last_response.status, last_response_body + + put_json "#{path}/#{attachment.id}", { action: 'upload' } + assert_equal 419, last_response.status, last_response_body + + delete "#{path}/#{attachment.id}" + assert_equal 419, last_response.status, last_response_body + assert TiiGroupAttachment.exists?(attachment.id) + + add_auth_header_for(user: @unit.main_convenor_user, auth_token: 'invalid') + get path + assert_equal 419, last_response.status, last_response_body + end + def test_only_main_convenor_can_access_group_attachments_for_task_definition tutor = FactoryBot.create(:user, :tutor) @unit.employ_staff tutor, Role.tutor @@ -80,7 +99,7 @@ def test_can_get_group_attachments_for_task_definition get "/api/units/#{@unit.id}/task_definitions/#{@task_def.id}/tii_group_attachments" assert_equal 200, last_response.status, last_response_body assert_equal 2, last_response_body.length - assert_json_limit_keys_to_exactly(%w(id group_attachment_id filename status), last_response_body.first) + assert_json_limit_keys_to_exactly(%w[id group_attachment_id filename status], last_response_body.first) end def test_can_trigger_upload_group_attachments_for_task_definition diff --git a/test/api/tii/tii_hook_test.rb b/test/api/tii/tii_hook_test.rb index fbba5991ff..9092472e00 100644 --- a/test/api/tii/tii_hook_test.rb +++ b/test/api/tii/tii_hook_test.rb @@ -31,9 +31,9 @@ def test_submission_webhook ) # destroy will trigger delete of submission - delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) data = TCAClient::SubmissionCompleteWebhookRequest.new( "id" => "e884f478-9757-41c7-80da-37b94ebb2838", @@ -45,10 +45,10 @@ def test_submission_webhook "word_count" => 145, "character_count" => 760, "created_time" => "2017-08-30T22:13:41Z", - "capabilities" => [ - "INDEX", - "VIEWER", - "SIMILARITY" + "capabilities" => %w[ + INDEX + VIEWER + SIMILARITY ], "metadata" => { "custom" => "{\"Type\":\"Final Paper\"}" @@ -65,11 +65,11 @@ def test_submission_webhook header "X-Turnitin-EventType", "SUBMISSION_COMPLETE" # Callback will trigger similarity request - stub_request(:put, "https://#{ENV['TCA_HOST']}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity"). - with(tii_headers). - to_return( - { status: 200, body: TCAClient::SimilarityMetadata.new(status: 'PROCESSING').to_hash.to_json, headers: {}}, - ) + stub_request(:put, "https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity") + .with(tii_headers) + .to_return( + { status: 200, body: TCAClient::SimilarityMetadata.new(status: 'PROCESSING').to_hash.to_json, headers: {} } + ) post_json '/api/tii_hook', data @@ -105,9 +105,9 @@ def test_similarity_webhook ) # destroy will trigger delete of submission - delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) data = TCAClient::SimilarityCompleteWebhookRequest.new( "submission_id" => "e884f478-9757-41c7-80da-37b94ebb2838", @@ -178,9 +178,9 @@ def test_similarity_webhook ) # destroy will trigger delete of submission - delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) data = TCAClient::SimilarityCompleteWebhookRequest.new( "submission_id" => "e884f478-9757-41c7-80da-37b94ebb2838", @@ -264,9 +264,9 @@ def test_pdf_status_webhook ) # destroy will trigger delete of submission - delete_request = stub_request(:delete, /https:\/\/#{ENV['TCA_HOST']}\/api\/v1\/submissions\/e884f478-9757-41c7-80da-37b94ebb2838/). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + delete_request = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) data = TCAClient::PDFStatusWebhookRequest.new( "status" => "SUCCESS", @@ -277,9 +277,9 @@ def test_pdf_status_webhook } ) - download_pdf_request = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity/pdf/123312"). - with(tii_headers). - to_return(status: 200, body: File.read(test_file_path('submissions/1.2P.pdf')), headers: {}) + download_pdf_request = stub_request(:get, "https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/submissions/e884f478-9757-41c7-80da-37b94ebb2838/similarity/pdf/123312") + .with(tii_headers) + .to_return(status: 200, body: File.read(test_file_path('submissions/1.2P.pdf')), headers: {}) # puts data.to_json @@ -299,7 +299,7 @@ def test_pdf_status_webhook assert File.exist?(subm.similarity_pdf_path) task.unit.destroy! - refute File.exist?(subm.similarity_pdf_path) + assert_not File.exist?(subm.similarity_pdf_path) end # Test the group_attachment webhook @@ -318,15 +318,15 @@ def test_group_attachment_webhook grp_attachment.update(group_attachment_id: "16c45fbe-25f5-458b-9a4c-c3deeaff8af4") - delete_stub = stub_request(:delete, %r[https://#{ENV['TCA_HOST']}/api/v1/groups/#{td.tii_group_id}/attachments/.*]). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + delete_stub = stub_request(:delete, %r{https://#{ENV.fetch('TCA_HOST', nil)}/api/v1/groups/#{td.tii_group_id}/attachments/.*}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) data = TCAClient::GroupAttachmentResponse.new( - "id": grp_attachment.group_attachment_id, - "title": "large2", - "status": "COMPLETE", - "template": true + id: grp_attachment.group_attachment_id, + title: "large2", + status: "COMPLETE", + template: true ) # puts data.to_json diff --git a/test/api/tii/turn_it_in_api_test.rb b/test/api/tii/turn_it_in_api_test.rb new file mode 100644 index 0000000000..daa9ad41ba --- /dev/null +++ b/test/api/tii/turn_it_in_api_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' + +class TurnItInApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + + def app + Rails.application + end + + def test_accept_eula_requires_a_valid_token + user = FactoryBot.create(:user) + path = "/api/tii_eula/users/#{user.id}/accept" + + clear_auth_header + header 'username', user.username + put path + assert_equal 419, last_response.status + assert_nil user.reload.tii_eula_date + + add_auth_header_for(user: user, auth_token: 'invalid') + put path + assert_equal 419, last_response.status + assert_nil user.reload.tii_eula_date + end + + def test_accept_eula_only_for_the_authenticated_user + user = FactoryBot.create(:user) + other_user = FactoryBot.create(:user) + add_auth_header_for(user: user) + + put "/api/tii_eula/users/#{other_user.id}/accept" + assert_equal 403, last_response.status + assert_nil other_user.reload.tii_eula_date + + put "/api/tii_eula/users/#{user.id}/accept" + assert_equal 200, last_response.status + assert_not_nil user.reload.tii_eula_date + end +end diff --git a/test/api/tutorial_stream_api_test.rb b/test/api/tutorial_stream_api_test.rb index b3f1385848..e33d5414b4 100644 --- a/test/api/tutorial_stream_api_test.rb +++ b/test/api/tutorial_stream_api_test.rb @@ -19,9 +19,9 @@ def test_create_checks_uniqueness add_auth_header_for(user: unit.main_convenor_user) assert_equal 0, unit.tutorial_streams.count - post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation} + post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation } assert_equal 201, last_response.status - post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation} + post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation } assert_equal 201, last_response.status unit.reload @@ -31,7 +31,7 @@ def test_create_checks_uniqueness assert_equal 1, unit.tutorial_streams.count assert_equal 200, last_response.status - post_json "/api/units/#{unit.id}/tutorial_streams", {activity_type_abbr: activity_type.abbreviation} + post_json "/api/units/#{unit.id}/tutorial_streams", { activity_type_abbr: activity_type.abbreviation } assert_equal 201, last_response.status end end diff --git a/test/api/unit_contents_api_test.rb b/test/api/unit_contents_api_test.rb new file mode 100644 index 0000000000..3f4b4a1709 --- /dev/null +++ b/test/api/unit_contents_api_test.rb @@ -0,0 +1,67 @@ +require 'test_helper' + +class UnitContentsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + + def app + Rails.application + end + + def setup + @unit = FactoryBot.create(:unit, with_students: false, stream_count: 0) + @archive = Tempfile.new(['unit-content-download', '.zip']) + write_zip(@archive.path, 'index.html' => '

    Unit content

    ') + @site = @unit.unit_content_sites.create!( + name: 'Course site', + original_filename: 'course site.zip', + archive_path: @archive.path, + root_dir: '/' + ) + end + + def teardown + @archive.close + super + end + + def test_convenor_can_download_content_site_archive + add_auth_header_for(user: @unit.main_convenor_user) + + get "/api/units/#{@unit.id}/content/sites/#{@site.id}/archive" + + assert_equal 200, last_response.status + assert_equal 'application/zip', last_response.content_type + assert_equal 'attachment; filename="course_site.zip"', last_response.headers['Content-Disposition'] + assert_equal File.binread(@archive.path), last_response.body + end + + def test_content_site_response_includes_the_cache_version + add_auth_header_for(user: @unit.main_convenor_user) + + get "/api/units/#{@unit.id}/content/sites" + + assert_equal 200, last_response.status + assert_equal @site.content_version, JSON.parse(last_response.body).first['content_version'] + end + + def test_student_cannot_download_content_site_archive + student = FactoryBot.create(:user, :student) + @unit.enrol_student(student, nil) + add_auth_header_for(user: student) + + get "/api/units/#{@unit.id}/content/sites/#{@site.id}/archive" + + assert_equal 403, last_response.status + end + + private + + def write_zip(path, entries) + Zip::File.open(path, Zip::File::CREATE) do |zip| + entries.each do |entry_name, contents| + zip.get_output_stream(entry_name) { |stream| stream.write(contents) } + end + end + end +end diff --git a/test/api/unit_roles_test.rb b/test/api/unit_roles_test.rb index 5e24941fd0..b50949c86b 100644 --- a/test/api/unit_roles_test.rb +++ b/test/api/unit_roles_test.rb @@ -11,7 +11,6 @@ def app # GET /api/units_roles def test_get_unit_roles - # Add username and auth_token to Header add_auth_header_for(user: User.first) @@ -82,10 +81,10 @@ def test_delete_unit_role assert_equal 200, last_response.status # check if the number of unit roles reduces by 1 - assert_equal UnitRole.count, number_of_ur -1 + assert_equal UnitRole.count, number_of_ur - 1 # Check that you can't find the deleted id - refute UnitRole.exists?(id_of_ur) + assert_not UnitRole.exists?(id_of_ur) end # Delete a teaching period using unauthorised account @@ -142,7 +141,117 @@ def test_delete_main_convenor delete "/api/unit_roles/#{initial_id}" assert_equal 200, last_response.status, last_response.inspect - refute UnitRole.where(id: initial_id).present? + assert_not UnitRole.where(id: initial_id).present? + end + + def test_delete_unit_role_with_assigned_tutorials_requires_reassignment + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1 + tutor_user = FactoryBot.create :user, :tutor + tutor_role = unit.employ_staff tutor_user, Role.tutor + tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role + + add_auth_header_for(user: unit.main_convenor_user) + + delete "/api/unit_roles/#{tutor_role.id}" + + assert_equal 400, last_response.status + assert_equal tutor_role.id, tutorial.reload.unit_role_id + assert UnitRole.exists?(tutor_role.id) + end + + def test_delete_unit_role_with_assigned_tutorials_reassigns_and_deletes + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1 + tutor_user = FactoryBot.create :user, :tutor + replacement_user = FactoryBot.create :user, :tutor + tutor_role = unit.employ_staff tutor_user, Role.tutor + replacement_role = unit.employ_staff replacement_user, Role.tutor + tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role + + add_auth_header_for(user: unit.main_convenor_user) + + delete "/api/unit_roles/#{tutor_role.id}", { reassign_to_unit_role_id: replacement_role.id } + + assert_equal 200, last_response.status + assert_equal replacement_role.id, tutorial.reload.unit_role_id + assert_not UnitRole.exists?(tutor_role.id) + end + + def test_delete_unit_role_with_assigned_tutorials_rejects_invalid_reassignment_target + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1 + other_unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1 + tutor_user = FactoryBot.create :user, :tutor + tutor_role = unit.employ_staff tutor_user, Role.tutor + invalid_role = other_unit.staff.first + tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: tutor_role + + add_auth_header_for(user: unit.main_convenor_user) + + delete "/api/unit_roles/#{tutor_role.id}", { reassign_to_unit_role_id: invalid_role.id } + + assert_equal 400, last_response.status + assert_equal tutor_role.id, tutorial.reload.unit_role_id + assert UnitRole.exists?(tutor_role.id) + end + + def test_delete_main_convenor_with_reassignment_rolls_back_tutorial_updates + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 1 + replacement_user = FactoryBot.create :user, :convenor + replacement_role = unit.employ_staff replacement_user, Role.convenor + tutorial = FactoryBot.create :tutorial, unit: unit, unit_role: unit.main_convenor + initial_main_convenor_id = unit.main_convenor_id + + add_auth_header_for(user: unit.main_convenor_user) + + delete "/api/unit_roles/#{initial_main_convenor_id}", { reassign_to_unit_role_id: replacement_role.id } + + assert_equal 400, last_response.status, last_response.inspect + assert_equal initial_main_convenor_id, tutorial.reload.unit_role_id + assert UnitRole.exists?(initial_main_convenor_id) + assert UnitRole.exists?(replacement_role.id) + end + + def test_admin_not_in_unit_can_update_unit_role + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 0 + + admin_user = FactoryBot.create :user, :admin + tutor_user = FactoryBot.create :user, :convenor + tutor_role = unit.employ_staff tutor_user, Role.tutor + + # The admin is not employed within the unit + assert_nil unit.unit_role_for(admin_user) + + add_auth_header_for(user: admin_user) + + # Promote the staff member to convenor + put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id } } + + assert_equal 200, last_response.status, last_response.inspect + assert_equal Role.convenor.id, tutor_role.reload.role_id + + # ... and mark them as observer only + put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id, observer_only: true } } + + assert_equal 200, last_response.status, last_response.inspect + assert tutor_role.reload.observer_only + + unit.destroy + end + + def test_tutor_not_in_unit_cannot_update_unit_role + unit = FactoryBot.create :unit, with_students: false, task_count: 0, tutorials: 0, outcome_count: 0, staff_count: 0, campus_count: 0 + + other_tutor_user = FactoryBot.create :user, :tutor + tutor_user = FactoryBot.create :user, :convenor + tutor_role = unit.employ_staff tutor_user, Role.tutor + + add_auth_header_for(user: other_tutor_user) + + put_json "/api/unit_roles/#{tutor_role.id}", { unit_role: { role_id: Role.convenor.id } } + + assert_equal 403, last_response.status, last_response.inspect + assert_equal Role.tutor.id, tutor_role.reload.role_id + + unit.destroy end def test_observer_unit_role @@ -265,4 +374,27 @@ def test_observer_unit_role assert_not_equal 403, last_response.status, "#{route}: (#{last_response_body})" end end + + def test_student_cannot_moderate_feedback + unit = FactoryBot.create :unit, with_students: true, task_count: 1, outcome_count: 0, staff_count: 0 + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + task.save! if task.new_record? + + # Tutors without a mentor have a nil mentor, as do users without a unit role + tutor_role = unit.unit_role_for(project.tutor_for(task.task_definition)) + tutor_role.update!(mentor: nil) + + moderated_task = ModeratedTask.create!(task: task, task_definition: task.task_definition, state: :open, moderation_type: :escalation) + + add_auth_header_for(user: project.student) + post "/api/unit_roles/#{tutor_role.id}/moderation/#{task.id}", { action: 'overturn' } + + assert_equal 400, last_response.status, last_response_body + assert moderated_task.reload.open? + assert_nil moderated_task.outcome + assert_nil TutorFeedbackScore.find_by(unit_role: tutor_role, task_definition: task.task_definition) + + unit.destroy + end end diff --git a/test/api/units/feedback_test.rb b/test/api/units/feedback_test.rb index 1b51591544..bc4387f1bc 100644 --- a/test/api/units/feedback_test.rb +++ b/test/api/units/feedback_test.rb @@ -145,6 +145,34 @@ def test_tasks_for_task_inbox unit.destroy end + def test_convenor_can_view_inbox_as_tutor + unit = FactoryBot.create(:unit, perform_submissions: true, unenrolled_student_count: 0, part_enrolled_student_count: 0, tutorials: 2, staff_count: 2, student_count: 2) + + convenor = unit.main_convenor_user + tutor = FactoryBot.create(:user, :tutor) + tutor_role = unit.employ_staff(tutor, Role.tutor) + unit.tutorials.first.assign_tutor(tutor) + + add_auth_header_for(user: convenor) + get "/api/units/#{unit.id}/tasks/inbox?my_students_only=true&view_as_unit_role_id=#{tutor_role.id}" + + assert_equal 200, last_response.status, last_response_body + expected_ids = unit.tasks_for_task_inbox(tutor, true).map(&:task_id) + assert_equal expected_ids, last_response_body.pluck('id') + + add_auth_header_for(user: tutor) + get "/api/units/#{unit.id}/tasks/inbox?view_as_unit_role_id=#{unit.unit_role_for(convenor).id}" + assert_equal 403, last_response.status + + other_unit = FactoryBot.create(:unit, unenrolled_student_count: 0, part_enrolled_student_count: 0, student_count: 0) + add_auth_header_for(user: convenor) + get "/api/units/#{unit.id}/tasks/inbox?view_as_unit_role_id=#{other_unit.unit_roles.first.id}" + assert_equal 404, last_response.status + ensure + unit&.destroy + other_unit&.destroy + end + def test_task_similarity_inbox unit = FactoryBot.create(:unit, perform_submissions: true, unenrolled_student_count: 0, part_enrolled_student_count: 0, tutorials: 2, staff_count: 2) diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 00967f32b0..30656e2381 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -12,19 +12,19 @@ def app end def all_task_def_keys - [ - 'name', - 'description', - 'target_grade', - 'group_set_id', - 'start_date', - 'target_date', - 'due_date', - 'abbreviation', - 'restrict_status_updates', - 'plagiarism_warn_pct', - 'is_graded', - 'max_quality_pts' + %w[ + name + description + target_grade + group_set_id + start_date + target_date + due_date + abbreviation + restrict_status_updates + plagiarism_warn_pct + is_graded + max_quality_pts ] end @@ -35,21 +35,21 @@ def test_task_definition_cud data_to_post = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, - name: 'New Task Def', - description: 'First task def', - weighting: 4, - target_grade: 1, - group_set_id: unit.group_sets.first.id, - start_date: unit.start_date, - target_date: unit.start_date + 7.days, - due_date: unit.start_date + 21.days, - abbreviation: 'P1.1', - restrict_status_updates: false, - upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, + name: 'New Task Def', + description: 'First task def', + weighting: 4, + target_grade: 1, + group_set_id: unit.group_sets.first.id, + start_date: unit.start_date, + target_date: unit.start_date + 7.days, + due_date: unit.start_date + 21.days, + abbreviation: 'P1.1', + restrict_status_updates: false, + upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -69,21 +69,21 @@ def test_task_definition_cud data_to_put = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, - name: 'New Task Def 1', - description: 'First task def 1', - weighting: 2, - target_grade: 2, - group_set_id: nil, - start_date: unit.start_date + 2.days, - target_date: unit.start_date + 9.days, - due_date: unit.start_date + 23.days, - abbreviation: 'P1.2', - restrict_status_updates: true, - upload_requirements: [ { "key": "file0", "name": "Other Class", "type": "document" } ].to_json, - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, + name: 'New Task Def 1', + description: 'First task def 1', + weighting: 2, + target_grade: 2, + group_set_id: nil, + start_date: unit.start_date + 2.days, + target_date: unit.start_date + 9.days, + due_date: unit.start_date + 23.days, + abbreviation: 'P1.2', + restrict_status_updates: true, + upload_requirements: [{ key: "file0", name: "Other Class", type: "document" }].to_json, + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -117,6 +117,18 @@ def test_post_invalid_file_tasksheet assert last_response_body.key?('error') end + def test_update_rejects_unsupported_similarity_language + unit = FactoryBot.create(:unit, task_count: 1) + task_definition = unit.task_definitions.first + add_auth_header_for(user: unit.main_convenor_user) + + put_json "/api/units/#{unit.id}/task_definitions/#{task_definition.id}", + { task_def: { similarity_language: 'java; touch /tmp/injected' } } + + assert_equal 400, last_response.status, last_response_body + assert_nil task_definition.reload.similarity_language + end + def test_post_tasksheet test_unit = Unit.first test_task_definition = TaskDefinition.first @@ -181,30 +193,30 @@ def test_post_task_resources ] # Save will trigger TII integration - create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]). - with(tii_headers). - with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]). - to_return(status: 200, body: "", headers: {}) - - post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]). - with(tii_headers). - with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}"). - to_return( - status: 200, - body: TCAClient::AddGroupAttachmentResponse.new( - id: SecureRandom.uuid - ).to_json, - headers: {} - ) - - upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]). - with(tii_headers). - with(headers: {'Content-Type'=>'binary/octet-stream'}). - to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) - - delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + create_tii_group_stub = stub_request(:put, %r{https://localhost/api/v1/groups/.*}) + .with(tii_headers) + .with(body: /.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*/) + .to_return(status: 200, body: "", headers: {}) + + post_attachment_stub = stub_request(:post, %r{https://localhost/api/v1/groups/.*/attachments}) + .with(tii_headers) + .with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}") + .to_return( + status: 200, + body: TCAClient::AddGroupAttachmentResponse.new( + id: SecureRandom.uuid + ).to_json, + headers: {} + ) + + upload_stub = stub_request(:put, %r{https://localhost/api/v1/groups/.*/attachments/.*/original}) + .with(tii_headers) + .with(headers: { 'Content-Type' => 'binary/octet-stream' }) + .to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) + + delete_stub = stub_request(:delete, %r{https://localhost/api/v1/groups/.*/attachments/.*}) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) td.save! @@ -215,6 +227,7 @@ def test_post_task_resources assert_requested upload_stub, times: 1 assert_requested delete_stub, times: 0 + TaskPrerequisite.where(prerequisite_id: td.id).destroy_all td.destroy! assert_requested delete_stub, times: 1 end @@ -241,21 +254,21 @@ def test_post_scorm def test_submission_creates_folders unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'test_submission_creates_folders', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'test_submission_creates_folders', - restrict_status_updates: false, - upload_requirements: [ { "key" => "file0", "name" => "Shape Class", "type" => "document" } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'test_submission_creates_folders', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'test_submission_creates_folders', + restrict_status_updates: false, + upload_requirements: [{ "key" => "file0", "name" => "Shape Class", "type" => "document" }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -295,21 +308,21 @@ def test_submission_creates_folders def test_change_to_group_after_submissions unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'document' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'document' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -335,13 +348,13 @@ def test_change_to_group_after_submissions # Change it to a group task - group_set = GroupSet.create!({name: 'test group set', unit: unit}) + group_set = GroupSet.create!({ name: 'test group set', unit: unit }) group_set.save! td.group_set = group_set - assert !td.save + assert_not td.save - task.reload() + task.reload task.task_definition = td path = task.zip_file_path_for_done_task assert path @@ -836,8 +849,8 @@ def test_task_related_to_task_def_when_multiple_projects_tasks_and_tutorials end def test_change_draft_learning_summary_upload_requirements - unit = FactoryBot.create :unit, student_count:1, task_count:0 - upload_reqs = [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}] + unit = FactoryBot.create :unit, student_count: 1, task_count: 0 + upload_reqs = [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }] task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: upload_reqs) # Set draft learning summary task defintion @@ -857,7 +870,7 @@ def test_change_draft_learning_summary_upload_requirements # Test change upload requirements to a non-document upload data_to_put = { task_def: { - upload_requirements: [{"key": "file0","name": "Code file","type": "code"}].to_json + upload_requirements: [{ key: "file0", name: "Code file", type: "code" }].to_json } } @@ -937,6 +950,27 @@ def test_task_prerequisites end end + def test_cannot_delete_task_definition_used_as_a_prerequisite + unit = FactoryBot.create(:unit, task_count: 0) + prerequisite = FactoryBot.create(:task_definition, unit: unit, target_grade: 0) + dependent = FactoryBot.create(:task_definition, unit: unit, target_grade: 0) + task_prerequisite = TaskPrerequisite.create!( + task_definition: dependent, + prerequisite: prerequisite, + task_status_id: TaskStatus.complete.id + ) + + add_auth_header_for(user: unit.main_convenor_user) + delete "/api/units/#{unit.id}/task_definitions/#{prerequisite.id}" + + assert_equal 403, last_response.status, last_response_body + assert_equal "Cannot delete task definition while it is used as a prerequisite. Remove the prerequisite links first.", + last_response_body['error'] + assert TaskDefinition.exists?(prerequisite.id) + assert TaskDefinition.exists?(dependent.id) + assert TaskPrerequisite.exists?(task_prerequisite.id) + end + def test_download_student_submission_jobs unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index 9ff3ed5d03..cb6e0dadd6 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -127,19 +127,19 @@ def test_units_post_other_main_convenor_not_permitted_for_student def create_unit { - name:'Intro to Social Skills', - code:'JRRW40003', - start_date:'2016-05-14T00:00:00.000Z', - end_date:'2017-05-14T00:00:00.000Z' + name: 'Intro to Social Skills', + code: 'JRRW40003', + start_date: '2016-05-14T00:00:00.000Z', + end_date: '2017-05-14T00:00:00.000Z' } end - def test_post_create_unit_custom_token(token='abcdef') + def test_post_create_unit_custom_token(token = 'abcdef') count = Unit.all.length unit = create_unit data_to_post = { - unit: unit + unit: unit } # Add username and auth_token to Header @@ -147,7 +147,7 @@ def test_post_create_unit_custom_token(token='abcdef') # Override the header for empty auth_token if token == '' - header 'auth_token','' + header 'auth_token', '' end post_json '/api/units', data_to_post @@ -189,7 +189,7 @@ def test_add_tutorial_to_unit assert_equal 201, last_response.status, last_response_body # Check there is a new tutorial assert_equal count_tutorials + 1, Tutorial.all.length, last_response_body - assert_json_matches_model tutorial, last_response_body, ["abbreviation", "capacity", "meeting_location", "meeting_day", "meeting_time"] + assert_json_matches_model tutorial, last_response_body, %w[abbreviation capacity meeting_location meeting_day meeting_time] unit.destroy end @@ -261,8 +261,8 @@ def test_permissions_on_get assert_equal 200, last_response.status assert last_response_body.map { |r| r['id'] }.include? test_unit_inside.id - refute last_response_body.map { |r| r['id'] }.include? test_unit_before.id - refute last_response_body.map { |r| r['id'] }.include? test_unit_after.id + assert_not last_response_body.map { |r| r['id'] }.include? test_unit_before.id + assert_not last_response_body.map { |r| r['id'] }.include? test_unit_after.id # Test convenor can not get all add_auth_header_for(user: aconvenor) @@ -342,7 +342,7 @@ def test_unit_output assert actual_unit.key?("tutorials"), actual_unit.inspect # assert actual_unit.key?("tutorial_enrolments"), actual_unit.inspect assert actual_unit.key?("task_definitions"), actual_unit.inspect - #TODO: expand tests to check details returned + # TODO: expand tests to check details returned assert actual_unit.key?("staff"), actual_unit.inspect assert_equal expected_unit.staff.count, actual_unit["staff"].count, actual_unit["staff"].inspect @@ -380,7 +380,7 @@ def test_unit_output assert actual_unit.key?("groups"), actual_unit.inspect assert_equal expected_unit.groups.count, actual_unit["groups"].count, actual_unit["groups"].inspect actual_unit["groups"].each do |group| - keys = %w(id name tutorial_id group_set_id student_count capacity_adjustment locked) + keys = %w[id name tutorial_id group_set_id student_count capacity_adjustment locked] assert_json_limit_keys_to_exactly keys, group assert_json_matches_model Group.find(group['id']), group, keys end @@ -421,9 +421,8 @@ def test_units_get_has_streams assert_equal 3, actual_unit['tutorial_streams'].count end - #Test GET for getting the unit details of current user + # Test GET for getting the unit details of current user def test_units_current - # Add username and auth_token to Header add_auth_header_for(user: User.first) @@ -431,17 +430,16 @@ def test_units_current assert_equal 200, last_response.status end - - #Test PUT for updating unit details with valid id + # Test PUT for updating unit details with valid id def test_units_put original = FactoryBot.create(:unit, with_students: false) - unit={} + unit = {} unit['name'] = 'Intro to python' unit['code'] = 'JRSW40004' unit['description'] = 'new language' unit['start_date'] = '2018-12-14T00:00:00.000Z' - unit['end_date']='2019-05-14T00:00:00.000Z' + unit['end_date'] = '2019-05-14T00:00:00.000Z' unit['active'] = false unit['auto_apply_extension_before_deadline'] = false unit['send_notifications'] = false @@ -456,16 +454,71 @@ def test_units_put put_json "/api/units/#{original.id}", data_to_put assert_equal 200, last_response.status - assert_json_matches_model original.reload, unit, %w( name code description start_date end_date active auto_apply_extension_before_deadline send_notifications ) + assert_json_matches_model original.reload, unit, %w[name code description start_date end_date active auto_apply_extension_before_deadline send_notifications] + end + + def test_units_put_rejects_unsupported_name_and_code_characters + unit = FactoryBot.create(:unit, with_students: false, name: 'Original-Unit #1 (Core)', code: 'ABC1001-ABC1002') + add_auth_header_for(user: unit.main_convenor_user) + + ['={', 'Unit+Name', 'Unit@Name'].each do |name| + put_json "/api/units/#{unit.id}", { unit: { name: name } } + + assert_equal 400, last_response.status, last_response_body + assert_equal 'Original-Unit #1 (Core)', unit.reload.name + end + + put_json "/api/units/#{unit.id}", { unit: { code: 'ABC1001-ABC1002=' } } + + assert_equal 400, last_response.status, last_response_body + assert_equal 'ABC1001-ABC1002', unit.reload.code + end + + def test_units_put_allows_hash_in_unit_code + unit = FactoryBot.create(:unit, with_students: false, code: 'ABC1001-ABC1002') + add_auth_header_for(user: unit.main_convenor_user) + + put_json "/api/units/#{unit.id}", { unit: { code: 'ABC1001-ABC1002C#' } } + + assert_equal 200, last_response.status, last_response_body + assert_equal 'ABC1001-ABC1002C#', unit.reload.code end - #Test PUT for updating unit details with empty name + def test_system_administrator_enrolled_as_student_can_update_unit + unit = FactoryBot.create(:unit, with_students: false) + administrator = FactoryBot.create(:user, :admin) + unit.enrol_student(administrator, Campus.first) + + assert_equal Role.student, unit.role_for(administrator) + + add_auth_header_for(user: administrator) + put_json "/api/units/#{unit.id}", { unit: { name: 'Updated by student administrator' } } + + assert_equal 200, last_response.status, last_response_body + assert_equal 'Updated by student administrator', unit.reload.name + end + + def test_system_administrator_employed_as_tutor_can_update_unit + unit = FactoryBot.create(:unit, with_students: false) + administrator = FactoryBot.create(:user, :admin) + unit.employ_staff(administrator, Role.tutor) + + assert_equal Role.tutor, unit.role_for(administrator) + + add_auth_header_for(user: administrator) + put_json "/api/units/#{unit.id}", { unit: { name: 'Updated by tutor administrator' } } + + assert_equal 200, last_response.status, last_response_body + assert_equal 'Updated by tutor administrator', unit.reload.name + end + + # Test PUT for updating unit details with empty name def test_put_update_unit_empty_name unit = Unit.first unit[:name] = '' data_to_put = { - unit: unit + unit: unit } # Add username and auth_token to Header @@ -475,10 +528,10 @@ def test_put_update_unit_empty_name assert_equal 400, last_response.status end - #Test PUT for updating unit details with invalid id + # Test PUT for updating unit details with invalid id def test_put_update_unit_invalid_id data_to_put = { - unit: { name: 'test'} + unit: { name: 'test' } } # Add username and auth_token to Header @@ -516,7 +569,7 @@ def test_fail_units_get_by_id assert_equal 404, last_response.status end - def test_put_update_unit_custom_token() + def test_put_update_unit_custom_token unit = Unit.first token = 'abcdef' data_to_put = { @@ -576,9 +629,9 @@ def test_update_main_convenor def test_draft_learning_summary_upload_requirements unit = FactoryBot.create :unit, student_count: 1, task_count: 0 - task_def_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Code file','type' => 'code'}]) - task_def_doc = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}]) - task_def_doc_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}, {'key' => 'file1','name' => 'Code file','type' => 'code'}]) + task_def_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Code file', 'type' => 'code' }]) + task_def_doc = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }]) + task_def_doc_code = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }, { 'key' => 'file1', 'name' => 'Code file', 'type' => 'code' }]) # Test with a task containing non document upload requirement data_to_put = { @@ -622,4 +675,174 @@ def test_draft_learning_summary_upload_requirements unit.reload assert_equal task_def_doc.id, unit.draft_task_definition_id end + + def test_get_task_completion_snapshots + unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1 + tutorial = unit.tutorials.first + task_definition = unit.task_definitions_by_grade.first + + older_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-04-01 10:00:00').to_i.to_s + ) + + mid_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s + ) + + latest_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-04-03 10:00:00').to_i.to_s + ) + + older_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.not_started.id])) + mid_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id])) + latest_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id, TaskStatus.complete.id])) + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots", { limit: 2 } + + assert_equal 200, last_response.status, last_response_body + assert_equal 2, last_response_body.length + + assert_equal latest_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s + assert_equal mid_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s + + latest_stats = last_response_body[0]['stats'] + assert_equal 3, latest_stats[tutorial.campus.name][tutorial.abbreviation][task_definition.abbreviation]['complete'] + assert_equal 3, last_response_body[0]['student_count'] + assert_equal 3, last_response_body[0]['campus_student_counts'][tutorial.campus.name] + assert_equal 3, last_response_body[0]['target_grade_student_counts']['0'] + assert_equal 3, last_response_body[0]['target_grade_campus_student_counts']['0'][tutorial.campus.name] + assert_equal 3, last_response_body[0]['status_population_stats']['all'][tutorial.campus.name]['__all__'][task_definition.abbreviation]['complete'] + + assert_not_equal older_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s + end + + def test_get_task_completion_snapshots_filters_by_date + unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1 + tutorial = unit.tutorials.first + task_definition = unit.task_definitions_by_grade.first + + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-03-30 10:00:00').to_i.to_s + ) + + included_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s + ) + + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.parse('2026-04-05 10:00:00').to_i.to_s + ) + + unit.task_completion_snapshots.find_each do |snapshot| + snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id])) + end + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots", { + start_date: Date.new(2026, 4, 1), + end_date: Date.new(2026, 4, 3) + } + + assert_equal 200, last_response.status, last_response_body + assert_equal 1, last_response_body.length + assert_equal included_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s + end + + def test_get_task_completion_snapshots_not_authorised + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_timestamp: Time.zone.now.to_i.to_s + ) + + add_auth_header_for(user: User.where(role: Role.student).first) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots" + + assert_equal 403, last_response.status + end + + def test_post_capture_task_completion_snapshot + Sidekiq::Testing.inline! do + unit = FactoryBot.create :unit + + count_before = TaskCompletionSnapshot.where(unit: unit).count + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" + + assert_equal 201, last_response.status, last_response_body + assert_not_nil last_response_body['id'] + + snapshot = TaskCompletionSnapshot.where(unit: unit).order(snapshot_timestamp: :desc).first + assert_not_nil snapshot + assert_equal count_before + 1, TaskCompletionSnapshot.where(unit: unit).count + + assert_equal Date.current.to_s, snapshot.snapshot_date.to_s + assert_not_empty snapshot.load_stats + assert File.exist?(snapshot.snapshot_file_path) + ensure + Sidekiq::Testing.fake! + end + end + + def test_post_capture_task_completion_snapshot_not_authorised + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + + add_auth_header_for(user: User.where(role: Role.student).first) + header 'Host', 'localhost' + post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" + + assert_equal 403, last_response.status + end + + private + + def build_task_completion_snapshot_csv(tutorial, task_definition, statuses) + headers = [ + 'Student ID', + 'Username', + 'Student Name', + 'Campus', + 'Target Grade', + 'Email', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + 'Tutorial', + task_definition.abbreviation + ] + + CSV.generate do |csv| + csv << headers + + statuses.each_with_index do |status, index| + csv << [ + "#{index + 1}", + "student-#{index + 1}", + "Student #{index + 1}", + tutorial.campus.abbreviation, + tutorial.unit.grade_label(0), + "student-#{index + 1}@example.com", + 'false', + '', + '', + '', + tutorial.abbreviation, + status + ] + end + end + end end diff --git a/test/api/users_test.rb b/test/api/users_test.rb index 05b21395c5..e4f86d6e8d 100644 --- a/test/api/users_test.rb +++ b/test/api/users_test.rb @@ -36,7 +36,6 @@ def create_user # Get users' details def test_get_users - # Add username and auth_token to Header add_auth_header_for(user: User.first) @@ -53,7 +52,7 @@ def test_get_users response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup] # Loop through all of the responses - last_response_body.each do | data | + last_response_body.each do |data| # Find the matching user, by id from response user = User.find(data['id']) # Match json with object @@ -76,12 +75,11 @@ def test_get_a_users_details assert_equal 200, last_response.status # Check the returned details match as expected - response_keys = %w(first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup) + response_keys = %w[first_name last_name email student_id nickname receive_task_notifications receive_portfolio_notifications receive_feedback_notifications opt_in_to_research has_run_first_time_setup] assert_json_matches_model(expected_user, returned_user, response_keys) end def test_get_convenors - # Add username and auth_token to Header add_auth_header_for(user: User.first) @@ -90,7 +88,6 @@ def test_get_convenors end def test_get_tutors - # Add username and auth_token to Header add_auth_header_for(user: User.first) @@ -99,7 +96,7 @@ def test_get_tutors end def test_get_no_token - models = %w(users users/tutors users/convenors) + models = %w[users users/tutors users/convenors] models.each do |m| get "/api/#{m}" @@ -108,12 +105,12 @@ def test_get_no_token end def test_get_invalid_token - models = %w(users users/tutors users/convenors) + models = %w[users users/tutors users/convenors] - models.each { |m| + models.each do |m| get "/api/#{m}?auth_token=1234" assert_equal 419, last_response.status - } + end end # ======================================================================== @@ -163,7 +160,7 @@ def test_post_create_same_user_different_email user = create_user data_to_post = { - user: user + user: user } # Add username and auth_token to Header @@ -186,7 +183,7 @@ def test_post_create_same_user_different_username user = create_user data_to_post = { - user: user + user: user } # Add username and auth_token to Header @@ -208,14 +205,14 @@ def test_post_create_user_invalid_email pre_count = User.all.length user = create_user - invalid_emails = %w(qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com) + invalid_emails = %w[qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com] invalid_emails.each do |email| # Assign invalid email user[:email] = email data_to_post = { - user: user + user: user } # Add username and auth_token to Header @@ -251,14 +248,14 @@ def test_post_create_user_empty_required_fields end end - def test_post_create_user_custom_role(role='asdasd') + def test_post_create_user_custom_role(role = 'asdasd') pre_count = User.all.length user = create_user user[:system_role] = role data_to_post = { - user: user + user: user } # Add username and auth_token to Header @@ -274,12 +271,12 @@ def test_post_create_user_role_root test_post_create_user_custom_role 'root' end - def test_post_create_user_custom_token(token='asdasd') + def test_post_create_user_custom_token(token = 'asdasd') pre_count = User.all.length user = create_user data_to_post = { - user: user + user: user } # Add username and auth_token to Header @@ -287,7 +284,7 @@ def test_post_create_user_custom_token(token='asdasd') # Override header to empty auth_token if token == '' - header 'auth_token','' + header 'auth_token', '' end post_json '/api/users', data_to_post @@ -325,7 +322,7 @@ def test_put_update_user_existing_email user[:email] = User.third.email data_to_put = { - user: user + user: user } # Add username and auth_token to Header @@ -338,14 +335,14 @@ def test_put_update_user_existing_email def test_put_update_user_invalid_email user = User.second - invalid_emails = %w(qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com) + invalid_emails = %w[qwertyuiop qwertyuiop@qwe qwertyuiop@.com qwertyuiop@blah..com] invalid_emails.each do |email| # Assign invalid email user[:email] = email data_to_put = { - user: user + user: user } # Add username and auth_token to Header @@ -361,7 +358,7 @@ def test_put_update_user_empty_email user[:email] = '' data_to_put = { - user: user + user: user } # Add username and auth_token to Header @@ -371,19 +368,19 @@ def test_put_update_user_empty_email assert_equal 400, last_response.status end - def test_put_update_user_custom_token(token='asdasd') + def test_put_update_user_custom_token(token = 'asdasd') user = User.second user[:email] = '' data_to_put = { - user: user + user: user } # Add username and auth_token to Header add_auth_header_for(user: User.first, auth_token: token) if token == '' - header 'auth_token',token + header 'auth_token', token end put_json '/api/users/2', data_to_put diff --git a/test/api/webcal_api_test.rb b/test/api/webcal_api_test.rb index ae7f6c37ac..21c40e8a65 100644 --- a/test/api/webcal_api_test.rb +++ b/test/api/webcal_api_test.rb @@ -77,7 +77,7 @@ def app assert_equal current_guid, last_response_body['guid'] end - test 'Ical endpoint is public and serves webcal with corect content type' do + test 'Ical endpoint is public and serves webcal with correct content type' do add_auth_header_for user: @student # Enable webcal, get GUID put_json '/api/webcal', { webcal: { enabled: true } } @@ -89,6 +89,9 @@ def app # Ensure correct content type assert_equal 200, last_response.status assert_equal 'text/calendar', last_response['Content-Type'] + assert last_response.body.start_with?("BEGIN:VCALENDAR\r\n") + assert_includes last_response.body, "\r\nEND:VCALENDAR\r\n" + assert_not last_response.body.start_with?('"') end test 'Reminder must be specified with both time & unit' do @@ -106,7 +109,7 @@ def app assert 400, last_response.status # Specify both time & unit - Webcal.valid_time_units.each_with_index { |u, i| + Webcal.valid_time_units.each_with_index do |u, i| t = i + 1 put_json '/api/webcal', { webcal: { reminder: { time: t, unit: u } } } @@ -115,7 +118,7 @@ def app assert_not_nil last_response_body['reminder'] assert_equal t, last_response_body['reminder']['time'] assert_equal u, last_response_body['reminder']['unit'] - } + end end test 'Can update unit exclusions' do diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 989ed8df84..d7392b44bc 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -3,7 +3,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :chrome, screen_size: [1400, 1400] - register_spec_type(self) do |desc, *addl| + register_spec_type(self) do |_desc, *addl| addl.include? :system end end diff --git a/test/config/cors_test.rb b/test/config/cors_test.rb new file mode 100644 index 0000000000..6f372f655c --- /dev/null +++ b/test/config/cors_test.rb @@ -0,0 +1,47 @@ +require 'test_helper' +require 'rack/mock' + +class CorsTest < ActiveSupport::TestCase + test 'normalizes configured institution hosts into origins' do + assert_equal 'https://ontrack.example.edu', Doubtfire::Application.cors_origin_for('ontrack.example.edu') + assert_equal 'https://ontrack.example.edu', Doubtfire::Application.cors_origin_for('https://ontrack.example.edu') + assert_equal 'http://localhost:4200', Doubtfire::Application.cors_origin_for('http://localhost:4200') + end + + test 'allows the frontend authentication headers and patch method' do + response_headers = preflight( + origin: 'http://localhost:4200', + method: 'PATCH', + headers: 'Auth-Token,Username,Content-Type' + ) + + assert_equal 'http://localhost:4200', response_headers['access-control-allow-origin'] + assert_includes response_headers['access-control-allow-methods'], 'PATCH' + assert_equal 'Auth-Token,Username,Content-Type', response_headers['access-control-allow-headers'] + end + + test 'rejects origins outside the allowlist' do + response_headers = preflight( + origin: 'https://untrusted.example.edu', + method: 'GET', + headers: 'Authorization' + ) + + assert_nil response_headers['access-control-allow-origin'] + end + + private + + def preflight(origin:, method:, headers:) + env = Rack::MockRequest.env_for( + 'http://www.example.com/api/settings', + method: 'OPTIONS', + 'HTTP_ORIGIN' => origin, + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => method, + 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => headers + ) + + _status, response_headers, = Rails.application.call(env) + response_headers + end +end diff --git a/test/config/custom_institution_setting_test.rb b/test/config/custom_institution_setting_test.rb new file mode 100644 index 0000000000..e73fa3961d --- /dev/null +++ b/test/config/custom_institution_setting_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CustomInstitutionSettingTest < ActiveSupport::TestCase + setup do + @settings = Doubtfire::Application.config.institution_settings + skip 'Custom Moodle group mapping settings are not installed' unless @settings.respond_to?(:moodle_tutorial_details) + + @burwood = Campus.find_by(abbreviation: 'BU') || FactoryBot.create(:campus, name: 'Burwood', abbreviation: 'BU') + @geelong = Campus.find_by(abbreviation: 'GE') || FactoryBot.create(:campus, name: 'Geelong', abbreviation: 'GE') + @online = Campus.find_by(abbreviation: 'OL') || FactoryBot.create(:campus, name: 'Online', abbreviation: 'OL') + campus_details = { + 'BU' => { suffix: 'bu', names: %w[BU Burwood] }, + 'GE' => { suffix: 'ge', names: %w[GE Geelong] }, + 'OL' => { suffix: 'ol', names: %w[OL Online] } + } + @settings.define_singleton_method(:moodle_campus_details) { campus_details } + @unit = FactoryBot.create(:unit, with_students: false) + + workshop = ActivityType.where(name: 'Workshop').first_or_create!(abbreviation: 'WRK') + seminar = ActivityType.where(name: 'Seminar').first_or_create!(abbreviation: 'SEM') + @workshop_stream = FactoryBot.create(:tutorial_stream, unit: @unit, activity_type: workshop) + @seminar_stream = FactoryBot.create(:tutorial_stream, unit: @unit, activity_type: seminar) + end + + test 'pre-fills local and regional timetable activities' do + suggestions = @settings.prefill_lms_group_mappings( + @unit, + [ + { id: 1, name: 'Allocate+ COS10001 Workshop 07_OnCampus BU_Building.B2.123 Tue 10:00 (24)' }, + { id: 2, name: 'Allocate+ COS10001 Workshop 08 GE_Room_2.15 Fri 14:30 (30)' } + ] + ) + + local = suggestions.first[:tutorial_draft] + assert_equal ['workshop-07-bu', @burwood.id, @workshop_stream.id, 'Building.B2.123', 'Tuesday', '10:00', 24], + local.values_at( + :abbreviation, + :campus_id, + :tutorial_stream_id, + :meeting_location, + :meeting_day, + :meeting_time, + :capacity + ) + + regional = suggestions.second[:tutorial_draft] + assert_equal 'workshop-08-ge', regional[:abbreviation] + assert_equal @geelong.id, regional[:campus_id] + assert_equal 'Room_2.15', regional[:meeting_location] + end + + test 'makes repeated activity abbreviations unique and ignores unrelated groups' do + suggestions = @settings.prefill_lms_group_mappings( + @unit, + [ + { id: 1, name: 'Allocate+ COS10001 Seminar 03_OnCampus-P1 OL_Site.Room_5 Mon 09:00' }, + { id: 2, name: 'Allocate+ COS10001 Seminar 03_OnCampus-P2 OL_Site.Room_5 Thu 15:00' }, + { id: 3, name: 'Allocate+ COS10001 Workshop 09 GE_Room_4 Wed 11:00 (20)' }, + { id: 4, name: 'Allocate+ COS10001 Workshop 09 GE_Room_4 Fri 13:00 (20)' }, + { id: 5, name: 'Course participants' } + ] + ) + + abbreviations = suggestions.filter_map { |suggestion| suggestion.dig(:tutorial_draft, :abbreviation) } + assert_equal %w[seminar-03-p1-ol seminar-03-p2-ol workshop-09-wed-1100-ge workshop-09-fri-1300-ge], + abbreviations + assert_equal @online.id, suggestions.first.dig(:tutorial_draft, :campus_id) + assert_equal @seminar_stream.id, suggestions.first.dig(:tutorial_draft, :tutorial_stream_id) + assert_equal 'Site.Room_5', suggestions.first.dig(:tutorial_draft, :meeting_location) + assert_equal 'ignore', suggestions.last[:target_type] + end + + test 'selects an existing tutorial instead of returning a draft' do + existing = FactoryBot.create( + :tutorial, + unit: @unit, + tutorial_stream: @workshop_stream, + campus: @burwood, + abbreviation: 'workshop-07-bu' + ) + suggestion = @settings.prefill_lms_group_mappings( + @unit, + [{ id: 1, name: 'Allocate+ COS10001 Workshop 07_OnCampus BU_Building.B2.123 Tue 10:00 (24)' }] + ).first + + assert_equal existing.id, suggestion[:tutorial_id] + assert_equal @workshop_stream.id, suggestion[:tutorial_stream_id] + assert_nil suggestion[:tutorial_draft] + end +end diff --git a/test/config/deakin_config_test.rb b/test/config/deakin_config_test.rb index 25da492eec..8c8c16c936 100644 --- a/test/config/deakin_config_test.rb +++ b/test/config/deakin_config_test.rb @@ -10,11 +10,11 @@ class DeakinConfigTest < ActiveSupport::TestCase ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL'] = 'https://test.com/enrolment' ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL'] = 'https://test.com/timetable' - require "#{Rails.root}/config/deakin" + require "#{Rails.root.join('config/deakin')}" Doubtfire::Application.config.institution_settings = DeakinInstitutionSettings.new # Rename and adjust abbreviation of all other campuses... so we can make sure they have the right details - Campus.update_all "name = #{db_concat("'Test-'", "name")}, abbreviation = #{db_concat("'-'", "abbreviation")}" + Campus.update_all "name = #{db_concat("'Test-'", 'name')}, abbreviation = #{db_concat("'-'", 'abbreviation')}" FactoryBot.create(:campus, name: 'Test Sync Campus', abbreviation: 'T') FactoryBot.create(:campus, name: 'Online', abbreviation: 'C') @@ -29,13 +29,13 @@ def test_sync_deakin_unit # Setup enrolments stub raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json")) - enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200) + enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200) raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json")) - timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200) + timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200) raw_timetable_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json")) - timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200) + timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200) tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020) unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) @@ -43,7 +43,7 @@ def test_sync_deakin_unit assert_equal 0, unit.tutorials.count assert_equal 0, unit.projects.count - result = unit.sync_enrolments() + result = unit.sync_enrolments assert_equal 3, unit.projects.count, result # 3 students and others skipped assert_equal 1, unit.tutorials.count, result # campus @@ -60,13 +60,13 @@ def test_sync_deakin_unit_without_timetable # Setup enrolments stubs raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json")) - enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200) + enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200) raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json")) - timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200) + timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200) raw_timetable_cls_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json")) - timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_cls_activity_file, status: 200) + timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_cls_activity_file, status: 200) tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020) unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) @@ -90,7 +90,7 @@ def test_sync_deakin_retry_requests # Setup enrolments stubs raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json")) - enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/) + enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/) .to_return([ { body: "Too many requests", status: 429 }, { body: "Internal server error", status: 500 }, @@ -98,20 +98,20 @@ def test_sync_deakin_retry_requests ]) raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json")) - timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/). - to_return([ - { body: "Too many requests", status: 429 }, - { body: "Internal server error", status: 500 }, - { body: raw_timetable_file, status: 200 } - ]) + timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/) + .to_return([ + { body: "Too many requests", status: 429 }, + { body: "Internal server error", status: 500 }, + { body: raw_timetable_file, status: 200 } + ]) raw_timetable_cls_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json")) - timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/). - to_return([ - { body: "Too many requests", status: 429 }, - { body: "Internal server error", status: 500 }, - { body: raw_timetable_cls_activity_file, status: 200 } - ]) + timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/) + .to_return([ + { body: "Too many requests", status: 429 }, + { body: "Internal server error", status: 500 }, + { body: raw_timetable_cls_activity_file, status: 200 } + ]) tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020) unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) @@ -134,13 +134,13 @@ def test_sync_deakin_multi_unit raw_enrolment_file_3 = File.new(test_file_path("deakin/enrol_multi_1.json")) raw_enrolment_file_4 = File.new(test_file_path("deakin/enrol_multi_2.json")) - enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/). - to_return([ - { body: raw_enrolment_file_1, status: 200 }, - { body: raw_enrolment_file_2, status: 200 }, - { body: raw_enrolment_file_3, status: 200 }, - { body: raw_enrolment_file_4, status: 200 } - ]) + enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/) + .to_return([ + { body: raw_enrolment_file_1, status: 200 }, + { body: raw_enrolment_file_2, status: 200 }, + { body: raw_enrolment_file_3, status: 200 }, + { body: raw_enrolment_file_4, status: 200 } + ]) tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2024) unit = FactoryBot.create(:unit, code: 'SIT724/SIT746', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) @@ -169,13 +169,13 @@ def test_sync_deakin_unit_disabled # Setup enrolments stubs raw_enrolment_file = File.new(test_file_path("deakin/enrolment_sample.json")) - enrolment_stub = stub_request(:get, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_BASE_URL']}.*/).to_return(body: raw_enrolment_file, status: 200) + enrolment_stub = stub_request(:get, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_BASE_URL', nil)}.*/).to_return(body: raw_enrolment_file, status: 200) raw_timetable_file = File.new(test_file_path("deakin/timetable_sample.json")) - timetable_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*allocated$/).to_return(body: raw_timetable_file, status: 200) + timetable_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*allocated$/).to_return(body: raw_timetable_file, status: 200) raw_timetable_activity_file = File.new(test_file_path("deakin/timetable_activity_sample.json")) - timetable_activity_stub = stub_request(:post, /#{ENV['DF_INSTITUTION_SETTINGS_SYNC_STAR_URL']}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200) + timetable_activity_stub = stub_request(:post, /#{ENV.fetch('DF_INSTITUTION_SETTINGS_SYNC_STAR_URL', nil)}.*activities$/).to_return(body: raw_timetable_activity_file, status: 200) tp = FactoryBot.create(:teaching_period, period: 'T2', year: 2020) unit = FactoryBot.create(:unit, code: 'SIT999', name: 'Test Sync', teaching_period: tp, with_students: false, stream_count: 0, tutorials: 0) @@ -184,7 +184,7 @@ def test_sync_deakin_unit_disabled unit.save # unit.update enable_sync_timetable: false - unit.sync_enrolments() + unit.sync_enrolments assert_equal 0, unit.tutorials.count # none created diff --git a/test/config/lti_secret_test.rb b/test/config/lti_secret_test.rb new file mode 100644 index 0000000000..fd52cccaf8 --- /dev/null +++ b/test/config/lti_secret_test.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +class LtiSecretTest < ActiveSupport::TestCase + test 'rejects missing, example and short LTI secrets' do + assert_equal 'is not set', Doubtfire::Application.lti_api_secret_problem(nil) + assert_equal 'is not set', Doubtfire::Application.lti_api_secret_problem(' ') + assert_equal 'is still the example value', Doubtfire::Application.lti_api_secret_problem('your-secret-lti-shared-api-secret') + assert_equal 'must be at least 32 bytes', Doubtfire::Application.lti_api_secret_problem('a' * 31) + end + + test 'accepts a generated LTI secret' do + assert_nil Doubtfire::Application.lti_api_secret_problem(SecureRandom.hex(32)) + end +end diff --git a/test/config/sentry_redaction_test.rb b/test/config/sentry_redaction_test.rb new file mode 100644 index 0000000000..fa0e1564eb --- /dev/null +++ b/test/config/sentry_redaction_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class SentryRedactionTest < ActiveSupport::TestCase + test "scrubs exception values from Sentry's exception interface" do + exception_value = Sentry::SingleExceptionInterface.new( + exception: RuntimeError.new("tmp/rails-latex/task-20260724-1100-student-name-task-1-2/file.tex"), + mechanism: nil + ) + exception = Sentry::ExceptionInterface.new(exceptions: [exception_value]) + + OnTrackSentryRedaction.scrub_exception(exception) + + assert_equal( + "tmp/rails-latex/task-20260724-1100-[username]-task-1-2/file.tex (RuntimeError)", + exception_value.value + ) + end + + test "scrubs student details from unit-labelled LaTeX work directories" do + task_path = "tmp/rails-latex/task-20260724-1100-COURSE1234-student-name-task-1-2/file.tex" + portfolio_path = "tmp/rails-latex/portfolio-20260724-1100-COURSE1234-student-name-1-2/file.tex" + + assert_equal( + "tmp/rails-latex/task-20260724-1100-[username]-task-1-2/file.tex", + OnTrackSentryRedaction.scrub_string(task_path) + ) + assert_equal( + "tmp/rails-latex/portfolio-20260724-1100-[username]-1-2/file.tex", + OnTrackSentryRedaction.scrub_string(portfolio_path) + ) + end + + test "filters Sidekiq job args from Sentry's contexts" do + contexts = { sidekiq: { "class" => "ImportBatchFeedbackJob", "args" => [20, "student@example.com"], "jid" => "abc" } } + + OnTrackSentryRedaction.scrub_contexts(contexts) + + assert_equal({ "class" => "ImportBatchFeedbackJob", "args" => "[Filtered]", "jid" => "abc" }, contexts[:sidekiq]) + end + + test "scrubs student details from portfolio PDF failure exceptions" do + exception_value = Sentry::SingleExceptionInterface.new( + exception: PdfGeneration::ProjectCompilePortfolioModule::LatexError.new( + "Failed to convert portfolio to PDF - 1234 - Test Example (example1234) COURSE123" + ), + mechanism: nil + ) + exception = Sentry::ExceptionInterface.new(exceptions: [exception_value]) + + OnTrackSentryRedaction.scrub_exception(exception) + + assert_equal( + "Failed to convert portfolio to PDF - 1234 - [Filtered] ([username]) COURSE123 (PdfGeneration::ProjectCompilePortfolioModule::LatexError)", + exception_value.value + ) + end +end diff --git a/test/config/uri_credential_leak_mitigation_test.rb b/test/config/uri_credential_leak_mitigation_test.rb new file mode 100644 index 0000000000..0219b6f6f8 --- /dev/null +++ b/test/config/uri_credential_leak_mitigation_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' + +class UriCredentialLeakMitigationTest < ActiveSupport::TestCase + test 'strips credentials when merging a relative URI object' do + base = URI.parse('https://user:password@internal.example/service') + + assert_equal 'https://internal.example/next', (base + URI.parse('/next')).to_s + end + + test 'strips credentials when merging a relative string' do + base = URI.parse('https://user:password@internal.example/service') + + assert_equal 'https://internal.example/next', base.public_send(:+, '/next').to_s + end + + test 'does not alter absolute URI merges' do + base = URI.parse('https://user:password@internal.example/service') + + assert_equal 'https://other:secret@external.example/next', + (base + URI.parse('https://other:secret@external.example/next')).to_s + end +end diff --git a/test/controllers/download_authorization_boundary_test.rb b/test/controllers/download_authorization_boundary_test.rb new file mode 100644 index 0000000000..d026aaf603 --- /dev/null +++ b/test/controllers/download_authorization_boundary_test.rb @@ -0,0 +1,484 @@ +require 'test_helper' + +class DownloadAuthorizationBoundaryTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + + CADDY_SECRET = 'download-authorization-test-secret'.freeze + INTERNAL_REQUESTS = { + '/api/internal/downloads/submission' => '/api/projects/1/task_def_id/1/submission', + '/api/internal/downloads/portfolio' => '/api/submission/unit/1/portfolio', + '/api/internal/downloads/task-submission-files' => + '/api/submission/unit/1/task_definitions/1/download_submissions', + '/api/internal/downloads/task-submission-pdfs' => + '/api/submission/unit/1/task_definitions/1/student_pdfs', + '/api/internal/downloads/pdf-file' => '/api/units/1/task_definitions/1/task_pdf', + '/api/internal/downloads/unit-content' => '/api/units/1/content/sites/1/files/index.html' + }.freeze + + def app + Rails.application + end + + setup do + @original_caddy_secret = Doubtfire::Application.config.caddy_download_auth_secret + @original_student_work_dir = Doubtfire::Application.config.student_work_dir + @original_archive_dir = Doubtfire::Application.config.archive_dir + @student_work_dir = Dir.mktmpdir('download-authorization-boundary') + + Doubtfire::Application.config.caddy_download_auth_secret = CADDY_SECRET + Doubtfire::Application.config.student_work_dir = @student_work_dir + Doubtfire::Application.config.archive_dir = File.join(@student_work_dir, 'archive') + clear_cookies + clear_request_headers + end + + teardown do + Doubtfire::Application.config.caddy_download_auth_secret = @original_caddy_secret + Doubtfire::Application.config.student_work_dir = @original_student_work_dir + Doubtfire::Application.config.archive_dir = @original_archive_dir + FileUtils.rm_rf(@student_work_dir) + end + + def test_internal_download_endpoints_reject_missing_or_incorrect_caddy_secret + INTERNAL_REQUESTS.each do |endpoint, original_uri| + request_internal_download(endpoint, original_uri, secret: nil) + assert_equal 404, last_response.status, "#{endpoint} accepted a missing Caddy secret" + + request_internal_download(endpoint, original_uri, secret: 'incorrect-secret') + assert_equal 404, last_response.status, "#{endpoint} accepted an incorrect Caddy secret" + end + end + + def test_blank_server_secret_disables_every_internal_download_endpoint + Doubtfire::Application.config.caddy_download_auth_secret = nil + + INTERNAL_REQUESTS.each do |endpoint, original_uri| + request_internal_download(endpoint, original_uri) + assert_equal 404, last_response.status, "#{endpoint} remained enabled without a server secret" + end + end + + def test_trusted_internal_download_endpoints_still_require_user_authentication + INTERNAL_REQUESTS.each do |endpoint, original_uri| + request_internal_download(endpoint, original_uri) + assert_equal 401, last_response.status, "#{endpoint} did not require user authentication" + end + end + + def test_internal_download_endpoints_reject_unrecognised_original_uris + user = FactoryBot.create(:user) + add_auth_header_for(user: user) + + INTERNAL_REQUESTS.each_key do |endpoint| + header 'X-OnTrack-File', '../../etc/passwd' + request_internal_download(endpoint, '/etc/passwd') + assert_equal 404, last_response.status, "#{endpoint} accepted an unrecognised original URI" + assert_nil last_response.headers['X-OnTrack-File'] + end + end + + def test_submission_download_returns_only_an_authorised_relative_path + unit, project, task_definition, task = create_student_task + submission_path = task.final_pdf_path + write_file(submission_path, '%PDF submission') + + add_auth_header_for(user: project.student) + header 'X-OnTrack-File', '../../etc/passwd' + request_internal_download( + '/api/internal/downloads/submission', + "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission" + ) + + assert_equal 200, last_response.status + assert_safe_relative_file_header(submission_path) + assert_equal 'application/pdf', last_response.headers['X-OnTrack-Content-Type'] + + clear_auth_header + add_auth_header_for(user: FactoryBot.create(:user, :student)) + request_internal_download( + '/api/internal/downloads/submission', + "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission" + ) + + assert_equal 403, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + ensure + unit&.destroy + end + + def test_submission_download_rejects_a_task_definition_from_another_unit + unit, project, = create_student_task + other_unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + other_task_definition = other_unit.task_definitions.first + add_auth_header_for(user: project.student) + + request_internal_download( + '/api/internal/downloads/submission', + "/api/projects/#{project.id}/task_def_id/#{other_task_definition.id}/submission" + ) + + assert_equal 404, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + ensure + unit&.destroy + other_unit&.destroy + end + + def test_download_rejects_a_symlink_that_escapes_student_work + unit, project, task_definition, task = create_student_task + + Tempfile.create('outside-student-work') do |outside_file| + outside_file.write('not student work') + outside_file.flush + submission_path = task.final_pdf_path + FileUtils.mkdir_p(File.dirname(submission_path)) + FileUtils.rm_f(submission_path) + File.symlink(outside_file.path, submission_path) + + add_auth_header_for(user: project.student) + request_internal_download( + '/api/internal/downloads/submission', + "/api/projects/#{project.id}/task_def_id/#{task_definition.id}/submission" + ) + + assert_equal 404, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + end + ensure + unit&.destroy + end + + def test_pdf_download_enforces_record_scope_and_permissions + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + task_definition = unit.task_definitions.first + task_sheet_path = task_definition.task_sheet(false) + write_file(task_sheet_path, '%PDF task sheet') + + add_auth_header_for(user: unit.main_convenor_user) + request_internal_download( + '/api/internal/downloads/pdf-file', + "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/task_pdf" + ) + + assert_equal 200, last_response.status + assert_safe_relative_file_header(task_sheet_path) + + clear_auth_header + add_auth_header_for(user: FactoryBot.create(:user, :student)) + request_internal_download( + '/api/internal/downloads/pdf-file', + "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/task_pdf" + ) + + assert_equal 403, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + ensure + unit&.destroy + end + + def test_jplag_report_download_requires_permission_to_download_jplag_reports + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + task_definition = unit.task_definitions.first + report_path = FileHelper.task_jplag_report_path(unit, task_definition) + write_file(report_path, 'PK jplag report') + report_uri = "/api/units/#{unit.id}/task_definitions/#{task_definition.id}/jplag_report" + + add_auth_header_for(user: unit.main_convenor_user) + request_internal_download('/api/internal/downloads/pdf-file', report_uri) + + assert_equal 200, last_response.status + assert_safe_relative_file_header(report_path) + assert_equal 'application/octet-stream', last_response.headers['X-OnTrack-Content-Type'] + assert_includes last_response.headers['X-OnTrack-Content-Disposition'], + "#{unit.code}-#{task_definition.abbreviation}-jplag-report.jplag" + + clear_auth_header + add_auth_header_for(user: FactoryBot.create(:user, :student)) + request_internal_download('/api/internal/downloads/pdf-file', report_uri) + + assert_equal 403, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + ensure + unit&.destroy + end + + def test_unit_content_download_requires_content_cookie_and_confines_the_file + unit, site = create_content_site + content_token = unit.main_convenor_user.generate_content_authentication_token! + set_content_credentials(unit.main_convenor_user, content_token) + + request_internal_download( + '/api/internal/downloads/unit-content', + "/api/units/#{unit.id}/content/sites/#{site.id}/files/index.html" + ) + + assert_equal 200, last_response.status + assert_safe_relative_file_header(File.join(site.served_dir, 'index.html')) + + Tempfile.create('outside-unit-content') do |outside_file| + outside_file.write('outside content') + outside_file.flush + served_file = File.join(site.served_dir, 'index.html') + FileUtils.rm_f(served_file) + File.symlink(outside_file.path, served_file) + + request_internal_download( + '/api/internal/downloads/unit-content', + "/api/units/#{unit.id}/content/sites/#{site.id}/files/index.html" + ) + + assert_equal 404, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + end + ensure + unit&.destroy + end + + def test_unit_content_file_paths_are_url_encoded_for_the_file_server + unit, site = create_content_site + content_token = unit.main_convenor_user.generate_content_authentication_token! + set_content_credentials(unit.main_convenor_user, content_token) + + # Caddy rewrites the request to X-OnTrack-File, so an unencoded '?' would + # truncate the path and an unencoded '%' would decode to a different file. + { + 'query?name.txt' => 'query%3Fname.txt', + '100%20done.txt' => '100%2520done.txt', + 'with space.txt' => 'with%20space.txt' + }.each do |name, expected| + write_file(File.join(site.served_dir, name), 'content') + + request_internal_download( + '/api/internal/downloads/unit-content', + "/api/units/#{unit.id}/content/sites/#{site.id}/files/#{ERB::Util.url_encode(name)}" + ) + + assert_equal 200, last_response.status, "#{name} was not served" + assert_equal expected, last_response.headers['X-OnTrack-File'].split('/').last + end + ensure + unit&.destroy + end + + def test_native_download_cookies_are_scoped_to_their_own_download + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + user = unit.main_convenor_user + task_definition = unit.task_definitions.first + downloads = native_downloads(unit, task_definition, user) + + downloads.each do |download| + write_file(download[:file_path], 'archive') + clear_cookies + clear_request_headers + add_auth_header_for(user: user) + post download[:access_path] + + assert_equal 204, last_response.status, "failed to issue #{download[:cookie_name]}" + set_cookie = set_cookie_line(download[:cookie_name]) + assert_match(/path=#{Regexp.escape(download[:public_path])}/i, set_cookie) + assert_match(/HttpOnly/i, set_cookie) + assert_match(/SameSite=Strict/i, set_cookie) + + encrypted_cookie = issued_cookie_value(download[:cookie_name]) + clear_auth_header + + # Resumed downloads re-request the same URL, so reuse must work. + 2.times do |attempt| + replay_cookie(download[:cookie_name], encrypted_cookie) + request_internal_download(download[:internal_path], download[:public_path]) + + assert_equal 200, last_response.status, + "use #{attempt + 1} of #{download[:cookie_name]} was rejected" + assert_safe_relative_file_header(download[:file_path]) + end + + downloads.reject { |other| other[:cookie_name] == download[:cookie_name] }.each do |other| + replay_cookie(download[:cookie_name], encrypted_cookie) + request_internal_download(other[:internal_path], other[:public_path]) + + assert_equal 401, last_response.status, + "#{download[:cookie_name]} authorised #{other[:internal_path]}" + assert_nil last_response.headers['X-OnTrack-File'] + end + end + ensure + unit&.destroy + end + + def test_native_download_cookie_does_not_authorise_another_task_definition + unit = FactoryBot.create(:unit, with_students: false, task_count: 2, stream_count: 0) + user = unit.main_convenor_user + issued_for, other = unit.task_definitions.order(:id).first(2) + download = native_downloads(unit, issued_for, user).second + other_download = native_downloads(unit, other, user).second + write_file(download[:file_path], 'archive') + write_file(other_download[:file_path], 'archive') + + add_auth_header_for(user: user) + post download[:access_path] + encrypted_cookie = issued_cookie_value(download[:cookie_name]) + clear_auth_header + + replay_cookie(download[:cookie_name], encrypted_cookie) + request_internal_download(other_download[:internal_path], other_download[:public_path]) + + assert_equal 401, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + ensure + unit&.destroy + end + + def test_direct_rails_downloads_name_the_file_in_content_disposition + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + user = unit.main_convenor_user + task_definition = unit.task_definitions.first + + native_downloads(unit, task_definition, user).each do |download| + write_file(download[:file_path], 'archive') + clear_cookies + clear_request_headers + add_auth_header_for(user: user) + post download[:access_path] + + encrypted_cookie = issued_cookie_value(download[:cookie_name]) + clear_auth_header + replay_cookie(download[:cookie_name], encrypted_cookie) + get download[:public_path] + + assert_equal 200, last_response.status, "#{download[:public_path]} was not served" + + # Without this the browser falls back to naming the file after the last + # URL segment, e.g. "portfolio" with no extension. + disposition = last_response.headers['Content-Disposition'] + assert_match(/\Aattachment;/, disposition) + assert_match(/filename="[^"]+\.zip"/, disposition) + assert_equal 'application/zip', last_response.headers['Content-Type'] + assert_equal 'rails', last_response.headers['X-OnTrack-Served-By'] + end + ensure + unit&.destroy + end + + def test_expired_native_download_cookie_is_rejected_without_serving_a_file + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + user = unit.main_convenor_user + task_definition = unit.task_definitions.first + download = native_downloads(unit, task_definition, user).first + write_file(download[:file_path], 'archive') + + add_auth_header_for(user: user) + post download[:access_path] + encrypted_cookie = issued_cookie_value(download[:cookie_name]) + clear_auth_header + + travel 31.seconds do + replay_cookie(download[:cookie_name], encrypted_cookie) + request_internal_download(download[:internal_path], download[:public_path]) + + assert_equal 401, last_response.status + assert_nil last_response.headers['X-OnTrack-File'] + end + ensure + unit&.destroy + end + + private + + def request_internal_download(endpoint, original_uri, secret: CADDY_SECRET) + header 'X-OnTrack-Download-Auth', secret + header 'X-Forwarded-Uri', original_uri + get endpoint + end + + def clear_request_headers + clear_auth_header + header 'Cookie', nil + header 'X-OnTrack-Download-Auth', nil + header 'X-Forwarded-Uri', nil + end + + def create_student_task + unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0) + student = FactoryBot.create(:user, :student) + project = unit.enrol_student(student, nil) + task_definition = unit.task_definitions.first + task = project.task_for_task_definition(task_definition) + [unit, project, task_definition, task] + end + + def create_content_site + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, stream_count: 0) + archive = Tempfile.new(['download-authorization-content', '.zip']) + Zip::File.open(archive.path, Zip::File::CREATE) do |zip| + zip.get_output_stream('index.html') { |stream| stream.write('

    Content

    ') } + end + site = UnitContentSite.store_upload!( + unit, + { filename: 'content.zip', tempfile: archive } + ) + archive.close + [unit, site] + end + + def set_content_credentials(user, token) + set_cookie "username=#{Rack::Utils.escape(user.username)}" + set_cookie "#{AuthenticationHelpers::CONTENT_TOKEN_COOKIE}=#{Rack::Utils.escape(token.authentication_token)}" + end + + def native_downloads(unit, task_definition, user) + [ + { + access_path: "/api/submission/unit/#{unit.id}/portfolio/access", + public_path: "/api/submission/unit/#{unit.id}/portfolio", + internal_path: '/api/internal/downloads/portfolio', + cookie_name: NativeDownloadCookie.cookie_name(:portfolio), + file_path: unit.get_portfolio_zip_filename(user) + }, + { + access_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/download_submissions/access", + public_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/download_submissions", + internal_path: '/api/internal/downloads/task-submission-files', + cookie_name: NativeDownloadCookie.cookie_name(:task_submission_files), + file_path: unit.task_submissions_zip_path(user, task_definition) + }, + { + access_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/student_pdfs/access", + public_path: "/api/submission/unit/#{unit.id}/task_definitions/#{task_definition.id}/student_pdfs", + internal_path: '/api/internal/downloads/task-submission-pdfs', + cookie_name: NativeDownloadCookie.cookie_name(:task_submission_pdfs), + file_path: unit.task_submissions_pdf_zip_path(user, task_definition) + } + ] + end + + def replay_cookie(name, value) + # `value` is already in Set-Cookie wire form, so replay it verbatim. + header 'Cookie', "#{name}=#{value}" + end + + def set_cookie_line(name) + Array(last_response.headers['Set-Cookie']).join("\n").lines.map(&:strip) + .find { |line| line.start_with?("#{name}=") } + end + + # CGI::Cookie#value is array-like and holds the percent-encoded wire value, + # which is what the browser sends back. Rack::Test's own accessors drop the + # cookie's flags, so attribute assertions read the raw header instead. + def issued_cookie_value(name) + last_response.cookies.fetch(name).value.first.to_s + end + + def write_file(path, contents) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, contents) + end + + def assert_safe_relative_file_header(expected_path) + relative_path = last_response.headers['X-OnTrack-File'] + expected = Pathname.new(expected_path).realpath.relative_path_from(Pathname.new(@student_work_dir).realpath).to_s + assert_equal expected.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/'), relative_path + assert_not Pathname.new(relative_path).absolute? + assert_not_includes Pathname.new(relative_path).each_filename.to_a, '..' + end +end diff --git a/test/factories/campuses_factory.rb b/test/factories/campuses_factory.rb index c0fa5b55bb..1bd881497a 100644 --- a/test/factories/campuses_factory.rb +++ b/test/factories/campuses_factory.rb @@ -2,7 +2,7 @@ factory :campus do name { Faker::Educator.unique.campus } abbreviation { name[0...9] } - mode { ['timetable', 'automatic', 'manual'].sample } + mode { %w[timetable automatic manual].sample } active { true } end end diff --git a/test/factories/discussion_comments.rb b/test/factories/discussion_comments.rb index a32a5e89c8..554c46da08 100644 --- a/test/factories/discussion_comments.rb +++ b/test/factories/discussion_comments.rb @@ -2,7 +2,7 @@ factory :discussion_comment do time_started { "2019-04-23 18:27:36" } time_completed { "2019-04-23 18:27:36" } - started { "" } + started { "" } completed { "" } end end diff --git a/test/factories/engagements_factory.rb b/test/factories/engagements_factory.rb new file mode 100644 index 0000000000..0f21d6fd17 --- /dev/null +++ b/test/factories/engagements_factory.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :engagement do + project + user + engagement_type { 'attendance' } + note { 'Attended the weekly discussion.' } + occurred_at { Time.zone.now } + end + + factory :engagement_comment do + engagement + user + comment { 'Thanks for recording this.' } + end +end diff --git a/test/factories/groups_factory.rb b/test/factories/groups_factory.rb index 684dcf8e09..19a28dce24 100644 --- a/test/factories/groups_factory.rb +++ b/test/factories/groups_factory.rb @@ -4,9 +4,9 @@ factory :group do group_set - sequence(:name) { |n| "Group-#{n}" } + sequence(:name) { |n| "Group-#{n}" } - after(:build) do |group, eval| + after(:build) do |group, _eval| if group.tutorial.nil? group.tutorial = group.group_set.unit.tutorials.first end diff --git a/test/factories/moderated_tasks.rb b/test/factories/moderated_tasks.rb index df1075db35..d936d62b93 100644 --- a/test/factories/moderated_tasks.rb +++ b/test/factories/moderated_tasks.rb @@ -1,5 +1,4 @@ FactoryBot.define do factory :moderated_task do - end end diff --git a/test/factories/overflow_task_claim_logs.rb b/test/factories/overflow_task_claim_logs.rb new file mode 100644 index 0000000000..a853348015 --- /dev/null +++ b/test/factories/overflow_task_claim_logs.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :overflow_task_claim_log do + task { create(:task) } + unit { task.project.unit } + claimed_by_unit_role do + create(:unit_role, unit: unit, user: create(:user, :tutor), role: Role.tutor) + end + claimed_by_user { claimed_by_unit_role.user } + original_tutor_user_id { task.tutor&.id } + student_user_id { task.project.student.id } + days_awaiting_feedback { task.days_awaiting_feedback } + claimed_at { Time.zone.now } + end +end diff --git a/test/factories/overflow_task_claims.rb b/test/factories/overflow_task_claims.rb index b4def1a12c..1bb3abb700 100644 --- a/test/factories/overflow_task_claims.rb +++ b/test/factories/overflow_task_claims.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :overflow_task_claim do - + task + claimed_by_unit_role { create(:unit_role, unit: task.project.unit, user: create(:user, :tutor), role: Role.tutor) } end end diff --git a/test/factories/overseer_assessments.rb b/test/factories/overseer_assessments.rb index cd1020a653..d858906fb6 100644 --- a/test/factories/overseer_assessments.rb +++ b/test/factories/overseer_assessments.rb @@ -1,7 +1,8 @@ FactoryBot.define do factory :overseer_assessment do - task nil - submission_timestamp { "MyString" } + submission_history + task { submission_history.task } + submission_timestamp { submission_history.submission_timestamp } result_task_status { "MyString" } end end diff --git a/test/factories/overseer_steps.rb b/test/factories/overseer_steps.rb index 284b2e6b1a..5bce08ba25 100644 --- a/test/factories/overseer_steps.rb +++ b/test/factories/overseer_steps.rb @@ -1,5 +1,4 @@ FactoryBot.define do factory :overseer_step do - end end diff --git a/test/factories/projects_factory.rb b/test/factories/projects_factory.rb index a5823e0714..8e975e3ff1 100644 --- a/test/factories/projects_factory.rb +++ b/test/factories/projects_factory.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :project do - unit { create(:unit, with_students:false) } + unit { create(:unit, with_students: false) } campus { Campus.offset(rand(Campus.count)).first || create(:campus) } task_stats { Project::DEFAULT_TASK_STATS } user diff --git a/test/factories/submission_histories.rb b/test/factories/submission_histories.rb new file mode 100644 index 0000000000..974aa49390 --- /dev/null +++ b/test/factories/submission_histories.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :submission_history do + task + sequence(:submission_timestamp) { |n| "#{Time.current.to_i}-#{n}" } + end +end diff --git a/test/factories/task_completion_snapshot_factory.rb b/test/factories/task_completion_snapshot_factory.rb new file mode 100644 index 0000000000..871bd0611a --- /dev/null +++ b/test/factories/task_completion_snapshot_factory.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :task_completion_snapshot do + unit + snapshot_timestamp { Time.current.to_i.to_s } + end +end diff --git a/test/factories/task_definition_grade_due_dates.rb b/test/factories/task_definition_grade_due_dates.rb index 027a44fee7..237d4758dd 100644 --- a/test/factories/task_definition_grade_due_dates.rb +++ b/test/factories/task_definition_grade_due_dates.rb @@ -1,5 +1,4 @@ FactoryBot.define do factory :task_definition_grade_due_date do - end end diff --git a/test/factories/teaching_period_factory.rb b/test/factories/teaching_period_factory.rb index 9af6fdca54..1c4c652c8b 100644 --- a/test/factories/teaching_period_factory.rb +++ b/test/factories/teaching_period_factory.rb @@ -2,14 +2,13 @@ FactoryBot.define do factory :teaching_period do sequence(:period, (1..3).cycle) { |n| "T#{n}" } - sequence(:start_date, (1..3).cycle) { |n| Time.zone.now + n * 15.weeks } + sequence(:start_date, (1..3).cycle) { |n| Time.zone.now + (n * 15.weeks) } year { start_date.year } end_date { start_date + 14.weeks } active_until { end_date + rand(1..2).weeks } - end factory :breaks do - start_date {start_date.Time.zone.now} - number_of_weeks {number_of_weeks.rand(1..3)} - end + start_date { start_date.Time.zone.now } + number_of_days { number_of_days.rand(1..3) * 7 } + end end diff --git a/test/factories/tutor_feedback_scores.rb b/test/factories/tutor_feedback_scores.rb index 9ac2a7acd5..ec9502143c 100644 --- a/test/factories/tutor_feedback_scores.rb +++ b/test/factories/tutor_feedback_scores.rb @@ -1,5 +1,4 @@ FactoryBot.define do factory :tutor_feedback_score do - end end diff --git a/test/factories/units_factory.rb b/test/factories/units_factory.rb index 7cc6de1090..4c36d42e9e 100644 --- a/test/factories/units_factory.rb +++ b/test/factories/units_factory.rb @@ -2,13 +2,12 @@ require './lib/helpers/database_populator' FactoryBot.define do - factory :task_definition do unit name { Faker::Lorem.unique.word } description { Faker::Lorem.sentence } target_grade { rand(GradeHelper::RANGE) } - upload_requirements { [{'key' => 'file0','name' => 'Imported Code','type' => 'code'}] } + upload_requirements { [{ 'key' => 'file0', 'name' => 'Imported Code', 'type' => 'code' }] } start_date { unit.start_date + rand(1..12).weeks } sequence(:abbreviation) { |n| "#{GradeHelper.short_grade_for target_grade}#{((unit.start_date - start_date) / 1.week).floor + 1}.#{n}" } weighting { rand(1..5) } @@ -44,15 +43,15 @@ part_enrolled_student_count { 2 } inactive_student_count { 1 } task_count { 2 } - tutorials { 1 } #per campus - tutorial_config { [] } #[ {stream: 0, campus: 0} ] + tutorials { 1 } # per campus + tutorial_config { [] } # [ {stream: 0, campus: 0} ] group_sets { 0 } - groups { [] } #[ { gs: 0, students:0 } ] - group_tasks { [] } #[ {idx: 0, gs: 0 }] - index of task, and index of group set + groups { [] } # [ { gs: 0, students:0 } ] + group_tasks { [] } # [ {idx: 0, gs: 0 }] - index of task, and index of group set outcome_count { 2 } stream_count { 0 } campus_count { 1 } - set_one_of_each_task { false } # In addition to the standard tasks, also add one of each different think of task - group, quality, graded, etc. + set_one_of_each_task { false } # In addition to the standard tasks, also add one of each different think of task - group, quality, graded, etc. perform_submissions { false } staff_count { 1 } end @@ -68,7 +67,7 @@ send_notifications { true } allow_flexible_dates { false } - after(:create) do | unit, eval | + after(:create) do |unit, eval| group_sets = eval.group_sets task_count = eval.task_count group_tasks = eval.group_tasks.clone @@ -79,10 +78,10 @@ if eval.set_one_of_each_task group_sets = 1 unless group_sets > 0 task_count = 3 if task_count < 3 - group_tasks << {idx: 0, gs: group_sets - 1} + group_tasks << { idx: 0, gs: group_sets - 1 } group_sets.times do |gs| (eval.student_count / 4).times do - groups << { gs: gs, students: 4} + groups << { gs: gs, students: 4 } end end end @@ -109,15 +108,15 @@ break if (c == campuses.last) && tutorial_streams.count > 0 if tutorial_streams.count > 0 - tutorial_streams.each { |ts| create_list(:tutorial, eval.tutorials, unit: unit, campus: c, tutorial_stream: ts ) } + tutorial_streams.each { |ts| create_list(:tutorial, eval.tutorials, unit: unit, campus: c, tutorial_stream: ts) } else - create_list(:tutorial, eval.tutorials, unit: unit, campus: c ) + create_list(:tutorial, eval.tutorials, unit: unit, campus: c) end end # Now update last campus - give it tutorials with no tutorial streams to mimic "cloud" if tutorial_streams.count > 0 - create_list(:tutorial, eval.tutorials, unit: unit, campus: campuses.last ) + create_list(:tutorial, eval.tutorials, unit: unit, campus: campuses.last) end unit.employ_staff(FactoryBot.create(:user, :convenor), Role.convenor) @@ -136,15 +135,17 @@ campuses.each do |c| campus_tutorials = unit.tutorials.where(campus: c) (eval.unenrolled_student_count + eval.student_count + eval.part_enrolled_student_count + eval.inactive_student_count).times do |i| - p = unit.enrol_student( FactoryBot.create(:user, :student), c ) + p = unit.enrol_student(FactoryBot.create(:user, :student), c) next if i < eval.unenrolled_student_count if i < eval.unenrolled_student_count + eval.inactive_student_count p.update(enrolled: false) next end + next if campus_tutorials.empty? + if campus_tutorials.first.tutorial_stream.present? - tutorial_streams.each_with_index do |ts, i| + tutorial_streams.each_with_index do |ts, _i| p.enrol_in ts.tutorials.where(campus_id: c.id).sample end else @@ -153,7 +154,7 @@ end eval.part_enrolled_student_count.times do - unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample.destroy + unit.tutorial_enrolments.joins(:project).where('projects.campus_id = :campus_id', campus_id: c.id).sample&.destroy end end @@ -182,10 +183,10 @@ task = p.task_for_task_definition td tutor = p.tutor_for(td) - unless [TaskStatus.ready_for_feedback, TaskStatus.need_help, TaskStatus.working_on_it].include? ts - DatabasePopulator.assess_task(p, task, tutor, ts, td.start_date + 1.week) - else + if [TaskStatus.ready_for_feedback, TaskStatus.need_help, TaskStatus.working_on_it].include? ts DatabasePopulator.assess_task(p, task, p.student, ts, td.start_date + 1.week) + else + DatabasePopulator.assess_task(p, task, tutor, ts, td.start_date + 1.week) end end end diff --git a/test/factories/users_factory.rb b/test/factories/users_factory.rb index 6df2c2c6ca..b7e9fe62ea 100644 --- a/test/factories/users_factory.rb +++ b/test/factories/users_factory.rb @@ -10,19 +10,19 @@ role { Role.student } student_id { Faker::Number.number(digits: 7) } - before(:create) do |user, eval| - while User.where(username: user.username).count > 0 - user.username = "#{user.username}-#{rand(1000)}" - end + before(:create) do |user, _eval| + user.username = "#{user.username}-#{rand(1000)}" while User.where(username: user.username).count > 0 - while User.where(student_id: user.student_id).count > 0 - user.student_id = user.student_id.to_i + 1 - end + user.student_id = user.student_id.to_i + 1 while User.where(student_id: user.student_id).count > 0 + + # Faker only dedupes within a run, so a seeded database can already hold + # the address it hands out. + user.email = "#{rand(1000)}-#{user.email}" while User.where(email: user.email).count > 0 end trait :student do transient do - enrol_in { 0 } # Number of units to enrol into + enrol_in { 0 } # Number of units to enrol into end after(:create) do |user, eval| diff --git a/test/helpers/account_helper.rb b/test/helpers/account_helper.rb index b512e6872c..43278040c5 100644 --- a/test/helpers/account_helper.rb +++ b/test/helpers/account_helper.rb @@ -1,7 +1,5 @@ module TestHelpers - module AccountHelper - ################################################## # Student user ################################################## @@ -65,7 +63,5 @@ def first_admin_user def last_admin_user admin_users.last end - end - -end \ No newline at end of file +end diff --git a/test/helpers/auth_helper.rb b/test/helpers/auth_helper.rb index c60348949b..205e84c4bc 100644 --- a/test/helpers/auth_helper.rb +++ b/test/helpers/auth_helper.rb @@ -17,7 +17,7 @@ def auth_token(user = User.first, token_type = :general) token = user.valid_auth_tokens.where(token_type: token_type).first return token.authentication_token unless token.nil? - return user.generate_authentication_token!(token_type: token_type).authentication_token + user.generate_authentication_token!(token_type: token_type).authentication_token end # diff --git a/test/helpers/json_helper.rb b/test/helpers/json_helper.rb index d1f52d490d..6e9fe248a0 100644 --- a/test/helpers/json_helper.rb +++ b/test/helpers/json_helper.rb @@ -7,6 +7,8 @@ module TestHelpers module JsonHelper module_function + # These helpers use Rack::Test, which takes the request body as its second positional argument. + # rubocop:disable Rails/HttpPositionalArguments # # POSTs a hash data as JSON with content-type "application/json" # @@ -20,8 +22,11 @@ def post_json(endpoint, data) # PUTs a hash data as JSON with content-type "application/json" # def put_json(endpoint, data) - put URI::Parser.new.escape(endpoint), data.to_json, 'CONTENT_TYPE' => 'application/json' + header 'CONTENT_TYPE', 'application/json' + + put URI::Parser.new.escape(endpoint), data.to_json end + # rubocop:enable Rails/HttpPositionalArguments # # PUTs a hash data as JSON with content-type "application/json" diff --git a/test/helpers/lti_helper_test.rb b/test/helpers/lti_helper_test.rb new file mode 100644 index 0000000000..29369d02a2 --- /dev/null +++ b/test/helpers/lti_helper_test.rb @@ -0,0 +1,10 @@ +require 'test_helper' + +class LtiHelperTest < ActiveSupport::TestCase + test 'only one of many concurrent uses of a token id succeeds' do + jti = SecureRandom.uuid + results = Array.new(8) { Thread.new { LtiHelper.first_use?(jti, 30) } }.map(&:value) + + assert_equal 1, results.count(true), results.inspect + end +end diff --git a/test/helpers/test_file_helper.rb b/test/helpers/test_file_helper.rb index 0f4c543180..7001289fb5 100644 --- a/test/helpers/test_file_helper.rb +++ b/test/helpers/test_file_helper.rb @@ -11,15 +11,15 @@ def test_file_path(filename) Rails.root.join('test_files', filename) end - def with_files( files, hash ) - files.each_with_index do | file, idx | - hash[ "file#{idx}".to_sym ] = Rack::Test::UploadedFile.new(Rails.root.join(file[:path]), file[:type]) + def with_files(files, hash) + files.each_with_index do |file, idx| + hash[:"file#{idx}"] = Rack::Test::UploadedFile.new(Rails.root.join(file[:path]), file[:type]) end hash end def with_file(path, type, hash) - with_files( [ {path: path, type: type} ], hash) + with_files([{ path: path, type: type }], hash) end def upload_file_csv(path) @@ -29,6 +29,5 @@ def upload_file_csv(path) def upload_file(path, type) Rack::Test::UploadedFile.new(Rails.root.join(path), type) end - end end diff --git a/test/helpers/tii_test_helper.rb b/test/helpers/tii_test_helper.rb index 0a76ab35c5..1e52499659 100644 --- a/test/helpers/tii_test_helper.rb +++ b/test/helpers/tii_test_helper.rb @@ -8,12 +8,13 @@ module TiiTestHelper module_function def tii_headers(base = {}) + api_key = Doubtfire::Application.fetch_credential_or_env(:tii, :api_key, env_key: 'TCA_API_KEY') base["headers"] = { - 'Accept'=>'application/json', - 'Authorization'=>"Bearer #{ENV['TCA_API_KEY']}", - 'Content-Type'=>'application/json', - 'X-Turnitin-Integration-Name'=>'formatif-tii', - 'X-Turnitin-Integration-Version'=>'1.0' + 'Accept' => 'application/json', + 'Authorization' => "Bearer #{api_key}", + 'Content-Type' => 'application/json', + 'X-Turnitin-Integration-Name' => 'formatif-tii', + 'X-Turnitin-Integration-Version' => '1.0' } base @@ -36,46 +37,46 @@ def setup_tii_features_enabled TCAClient::FeaturesEnabled.new( { similarity: TCAClient::FeaturesSimilarity.new( - viewer_modes: { - match_overview: true, - all_sources: true - }, - generation_settings: TCAClient::FeaturesGenerationSettings.new( - search_repositories: [ - 'INTERNET', - 'SUBMITTED_WORK', - 'PUBLICATION', - 'CROSSREF', - 'CROSSREF_POSTED_CONTENT', - ], - submission_auto_excludes: true - ), - view_settings: { - exclude_bibliography: true, - exclude_quotes: true, - exclude_abstract: true, - exclude_methods: true, - exclude_small_matches: true, - exclude_internet: true, - exclude_publications: true, - exclude_crossref: true, - exclude_crossref_posted_content: true, - exclude_submitted_works: true, - exclude_citations: true, - exclude_preprints: true - } + viewer_modes: { + match_overview: true, + all_sources: true + }, + generation_settings: TCAClient::FeaturesGenerationSettings.new( + search_repositories: %w[ + INTERNET + SUBMITTED_WORK + PUBLICATION + CROSSREF + CROSSREF_POSTED_CONTENT + ], + submission_auto_excludes: true ), + view_settings: { + exclude_bibliography: true, + exclude_quotes: true, + exclude_abstract: true, + exclude_methods: true, + exclude_small_matches: true, + exclude_internet: true, + exclude_publications: true, + exclude_crossref: true, + exclude_crossref_posted_content: true, + exclude_submitted_works: true, + exclude_citations: true, + exclude_preprints: true + } + ), tenant: TCAClient::FeaturesTenant.new({ - require_eula: true - }), + require_eula: true + }), product_name: 'Turnitin Originality', - access_options: [ - 'NATIVE', - 'CORE_API', - 'DRAFT_COACH' + access_options: %w[ + NATIVE + CORE_API + DRAFT_COACH ] - } - ) + } + ) end end @@ -99,31 +100,31 @@ def setup_tii_eula valid_from: "2018-04-30T17:00:00Z", valid_until: nil, url: "https://static.turnitin.com/eula/v1beta/fr-fr/eula.html", - available_languages: [ - "sv-SE", - "zh-CN", - "ja-JP", - "ko-KR", - "es-MX", - "nl-NL", - "ru-RU", - "zh-TW", - "ar-SA", - "pt-BR", - "de-DE", - "el-GR", - "nb-NO", - "cs-CZ", - "da-DK", - "tr-TR", - "pl-PL", - "fi-FI", - "it-IT", - "bl-AH", - "vi-VN", - "fr-FR", - "en-US", - "ro-RO" + available_languages: %w[ + sv-SE + zh-CN + ja-JP + ko-KR + es-MX + nl-NL + ru-RU + zh-TW + ar-SA + pt-BR + de-DE + el-GR + nb-NO + cs-CZ + da-DK + tr-TR + pl-PL + fi-FI + it-IT + bl-AH + vi-VN + fr-FR + en-US + ro-RO ] ) end diff --git a/test/mailers/communications_mailer_test.rb b/test/mailers/communications_mailer_test.rb new file mode 100644 index 0000000000..692af180e2 --- /dev/null +++ b/test/mailers/communications_mailer_test.rb @@ -0,0 +1,34 @@ +require 'test_helper' + +class CommunicationsMailerTest < ActionMailer::TestCase + def test_plain_text_email_contains_only_the_entered_body + body = "Hello Ada,\n\nPlease review your work." + message = communication_email(body: body, plain_text: true) + + assert_match %r{\Atext/plain}, message.content_type + assert_nil message.html_part + assert_equal body, message.body.decoded.gsub("\r\n", "\n") + end + + def test_default_email_keeps_the_notification_layout + message = communication_email(body: 'Please review your work.') + + assert message.html_part + assert message.text_part + assert_includes message.html_part.body.decoded, 'Notification' + end + + private + + def communication_email(body:, plain_text: false) + CommunicationsMailer.communication_email( + to: 'student@example.com', + from: 'convenor@example.com', + subject: 'Reminder', + body: body, + recipient: nil, + sender: Struct.new(:name).new('Convenor'), + plain_text: plain_text + ) + end +end diff --git a/test/mailers/error_log_mailer_test.rb b/test/mailers/error_log_mailer_test.rb index ed990597f9..8f98e5f36c 100644 --- a/test/mailers/error_log_mailer_test.rb +++ b/test/mailers/error_log_mailer_test.rb @@ -30,4 +30,31 @@ def test_latex_error_logs_are_attached assert mail.attachments['log.txt'].present? assert mail.attachments['log.txt'].body.include? 'this is the content of the log' end + + def test_portfolio_error_mail_keeps_student_details_and_latex_log + Doubtfire::Application.config.email_errors_to = 'test ' + details = '5928 - Test Example (example123) COURSE123' + exception = Project::LatexError.new('full portfolio latex log') + mail = ErrorLogMailer.error_message( + "Failed portfolio job: #{details}", + "Failed to create portfolio for project #{details}", + exception + ) + + assert_includes mail.subject, details + assert_includes mail.text_part.body.decoded, details + assert_equal 'full portfolio latex log', mail.attachments['log.txt'].body.decoded + end + + def test_can_send_error_log_mail_without_backtrace + Doubtfire::Application.config.email_errors_to = 'test ' + exception = StandardError.new('test') + + mail = ErrorLogMailer.error_message('test', 'test message', exception) + + assert mail.present? + assert mail.to.include? 'test@test.com' + assert mail.body.include? exception.message + assert mail.body.include? 'No backtrace available' + end end diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 1acf5aa90a..9dda5cc7cb 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -30,8 +30,8 @@ def test_send_portfolio_ready_from_main_convenor mail = PortfolioEvidenceMailer.portfolio_ready(project) - assert_equal 1, mail.from().count - assert_equal convenor.email, mail.from().first + assert_equal 1, mail.from.count + assert_equal convenor.email, mail.from.first assert mail.html_part.body.include? "projects/#{project.id}/portfolio" unit.destroy! end @@ -48,10 +48,143 @@ def test_send_portfolio_fail_from_main_convenor mail = PortfolioEvidenceMailer.portfolio_failed(project) - assert_equal 1, mail.from().count - assert_equal convenor.email, mail.from().first + assert_equal 1, mail.from.count + assert_equal convenor.email, mail.from.first assert mail.html_part.body.include? "projects/#{project.id}/portfolio" unit.destroy! end + def test_send_overseer_assessment_failed_email + unit = FactoryBot.create :unit + convenor = FactoryBot.create :user, :convenor + + ur = unit.employ_staff convenor, Role.convenor + + unit.update main_convenor: ur + + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + + mail = PortfolioEvidenceMailer.overseer_assessment_failed(project, [task]) + + assert_equal 1, mail.from.count + assert_equal convenor.email, mail.from.first + assert_equal project.student.email, mail.to.first + assert mail.html_part.body.include? "projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + end + + def test_send_discussion_deadline_emails + unit = FactoryBot.create(:unit) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + sender = unit.main_convenor_user + deadline = 7.days.from_now.to_date + + approaching = NotificationsMailer.discussion_deadline_approaching(task, sender, deadline) + missed = NotificationsMailer.discussion_deadline_missed(task, sender) + + assert_equal project.student.email, approaching.to.first + assert_includes approaching.subject, 'Discussion deadline approaching' + assert_includes approaching.text_part.body.to_s, 'on or before' + assert_includes approaching.html_part.body.to_s, 'on or before' + assert_includes approaching.text_part.body.to_s, unit.formatted_discuss_timeout_date(deadline) + assert_includes approaching.text_part.body.to_s, "projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + assert_equal 1, approaching.html_part.body.to_s.scan('