diff --git a/.ci-setup/texlive-install.sh b/.ci-setup/texlive-install.sh index 52e7844df..83b57f147 100755 --- a/.ci-setup/texlive-install.sh +++ b/.ci-setup/texlive-install.sh @@ -28,7 +28,7 @@ if ! command -v "$TEX_COMPILER" > /dev/null; then echo "----------------------------------------" echo "Installing additional texlive packages:" - tlmgr install fontawesome minted fvextra catchfile xstring framed lastpage tcolorbox environ pdfcol tikzfill markdown paralist csvsimple upquote tagpdf + tlmgr install fontawesome luatextra luacode minted fvextra catchfile xstring framed lastpage pdfmanagement-testphase newpax tcolorbox environ pdfcol tikzfill markdown paralist csvsimple gobble upquote tagpdf echo "----------------------------------------" echo "Ensuring the newpax package is sufficiently up to date:" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..d56abbf30 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 481e82b6c..3ef1f7a03 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,4 @@ student-work/ .idea/ .byebug_history coverage/ -.vscode _history diff --git a/.rubocop.yml b/.rubocop.yml index a9294627d..8f52c39ca 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,6 +9,12 @@ AllCops: TargetRubyVersion: 3.1 NewCops: disable +require: + - rubocop-rails + # - rubocop-performance + # - rubocop-minitest + # - rubocop-factory_bot + Style/HashSyntax: EnforcedShorthandSyntax: never diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7e6882bfa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4744417cb..623d30169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,590 @@ 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. +### [8.0.42](https://github.com/macite/doubtfire-deploy/compare/v8.0.41...v8.0.42) (2025-03-10) + + +### Bug Fixes + +* ensure submission history uses archive and moves ([e9161b3](https://github.com/macite/doubtfire-deploy/commit/e9161b380d0b32850df53baf2f7ab3f356fb64ba)) +* remove archive old unit from schedule ([f16fdee](https://github.com/macite/doubtfire-deploy/commit/f16fdeed9d0dcc9882a0615784800406fd021530)) + +### [8.0.41](https://github.com/macite/doubtfire-deploy/compare/v8.0.40...v8.0.41) (2025-03-07) + + +### Features + +* add ability to get individual overseer images ([9c32326](https://github.com/macite/doubtfire-deploy/commit/9c323269bcc1a41c2d1e37702a7bb90efea72773)) + +### [8.0.40](https://github.com/macite/doubtfire-deploy/compare/v8.0.39...v8.0.40) (2025-01-31) + + +### Features + +* add scheduled archive of units ([9b2cf03](https://github.com/macite/doubtfire-deploy/commit/9b2cf03986277a1d0016b5fd2d020cdf19c84271)) +* auto archive of units is optional ([23bd227](https://github.com/macite/doubtfire-deploy/commit/23bd2271fc2d8420ccbee142389b539c13c299c7)) +* email users on D2L grade transfer fail ([00cb85e](https://github.com/macite/doubtfire-deploy/commit/00cb85ecb84709f9c4500364a0da6f27c8097436)) +* remove stored pdf path and allow move to archive ([33944dd](https://github.com/macite/doubtfire-deploy/commit/33944dd05e1e6d197872a5c67fbec15206f04692)) + +### [8.0.39](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.38...v8.0.39) (2025-01-22) + + +### Features + +* add ability to post grades to d2l ([8e3a387](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8e3a387c2a9e45a36e15e3d45783c02c023194b8)) +* add redirect to success page on oauth success ([ba447ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ba447ba145674411e81db37af8829c87692ef9ed)) +* add start of new d2l integration feature ([dee040f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dee040f44527e9126e375bca49df8234ef3b4f4e)) +* allow oauth login to d2l ([b300211](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b300211ecb84429c89b85722ded38a9c9e36aec5)) +* d2l mappings have crud api ([ccee700](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ccee7006422131f089372e464fe22331c1945537)) +* ensure only convenor or higher can use d2l details ([76cb354](https://github.com/doubtfire-lms/doubtfire-deploy/commit/76cb354b5f7075242c33bcbb68d59f90b640f18c)) +* ensure only one d2l mapping per unit ([178a040](https://github.com/doubtfire-lms/doubtfire-deploy/commit/178a0406ba03fc0710032c5bbf58df9f10513fc0)) +* give ability to trigger d2l result post and get results ([ca85b98](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ca85b984d0c25c5233afa09c596462c6b22ee89e)) +* handle multiple d2l grade transfer submissions ([1be09a6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1be09a6f288c8ec5662fc8d2c268623d6e78967c)) +* improve error reporting to send task log details ([9815fd2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9815fd26802dc724290dbf76261488054f122d7a)) +* include grade in d2l copy report ([9b71f85](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9b71f85293ab24271ff2cd3a34afd8c3698ae630)) +* remove old portfolios in maintenance ([ae54a1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ae54a1c884c91d3cc0c0abd0e74808f7bb2138d7)) +* simplified logging of d2l result transfer ([8ada69c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8ada69c6f8c71b3fccd21b344f3da5f6e5ab8b77)) +* use api to get login url for d2l ([e2d0fa7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e2d0fa75fb47fed484eb72bc5bc75c2f881fc4d5)) + + +### Bug Fixes + +* allow none or no_auth for smtp authentication options ([c80ccf3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c80ccf36efbd5a099c4b5a6bcae63eaac5799378)) +* check for weighted grading in d2l integration ([97d8241](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97d8241df9d29bff37ec98b9e20a4f43da0457e6)) +* correct issue creating unit folders on destroy ([8bec4b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8bec4b0d3f324f334c7fb868d7524e37c0973bff)) +* correct output from d2l csv ([c1ea118](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c1ea1180f2cac9852fa903b1f47e05a05c554f56)) +* ensure smtp can have no auth ([b2691bc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2691bcff0c04ebdbf50c58682c9ae7856870a5e)) +* ensure units update plagiarism stats for moss integration ([d59c6b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d59c6b87cc636568244117e524824c742d2ea39b)) +* handle missing task or user details in accept submission job ([5fd31c3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5fd31c3b3a3748003331d9f79d1d48c5107310a0)) +* include grade object id in api ([a17e21a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a17e21ac6fa0c206d55b6daff150d9d1e5836dfc)) +* redirect to new html5 site ([19daa53](https://github.com/doubtfire-lms/doubtfire-deploy/commit/19daa532777ac535833375c33f023812f9d58ab6)) + +### [8.0.38](https://github.com/macite/doubtfire-deploy/compare/v8.0.37...v8.0.38) (2024-11-06) + + +### Bug Fixes + +* ensure task definitions can be created without scorm details ([b45f0fa](https://github.com/macite/doubtfire-deploy/commit/b45f0fa15d790396f0ba3dbfebb1c059bffe622d)) + +### [8.0.37](https://github.com/macite/doubtfire-deploy/compare/v8.0.36...v8.0.37) (2024-10-25) + + +### Bug Fixes + +* enhance substitutions for ipynb ([c19e149](https://github.com/macite/doubtfire-deploy/commit/c19e14992fe81cef1a17ab1dceda4e81888a8979)) +* ensure broken aux file does not kill future pdf generation ([715ccaf](https://github.com/macite/doubtfire-deploy/commit/715ccaf101655e63e9d081ef9b364cfccae62d13)) +* improve ipynb processing ([3d24fb2](https://github.com/macite/doubtfire-deploy/commit/3d24fb25d613515ee86c625c840dcf9db2636ca9)) +* revert notebook replacements from file helper ([e41cad3](https://github.com/macite/doubtfire-deploy/commit/e41cad3d9b27733cb3c79dd61449f9922f6f07ab)) + +### [8.0.25](https://github.com/macite/doubtfire-deploy/compare/v8.0.24...v8.0.25) (2024-08-09) + + +### Bug Fixes + +* ensure schema has index for auth token type ([7d3e4d3](https://github.com/macite/doubtfire-deploy/commit/7d3e4d369e66815b422faf46f8924397600266f1)) +* ensure test attempt review exception is handled ([bb3590c](https://github.com/macite/doubtfire-deploy/commit/bb3590c14c5c66191833fa98ee6c6eeebc2a3d78)) +* remove default from cmi_datamodel in test attempt ([ccb20dc](https://github.com/macite/doubtfire-deploy/commit/ccb20dc5c1efea2e5d0331026bc17d39dda3db11)) + +### [8.0.24](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.24) (2024-08-09) + + +### Features + +* add attribute to allow file upload before scorm is passed ([fce7e75](https://github.com/macite/doubtfire-deploy/commit/fce7e7519bb9171726a030b409aee23de65f44fd)) +* add Numbas config options to task def backend ([d53610a](https://github.com/macite/doubtfire-deploy/commit/d53610a3f4b0c8077aea34cbfa2924e301914e1f)) +* add numbas task comment on test completion ([3f5aa2b](https://github.com/macite/doubtfire-deploy/commit/3f5aa2be6bd69441730375b689751fe881d7617a)) +* add test attempt auth ([7d31f7c](https://github.com/macite/doubtfire-deploy/commit/7d31f7caaae6dc1efa24f78842873e9f55796279)) +* change Numbas time delay config to enable incremental delays ([54c27ce](https://github.com/macite/doubtfire-deploy/commit/54c27cef2b8ff57fd8ac972728ec3d249e2862b8)) +* create unique token for scorm asset retrieval ([fc8134a](https://github.com/macite/doubtfire-deploy/commit/fc8134ab6b734b7daf064a67ad15f3cefba1d7d6)) +* enable reviewing, passing, and deleting test attempts ([8c9a68b](https://github.com/macite/doubtfire-deploy/commit/8c9a68ba6b3914da24ba33ee62f6a5a00e101c76)) +* enable students to request extra scorm attempt ([c5055b8](https://github.com/macite/doubtfire-deploy/commit/c5055b858c30ba693c535590e1ccff0e8e0b42da)) +* restrict test attempts by limit and comments to when test is completed ([26d75f5](https://github.com/macite/doubtfire-deploy/commit/26d75f51b7fcf11dac0834ddc5a46f40c07407de)) + + +### Bug Fixes + +* add allow review property to task def related files ([3539d95](https://github.com/macite/doubtfire-deploy/commit/3539d957022f0c6310a2939dd6eccad946cb6610)) +* add missing numbas config fields to fix unit tests ([89a6615](https://github.com/macite/doubtfire-deploy/commit/89a66157b4fde887a19912ca40261243b4961e2f)) +* add scorm bypass to excel file ([4139690](https://github.com/macite/doubtfire-deploy/commit/413969069969316f6ea9c515e4ec9da6b332be0a)) +* calculate attempt number and limit instead of using stored int ([28f3279](https://github.com/macite/doubtfire-deploy/commit/28f327964edb0c9326b487a674d19b7da7da8c89)) +* change scorm comment text ([69053ee](https://github.com/macite/doubtfire-deploy/commit/69053ee147503e7916e929aac5c834903c0087ba)) +* check for attempts before accessing properties ([4255347](https://github.com/macite/doubtfire-deploy/commit/42553479eb2a018a9273931e033084e26b3d18d5)) +* check if no old scorm tokens exist ([6108b52](https://github.com/macite/doubtfire-deploy/commit/6108b52bc04d7866548c8738b39d37c30d24f602)) +* consolidate numbas api endpoints ([27253bd](https://github.com/macite/doubtfire-deploy/commit/27253bd1b1d5640d00098f692160dd4b50675640)) +* enforce attempt limit ([d71ea14](https://github.com/macite/doubtfire-deploy/commit/d71ea14d319a59ba1e96bbd5bf34c85a21f0c0f6)) +* expose enable Numbas test config to all users ([20d5265](https://github.com/macite/doubtfire-deploy/commit/20d526533a2ecab592d7d22f3330d37cee7e0f45)) +* expose scorm configs to student ([910eecd](https://github.com/macite/doubtfire-deploy/commit/910eecdc218f52e572d39059e64a0b28acb44dce)) +* grant same number of extra attempts as scorm limit ([3d44ef2](https://github.com/macite/doubtfire-deploy/commit/3d44ef2ea57829131cc3c70d1655ccd996154ee2)) +* post scorm comment after test attempt termination ([0812e20](https://github.com/macite/doubtfire-deploy/commit/0812e206a9dadcfe7d575feec04e49b15b412556)) +* preload unit in test attempt and ensure limit flexibility in validation ([8059213](https://github.com/macite/doubtfire-deploy/commit/80592130bfb33bbb74322c5950e62e2663223af1)) +* prevent new attempt if last is incomplete or passed ([1240b3f](https://github.com/macite/doubtfire-deploy/commit/1240b3fa853d3a3f3fd1ad061f9cc6f6635c2c37)) +* prevent scorm extensions if no attempt limit ([1ae0347](https://github.com/macite/doubtfire-deploy/commit/1ae03478bb2c55b5e281a876bae37f730206ac3e)) +* refactor numbas config reset logic ([ff5ff62](https://github.com/macite/doubtfire-deploy/commit/ff5ff62061c05e509f15af3048fe047b0d69dc68)) +* rename entity file and add update fields in task spreadsheet ([b498924](https://github.com/macite/doubtfire-deploy/commit/b4989242e37ccd046651bfc8db32934ee94e190a)) +* reorder columns for csv export ([5db5f35](https://github.com/macite/doubtfire-deploy/commit/5db5f35dc6cc1874c50f5891ca7bbd752ea32b55)) +* reset Numbas configs if no zip file has been uploaded ([3f19ffa](https://github.com/macite/doubtfire-deploy/commit/3f19ffa6f4f465ed0691582b5012cf997ec62852)) +* temporarily disable auth and fix test attempt lookup ([b4d3f9d](https://github.com/macite/doubtfire-deploy/commit/b4d3f9dc1661b733eaf704c551ceb5836789db22)) +* update auth token to work with scorm and general ([e7a6eed](https://github.com/macite/doubtfire-deploy/commit/e7a6eed53d8e7049b6144e2b07b8018725be01fb)) +* use correct endpoint url and include exam result for numbas test attempts ([ee992f4](https://github.com/macite/doubtfire-deploy/commit/ee992f4218b8ca07c9259d6569c9c946af7701ef)) +* use correct Numbas data path in Numbas api ([5d80830](https://github.com/macite/doubtfire-deploy/commit/5d80830d3564bb7137db3c4adb3b1d906342e851)) +* use custom endpoint for Numbas ([0cc4915](https://github.com/macite/doubtfire-deploy/commit/0cc4915c85d7d55b48ca6832f6779e49362a7870)) +* use project and task def to fix issue where task is undefined on launching scorm test ([2a04a06](https://github.com/macite/doubtfire-deploy/commit/2a04a068282f69b11a6243a590bb25edcdd5c2c1)) +* use test attempt entity in file instead ([a7c4006](https://github.com/macite/doubtfire-deploy/commit/a7c400669bf199f30b627b54c4ed49157ff88222)) +* use unique perms for scorm test retrieval ([08a0090](https://github.com/macite/doubtfire-deploy/commit/08a00906019ce0c2706c34cf053a511b6e5ddca2)) +* validate attempt id ([c5240d8](https://github.com/macite/doubtfire-deploy/commit/c5240d8da378b84deb3ac64e1584808b07d5e671)) + +### [8.0.36](https://github.com/macite/doubtfire-deploy/compare/v8.0.35...v8.0.36) (2024-09-24) + + +### Bug Fixes + +* markdown in ipynb ([4fd4ed3](https://github.com/macite/doubtfire-deploy/commit/4fd4ed3c633e7aa4ee801e5d274c82aa840f31ac)) + +### [8.0.35](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.34...v8.0.35) (2024-09-23) + + +### Bug Fixes + +* ensure default lsr task def can be updated ([491691f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/491691f2b7b5d51c838d22299099f3725d2ee47e)) + +### [8.0.34](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.33...v8.0.34) (2024-09-21) + + +### Features + +* send latex log on convert failure ([166690f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/166690fffe8160146f734d6a7df42dda4174866b)) + + +### Bug Fixes + +* correct error reporting with AAF failure ([58b6391](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58b6391c5658b80ef4e803cd955bdde90c79d87c)) +* ensure unicode control characters work in pdf gen ([cd32b06](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cd32b0672a4491bdc9b1d0ebef192abc4726ccf0)) +* migration to fix invalid task upload filenames ([ac80fec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ac80fec11a6748671821b8d82beae016464eec0b)) + +### [8.0.33](https://github.com/macite/doubtfire-deploy/compare/v8.0.32...v8.0.33) (2024-09-13) + + +### Bug Fixes + +* correct access to moss and devise secrets ([8fae9c6](https://github.com/macite/doubtfire-deploy/commit/8fae9c6f02a08e27daa141d448a4dae0ef62b241)) + +### [8.0.32](https://github.com/macite/doubtfire-deploy/compare/v8.0.31...v8.0.32) (2024-09-05) + + +### Features + +* add support for upload of vue components ([7c85aaf](https://github.com/macite/doubtfire-deploy/commit/7c85aaf1e1080554f4132bd7da18c1e67e2d2aea)) + +### [8.0.31](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.30...v8.0.31) (2024-08-29) + + +### Bug Fixes + +* ensure sidekiq logs latex errors to stdout ([78151b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/78151b3c00f768ee83dd6838628eee2163bd6cde)) +* limit sidekiq concurrency to 1 ([0046562](https://github.com/doubtfire-lms/doubtfire-deploy/commit/004656216508f5469b234f3024c0d95a19d3b014)) +* revert delay in sidekiq pdf generation ([904ca34](https://github.com/doubtfire-lms/doubtfire-deploy/commit/904ca3432cf777f88121e1cd1cf59284c628e1cf)) + +### [8.0.30](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.29...v8.0.30) (2024-08-29) + + +### Bug Fixes + +* add short delay for accept submission job ([b3861ff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b3861ff2f44467e135a92427141844f9d33d6164)) + +### [8.0.29](https://github.com/macite/doubtfire-deploy/compare/v8.0.28...v8.0.29) (2024-08-28) + + +### Bug Fixes + +* correct email reporting of pdf errors in sidekiq ([ff2686a](https://github.com/macite/doubtfire-deploy/commit/ff2686ab0074c5f9442debdaddc9fce02dcdae54)) + +### [8.0.28](https://github.com/macite/doubtfire-deploy/compare/v8.0.27...v8.0.28) (2024-08-28) + + +### Bug Fixes + +* ensure that TII can log multiple similarity issues for each task ([55aa194](https://github.com/macite/doubtfire-deploy/commit/55aa1940b418d5bcb7d43663d5453e7cc6f8610a)) + +### [8.0.27](https://github.com/macite/doubtfire-deploy/compare/v8.0.26...v8.0.27) (2024-08-28) + + +### Bug Fixes + +* correct link to error log mailer and add test ([312f22e](https://github.com/macite/doubtfire-deploy/commit/312f22eacead8b8d666116df52a7c11e49ce1794)) + +### [8.0.26](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.26) (2024-08-26) + + +### Bug Fixes + +* logging of fail to send message in accept submission ([38abe9e](https://github.com/macite/doubtfire-deploy/commit/38abe9eeb7dedf8f7d26b7b1c659be94d9c42d4a)) +* use system timeout command with timeout helper ([b77147c](https://github.com/macite/doubtfire-deploy/commit/b77147c791396e202bbf2e01eb60385a1ae6cd7b)) + +### [8.0.23](https://github.com/macite/doubtfire-deploy/compare/v8.0.22...v8.0.23) (2024-08-05) + + +### Bug Fixes + +* ensure folders are removed when we move files with file helper ([cbec03d](https://github.com/macite/doubtfire-deploy/commit/cbec03d9e148e5d3fbead9b88621f6c06d368371)) +* remove global error and report failures to admin user for tii ([842f233](https://github.com/macite/doubtfire-deploy/commit/842f233d210345682d051e77cc3ddedb98baadc9)) + +### [8.0.22](https://github.com/macite/doubtfire-deploy/compare/v8.0.21...v8.0.22) (2024-08-01) + + +### Features + +* add email on accept submission error ([1e3acd4](https://github.com/macite/doubtfire-deploy/commit/1e3acd4e64af2f41227c03b8fa53ab71811dac20)) +* report high usage on database timeout ([8139f41](https://github.com/macite/doubtfire-deploy/commit/8139f41207a2a2b38f6560cc254d8e65bce40988)) + + +### Bug Fixes + +* add awaiting processing pdf ([3e0a1ba](https://github.com/macite/doubtfire-deploy/commit/3e0a1bac485322b6f7936fd3c244cdc5594ca9b1)) +* avoid attempts to read negative size in file stream helper ([758a51d](https://github.com/macite/doubtfire-deploy/commit/758a51dfb9f8c5bc2cf2471caf5b8c0875467971)) +* change zip of new upload to avoid loss ([218afb9](https://github.com/macite/doubtfire-deploy/commit/218afb9291b6864c3ede03b4f74bceaef81b339f)) +* ensure scoop files checks files are a hash ([33ee3ce](https://github.com/macite/doubtfire-deploy/commit/33ee3cecd6e8317cd54f51c4e1e4314725b5085c)) +* only try overseer assessment when overseer enabled ([e3d36c2](https://github.com/macite/doubtfire-deploy/commit/e3d36c27cffbeb8e56dc6c2b085665c5d91cd9ce)) + +### [8.0.21](https://github.com/macite/doubtfire-deploy/compare/v8.0.20...v8.0.21) (2024-07-30) + + +### Bug Fixes + +* delay pdf generation to ensure sufficient time for async task to run ([6753d80](https://github.com/macite/doubtfire-deploy/commit/6753d803a573c3ced9fb58ef202486cc69d3329c)) + +### [8.0.20](https://github.com/macite/doubtfire-deploy/compare/v8.0.19...v8.0.20) (2024-07-29) + + +### Bug Fixes + +* webhook registration key check ([70f095c](https://github.com/macite/doubtfire-deploy/commit/70f095c3bb762b73738344f6902cfd53e11daf0b)) + +### [8.0.19](https://github.com/macite/doubtfire-deploy/compare/v8.0.18...v8.0.19) (2024-07-26) + + +### Bug Fixes + +* ensure accept submission checks number of files ([cea12e5](https://github.com/macite/doubtfire-deploy/commit/cea12e5bee7ba7b954bdeff1c5257d2c9c9a841a)) +* remove newlines from signing key base64 encoding ([d84856b](https://github.com/macite/doubtfire-deploy/commit/d84856b8e90126e34cb34e4d405acc462af7e147)) + +### [8.0.18](https://github.com/macite/doubtfire-deploy/compare/v8.0.17...v8.0.18) (2024-07-25) + + +### Features + +* add ability to manually remove webhooks from rails console ([7e9adaa](https://github.com/macite/doubtfire-deploy/commit/7e9adaa50b8db70fb488ae3a489ad521dac5e28a)) + + +### Bug Fixes + +* ensure tii signing secret is sent as a base64 string ([efa6692](https://github.com/macite/doubtfire-deploy/commit/efa669273bc8aa56ecfced4d63ab0f9af4649273)) + +### [8.0.17](https://github.com/macite/doubtfire-deploy/compare/v8.0.16...v8.0.17) (2024-07-22) + +### [8.0.16](https://github.com/macite/doubtfire-deploy/compare/v8.0.15...v8.0.16) (2024-07-22) + + +### Bug Fixes + +* ensure comment added on task pdf convert fail ([232dcaa](https://github.com/macite/doubtfire-deploy/commit/232dcaa7c5ea11109d35bc3bd7cd9d3c737259cd)) + +### [8.0.15](https://github.com/macite/doubtfire-deploy/compare/v8.0.14...v8.0.15) (2024-07-22) + + +### Bug Fixes + +* correct turn it in hmac calculation ([a249662](https://github.com/macite/doubtfire-deploy/commit/a249662d6866a80cf03c5793bc4816a766ad2b97)) +* ensure pax header is not included in tex on 2nd pass ([1b2a43c](https://github.com/macite/doubtfire-deploy/commit/1b2a43c0bfe45019b69bbf1952373709c09b67c5)) + +### [8.0.14](https://github.com/macite/doubtfire-deploy/compare/v8.0.13...v8.0.14) (2024-07-18) + + +### Features + +* allow logging to stdout using env var ([7d47eda](https://github.com/macite/doubtfire-deploy/commit/7d47eda6affafb6056d391a101c39670e3a1b7f6)) + + +### Bug Fixes + +* add logging info to debug hmac issues ([de3ec39](https://github.com/macite/doubtfire-deploy/commit/de3ec392612470a1103f6a04c737775965e58ccf)) + +### [8.0.13](https://github.com/macite/doubtfire-deploy/compare/v8.0.12...v8.0.13) (2024-07-17) + + +### Features + +* add env var to configure log to stdout ([0bf29eb](https://github.com/macite/doubtfire-deploy/commit/0bf29eb79824cfab89a6f4ce5ce15d89f1a77ca5)) +* check that old tii submissions upload when eula accepted ([6b08013](https://github.com/macite/doubtfire-deploy/commit/6b08013b423ae990c34224fdd6c358b08026e9f0)) + + +### Bug Fixes + +* check need to register webhooks in tii action ([ebbacb9](https://github.com/macite/doubtfire-deploy/commit/ebbacb90cd1602b04489d2d41ee9723d13a75852)) +* ensure tii module looks for appropriate user ([4dae884](https://github.com/macite/doubtfire-deploy/commit/4dae884dd29bf443d64654e36134e09e570ce31e)) +* ensure webhook test will register hooks ([be21763](https://github.com/macite/doubtfire-deploy/commit/be21763e2b486df0181da1a87ffbddcfb7407388)) +* limit tii action log to 25 entries ([03e9214](https://github.com/macite/doubtfire-deploy/commit/03e9214182e07561100b051cbed6e82191cc8750)) +* merge student records for deakin students ([4f3979b](https://github.com/macite/doubtfire-deploy/commit/4f3979ba4c00a0040f4899e33e48cd950cb6e833)) +* tii action retry resets retries ([789fbad](https://github.com/macite/doubtfire-deploy/commit/789fbada30f8d91cfaff732a4392ecb12d346e3f)) + +### [8.0.12](https://github.com/macite/doubtfire-deploy/compare/v8.0.11...v8.0.12) (2024-07-15) + + +### Features + +* allow register webhooks to be controlled via config ([e01ed19](https://github.com/macite/doubtfire-deploy/commit/e01ed1940ecc7f91c66ea9d22ebbacae04ce7b70)) + +### [8.0.11](https://github.com/macite/doubtfire-deploy/compare/v8.0.10...v8.0.11) (2024-07-12) + + +### Features + +* ensure deakin sync retries failed connections ([d4808b0](https://github.com/macite/doubtfire-deploy/commit/d4808b0f9d2653a02e56f868a9f0d9bec6e53826)) + +### [8.0.10](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.9...v8.0.10) (2024-07-10) + + +### Bug Fixes + +* ensure failure to send email is handled ([32b1d9f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/32b1d9f94c225e326ed7fbc111565fa75de3ec00)) +* ensure logger only logs to stdout in development ([e3fab0d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e3fab0d897bac82dcc14d3ff4b3948245a203b1c)) +* ensure sidekiq moves to Rails root before task pdf creation ([bb29f84](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb29f84c8c4808886cf84b89069a622308d7b859)) +* ensure task definitions render when upload requirements are nil ([6373eee](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6373eee8ab38f5b1c79be5e88302c1880e36cc90)) +* ensure turn it in actions only occur when tii enabled ([5b8f5d3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b8f5d35f520f7e59ddfe53d795200f45882c517)) +* guard access of pwd incase pwd is invalid ([58d8281](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58d828193ee4448df15d4fcc391d2a1a22338efc)) +* turn it in enabled property ([a49fc8c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a49fc8c042d608f109706278f933071e0f058ed2)) + +### [8.0.9](https://github.com/macite/doubtfire-deploy/compare/v8.0.8...v8.0.9) (2024-07-03) + + +### Features + +* allow new unit code to be provided to rollover ([7f3b752](https://github.com/macite/doubtfire-deploy/commit/7f3b7529a9c8ee0a8800e28aa1504f221f80bc5d)) + + +### Bug Fixes + +* ensure main convenor validation on change only ([52450be](https://github.com/macite/doubtfire-deploy/commit/52450bec9039fda80f6f8a6d3a742adc8def8d77)) +* remove rollover teaching period ([eacbac1](https://github.com/macite/doubtfire-deploy/commit/eacbac1f659e09252ab24a4fc9e0d5a02d811a00)) +* streamline archiving units in maintenance task ([e740d82](https://github.com/macite/doubtfire-deploy/commit/e740d8218478b6ef27795fc15093082c07e0c69a)) + +### [8.0.8](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.7...v8.0.8) (2024-07-01) + + +### Features + +* provide task to archive pdfs ([9e85c21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e85c2186880a374d5306a8aa4e6eccc108239ff)) + +## [8.1.0](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.7...v8.1.0) (2024-07-01) + + +### Features + +* provide task to archive pdfs ([9e85c21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e85c2186880a374d5306a8aa4e6eccc108239ff)) + +### [8.0.7](https://github.com/macite/doubtfire-deploy/compare/v8.0.6...v8.0.7) (2024-07-01) + + +### Bug Fixes + +* remove sync of online students at deakin ([2b64bce](https://github.com/macite/doubtfire-deploy/commit/2b64bcef3b74882403d2b04a9da80a7d0e8c68b6)) + +### [8.0.6](https://github.com/macite/doubtfire-deploy/compare/v8.0.5...v8.0.6) (2024-06-28) + + +### Bug Fixes + +* ensure upload requirements works in edit ([61f35ce](https://github.com/macite/doubtfire-deploy/commit/61f35cecf8b4af9ab520bfcc9bde72a0d11c7481)) + +### [8.0.5](https://github.com/macite/doubtfire-deploy/compare/v8.0.4...v8.0.5) (2024-06-27) + + +### Bug Fixes + +* ensure new units can have a different main convenor ([44b6566](https://github.com/macite/doubtfire-deploy/commit/44b656605e44a529078656ba9174b843056e0e31)) + +### [8.0.4](https://github.com/macite/doubtfire-deploy/compare/v8.0.3...v8.0.4) (2024-06-27) + + +### Bug Fixes + +* ensure unit recode results in file moves ([4d0c10f](https://github.com/macite/doubtfire-deploy/commit/4d0c10faff97db01c2f952f4afd66f02af283bb5)) + +### [8.0.3](https://github.com/macite/doubtfire-deploy/compare/v8.0.2...v8.0.3) (2024-06-25) + + +### Bug Fixes + +* export task definition to csv ([793b734](https://github.com/macite/doubtfire-deploy/commit/793b73466fa468f1cb51ed69a07d1c8701dff3a8)) +* limit exposure of nil for task def fields ([fc1bcfd](https://github.com/macite/doubtfire-deploy/commit/fc1bcfd88407f877d6ed7fadc6f70a8dad0e279f)) + +### [8.0.2](https://github.com/macite/doubtfire-deploy/compare/v8.0.1...v8.0.2) (2024-06-21) + + +### Bug Fixes + +* ensure file stream has a string path ([fa5ca52](https://github.com/macite/doubtfire-deploy/commit/fa5ca52b1e2470fbb2537e15259f30946b1e8a54)) + +### [8.0.1](https://github.com/macite/doubtfire-deploy/compare/v7.0.32...v8.0.1) (2024-06-21) + + +### Bug Fixes + +* correct handling of group submissions ([931c9dd](https://github.com/macite/doubtfire-deploy/commit/931c9dd4280e31e935f796bf1d349add1b431c63)) +* correct ipynb code ([9e2056d](https://github.com/macite/doubtfire-deploy/commit/9e2056d8d721325683d115db2356fbca8f7380c7)) +* correct issues with missing rsvg convert and identified test problems ([2024350](https://github.com/macite/doubtfire-deploy/commit/2024350f8080928597bad2b00f6aacd7a6a1be1f)) +* correct merge issues to ensure tests pass ([192bd41](https://github.com/macite/doubtfire-deploy/commit/192bd4175607f8ac2efa1acb6f029883a3bdcea1)) +* correct typos in unit role needed for teaching role ([f808ad4](https://github.com/macite/doubtfire-deploy/commit/f808ad437f424f40a4eb68d5218ddf4317ba44b6)) +* ensure error reported when viewer not available ([2aaacb6](https://github.com/macite/doubtfire-deploy/commit/2aaacb6e9c181b260e9c7f62f362dd1da2ab98ad)) +* ensure ipynb handles markdown, raw, and long output ([955ca0b](https://github.com/macite/doubtfire-deploy/commit/955ca0bf844ad673a445e04012e6950a07f748d8)) +* handle long, raw, and markdown ipynb ([609b49b](https://github.com/macite/doubtfire-deploy/commit/609b49bf1b73af9eeeb66e4788c7d6dffbca94fa)) +* limit to 3 group attachments in tii upload ([5252639](https://github.com/macite/doubtfire-deploy/commit/525263903a11af362d78b82c8065a665024a3a1f)) +* reinstate teaching staff ids ([167eb1a](https://github.com/macite/doubtfire-deploy/commit/167eb1a144ad667d00a8b7c9a469115943048fc4)) +* task file import ([#438](https://github.com/macite/doubtfire-deploy/issues/438)) ([8f37943](https://github.com/macite/doubtfire-deploy/commit/8f379430fd48b0449ef21f680165e4323cad1750)) +* truncate long lines in PDF conversion ([#439](https://github.com/macite/doubtfire-deploy/issues/439)) ([2425997](https://github.com/macite/doubtfire-deploy/commit/2425997305afb4f6a7964a7cd689a04418828ea1)) + +## [8.0.0-11](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-10...v8.0.0-11) (2024-05-13) + +## [8.0.0-10](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-9...v8.0.0-10) (2024-05-13) + + +### Bug Fixes + +* host url for turn it in integration ([3cd67d7](https://github.com/macite/doubtfire-deploy/commit/3cd67d7c58916cda429d3c0266942cfc2c0ef878)) + +## [8.0.0-9](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-8...v8.0.0-9) (2024-05-11) + + +### Bug Fixes + +* ensure default log in tii actions ([a9959fe](https://github.com/macite/doubtfire-deploy/commit/a9959fef2223ffca41338b15c973b888253225bf)) +* ensure tii launch handles errors so rails can progress ([d7c9c3c](https://github.com/macite/doubtfire-deploy/commit/d7c9c3c8c60b49721aa9cac1f8df6ec716b82422)) +* revert to default cache store ([c3a22bf](https://github.com/macite/doubtfire-deploy/commit/c3a22bfee6e9912fd8b4d331d6a6e6f350b72ffa)) + +## [8.0.0-8](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-7...v8.0.0-8) (2024-05-11) + + +### Bug Fixes + +* adjust log and params in tii_actions ([4bfdfb1](https://github.com/macite/doubtfire-deploy/commit/4bfdfb1faf5bbaf45ec7827ec89e4cd231f88dba)) +* display latex math properly in jupyter notebooks ([ba6d615](https://github.com/macite/doubtfire-deploy/commit/ba6d61506a5f699aed299658f9a664123fdaf57b)) +* update for dotenv 3 ([ef8611f](https://github.com/macite/doubtfire-deploy/commit/ef8611f917b198064a891f81c02408ff081e977b)) + +## [8.0.0-7](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-6...v8.0.0-7) (2024-05-02) + +## [8.0.0-6](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-5...v8.0.0-6) (2024-05-02) + + +### Bug Fixes + +* revert to doubtfire local image for unit tests ([73fcbe3](https://github.com/macite/doubtfire-deploy/commit/73fcbe3f5adb603253033e7b126502a5d3c006f1)) + +## [8.0.0-5](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-4...v8.0.0-5) (2024-05-02) + + +### Bug Fixes + +* correct updates in TII migration ([d1ab30b](https://github.com/macite/doubtfire-deploy/commit/d1ab30ba666898f556b69db53766124742b4f593)) + +## [8.0.0-4](https://github.com/macite/doubtfire-deploy/compare/v7.0.24...v8.0.0-4) (2024-05-01) + + +### Features + +* add the pdf-reader gem for validating pdf submissions ([71c845b](https://github.com/macite/doubtfire-deploy/commit/71c845bf28fccf28de17ed83e3da1cf243646e7b)) +* implement unit test for pdf validation on submit ([57db1dc](https://github.com/macite/doubtfire-deploy/commit/57db1dc57a75aaf211030ecf78b9252a5d8b583b)) +* improve pdf file validation and detect encrypted pdfs ([dd729cf](https://github.com/macite/doubtfire-deploy/commit/dd729cf31bec115bd0e7018f33a692bd35bb5519)) + + +### Bug Fixes + +* add missing moss language in task def post ([1fa7b0b](https://github.com/macite/doubtfire-deploy/commit/1fa7b0b10f855bc2e23aa27e78ed41ff3e5b4683)) +* add redis to the github actions workflow ([9935720](https://github.com/macite/doubtfire-deploy/commit/99357205d42d148f3a6165a96122691680409092)) +* correct tii migrationm defaults ([2beb6e8](https://github.com/macite/doubtfire-deploy/commit/2beb6e8599cf6b99992e34548615e7802e7ff141)) +* document two new env variables for redis ([749903f](https://github.com/macite/doubtfire-deploy/commit/749903f390a388fac2c2a8652975580611f1e072)) +* implement error reporting in database populator ([136b9f9](https://github.com/macite/doubtfire-deploy/commit/136b9f98151688d3d6a578db1f980b39b3e21514)) +* install ruby-lsp in the development environment ([c57290e](https://github.com/macite/doubtfire-deploy/commit/c57290e4b2f7ba1bab7d600965988489dc3dd5a4)) +* pick up redis url from env for sidekiq if present ([e9628eb](https://github.com/macite/doubtfire-deploy/commit/e9628eb31398719d78508a610a251a785f56a14f)) +* remove plagiarism checks field ([19107bf](https://github.com/macite/doubtfire-deploy/commit/19107bf87601115ea15036f25634b2ba30e23c7c)) +* remove serialisation of plagiarism checks ([1962cc9](https://github.com/macite/doubtfire-deploy/commit/1962cc96ff46134d756984473d1022792e9ada1a)) +* skip unit tests and linting for documentation updates ([2503fe6](https://github.com/macite/doubtfire-deploy/commit/2503fe61468f8ebe37e54ccb1d0cc2a11387949b)) + +## [8.0.0-3](https://github.com/macite/doubtfire-deploy/compare/v7.0.23...v8.0.0-3) (2024-03-22) + + +### Bug Fixes + +* ensure redis is in dockerfile ([c37f5ba](https://github.com/macite/doubtfire-deploy/commit/c37f5ba0e78fbbb6fb1f7423f8b1bbabb557f761)) +* revert new tii action field to text from json ([72a8f18](https://github.com/macite/doubtfire-deploy/commit/72a8f18c31a3b5476f4f7b414b54cf47e3db8087)) + +## [8.0.0-2](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-1...v8.0.0-2) (2024-03-22) + + +### Bug Fixes + +* remove switch to json db format ([1b789a2](https://github.com/macite/doubtfire-deploy/commit/1b789a2194b745a6f91f8989c12c9387932ca70c)) + +## [8.0.0-1](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-0...v8.0.0-1) (2024-03-21) + +## [8.0.0-0](https://github.com/macite/doubtfire-deploy/compare/v7.0.22...v8.0.0-0) (2024-03-21) + + +### Features + +* add ability to adjust similarity flag ([339acf8](https://github.com/macite/doubtfire-deploy/commit/339acf8d741ff8d53b9cf1bb7a00e88907d8a183)) +* add ability to fetch tii viewer url ([002bb07](https://github.com/macite/doubtfire-deploy/commit/002bb07972f9364eea74563af66efa8f783c2c2e)) +* add api to interact with tii group attachments ([f286302](https://github.com/macite/doubtfire-deploy/commit/f2863020a3619f097c182c98b1392e5c05e7c7c0)) +* add similarity report webhook ([07157f9](https://github.com/macite/doubtfire-deploy/commit/07157f9f0c6bbbc8d07b37807562b0ec00606c97)) +* add submission tii hook ([3f1c8ca](https://github.com/macite/doubtfire-deploy/commit/3f1c8ca1e9ef25b010c7a9061b986ce514775a71)) +* add tii submission to enable retry ([e38e884](https://github.com/macite/doubtfire-deploy/commit/e38e88423779a0d92cfe22eb96cc4e3d5ac07582)) +* add upload tii group attachment ([aa10e35](https://github.com/macite/doubtfire-deploy/commit/aa10e35c45920ef0504dd9073a1c38470cae39da)) +* allow score to 100 for tasks ([757d184](https://github.com/macite/doubtfire-deploy/commit/757d1845a48f5ec967b7094940b50e2bd4478ab4)) +* asynchronously process submissions ([5a1ab9c](https://github.com/macite/doubtfire-deploy/commit/5a1ab9c051e054f7a30f6a1958c78f14b226d365)) +* cache tii details in files ([ae2208c](https://github.com/macite/doubtfire-deploy/commit/ae2208c344c0959382e833557ccc69227e471da3)) +* can fetch and retry tii actions ([81ee714](https://github.com/macite/doubtfire-deploy/commit/81ee714c7c0043502100758accc3d526ee3d73e9)) +* check tii features ([21a0fcc](https://github.com/macite/doubtfire-deploy/commit/21a0fcc324f184e0de416fa7091f1e57ba33f329)) +* delay generation for a short period to allow sidekiq to handle ([a53a998](https://github.com/macite/doubtfire-deploy/commit/a53a9980c56556b3e65321ac8e0ca455ea9f2ce6)) +* ensure correct error when no token ([0aa8e71](https://github.com/macite/doubtfire-deploy/commit/0aa8e7130551a2f6e4c76302179a13aadb720344)) +* ensure eula loads from file where possible ([aa4d7e8](https://github.com/macite/doubtfire-deploy/commit/aa4d7e86649f60cce5ebbbcb5f403b64e31315f9)) +* ensure only high similarity for tii reported ([4c4e55a](https://github.com/macite/doubtfire-deploy/commit/4c4e55a9b8febc2d18954a4baf8de1d5993341de)) +* ensure turn it in viewer only available when report ready ([124558f](https://github.com/macite/doubtfire-deploy/commit/124558f6c2c28d14e8fd47fb7e5ab0fac09425d0)) +* move cache to redis to share across instances ([63ab5b2](https://github.com/macite/doubtfire-deploy/commit/63ab5b27a8b142209dd6bcf9aa148ada56a04a91)) +* pdf report web hook ([355b375](https://github.com/macite/doubtfire-deploy/commit/355b37582b2d430d66c2aafc1cc545c049fd5d78)) +* record max similarity percent and flag high tii submissions ([9f56be9](https://github.com/macite/doubtfire-deploy/commit/9f56be9a1fc562f0429ce73b7aab0ce4b7775c23)) +* record overall match percent in tii submission ([f0bd981](https://github.com/macite/doubtfire-deploy/commit/f0bd981ffe6aa967093ed7ce992b37a81aa7a4e3)) +* register turn it in webhooks ([b3fbc45](https://github.com/macite/doubtfire-deploy/commit/b3fbc45c759b34f7ab31bc251e1f441e3a0dbe36)) +* report tii presence via settings api ([5354584](https://github.com/macite/doubtfire-deploy/commit/5354584db125c4186cf23643ad672fadab367f9d)) +* report tii upload action status ([6dadc16](https://github.com/macite/doubtfire-deploy/commit/6dadc1630d5a88dbba69c3d05092a25d21653fd2)) +* trigger tii group attachment on change ([4adee6b](https://github.com/macite/doubtfire-deploy/commit/4adee6bafa71b9a2db91970932a84a517bf72c2c)) +* update group on due date change ([98187f1](https://github.com/macite/doubtfire-deploy/commit/98187f1b5c4a5751ddd2c051e93cfc9cdba6fbfd)) + + +### Bug Fixes + +* add description to tii actions ([039ca1a](https://github.com/macite/doubtfire-deploy/commit/039ca1a40f6ccfeb671de5f2400a58b71c210b5d)) +* change load of tii eula and feature to use file cache ([d17c5d6](https://github.com/macite/doubtfire-deploy/commit/d17c5d6fa69a7916a19807fe645bdcb3b8c1f428)) +* change tii batch upload to limit submission rate ([984524f](https://github.com/macite/doubtfire-deploy/commit/984524fbc3c04c04337137672554c25c8ea6c0de)) +* correct latex packages for texlive 2024 ([1e52ea5](https://github.com/macite/doubtfire-deploy/commit/1e52ea5a01e514f29eabae6ee6de655dd527ed79)) +* create missing portfolios ([259baa6](https://github.com/macite/doubtfire-deploy/commit/259baa6dd863122eccaec3d88d7fdfaaf1bb97e4)) +* ensure endpoint can accept eula ([f8a69a7](https://github.com/macite/doubtfire-deploy/commit/f8a69a72bf9a8a425d4f0d63fa1f36112390ac8a)) +* ensure file download returns something ([3439bab](https://github.com/macite/doubtfire-deploy/commit/3439babcd521155f1c78d76f71717c0645c23d95)) +* ensure similarities without files work in ui ([bbbedb7](https://github.com/macite/doubtfire-deploy/commit/bbbedb7e2dfecf96c7a31e628e6f3824bdbb033d)) +* ensure staff before tutorial data ([953068e](https://github.com/macite/doubtfire-deploy/commit/953068e219df6c3722a44389deca837d3cd47380)) +* ensure tests work and address tii check list items ([3dc5cb1](https://github.com/macite/doubtfire-deploy/commit/3dc5cb1f37ee1707da8b3f42f39b1bd43b2e5f09)) +* ensure tii initializer loads correctly ([0339ce5](https://github.com/macite/doubtfire-deploy/commit/0339ce5e1fa76e82bb82bf2b516f1ae990944e27)) +* ensure we can get the report url for moss reports ([582d13a](https://github.com/macite/doubtfire-deploy/commit/582d13a292f56e7d6294fb636b1aeb4228b98426)) +* ensure we do not ask to accept eula if not required ([3df2ade](https://github.com/macite/doubtfire-deploy/commit/3df2ade168970741b80413f396dbb6b312124cf0)) +* ensure we send indexing and eula details in viewer and submissions ([38d4059](https://github.com/macite/doubtfire-deploy/commit/38d4059bf2f301896c27fea83d635b496c218a0a)) +* eula link in upload action ([96e8bce](https://github.com/macite/doubtfire-deploy/commit/96e8bce8354026865a7792a62acc8898c7d18dee)) +* get tii user details for viewer url ([c7de571](https://github.com/macite/doubtfire-deploy/commit/c7de57158aa630fe62f430c247ad277baba49e5a)) +* no auth mirrors timeout ([b83f09c](https://github.com/macite/doubtfire-deploy/commit/b83f09c3b10f3d93216c34d1adb642d43c82476b)) +* only admin can retry tii actions ([7e019cc](https://github.com/macite/doubtfire-deploy/commit/7e019cc34005d8de6b69f6c4475a79e4e2ceff37)) +* remove debugging ([c6d067a](https://github.com/macite/doubtfire-deploy/commit/c6d067aed1dd6b283615a12706a7e9ed4c452052)) +* remove max pct similar ([87bc428](https://github.com/macite/doubtfire-deploy/commit/87bc42888d28fe6911fd25281845e54ab290bd8f)) +* rescue missing action in job ([ea84ac2](https://github.com/macite/doubtfire-deploy/commit/ea84ac21fbfbf72cb476458261f943e1a8ddbf4f)) +* simulate signoff adds similarities ([74a74e0](https://github.com/macite/doubtfire-deploy/commit/74a74e07dc7a61d56472e19ca49787d8ec2890cb)) +* update save status on actions ([096aee6](https://github.com/macite/doubtfire-deploy/commit/096aee685d2cf0652092b4f87a319de73e1dd1e9)) +* update schema to match migration dates ([5c1afe4](https://github.com/macite/doubtfire-deploy/commit/5c1afe421dd41b8f56284e776ce39996b3f28d71)) + ## [8.0.0](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-11...v8.0.0) (2024-05-23) diff --git a/Gemfile b/Gemfile index 51477714a..1c0a7a66c 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,10 @@ group :development, :test do gem 'listen' gem 'rails_best_practices' gem 'rubocop' + gem 'rubocop-factory_bot' gem 'rubocop-faker' + gem 'rubocop-minitest' + gem 'rubocop-performance' gem 'rubocop-rails' gem 'ruby-lsp' gem 'simplecov', require: false @@ -100,6 +103,7 @@ gem 'tca_client', '1.0.4' # Async jobs gem 'sidekiq' gem 'sidekiq-cron' +gem 'sidekiq-unique-jobs' # Redis for sidekiq, caching, and action cable (eventually) gem 'redis' @@ -109,3 +113,6 @@ gem 'shellwords' # PDF reader for validating PDF file submissions gem 'pdf-reader' + +# oauth gem for OAuth2 authentication - D2L +gem 'oauth2' diff --git a/Gemfile.lock b/Gemfile.lock index 58ca5d592..0256bed51 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,35 +2,35 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (1.1.1) - actioncable (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionmailbox (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.3) - actionpack (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionmailer (7.1.3.4) + actionpack (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.3) - actionview (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -38,35 +38,35 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.3) - actionpack (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + actiontext (7.1.3.4) + actionpack (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.3) - activesupport (= 7.1.3.3) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.3) - activesupport (= 7.1.3.3) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activemodel (7.1.3.3) - activesupport (= 7.1.3.3) - activerecord (7.1.3.3) - activemodel (= 7.1.3.3) - activesupport (= 7.1.3.3) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) timeout (>= 0.4.0) - activestorage (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activesupport (= 7.1.3.3) + activestorage (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activesupport (= 7.1.3.4) marcel (~> 1.0) - activesupport (7.1.3.3) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -94,7 +94,7 @@ GEM bindata (2.5.0) bootsnap (1.18.3) msgpack (~> 1.2) - builder (3.2.4) + builder (3.3.0) bunny (2.22.0) amq-protocol (~> 2.3, >= 2.3.1) sorted_set (~> 1, >= 1.0.2) @@ -107,7 +107,7 @@ GEM code_analyzer (0.5.5) sexp_processor coderay (1.1.3) - concurrent-ruby (1.3.1) + concurrent-ruby (1.3.3) connection_pool (2.4.1) crack (1.0.0) bigdecimal @@ -148,7 +148,7 @@ GEM dry-logic (~> 1.4) zeitwerk (~> 2.6) e2mmap (0.1.0) - erubi (1.12.0) + erubi (1.13.0) erubis (2.7.0) et-orbi (1.2.11) tzinfo @@ -161,25 +161,25 @@ GEM railties (>= 5.0.0) faker (3.4.1) i18n (>= 1.8.11, < 2) - faraday (2.9.0) + faraday (2.9.2) faraday-net_http (>= 2.0, < 3.2) faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) faraday-net_http (3.1.0) net-http ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-x86_64-linux-gnu) fugit (1.11.0) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - grape (2.0.0) - activesupport (>= 5) - builder + grape (2.1.0) + activesupport (>= 6) dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk grape-entity (1.0.1) activesupport (>= 3.0.0) multi_json (>= 1.3.2) @@ -190,6 +190,7 @@ GEM railties (>= 6.0.6.1) hashdiff (1.1.0) hashery (2.1.2) + hashie (5.0.0) hirb (0.7.3) http-accept (1.7.0) http-cookie (1.0.6) @@ -200,10 +201,10 @@ GEM ice_cube (~> 0.16) ice_cube (0.16.4) io-console (0.7.2) - irb (1.13.1) + irb (1.13.2) rdoc (>= 4.0.0) reline (>= 0.4.2) - jaro_winkler (1.5.6) + jaro_winkler (1.6.0) json (2.7.2) json-jwt (1.16.6) activesupport (>= 4.2) @@ -212,6 +213,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.9.3) + base64 kramdown (2.4.0) rexml kramdown-parser-gfm (1.1.0) @@ -231,9 +234,9 @@ GEM marcel (1.0.4) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2024.0507) + mime-types-data (3.2024.0604) mini_mime (1.1.5) - minitest (5.23.1) + minitest (5.24.0) minitest-around (0.5.0) minitest (~> 5.0) minitest-rails (7.1.0) @@ -243,15 +246,17 @@ GEM tcp_timeout (~> 0.1.1) msgpack (1.7.2) multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) mutex_m (0.2.0) mysql2 (0.5.6) net-http (0.4.1) uri - net-imap (0.4.12) + net-imap (0.4.13) date net-protocol net-ldap (0.19.0) @@ -263,12 +268,21 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.3) - nokogiri (1.16.5-aarch64-linux) + nokogiri (1.16.6-aarch64-linux) racc (~> 1.4) + nokogiri (1.16.6-x86_64-linux) + racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) observer (0.1.2) orm_adapter (0.5.0) - parallel (1.24.0) - parser (3.3.2.0) + parallel (1.25.1) + parser (3.3.3.0) ast (~> 2.4.1) racc pdf-reader (2.12.0) @@ -281,14 +295,12 @@ GEM prism (0.29.0) psych (5.1.2) stringio - public_suffix (5.0.5) + public_suffix (5.1.1) puma (6.4.2) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.0) - rack (3.0.11) - rack-accept (0.4.5) - rack (>= 0.4) + rack (3.1.3) rack-cors (2.0.2) rack (>= 2.0.0) rack-session (2.0.0) @@ -298,20 +310,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.1.3.3) - actioncable (= 7.1.3.3) - actionmailbox (= 7.1.3.3) - actionmailer (= 7.1.3.3) - actionpack (= 7.1.3.3) - actiontext (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activemodel (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + rails (7.1.3.4) + actioncable (= 7.1.3.4) + actionmailbox (= 7.1.3.4) + actionmailer (= 7.1.3.4) + actionpack (= 7.1.3.4) + actiontext (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activemodel (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) bundler (>= 1.15.0) - railties (= 7.1.3.3) + railties (= 7.1.3.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -329,9 +341,9 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -351,7 +363,7 @@ GEM redis-client (0.22.2) connection_pool regexp_parser (2.9.2) - reline (0.5.8) + reline (0.5.9) io-console (~> 0.5) require_all (3.0.0) responders (3.1.1) @@ -364,8 +376,8 @@ GEM netrc (~> 0.8) reverse_markdown (2.1.1) nokogiri - rexml (3.2.8) - strscan (>= 3.0.9) + rexml (3.3.0) + strscan rmagick (6.0.1) observer (~> 0.1) pkg-config (~> 1.4) @@ -376,7 +388,7 @@ GEM nokogiri roo (>= 2.0.0, < 3) spreadsheet (> 0.9.0) - rouge (4.2.1) + rouge (4.3.0) rubocop (1.64.1) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -390,16 +402,24 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.31.3) parser (>= 3.3.1.0) + rubocop-factory_bot (2.26.1) + rubocop (~> 1.61) rubocop-faker (1.1.0) faker (>= 2.12.0) rubocop (>= 0.82.0) + rubocop-minitest (0.35.0) + rubocop (>= 1.61, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-performance (1.21.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rails (2.25.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-filemagic (0.7.3) - ruby-lsp (0.17.1) + ruby-lsp (0.17.2) language_server-protocol (~> 3.17.0) prism (>= 0.29.0, < 0.30) sorbet-runtime (>= 0.5.10782) @@ -423,12 +443,19 @@ GEM fugit (~> 1.8) globalid (>= 1.0.1) sidekiq (>= 6) + sidekiq-unique-jobs (8.0.10) + concurrent-ruby (~> 1.0, >= 1.0.5) + sidekiq (>= 7.0.0, < 8.0.0) + thor (>= 1.0, < 3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) solargraph (0.50.0) backport (~> 1.2) benchmark @@ -445,7 +472,7 @@ GEM thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) - sorbet-runtime (0.5.11409) + sorbet-runtime (0.5.11435) sorted_set (1.0.3) rbtree set (~> 1.0) @@ -455,11 +482,11 @@ GEM sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.1) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - stringio (3.1.0) + stringio (3.1.1) strscan (3.1.0) tca_client (1.0.4) typhoeus (~> 1.0, >= 1.0.1) @@ -475,6 +502,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) uri (0.13.0) + version_gem (1.1.4) warden (1.2.9) rack (>= 2.0.9) webmock (3.23.1) @@ -486,10 +514,11 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) yard (0.9.36) - zeitwerk (2.6.15) + zeitwerk (2.6.16) PLATFORMS aarch64-linux + x86_64-linux DEPENDENCIES better_errors @@ -519,6 +548,7 @@ DEPENDENCIES moss_ruby (>= 1.1.4) mysql2 net-smtp + oauth2 pdf-reader puma rack-cors @@ -532,7 +562,10 @@ DEPENDENCIES roo (~> 2.7.0) roo-xls rubocop + rubocop-factory_bot rubocop-faker + rubocop-minitest + rubocop-performance rubocop-rails ruby-filemagic ruby-lsp @@ -541,6 +574,7 @@ DEPENDENCIES shellwords sidekiq sidekiq-cron + sidekiq-unique-jobs simplecov solargraph sprockets-rails @@ -551,4 +585,4 @@ RUBY VERSION ruby 3.1.4p223 BUNDLED WITH - 2.4.15 + 2.4.22 diff --git a/README.md b/README.md index faedf0645..2531f301f 100644 --- a/README.md +++ b/README.md @@ -27,24 +27,47 @@ See [Doubtfire Deploy](https://github.com/doubtfire-lms/doubtfire-deploy) for in 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` 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). | `database` | -| `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | -| `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _University of Foo_ | -| `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_INSTITUTION_PRODUCT_NAME` | The name of the product (i.e. Doubtfire) at your institution. | _Doubtfire_ | -| `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` | +| 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_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` | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | If you have chosen to use AAF Rapid Connect authentication, then you will also need to provide the following: @@ -58,6 +81,18 @@ If you have chosen to use AAF Rapid Connect authentication, then you will also n | `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. | No default | +| `DF_SAML_CONSUMER_SERVICE_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` | + You may choose to keep your environment variables inside a `.env` file using key-value pairs: ``` diff --git a/app/api/admin/overseer_admin_api.rb b/app/api/admin/overseer_admin_api.rb index d99fd3f3f..ebb6f5cab 100644 --- a/app/api/admin/overseer_admin_api.rb +++ b/app/api/admin/overseer_admin_api.rb @@ -59,6 +59,11 @@ class OverseerAdminApi < Grape::API .permit(:name, :tag) + # Clear image status and text when updating + overseer_image_params[:pulled_image_status] = nil + overseer_image_params[:pulled_image_text] = nil + overseer_image_params[:last_pulled_date] = nil + overseer_image.update!(overseer_image_params) present overseer_image, with: Entities::OverseerImageEntity end @@ -89,6 +94,19 @@ class OverseerAdminApi < Grape::API end end + desc 'Get all overseer images' + get '/admin/overseer_images/:id' do + unless authorise? current_user, User, :use_overseer + error!({ error: 'Not authorised to get overseer images' }, 403) + end + + if Doubtfire::Application.config.overseer_enabled + present OverseerImage.find(params[:id]), with: Entities::OverseerImageEntity + else + present [], with: Grape::Presenters::Presenter + end + end + desc 'Get overseer image by id and pull image' put '/admin/overseer_images/:id/pull_image' do unless authorise? current_user, User, :admin_overseer diff --git a/app/api/api_root.rb b/app/api/api_root.rb index bde5f0236..80e1fdc8d 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -33,8 +33,13 @@ class ApiRoot < Grape::API when ActionController::ParameterMissing message = "Missing value for #{e.param}" status = 400 + when ActiveRecord::ConnectionTimeoutError + message = 'There is currently high load on the system. Please wait a moment and try again.' + status = 503 else + # rubocop:disable Rails/Output puts e.inspect unless Rails.env.production? + # rubocop:enable Rails/Output logger.error "Unhandled exception: #{e.class}" logger.error e.inspect @@ -55,6 +60,7 @@ class ApiRoot < Grape::API mount BreaksApi mount DiscussionCommentApi mount ExtensionCommentsApi + mount ScormExtensionCommentsApi mount GroupSetsApi mount LearningOutcomesApi mount LearningAlignmentApi @@ -76,6 +82,8 @@ class ApiRoot < Grape::API mount Tii::TiiGroupAttachmentApi mount Tii::TiiActionApi + mount ScormApi + mount TestAttemptsApi mount CampusesPublicApi mount CampusesAuthenticatedApi mount TutorialsApi @@ -83,6 +91,10 @@ class ApiRoot < Grape::API mount TutorialEnrolmentsApi mount UnitRolesApi mount UnitsApi + + mount D2lIntegrationApi::D2lApi + mount D2lIntegrationApi::OauthPublicApi + mount UsersApi mount WebcalApi mount WebcalPublicApi @@ -96,11 +108,13 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to BreaksApi AuthenticationHelpers.add_auth_to DiscussionCommentApi AuthenticationHelpers.add_auth_to ExtensionCommentsApi + AuthenticationHelpers.add_auth_to ScormExtensionCommentsApi AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi AuthenticationHelpers.add_auth_to LearningAlignmentApi AuthenticationHelpers.add_auth_to ProjectsApi AuthenticationHelpers.add_auth_to StudentsApi + AuthenticationHelpers.add_auth_to SettingsApi AuthenticationHelpers.add_auth_to Submission::PortfolioApi AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi AuthenticationHelpers.add_auth_to Submission::BatchTaskApi @@ -122,6 +136,10 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to UnitRolesApi AuthenticationHelpers.add_auth_to UnitsApi AuthenticationHelpers.add_auth_to WebcalApi + AuthenticationHelpers.add_auth_to ScormApi + AuthenticationHelpers.add_auth_to TestAttemptsApi + + AuthenticationHelpers.add_auth_to D2lIntegrationApi::D2lApi add_swagger_documentation \ base_path: nil, diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index 3b43b10cf..abeae2bca 100644 --- a/app/api/authentication_api.rb +++ b/app/api/authentication_api.rb @@ -11,6 +11,7 @@ class AuthenticationApi < Grape::API helpers LogHelper helpers AuthenticationHelpers + helpers AuthorisationHelpers # # Sign in - only mounted if AAF auth is NOT used @@ -71,7 +72,7 @@ class AuthenticationApi < Grape::API # Return user details present :user, user, with: Entities::UserEntity - present :auth_token, user.generate_authentication_token!(remember).authentication_token + present :auth_token, user.generate_authentication_token!(remember: remember).authentication_token end end @@ -102,7 +103,7 @@ class AuthenticationApi < Grape::API # Lookup using email otherwise and set login_id # Otherwise create new user = User.find_by(login_id: login_id) || - User.find_by_username(email[/(.*)@/, 1]) || + User.find_by(username: email[/(.*)@/, 1]) || User.find_by(email: email) || User.find_or_create_by(login_id: login_id) do |new_user| role_response = attributes.fetch(/role/) || attributes.fetch(/userRole/) @@ -146,7 +147,7 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/#/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + redirect "#{host}/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" end end @@ -177,7 +178,7 @@ class AuthenticationApi < Grape::API # Lookup using email otherwise and set login_id # Otherwise create new user = User.find_by(login_id: login_id) || - User.find_by_username(email[/(.*)@/, 1]) || + User.find_by(username: email[/(.*)@/, 1]) || User.find_by(email: email) || User.find_or_create_by(login_id: login_id) do |new_user| role = Role.aaf_affiliation_to_role_id(attrs[:edupersonscopedaffiliation]) @@ -223,7 +224,7 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/#/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + redirect "#{host}/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" end end @@ -237,18 +238,18 @@ class AuthenticationApi < Grape::API requires :auth_token, type: String, desc: 'The user\'s temporary auth token' end post '/auth' do - error!({ error: 'Invalid token.' }, 404) if params[:auth_token].nil? - logger.info "Get user via auth_token from #{request.ip}" + error!({ error: 'Invalid authentication details.' }, 404) if params[:auth_token].blank? || params[:username].blank? + logger.info "Get user via auth_token from #{request.ip} - #{params[:username]}" # Authenticate that the token is okay - if authenticated? - user = User.find_by_username(params[:username]) - token = user.token_for_text?(params[:auth_token]) unless user.nil? - error!({ error: 'Invalid token.' }, 404) if token.nil? + if authenticated?(:login) + user = User.find_by(username: params[:username]) + token = user.token_for_text?(params[:auth_token], :login) unless user.nil? + error!({ error: 'Invalid authentication details.' }, 404) if token.nil? # Invalidate the token and regenrate a new one token.destroy! - token = user.generate_authentication_token! true + token = user.generate_authentication_token! logger.info "Login #{params[:username]} from #{request.ip}" @@ -323,8 +324,8 @@ class AuthenticationApi < Grape::API logger.info "Update token #{token_param} from #{request.ip} for #{user_param}" # Find user - user = User.find_by_username(user_param) - token = user.token_for_text?(token_param) unless user.nil? + user = User.find_by(username: user_param) + token = user.token_for_text?(token_param, :general) unless user.nil? remember = params[:remember] || false # Token does not match user @@ -358,8 +359,8 @@ class AuthenticationApi < Grape::API } } delete '/auth' do - user = User.find_by_username(headers['username'] || headers['Username']) - token = user.token_for_text?(headers['auth-token'] || headers['Auth-Token']) unless user.nil? + user = User.find_by(username: headers['username'] || headers['Username']) + token = user.token_for_text?(headers['auth-token'] || headers['Auth-Token'], :general) unless user.nil? if token.present? logger.info "Sign out #{user.username} from #{request.ip}" @@ -368,4 +369,21 @@ class AuthenticationApi < Grape::API present nil end + + desc 'Get SCORM authentication token' + get '/auth/scorm' do + if authenticated?(:general) + unless authorise? current_user, User, :get_scorm_token + error!({ error: 'You cannot get SCORM tokens' }, 403) + end + + token = current_user.auth_tokens.find_by(token_type: :scorm) + if token.nil? || token.auth_token_expiry <= Time.zone.now + token&.destroy + token = current_user.generate_scorm_authentication_token! + end + + present :scorm_auth_token, token.authentication_token + end + end end diff --git a/app/api/d2l_integration_api/d2l_api.rb b/app/api/d2l_integration_api/d2l_api.rb new file mode 100644 index 000000000..66f7ec635 --- /dev/null +++ b/app/api/d2l_integration_api/d2l_api.rb @@ -0,0 +1,184 @@ +require 'grape' + +module D2lIntegrationApi + # The D2l API provides the frontend with the ability to register + # integration details to connect units with D2L. This will allow + # grade book items to be copied from portfolio results to D2L. + class D2lApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileStreamHelper + include LogHelper + + before do + authenticated? + end + + desc 'Get the D2L assessment mapping for a unit' + get '/units/:unit_id/d2l' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view D2L details' }, 403) + end + + present unit.d2l_assessment_mapping, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Create a D2L assessment mapping for a unit' + params do + requires :org_unit_id, type: String, desc: 'The org unit id for the D2L unit' + optional :grade_object_id, type: Numeric, desc: 'The grade object id for the D2L unit' + end + post '/units/:unit_id/d2l' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to add D2L details' }, 403) + end + + accepted_params = ActionController::Parameters.new(params).permit(:unit_id, :org_unit_id, :grade_object_id) + + d2l = D2lAssessmentMapping.create!(accepted_params) + present d2l, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Delete a D2L assessment mapping for a unit' + delete '/units/:unit_id/d2l/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to delete D2L details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + if d2l.id != params[:id].to_i + error!({ error: 'D2L details not found' }, 404) + end + + d2l.destroy if d2l.present? + status 204 + end + + desc 'Update a D2L assessment mapping for a unit' + params do + optional :org_unit_id, type: String, desc: 'The org unit id for the D2L unit' + optional :grade_object_id, type: Numeric, desc: 'The grade object id for the D2L unit' + end + put '/units/:unit_id/d2l/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to update D2L details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + if d2l.id != params[:id].to_i + error!({ error: 'D2L details not found' }, 404) + end + + accepted_params = ActionController::Parameters.new(params).permit(:org_unit_id, :grade_object_id) + + d2l.update!(accepted_params) + present d2l, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Initiate a login to D2L as a convenor or admin' + post '/d2l/login_url' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Not authorised to login to D2L' }, 403) + end + + begin + response = D2lIntegration.login_url(current_user) + rescue StandardError => e + error!({ error: e.message }, 500) + end + + present response, with: Grape::Presenters::Presenter + end + + desc 'Trigger the posting of grades to D2L' + post '/units/:unit_id/d2l/grades' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to post grades to D2L' }, 403) + end + + if unit.d2l_assessment_mapping.blank? + error!({ error: 'Configure D2L details for unit before starting transfer' }, 403) + end + + token = current_user.user_oauth_tokens.where(provider: :d2l).last + if token.blank? || token.expires_at < 10.minutes.from_now + error!({ error: 'Login to D2L before transferring results' }, 403) + end + + D2lPostGradesJob.perform_async(unit.id, current_user.id) + + status 202 + end + + desc 'Get the result of a grade transfer to D2L' + get '/units/:unit_id/d2l/grades' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view grade transfer results' }, 403) + end + + file_path = D2lIntegration.result_file_path(unit) + unless File.exist?(file_path) + error!({ error: 'No grade transfer result found' }, 404) + end + + content_type 'text/csv' + + stream_file(file_path) + end + + desc 'Determing if grade results are available for a unit' + get '/units/:unit_id/d2l/grades/available' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view grade transfer results' }, 403) + end + + file_path = D2lIntegration.result_file_path(unit) + response = { + available: File.exist?(file_path), + running: D2lIntegration.d2l_grade_job_present?(unit) + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Determing if unit is weighted' + get '/units/:unit_id/d2l/grades/weighted' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view unit details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + return false unless d2l.present? && d2l.org_unit_id.present? + + present D2lIntegration.grade_weighted?(d2l, current_user), with: Grape::Presenters::Presenter + end + + desc 'Get D2L api endpoint' + get '/d2l/endpoint' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Not authorised to view D2L endpoint' }, 403) + end + + present D2lIntegration.d2l_api_host, with: Grape::Presenters::Presenter + end + end +end diff --git a/app/api/d2l_integration_api/entities/d2l_entity.rb b/app/api/d2l_integration_api/entities/d2l_entity.rb new file mode 100644 index 000000000..95454802b --- /dev/null +++ b/app/api/d2l_integration_api/entities/d2l_entity.rb @@ -0,0 +1,9 @@ +module D2lIntegrationApi + module Entities + class D2lEntity < Grape::Entity + expose :id + expose :org_unit_id + expose :grade_object_id + end + end +end diff --git a/app/api/d2l_integration_api/oauth_public_api.rb b/app/api/d2l_integration_api/oauth_public_api.rb new file mode 100644 index 000000000..04e050cff --- /dev/null +++ b/app/api/d2l_integration_api/oauth_public_api.rb @@ -0,0 +1,22 @@ +require 'grape' + +module D2lIntegrationApi + # Public api for oauth callback + class OauthPublicApi < Grape::API + include LogHelper + + desc 'Callback for oauth login' + params do + requires :code, type: String, desc: 'The code returned from the OAuth login' + requires :state, type: String, desc: 'The state returned from the OAuth login' + end + get '/d2l/callback' do + D2lIntegration.process_callback(params[:code], params[:state]) + + host = Doubtfire::Application.config.institution[:host] + redirect "#{host}/success-close" + rescue StandardError => e + error!({ error: "Error processing oauth callback: #{e.message}" }, 500) + end + end +end diff --git a/app/api/discussion_comment_api.rb b/app/api/discussion_comment_api.rb index 51bf67ecc..ffd70117f 100644 --- a/app/api/discussion_comment_api.rb +++ b/app/api/discussion_comment_api.rb @@ -5,6 +5,7 @@ class DiscussionCommentApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers FileStreamHelper before do authenticated? @@ -29,7 +30,7 @@ class DiscussionCommentApi < Grape::API for attached_file in attached_files do if attached_file.present? - error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 end end @@ -38,7 +39,7 @@ class DiscussionCommentApi < Grape::API logger.info("#{current_user.username} - added discussion comment for task #{task.id} (#{task_definition.abbreviation})") - if attached_files.nil? || attached_files.empty? + if attached_files.blank? error!({ error: 'Audio prompts are empty, unable to add new discussion comment' }, 403) end @@ -78,37 +79,9 @@ class DiscussionCommentApi < Grape::API # mark as attachment if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{prompt_path}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end - # Work out what part to return - file_size = File.size(prompt_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 - end - - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' - - # Read the binary data and return - result = File.binread(prompt_path, content_length, begin_point) - result + stream_file prompt_path end end @@ -140,38 +113,10 @@ class DiscussionCommentApi < Grape::API # mark as attachment if params[:as_attachment] - header['Content-Disposition'] = "attachment; filename=#{response_path}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' + header['Content-Disposition'] = "attachment; filename=response.ogg" end - # Work out what part to return - file_size = File.size(response_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 - end - - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' - - # Read the binary data and return - result = File.binread(response_path, content_length, begin_point) - result + stream_file response_path end end @@ -191,13 +136,13 @@ class DiscussionCommentApi < Grape::API attached_file = params[:attachment] if attached_file.present? - error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 end logger.info("#{current_user.username} - added a reply to the discussion comment #{params[:task_comment_id]} for task #{task.id} (#{task_definition.abbreviation})") - if attached_file.nil? || attached_file.empty? + if attached_file.blank? error!({ error: 'Discussion reply is empty, unable to add new reply to discussion comment' }, 403) end diff --git a/app/api/entities/task_definition_entity.rb b/app/api/entities/task_definition_entity.rb index 94ba180d4..637bf51a9 100644 --- a/app/api/entities/task_definition_entity.rb +++ b/app/api/entities/task_definition_entity.rb @@ -21,12 +21,12 @@ def staff?(my_role) expose :start_date end - expose :upload_requirements do |task_definition, options| + expose :upload_requirements, expose_nil: false do |task_definition, options| if staff?(options[:my_role]) task_definition.upload_requirements else # Filter out turn it in details - task_definition.upload_requirements.map { |r| r.except('tii_check', 'tii_pct') } + task_definition.upload_requirements.map { |r| r.except('tii_check', 'tii_pct') } unless task_definition.upload_requirements.nil? end end @@ -35,14 +35,20 @@ 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 :group_set_id + 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_scorm_data?, as: :has_scorm_data + expose :scorm_enabled + expose :scorm_allow_review + expose :scorm_bypass_test + expose :scorm_time_delay_enabled + expose :scorm_attempt_limit expose :is_graded expose :max_quality_pts - expose :overseer_image_id, if: ->(unit, options) { staff?(options[:my_role]) } + expose :overseer_image_id, if: ->(unit, options) { staff?(options[:my_role]) }, expose_nil: false expose :assessment_enabled, if: ->(unit, options) { staff?(options[:my_role]) } - expose :moss_language, if: ->(unit, options) { staff?(options[:my_role]) } + expose :moss_language, if: ->(unit, options) { staff?(options[:my_role]) }, expose_nil: false end end diff --git a/app/api/entities/task_entity.rb b/app/api/entities/task_entity.rb index cd88b53eb..ffb53bd86 100644 --- a/app/api/entities/task_entity.rb +++ b/app/api/entities/task_entity.rb @@ -17,6 +17,7 @@ class TaskEntity < Grape::Entity end expose :extensions + expose :scorm_extensions expose :times_assessed expose :grade, expose_nil: false diff --git a/app/api/entities/test_attempt_entity.rb b/app/api/entities/test_attempt_entity.rb new file mode 100644 index 000000000..d0d5ebc07 --- /dev/null +++ b/app/api/entities/test_attempt_entity.rb @@ -0,0 +1,12 @@ +module Entities + class TestAttemptEntity < Grape::Entity + expose :id + expose :task_id + expose :attempted_time + expose :terminated + expose :success_status + expose :score_scaled + expose :completion_status + expose :cmi_datamodel + end +end diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index 4ac79cb15..efd50eb51 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -56,7 +56,7 @@ def can_read_unit_config?(my_role) expose :tutorials, using: TutorialEntity, unless: :summary_only # expose :tutorial_enrolments, using: TutorialEnrolmentEntity, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } - expose :task_definitions, using: TaskDefinitionEntity, unless: :summary_only + expose :ordered_task_definitions, as: :task_definitions, using: TaskDefinitionEntity, unless: :summary_only expose :task_outcome_alignments, using: TaskOutcomeAlignmentEntity, unless: :summary_only expose :group_sets, using: GroupSetEntity, unless: :summary_only expose :groups, using: GroupEntity, unless: :summary_only diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb index 9ede2faf8..1a1155e10 100644 --- a/app/api/entities/user_entity.rb +++ b/app/api/entities/user_entity.rb @@ -13,7 +13,7 @@ class UserEntity < Grape::Entity expose :opt_in_to_research, unless: :minimal expose :has_run_first_time_setup, unless: :minimal - expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { Doubtfire::Application.config.tii_enabled } do |user, options| + expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { TurnItIn.enabled? } do |user, options| if TiiActionFetchFeaturesEnabled.eula_required? TurnItIn.eula_version == user.tii_eula_version else diff --git a/app/api/group_sets_api.rb b/app/api/group_sets_api.rb index ef231f2d3..eb78a85d4 100644 --- a/app/api/group_sets_api.rb +++ b/app/api/group_sets_api.rb @@ -220,7 +220,7 @@ class GroupSetsApi < Grape::API end num = group_set.groups.count + 1 - while group_params[:name].nil? || group_params[:name].empty? || group_set.groups.where(name: group_params[:name]).count > 0 + while group_params[:name].blank? || group_set.groups.where(name: group_params[:name]).count > 0 group_params[:name] = "Group #{num}" num += 1 end diff --git a/app/api/scorm_api.rb b/app/api/scorm_api.rb new file mode 100644 index 000000000..dc3c0e7c3 --- /dev/null +++ b/app/api/scorm_api.rb @@ -0,0 +1,71 @@ +require 'grape' +require 'zip' +require 'mime/types' +class ScormApi < Grape::API + # Include the AuthenticationHelpers for authentication functionality + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? :scorm + end + + helpers do + # Method to stream a file from a zip archive at the specified path + # @param zip_path [String] the path to the zip archive + # @param file_path [String] the path of the file within the zip archive + def stream_file_from_zip(zip_path, file_path) + file_stream = nil + + logger.debug "Streaming zip file at #{zip_path}" + # Get an input stream for the requested file within the ZIP archive + Zip::File.open(zip_path) do |zip_file| + zip_file.each do |entry| + next unless entry.name == file_path + logger.debug "Found file #{file_path} from SCORM container" + file_stream = entry.get_input_stream + break + end + end + + # If the file was not found in the ZIP archive, return a 404 response + unless file_stream + error!({ error: 'File not found' }, 404) + end + + # Set the content type based on the file extension + content_type = MIME::Types.type_for(file_path).first.content_type + logger.debug "Content type: #{content_type}" + + # Set the content type header + header 'Content-Type', content_type + + # Set cache control header to prevent caching + header 'Cache-Control', 'no-cache, no-store, must-revalidate' + + # Set the body to the contents of the file_stream and return the response + body file_stream.read + end + end + + desc 'Serve SCORM content' + params do + requires :task_def_id, type: Integer, desc: 'Task Definition ID to get SCORM test data for' + end + get '/scorm/:task_def_id/:username/:auth_token/*file_path' do + task_def = TaskDefinition.find(params[:task_def_id]) + + unless authorise? current_user, task_def.unit, :get_unit + error!({ error: 'You cannot access SCORM tests of unit' }, 403) + end + + env['api.format'] = :txt + if task_def.has_scorm_data? + zip_path = task_def.task_scorm_data + content_type 'application/octet-stream' + stream_file_from_zip(zip_path, params[:file_path]) + else + error!({ error: 'SCORM data does not exist.' }, 404) + end + end +end diff --git a/app/api/scorm_extension_comments_api.rb b/app/api/scorm_extension_comments_api.rb new file mode 100644 index 000000000..7a8626dc5 --- /dev/null +++ b/app/api/scorm_extension_comments_api.rb @@ -0,0 +1,54 @@ +require 'grape' + +class ScormExtensionCommentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + desc 'Request a scorm extension for a task' + params do + requires :comment, type: String, desc: 'The details of the request' + end + post '/projects/:project_id/task_def_id/:task_definition_id/request_scorm_extension' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + # check permissions using specific permission has with addition of request extension if allowed in unit + unless authorise? current_user, task, :request_scorm_extension + error!({ error: 'Not authorised to request a scorm extension for this task' }, 403) + end + + if task_definition.scorm_attempt_limit == 0 + error!({ message: 'This task allows unlimited attempts to complete the test' }, 400) + return + end + + result = task.apply_for_scorm_extension(current_user, params[:comment]) + present result.serialize(current_user), Grape::Presenters::Presenter + end + + desc 'Assess a scorm extension for a task' + params do + requires :granted, type: Boolean, desc: 'Assess a scorm extension' + end + put '/projects/:project_id/task_def_id/:task_definition_id/assess_scorm_extension/:task_comment_id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :assess_scorm_extension + error!({ error: 'Not authorised to assess a scorm extension for this task' }, 403) + end + + task_comment = task.all_comments.find(params[:task_comment_id]).becomes(ScormExtensionComment) + + unless task_comment.assess_scorm_extension(current_user, params[:granted]) + if task_comment.errors.count >= 1 + error!({ error: task_comment.errors.full_messages.first }, 403) + else + error!({ error: 'Error saving scorm extension' }, 403) + end + end + present task_comment.serialize(current_user), Grape::Presenters::Presenter + end +end diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 39be2d292..f538c2414 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -1,15 +1,42 @@ require 'grape' class SettingsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers # # Returns the current auth method # desc 'Return configurable details for the Doubtfire front end' get '/settings' do + # Require authentication for the main settings endpoint + authenticated? + + begin + response = { + externalName: Doubtfire::Application.config.institution[:product_name], + hasLogo: Doubtfire::Application.config.institution[:has_logo], + logoUrl: Doubtfire::Application.config.institution[:logo_url], + logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url], + overseerEnabled: Doubtfire::Application.config.overseer_enabled, + tiiEnabled: TurnItIn.enabled?, + d2lEnabled: D2lIntegration.enabled? + } + + present response, with: Grape::Presenters::Presenter + rescue StandardError => e + logger.error "Error fetching settings: #{e.message}" + error!({ error: "Could not retrieve settings due to an internal error" }, 500) + end + end + + # + # Public endpoint - safe to access without authentication + # + desc 'Return public application settings without authentication' + get '/settings/public' do response = { - externalName: Doubtfire::Application.config.institution[:product_name], - overseerEnabled: Doubtfire::Application.config.overseer_enabled, - tiiEnabled: Doubtfire::Application.config.tii_enabled + externalName: Doubtfire::Application.config.institution[:product_name] + # Include only non-sensitive settings here } present response, with: Grape::Presenters::Presenter @@ -17,6 +44,8 @@ class SettingsApi < Grape::API desc 'Return privacy policy details' get '/settings/privacy' do + authenticated? + response = { privacy: Doubtfire::Application.config.institution[:privacy], plagiarism: Doubtfire::Application.config.institution[:plagiarism] diff --git a/app/api/similarity/task_similarity_api.rb b/app/api/similarity/task_similarity_api.rb index 23e4c84c2..79df1ff79 100644 --- a/app/api/similarity/task_similarity_api.rb +++ b/app/api/similarity/task_similarity_api.rb @@ -115,6 +115,7 @@ class TaskSimilarityApi < Grape::API if similarity.present? && similarity.type == 'TiiTaskSimilarity' if similarity.ready_for_viewer? result = similarity.create_viewer_url(current_user) + error!({ error: 'Report viewer not currently available, please try again later' }, 503) if result.blank? present result, with: Grape::Presenters::Presenter else error!({ error: "Similarity report is not yet ready to be viewed for this submission" }, 404) diff --git a/app/api/submission/batch_task_api.rb b/app/api/submission/batch_task_api.rb index e1f6642ea..c53d721cd 100644 --- a/app/api/submission/batch_task_api.rb +++ b/app/api/submission/batch_task_api.rb @@ -10,57 +10,56 @@ class BatchTaskApi < Grape::API authenticated? end - desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" - params do - requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' - optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' - end - get '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) - unit = Unit.find(params[:unit_id]) + # desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" + # params do + # requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' + # optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' + # end + # get '/submission/assess/' do + # user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + # unit = Unit.find(params[:unit_id]) - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end + # unless authorise? user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + # end - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end + # unless authorise? current_user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + # end - # Array of tasks that need marking for the given unit id - tasks_to_download = UnitRole.tasks_to_review(user) + # # Array of tasks that need marking for the given unit id + # tasks_to_download = UnitRole.tasks_to_review(user) - output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) + # output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) - error!({ error: 'No files to download' }, 401) if output_zip.nil? + # error!({ error: 'No files to download' }, 401) if output_zip.nil? - # Set download headers... - content_type 'application/octet-stream' - download_id = "#{Time.new.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" - header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - env['api.format'] = :binary + # # Set download headers... + # content_type 'application/octet-stream' + # download_id = "#{Time.zone.now.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" + # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" + # env['api.format'] = :binary - out = File.read(output_zip) - File.unlink(output_zip) - out - end # get + # stream_file output_zip + # ensure + # File.unlink(output_zip) unless output_zip.blank? + # end # get - desc 'Upload submission documents for the given unit and user id' - params do - requires :file, type: File, desc: 'batch file upload' - requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' - optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' - end - post '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) - unit = Unit.find(params[:unit_id]) + # desc 'Upload submission documents for the given unit and user id' + # params do + # requires :file, type: File, desc: 'batch file upload' + # requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' + # optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' + # end + # post '/submission/assess/' do + # user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + # unit = Unit.find(params[:unit_id]) - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch upload marks' }, 401) - end + # unless authorise? user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch upload marks' }, 401) + # end - present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter - end # post + # present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter + # end # post end end diff --git a/app/api/submission/generate_helpers.rb b/app/api/submission/generate_helpers.rb index 073881339..1d74ac443 100644 --- a/app/api/submission/generate_helpers.rb +++ b/app/api/submission/generate_helpers.rb @@ -14,7 +14,7 @@ def scoop_files(params, upload_reqs) # upload_reqs.each do |detail| key = detail['key'] - next unless files.key? key + next unless files.key?(key) && files[key].is_a?(Hash) files[key][:id] = files[key]['name'] files[key][:name] = detail['name'] diff --git a/app/api/submission/portfolio_api.rb b/app/api/submission/portfolio_api.rb index 3b0182ec5..611616813 100644 --- a/app/api/submission/portfolio_api.rb +++ b/app/api/submission/portfolio_api.rb @@ -81,15 +81,14 @@ class PortfolioApi < Grape::API evidence_loc = project.portfolio_path if evidence_loc.nil? || File.exist?(evidence_loc) == false - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" + evidence_loc = Rails.root.join('public/resources/FileNotFound.pdf') + filename = 'FileNotFound.pdf' else filename = "#{project.unit.code}-#{project.student.username}-portfolio.pdf" end if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end # Set download headers... diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index ff7176821..3757a95be 100644 --- a/app/api/submission/portfolio_evidence_api.rb +++ b/app/api/submission/portfolio_evidence_api.rb @@ -48,28 +48,25 @@ def self.logger alignments = params[:alignment_data] upload_reqs = task.upload_requirements - student = task.project.student # Copy files to be PDFed - task.accept_submission(current_user, scoop_files(params, upload_reqs), student, self, params[:contributions], trigger, alignments, accepted_tii_eula: params[:accepted_tii_eula]) + task.accept_submission(current_user, scoop_files(params, upload_reqs), self, params[:contributions], trigger, alignments, accepted_tii_eula: params[:accepted_tii_eula]) - overseer_assessment = OverseerAssessment.create_for(task) - if overseer_assessment.present? - logger.info "Launching Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id}" + if task.overseer_enabled? + overseer_assessment = OverseerAssessment.create_for(task) + if overseer_assessment.present? + logger.info "Launching Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id}" - response = overseer_assessment.send_to_overseer + response = overseer_assessment.send_to_overseer - if response[:error].present? - error!({ error: response[:error] }, 403) + if response[:error].present? + error!({ error: response[:error] }, 403) + end + else + logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" end - - present :updated_task, task, with: Entities::TaskEntity, update_only: true - present :comment, response[:comment].serialize(current_user), with: Grape::Presenters::Presenter - return end - logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" - present task, with: Entities::TaskEntity, update_only: true end # post @@ -79,8 +76,8 @@ 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 - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + project = Project.eager_load(:unit).find(params[:id]) + task_definition = project.unit.task_definitions.select(:id, :name, :abbreviation).find(params[:task_definition_id]) # check the user can put this task unless authorise? current_user, project, :get_submission @@ -89,15 +86,13 @@ def self.logger task = project.task_for_task_definition(task_definition) - evidence_loc = task.portfolio_evidence_path - student = task.project.student - unit = task.project.unit + evidence_loc = task.final_pdf_path if task.processing_pdf? - evidence_loc = Rails.root.join('public', 'resources', 'AwaitingProcessing.pdf') + evidence_loc = Rails.root.join('public/resources/AwaitingProcessing.pdf') filename = 'AwaitingProcessing.pdf' - elsif evidence_loc.nil? - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + elsif evidence_loc.nil? || !File.exist?(evidence_loc) + evidence_loc = Rails.root.join('public/resources/FileNotFound.pdf') filename = 'FileNotFound.pdf' else filename = "#{task.task_definition.abbreviation}.pdf" @@ -105,7 +100,6 @@ def self.logger if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end # Set download headers... diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb index f5b25a5f1..dac7233ff 100644 --- a/app/api/task_comments_api.rb +++ b/app/api/task_comments_api.rb @@ -3,6 +3,7 @@ class TaskCommentsApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers FileStreamHelper before do authenticated? @@ -27,7 +28,7 @@ class TaskCommentsApi < Grape::API reply_to_id = params[:reply_to_id] if attached_file.present? - error!({ error: "Attachment is empty." }) unless File.size?(attached_file["tempfile"].path).present? + error!({ error: "Attachment is empty." }) if File.size?(attached_file["tempfile"].path).blank? error!({ error: "Attachment exceeds the maximum attachment size of 30MB." }) unless File.size?(attached_file["tempfile"].path) < 30_000_000 end @@ -37,13 +38,13 @@ class TaskCommentsApi < Grape::API if reply_to_id.present? originalTaskComment = TaskComment.find(reply_to_id) error!(error: 'You do not have permission to read the replied comment') unless authorise?(current_user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(current_user) != nil) - error!(error: 'Original comment is not in this task.') unless task.all_comments.find(reply_to_id).present? + error!(error: 'Original comment is not in this task.') if task.all_comments.find(reply_to_id).blank? end logger.info("#{current_user.username} - added comment for task #{task.id} (#{task_definition.abbreviation})") - if attached_file.nil? || attached_file.empty? - error!({ error: 'Comment text is empty, unable to add new comment' }, 403) unless text_comment.present? + if attached_file.blank? + error!({ error: 'Comment text is empty, unable to add new comment' }, 403) if text_comment.blank? result = task.add_text_comment(current_user, text_comment, reply_to_id) else file_result = FileHelper.accept_file(attached_file, 'comment attachment - TaskComment', 'comment_attachment') @@ -90,36 +91,9 @@ class TaskCommentsApi < Grape::API # mark as attachment if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{comment.attachment_file_name}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end - # Work out what part to return - file_size = File.size(comment.attachment_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 - end - - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' - - # Read the binary data and return - File.binread(comment.attachment_path, content_length, begin_point) + stream_file comment.attachment_path end end diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 03536c9ef..7de2264af 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -33,6 +33,11 @@ class TaskDefinitionsApi < Grape::API optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' optional :overseer_image_id, type: Integer, desc: 'The id of the Docker image for overseer' optional :moss_language, type: String, desc: 'The language to use for code similarity checks' + optional :scorm_enabled, type: Boolean, desc: 'Whether SCORM assessment is enabled for this task' + optional :scorm_allow_review, type: Boolean, desc: 'Whether a student is allowed to review their completed test attempts' + optional :scorm_bypass_test, type: Boolean, desc: 'Whether a student is allowed to upload files before passing SCORM test' + optional :scorm_time_delay_enabled, type: Boolean, desc: 'Whether there is an incremental time delay between SCORM test attempts' + optional :scorm_attempt_limit, type: Integer, desc: 'The number of times a SCORM test can be attempted' end end post '/units/:unit_id/task_definitions/' do @@ -42,8 +47,6 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to create a task definition of this unit' }, 403) end - params[:task_def][:upload_requirements] = [] if params[:task_def][:upload_requirements].nil? - task_params = ActionController::Parameters.new(params) .require(:task_def) .permit( @@ -57,15 +60,22 @@ class TaskDefinitionsApi < Grape::API :abbreviation, :restrict_status_updates, :plagiarism_warn_pct, + :scorm_enabled, + :scorm_allow_review, + :scorm_bypass_test, + :scorm_time_delay_enabled, + :scorm_attempt_limit, :is_graded, :max_quality_pts, :assessment_enabled, :overseer_image_id, - :moss_language + :moss_language, + :upload_requirements, + :unit_id ) task_params[:unit_id] = unit.id - task_params[:upload_requirements] = JSON.parse(params[:task_def][:upload_requirements]) unless params[:task_def][:upload_requirements].nil? + task_params[:upload_requirements] = params[:task_def][:upload_requirements].present? ? JSON.parse(params[:task_def][:upload_requirements]) : [] task_def = TaskDefinition.new(task_params) @@ -106,6 +116,11 @@ class TaskDefinitionsApi < Grape::API optional :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' 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' + optional :scorm_allow_review, type: Boolean, desc: 'Whether a student is allowed to review their completed test attempts' + optional :scorm_bypass_test, type: Boolean, desc: 'Whether a student is allowed to upload files before passing SCORM test' + optional :scorm_time_delay_enabled, type: Boolean, desc: 'Whether or not there is an incremental time delay between SCORM test attempts' + optional :scorm_attempt_limit, type: Integer, desc: 'The number of times a SCORM test can be attempted' optional :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' optional :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' @@ -134,25 +149,36 @@ class TaskDefinitionsApi < Grape::API :abbreviation, :restrict_status_updates, :plagiarism_warn_pct, + :scorm_enabled, + :scorm_allow_review, + :scorm_bypass_test, + :scorm_time_delay_enabled, + :scorm_attempt_limit, :is_graded, :max_quality_pts, :assessment_enabled, :overseer_image_id, - :moss_language + :moss_language, + :upload_requirements ) - task_params[:upload_requirements] = JSON.parse(params[:task_def][:upload_requirements]) unless params[:task_def][:upload_requirements].nil? + if params[:task_def][:upload_requirements].present? + upload_reqs = JSON.parse(params[:task_def][:upload_requirements]) + task_params[:upload_requirements] = upload_reqs - # Ensure changes to a TD defined as a "draft task definition" are validated - if unit.draft_task_definition_id == params[:id] - if params[:task_def][:upload_requirements] - requirements = params[:task_def][:upload_requirements] - if requirements.length != 1 || requirements[0]["type"] != "document" - error!({ error: 'Task is marked as the draft learning summary task definition. A draft learning summary task can only contain a single document upload.' }, 403) - end + # Ensure we permit all of the passed in upload requirements + if task_params[:upload_requirements].is_a? Array + # Force permit - the model validates the details + task_params[:upload_requirements].each(&:permit!) + end + + # Ensure changes to a TD defined as a 'draft task definition' are validated + if unit.draft_task_definition_id == params[:id] && (upload_reqs.length != 1 || upload_reqs[0]['type'] != 'document') + error!({ error: 'Task is marked as the draft learning summary. A draft learning summary task can only contain a single document upload.' }, 403) end end + # Bulk update task definition with permitted parameters task_def.update!(task_params) # Set the tutorial stream @@ -179,7 +205,6 @@ class TaskDefinitionsApi < Grape::API end end - puts task_def.upload_requirements present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) end @@ -195,8 +220,8 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to upload CSV of tasks' }, 403) end - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end path = params[:file][:tempfile].path @@ -274,7 +299,7 @@ class TaskDefinitionsApi < Grape::API # This API accepts more than 2 files, file0 and file1 are just examples. end post '/units/:unit_id/task_definitions/:task_def_id/test_overseer_assessment' do - logger.info "********* - Starting overseer test" + logger.info '********* - Starting overseer test' return 'Overseer is not enabled' if !Doubtfire::Application.config.overseer_enabled unit = Unit.find(params[:unit_id]) @@ -297,9 +322,9 @@ class TaskDefinitionsApi < Grape::API upload_reqs = task.upload_requirements # Copy files to be PDFed - task.accept_submission(current_user, scoop_files(params, upload_reqs), current_user, self, nil, 'ready_for_feedback', nil, accepted_tii_eula: false) + task.accept_submission(current_user, scoop_files(params, upload_reqs), self, nil, 'ready_for_feedback', nil, accepted_tii_eula: false) - logger.info "********* - about to perform overseer submission" + logger.info '********* - about to perform overseer submission' overseer_assessment = OverseerAssessment.create_for(task) if overseer_assessment.present? response = overseer_assessment.send_to_overseer @@ -351,8 +376,8 @@ class TaskDefinitionsApi < Grape::API task_def = unit.task_definitions.find(params[:task_def_id]) - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end file_path = params[:file][:tempfile].path @@ -438,8 +463,8 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to upload tasks of unit' }, 403) end - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end file = params[:file][:tempfile].path @@ -548,13 +573,12 @@ class TaskDefinitionsApi < Grape::API path = task_def.task_sheet filename = "#{task_def.unit.code}-#{task_def.abbreviation}.pdf" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" + path = Rails.root.join('public/resources/FileNotFound.pdf') + filename = 'FileNotFound.pdf' end if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end content_type 'application/pdf' @@ -579,11 +603,10 @@ class TaskDefinitionsApi < Grape::API content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-resources.zip" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + path = Rails.root.join('public/resources/FileNotFound.pdf') content_type 'application/pdf' header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' end - header['Access-Control-Expose-Headers'] = 'Content-Disposition' stream_file path end @@ -606,12 +629,86 @@ class TaskDefinitionsApi < Grape::API content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-assessment-resources.zip" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + path = Rails.root.join('public/resources/FileNotFound.pdf') content_type 'application/pdf' header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' end - header['Access-Control-Expose-Headers'] = 'Content-Disposition' stream_file path end + + desc 'Upload the SCORM container (zip file) for a task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + requires :file, type: File, desc: 'The SCORM data container' + end + post '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload SCORM data for the unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + if params[:file].blank? + error!({ error: "No file uploaded" }, 403) + end + + file_path = params[:file][:tempfile].path + + check_mime_against_list! file_path, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] + + # Actually import... + task_def.add_scorm_data(file_path) + true + end + + desc 'Download the SCORM test data' + params do + requires :unit_id, type: Integer, desc: 'The unit to modify tasks for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the SCORM test data of' + end + get '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + if task_def.has_scorm_data? + path = task_def.task_scorm_data + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-scorm.zip" + else + path = Rails.root.join('public/resources/FileNotFound.pdf') + content_type 'application/pdf' + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + end + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + + env['api.format'] = :binary + File.read(path) + end + + desc 'Remove the SCORM test data for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + end + delete '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to remove task SCORM data of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + # Actually remove... + task_def.remove_scorm_data + true + end end diff --git a/app/api/tasks_api.rb b/app/api/tasks_api.rb index 10e45917e..802ec839a 100644 --- a/app/api/tasks_api.rb +++ b/app/api/tasks_api.rb @@ -72,7 +72,8 @@ class TasksApi < Grape::API task_definition_id: task.task_definition_id, status: TaskStatus.id_to_key(task.task_status_id), due_date: task.due_date, - extensions: task.extensions + extensions: task.extensions, + scorm_extensions: task.scorm_extensions } end @@ -219,12 +220,11 @@ class TasksApi < Grape::API file_loc = FileHelper.zip_file_path_for_done_task(task) if file_loc.nil? || !File.exist?(file_loc) - file_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + file_loc = Rails.root.join('public/resources/FileNotFound.pdf') header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' else header['Content-Disposition'] = "attachment; filename=#{project.student.username}-#{task.task_definition.abbreviation}.zip" end - header['Access-Control-Expose-Headers'] = 'Content-Disposition' # Set download headers... content_type 'application/octet-stream' diff --git a/app/api/teaching_periods_authenticated_api.rb b/app/api/teaching_periods_authenticated_api.rb index d39260cfa..4670cf998 100644 --- a/app/api/teaching_periods_authenticated_api.rb +++ b/app/api/teaching_periods_authenticated_api.rb @@ -77,21 +77,4 @@ class TeachingPeriodsAuthenticatedApi < Grape::API TeachingPeriod.find(teaching_period_id).destroy end - desc 'Rollover a Teaching Period' - params do - requires :new_teaching_period_id, type: Integer, desc: 'The id of the rolled over teaching period' - optional :rollover_inactive, type: Boolean, default: false, desc: 'Are in active units included in the roll over' - optional :search_forward, type: Boolean, default: true, desc: 'When rolling over units, ensure that latest version is rolled over to new teaching period' - end - post '/teaching_periods/:existing_teaching_period_id/rollover' do - unless authorise? current_user, User, :rollover - error!({ error: 'Not authorised to rollover a teaching period' }, 403) - end - - new_teaching_period_id = params[:new_teaching_period_id] - new_teaching_period = TeachingPeriod.find(new_teaching_period_id) - - existing_teaching_period = TeachingPeriod.find(params[:existing_teaching_period_id]) - error!({ error: existing_teaching_period.errors.full_messages.first }, 403) unless existing_teaching_period.rollover(new_teaching_period, params[:search_forward], params[:rollover_inactive]) - end end diff --git a/app/api/test_attempts_api.rb b/app/api/test_attempts_api.rb new file mode 100644 index 000000000..2ac6007bb --- /dev/null +++ b/app/api/test_attempts_api.rb @@ -0,0 +1,192 @@ +require 'grape' + +class TestAttemptsApi < Grape::API + format :json + + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get all test results for a task' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + end + get '/projects/:project_id/task_def_id/:task_definition_id/test_attempts' do + project = Project.preload(:unit).find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorized to get scorm attempts for task" }, 403) + end + + task = project.task_for_task_definition(task_definition) + + attempts = TestAttempt.where(task_id: task.id) + tests = attempts.order(id: :desc) + present tests, with: Entities::TestAttemptEntity + end + + desc 'Get the latest test result' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + optional :completed, type: Boolean, desc: 'Get the latest completed test?' + end + get '/projects/:project_id/task_def_id/:task_definition_id/test_attempts/latest' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorized to get latest scorm attempt for task" }, 403) + end + + task = project.task_for_task_definition(task_definition) + + attempts = TestAttempt.where("task_id = ?", task.id) + + test = if params[:completed] + attempts.where(completion_status: true).order(id: :desc).first + else + attempts.order(id: :desc).first + end + + if test.nil? + error!({ message: 'No tests found for this task' }, 404) + else + present test, with: Entities::TestAttemptEntity + end + end + + desc 'Review a completed attempt' + params do + requires :id, type: Integer, desc: 'Test attempt ID to review' + end + get 'test_attempts/:id/review' do + test = TestAttempt.find(params[:id]) + + key = if current_user == test.student + :review_own_attempt + else + :review_other_attempt + end + + unless authorise? current_user, test, key, ->(role, perm_hash, other) { test.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to review this scorm attempt' }, 403) + end + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + else + logger.debug "Request to review test attempt #{params[:id]}" + begin + test.review + rescue StandardError => e + error!({ message: e.message }, 403) + end + end + present test, with: Entities::TestAttemptEntity + end + + desc 'Initiate a new test attempt' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + end + post '/projects/:project_id/task_def_id/:task_definition_id/test_attempts' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + # check permissions using specific permission has with addition of make scorm attempt if scorm is enabled in task def + unless authorise? current_user, task, :make_scorm_attempt, ->(role, perm_hash, other) { task.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to make a scorm attempt for this task' }, 403) + end + + attempts = TestAttempt.where("task_id = ?", task.id) + test_count = attempts.count + + # check if last attempt is complete + last_attempt = attempts.order(id: :desc).first + if test_count > 0 && last_attempt.terminated == false + error!({ message: 'An attempt is still ongoing. Cannot initiate new attempt.' }, 400) + return + end + + # check if last attempt is a pass + if test_count > 0 && last_attempt.success_status == true + error!({ message: 'User has passed the SCORM test. Cannot initiate more attempts.' }, 400) + return + end + + # check attempt limit + limit = task.task_definition.scorm_attempt_limit + task.scorm_extensions + if limit != 0 && test_count >= limit + error!({ message: 'Attempt limit has been reached' }, 400) + return + end + + test = TestAttempt.create!({ task_id: task.id }) + present test, with: Entities::TestAttemptEntity + end + + desc 'Update an existing attempt' + params do + requires :id, type: String, desc: 'ID of the test attempt' + optional :cmi_datamodel, type: String, desc: 'JSON CMI datamodel to update' + optional :terminated, type: Boolean, desc: 'Terminate the current attempt' + optional :success_status, type: Boolean, desc: 'Override the success status of the current attempt' + end + patch 'test_attempts/:id' do + test = TestAttempt.find(params[:id]) + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + end + + if params[:success_status].present? + unless authorise? current_user, test, :override_success_status + error!({ error: 'Not authorised to override the success status of this scorm attempt' }, 403) + end + + test.override_success_status(params[:success_status]) + else + unless authorise? current_user, test, :update_attempt + error!({ error: 'Not authorised to update this scorm attempt' }, 403) + end + + attempt_data = ActionController::Parameters.new(params).permit(:cmi_datamodel, :terminated) + + unless test.terminated + test.update!(attempt_data) + test.save! + if params[:terminated] + test.add_scorm_comment + end + end + end + + present test, with: Entities::TestAttemptEntity + end + + desc 'Delete a test attempt' + params do + requires :id, type: String, desc: 'ID of the test attempt' + end + delete 'test_attempts/:id' do + test = TestAttempt.find(params[:id]) + + unless authorise? current_user, test, :delete_attempt + error!({ error: 'Not authorised to delete this scorm attempt' }, 403) + end + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + else + test.destroy! + end + end +end diff --git a/app/api/tii/tii_action_api.rb b/app/api/tii/tii_action_api.rb index 979eae9ef..af7d2e2cc 100644 --- a/app/api/tii/tii_action_api.rb +++ b/app/api/tii/tii_action_api.rb @@ -52,8 +52,7 @@ class TiiActionApi < Grape::API case params[:action] when 'retry' error!({ error: 'Retry in progress. Please wait.' }, 403) if action.retry - action.update(retry: true) - action.perform_async + action.perform_retry else error!({ error: 'Invalid action' }, 400) end diff --git a/app/api/tii/turn_it_in_hooks_api.rb b/app/api/tii/turn_it_in_hooks_api.rb index 694099c26..8a039bf4d 100644 --- a/app/api/tii/turn_it_in_hooks_api.rb +++ b/app/api/tii/turn_it_in_hooks_api.rb @@ -17,14 +17,15 @@ class TurnItInHooksApi < Grape::API } } post 'tii_hook' do - data = JSON.parse(env['api.request.input']) + raw_data = env['api.request.input'] + data = JSON.parse(raw_data) digest = OpenSSL::Digest.new('sha256') - # puts data - hmac = OpenSSL::HMAC.hexdigest(digest, ENV.fetch('TCA_SIGNING_KEY', nil), data.to_json) + logger.debug("TII_HOOK_DEBUG:#{raw_data}") + hmac = OpenSSL::HMAC.hexdigest(digest, ENV.fetch('TCA_SIGNING_KEY', nil), raw_data) - # puts hmac - # puts headers['x-turnitin-signature'] + logger.debug("TII_HOOK_DEBUG:#{hmac}") + logger.debug("TII_HOOK_DEBUG:#{headers['x-turnitin-signature']}") if hmac != headers["x-turnitin-signature"] logger.error("TII: HMAC does not match") diff --git a/app/api/tutorial_enrolments_api.rb b/app/api/tutorial_enrolments_api.rb index cd86ad9f3..b3e83f05f 100644 --- a/app/api/tutorial_enrolments_api.rb +++ b/app/api/tutorial_enrolments_api.rb @@ -17,7 +17,7 @@ class TutorialEnrolmentsApi < Grape::API end tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) - error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) if tutorial.blank? # If the tutorial has a capacity, and we are at that capacity, and the user does not have permissions to exceed capacity... if tutorial.capacity > 0 && tutorial.tutorial_enrolments.count >= tutorial.capacity && !authorise?(current_user, unit, :exceed_capacity) @@ -44,10 +44,10 @@ class TutorialEnrolmentsApi < Grape::API end tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) - error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) if tutorial.blank? tutorial_enrolment = tutorial.tutorial_enrolments.find_by(project_id: params[:project_id]) - error!({ error: "Project not enrolled in the selected tutorial" }, 403) unless tutorial_enrolment.present? + error!({ error: "Project not enrolled in the selected tutorial" }, 403) if tutorial_enrolment.blank? tutorial_enrolment.destroy # present :enrolments, project.tutorial_enrolments, with: Entities::TutorialEnrolmentEntity diff --git a/app/api/units_api.rb b/app/api/units_api.rb index bbdc93fca..1dd92a3d1 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -47,7 +47,6 @@ class UnitsApi < Grape::API { tutorial_streams: :activity_type }, { tutorials: [:tutor, :tutorial_stream] }, :tutorial_enrolments, - { staff: [:role, :user] }, :group_sets, :groups, :group_memberships @@ -190,10 +189,15 @@ class UnitsApi < Grape::API :allow_student_change_tutorial, ) + # Ensure the user is authorised to convene units + unless authorise? current_user, User, :convene_units + error!({ error: 'You are not authorised to manage units' }, 403) + end + # Identify main convenor - ensure they have the correct role - main_convenor_user = unit_parameters[:main_convenor_user_id].present? ? User.find(unit_parameters[:main_convenor_user_id]) : current_user + main_convenor_user = params[:unit][:main_convenor_user_id].present? ? User.find(params[:unit][:main_convenor_user_id]) : current_user - unless main_convenor_user.present? + if main_convenor_user.blank? error!({ error: 'Main convenor user not found' }, 403) end @@ -209,7 +213,7 @@ class UnitsApi < Grape::API if teaching_period_id.blank? if unit_parameters[:start_date].nil? start_date = Date.parse('Monday') - delta = start_date > Date.today ? 0 : 7 + delta = start_date > Time.zone.today ? 0 : 7 unit_parameters[:start_date] = start_date + delta end @@ -231,9 +235,10 @@ class UnitsApi < Grape::API desc 'Rollover unit' params do - optional :teaching_period_id - optional :start_date - optional :end_date + 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 :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 @@ -249,9 +254,9 @@ class UnitsApi < Grape::API if teaching_period_id.present? tp = TeachingPeriod.find(teaching_period_id) - result = unit.rollover(tp, nil, nil) + result = unit.rollover(tp, nil, nil, params[:new_unit_code]) else - result = unit.rollover(nil, params[:start_date], params[:end_date]) + result = unit.rollover(nil, params[:start_date], params[:end_date], params[:new_unit_code]) end my_role = result.role_for(current_user) @@ -308,7 +313,7 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end @@ -328,7 +333,7 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end diff --git a/app/api/users_api.rb b/app/api/users_api.rb index ffcf6a42f..2900bbfba 100644 --- a/app/api/users_api.rb +++ b/app/api/users_api.rb @@ -206,7 +206,7 @@ class UsersApi < Grape::API error!({ error: 'Not authorised to upload CSV of users' }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 227579c77..a3e2ff1ab 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,7 +7,5 @@ class ApplicationController < ActionController::Base # redirect_to root_url, alert: exception.message # end - def headers - request.headers - end + delegate :headers, to: :request end diff --git a/app/controllers/lecture_resource_downloads_controller.rb b/app/controllers/lecture_resource_downloads_controller.rb index bb4c8bc60..f91e3eddb 100644 --- a/app/controllers/lecture_resource_downloads_controller.rb +++ b/app/controllers/lecture_resource_downloads_controller.rb @@ -32,7 +32,7 @@ def index error!({ error: 'No files to download' }, 403) if output_zip.nil? - download_id = "#{Time.new.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) diff --git a/app/controllers/portfolio_downloads_controller.rb b/app/controllers/portfolio_downloads_controller.rb index a11fdb65e..fa78ffb27 100644 --- a/app/controllers/portfolio_downloads_controller.rb +++ b/app/controllers/portfolio_downloads_controller.rb @@ -35,7 +35,7 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.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}-#{current_user.username}" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/controllers/task_downloads_controller.rb b/app/controllers/task_downloads_controller.rb index d30120955..2a0c8d076 100644 --- a/app/controllers/task_downloads_controller.rb +++ b/app/controllers/task_downloads_controller.rb @@ -37,7 +37,7 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.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}-#{current_user.username}-files" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/controllers/task_submission_pdfs_controller.rb b/app/controllers/task_submission_pdfs_controller.rb index 5a183d9a3..fec8fc20e 100644 --- a/app/controllers/task_submission_pdfs_controller.rb +++ b/app/controllers/task_submission_pdfs_controller.rb @@ -37,7 +37,7 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.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}-#{current_user.username}-pdfs" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fc6d56c59..6b150f815 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,6 +11,8 @@ def application_reference_date # Escape text for inclusion in Latex documents def lesc(text) # Convert to latex text, then use gsub to remove any characters that are not printable + # rubocop:disable Rails/OutputSafety raw(LatexToPdf.escape_latex(text).gsub(/[^[:print:]]/, '')) + # rubocop:enable Rails/OutputSafety end end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index 96128ae0a..9df3da157 100644 --- a/app/helpers/authentication_helpers.rb +++ b/app/helpers/authentication_helpers.rb @@ -13,7 +13,7 @@ module AuthenticationHelpers # Checks if the requested user is authenticated. # Reads details from the params fetched from the caller context. # - def authenticated? + def authenticated?(token_type = :general) auth_param = headers['auth-token'] || headers['Auth-Token'] || params['authToken'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token'] user_param = headers['username'] || headers['Username'] || params['username'] @@ -23,7 +23,7 @@ def authenticated? # Authenticate from header or params if auth_param.present? && user_param.present? && user.present? # Get the list of tokens for a user - token = user.token_for_text?(auth_param) + token = user.token_for_text?(auth_param, token_type) end # Check user by token @@ -53,7 +53,7 @@ def authenticated? # def current_user username = headers['username'] || headers['Username'] || params['username'] - User.eager_load(:role, :auth_tokens).find_by_username(username) + User.eager_load(:role, :auth_tokens).find_by(username: username) end # diff --git a/app/helpers/csv_helper.rb b/app/helpers/csv_helper.rb index 1dc44b957..e56af31a9 100644 --- a/app/helpers/csv_helper.rb +++ b/app/helpers/csv_helper.rb @@ -1,6 +1,6 @@ module CsvHelper def csv_date_to_date(date) - return if date.nil? || date.empty? + return if date.blank? date = date.strip diff --git a/app/helpers/d2l_integration.rb b/app/helpers/d2l_integration.rb new file mode 100644 index 000000000..c8fb57cda --- /dev/null +++ b/app/helpers/d2l_integration.rb @@ -0,0 +1,367 @@ +# frozen_string_literal: true + +require 'oauth2' + +# Class to load d2l integration features +# +class D2lIntegration + def self.enabled? + Doubtfire::Application.config.d2l_enabled && + Doubtfire::Application.config.d2l_client_id.present? && + Doubtfire::Application.config.d2l_client_secret.present? && + Doubtfire::Application.config.d2l_redirect_uri.present? + end + + def self.d2l_client_id + Doubtfire::Application.config.d2l_client_id + end + + def self.d2l_client_secret + Doubtfire::Application.config.d2l_client_secret + end + + def self.d2l_redirect_uri + Doubtfire::Application.config.d2l_redirect_uri + end + + def self.d2l_oauth_site + Doubtfire::Application.config.d2l_oauth_site + end + + def self.d2l_oauth_authorize_url + Doubtfire::Application.config.d2l_oauth_authorize_url + end + + def self.d2l_oauth_token_url + Doubtfire::Application.config.d2l_oauth_token_url + end + + def self.d2l_api_version + Doubtfire::Application.config.d2l_api_version + end + + def self.d2l_api_host + Doubtfire::Application.config.d2l_api_host + end + + def self.load_config(config) + config.d2l_enabled = ENV['D2L_ENABLED'].present? && (ENV['D2L_ENABLED'].to_s.downcase == 'true' || ENV['D2L_ENABLED'].to_i == 1) + + if config.d2l_enabled + config.d2l_client_id = ENV.fetch('D2L_CLIENT_ID', nil) + config.d2l_client_secret = ENV.fetch('D2L_CLIENT_SECRET', nil) + config.d2l_redirect_uri = ENV.fetch('D2L_REDIRECT_URI', nil) + config.d2l_oauth_site = ENV.fetch('D2L_OAUTH_SITE', 'https://auth.brightspace.com') + config.d2l_oauth_authorize_url = ENV.fetch('D2L_OAUTH_SITE_AUTHORIZE_URL', '/oauth2/auth') + config.d2l_oauth_token_url = ENV.fetch('D2L_OAUTH_SITE_TOKEN_URL', '/core/connect/token') + config.d2l_api_host = ENV.fetch('D2L_API_HOST', nil) + config.d2l_api_version = ENV.fetch('D2L_API_VERSION', '1.7') + end + end + + def self.oauth_client + return nil unless self.enabled? + + OAuth2::Client.new( + self.d2l_client_id, + self.d2l_client_secret, + site: self.d2l_oauth_site, + authorize_url: self.d2l_oauth_authorize_url, + token_url: self.d2l_oauth_token_url + ) + end + + def self.login_url(user) + return nil unless self.enabled? + + # Create oauth client to initiate login + client = self.oauth_client + + # Generate a random state, unique within the user_oauth_states table + state = SecureRandom.hex(16) + + # Ensure state is unique + i = 0 + state = SecureRandom.hex(16) until UserOauthState.create(state: state, user: user) || ++i > 5 + + if UserOauthState.find_by(state: state, user: user).nil? + raise 'Could not create unique state' + end + + # Generate login url + client.auth_code.authorize_url(redirect_uri: self.d2l_redirect_uri, 'scope' => 'core:*:* enrollment:orgunit:read grades:*:*', 'state' => state) + end + + def self.process_callback(code, state) + client = self.oauth_client + + begin + # Get the access token + access_token = client.auth_code.get_token( + code, + redirect_uri: self.d2l_redirect_uri + ) + rescue OAuth2::Error => e + Rails.logger.error("Error getting oauth access token: #{e.message}") + raise(StandardError, 'Error getting access token') + end + + # Extract the token needed to be stored + token = access_token.token + + # Find the state in the user_oauth_states table + user_oauth_state = UserOauthState.find_by(state: state) + + raise(StandardError, 'Invalid state') if user_oauth_state.nil? + + Rails.logger.info("User #{user_oauth_state.user.id} logged in with D2L") + + # Create a user oauth token + UserOauthToken.create( + user: user_oauth_state.user, + provider: :d2l, + token: token, + expires_at: Time.zone.now + 30.minutes + ) + + user_oauth_state.destroy + end + + def self.test_has_details_for!(unit, user) + raise(StandardError, 'D2L not enabled') unless self.enabled? + + # Find the D2L assessment mapping + d2l_mapping = unit.d2l_assessment_mapping + raise(StandardError, 'Add the org unit id in unit administration before posting grades') if d2l_mapping.nil? + + # Get the user's oauth token + token = user.user_oauth_tokens.find_by(provider: :d2l) + + raise(StandardError, `No D2L token found for user #{user.username} when accessing unit #{unit.code}`) if token.nil? + end + + def self.grades_url(d2l_mapping) + "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/#{d2l_mapping.grade_object_id}" + end + + def self.does_grade_item_exist?(d2l_mapping, access_token) + return false if d2l_mapping.grade_object_id.nil? + + url = self.grades_url(d2l_mapping) + + # Call D2L API to check if the grade item exists + begin + # Try to get the grade item, and if this succeeds, the grade item exists + response = access_token.get(url) + return false unless response.present? && response.status == 200 + response.parsed.id == d2l_mapping.grade_object_id + rescue OAuth2::Error => e + Rails.logger.error("Error checking grade item: #{e.message}") + d2l_mapping.grade_object_id = nil + d2l_mapping.save + false + end + end + + def self.create_grade_item(d2l_mapping, access_token) + return if self.does_grade_item_exist?(d2l_mapping, access_token) + + app_name = Doubtfire::Application.config.institution[:product_name] + + # Create a grade item in D2L + url = self.grades_url(d2l_mapping) + begin + response = access_token.post( + url, + body: { + 'MaxPoints' => 100, + 'CanExceedMaxPoints' => false, + 'IsBonus' => false, + 'ExcludeFromFinalGradeCalculation' => false, + 'GradeSchemeId' => nil, + 'Name' => "#{app_name} Result", + 'ShortName' => 'Result', + 'GradeType' => 'Numeric', + 'CategoryId' => nil, + 'Description' => { + 'Content' => "Result from #{app_name}", + 'Type' => 'Text' + }, + 'AssociatedTool' => nil, + 'IsHidden' => true + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + d2l_mapping.grade_object_id = response.parsed.id + d2l_mapping.save + rescue OAuth2::Error => e + Rails.logger.error("Error creating grade item: #{e.response.status} #{e.response.body}") + raise(StandardError, 'Error creating grade item') + end + end + + def self.get_grade_weight(d2l_mapping, access_token) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/categories/" + + begin + response = access_token.get(url) + response.parsed + rescue OAuth2::Error => e + Rails.logger.error("Error getting grade weight: #{e.message}") + raise(StandardError, 'Error getting grade weight') + end + end + + def self.get_class_list(d2l_mapping, access_token) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/classlist/" + + begin + response = access_token.get(url) + response.parsed + rescue OAuth2::Error => e + Rails.logger.error("Error getting class list: #{e.message}") + raise(StandardError, "Error getting class list") + end + end + + def self.find_project_for_d2l_user(unit, d2l_user) + # Find using the user's org defined id + unit.projects.joins(:user).find_by(users: { student_id: d2l_user['OrgDefinedId'] }) || + # Find using the user's username + unit.projects.joins(:user).find_by(users: { username: d2l_user['UserName'] }) || + # Find using the user's email + unit.projects.joins(:user).find_by(users: { email: d2l_user['Email'] }) + end + + def self.access_token_for_user(user) + oauth_token = user.user_oauth_tokens.where(provider: :d2l).last + if oauth_token.present? + oauth_token.access_token + else + oauth_token # Return nil + end + end + + def self.access_token_for_user!(user) + token = D2lIntegration.access_token_for_user(user) + if token.nil? + raise(StandardError, 'No D2L token found for user') + end + + token + end + + def self.post_grades(unit, user) + test_has_details_for!(unit, user) + + app_name = Doubtfire::Application.config.institution[:product_name] + + # Get the D2L assessment mapping + d2l_mapping = unit.d2l_assessment_mapping + + token = D2lIntegration.access_token_for_user!(user) + + # Check if we need to create the grade item + unless self.does_grade_item_exist?(d2l_mapping, token) + create_grade_item(d2l_mapping, token) + end + + # Get the class list + list = self.get_class_list(d2l_mapping, token) + + result = [] + done = [] + + list.each do |d2l_student| + if d2l_student['ClasslistRoleDisplayName'] != 'Student' + result << "Ignored,#{d2l_student['OrgDefinedId']},,#{d2l_student['DisplayName']&.remove(',')} is not a student" + next + end + + project = self.find_project_for_d2l_user(unit, d2l_student) + if project.nil? + result << "Error,#{d2l_student['OrgDefinedId']},,No #{app_name} result for #{d2l_student['DisplayName']&.remove(',')}" + next + end + + done << project.id + + # Get the grade for the project + if project.grade.nil? || project.grade <= 0 + result << "Skipped,#{d2l_student['OrgDefinedId']},,No grade for #{project.student.username} in #{app_name}" + next + end + + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/#{d2l_mapping.grade_object_id}/values/#{d2l_student['Identifier']}" + + # Post the grade to D2L + begin + response = token.put( + url, + body: { + "GradeObjectType" => 1, + "PointsNumerator" => project.grade + }.to_json + ) + + # Check if we need to sleep for rate limiting + if response.headers['X-Rate-Limit-Remaining'].present? && response.headers['X-Request-Cost'].present? && response.headers['X-Rate-Limit-Remaining'].to_i < ((response.headers['X-Request-Cost'].to_i * 3) || 10) + sleep(response.headers['X-Rate-Limit-Reset'].to_i) + end + + result << "Success,#{d2l_student['OrgDefinedId']},#{project.grade},Posted grade for #{project.student.username}" + rescue OAuth2::Error => e + Rails.logger.error("Error posting grade for #{unit.code} #{project.student.username}: #{e.response.status} #{e.response.body}") + result << "Error,#{d2l_student['OrgDefinedId']},#{project.grade},Faile to post grade for #{d2l_student['DisplayName']&.remove(',')}" + end + end + + # Report students not found in the class list + unit.active_projects.each do |project| + next if done.include?(project.id) + result << if project.grade.present? && (project.grade > 0) + "Error,#{project.user.username},#{project.grade},Not found in D2L" + else + "Skipped,#{project.user.username},#{project.grade},Result missing or 0 and not found in D2L" + end + end + + result + end + + def self.result_file_path(unit) + "#{FileHelper.unit_dir(unit)}/d2l_post_grades_job_result.csv" + end + + def self.d2l_grade_job_present?(unit) + queue = Sidekiq::Queue.new("default") + queue.each do |job| + return true if job.klass == 'D2lPostGradesJob' && job.args[0] == unit.id + end + + Sidekiq::Workers.new.map do |_process_id, _thread_id, work| + payload = JSON.parse(work['payload']) + + return true if payload['class'] == 'D2lPostGradesJob' && payload['args'][0] == unit.id + end + + false + end + + def self.grade_weighted?(d2l_mapping, user) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/setup/" + + access_token = D2lIntegration.access_token_for_user(user) + + return false if access_token.nil? + + begin + response = access_token.get(url) + 'Weighted'.casecmp?(response.parsed['GradingSystem']) + rescue OAuth2::Error => e + Rails.logger.error("Error getting class list: #{e.message}") + false + end + end +end diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 2ae3597b2..56b117df0 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -11,10 +11,10 @@ module FileHelper extend MimeCheckHelpers 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) + 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 empty or nil extensions for blobs otherwise check that it matches the allowed list - extn.nil? || extn.empty? || allow_extensions.include?(extn) + extn.blank? || allow_extensions.include?(extn) end # @@ -125,9 +125,7 @@ def sanitized_filename(filename) end def task_file_dir_for_unit(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/" # trust the server config and passed in type for paths - dst << sanitized_path("#{unit.code}-#{unit.id}", 'TaskFiles') << '/' + dst = unit_work_root(unit) << 'TaskFiles/' FileUtils.mkdir_p dst if create && (!Dir.exist? dst) @@ -176,6 +174,30 @@ def student_work_root Doubtfire::Application.config.student_work_dir end + def archive_root + Doubtfire::Application.config.archive_dir + end + + # Get the path to the unit root - will take into consideration if archived + # + # @param [Unit] unit - the unit to get the root path for + # @param [Boolean] archived - whether to use the archived property (true/false) + # or force it to be the archived path (:force) + def unit_work_root(unit, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/" + else + "#{student_work_root}/" + end + + dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' + end + + def project_work_root(project, archived: true, username: nil) + username = project.student.username.to_s if username.nil? + unit_work_root(project.unit, archived: archived) << sanitized_path(username) << '/' + end + # # Generates a path for storing student work # type = [:new, :in_process, :done, :pdf, :plagarism] @@ -187,18 +209,17 @@ def student_work_dir(type = nil, task = nil, create = true) file_server = Doubtfire::Application.config.student_work_dir dst = "#{file_server}/" # trust the server config and passed in type for paths - if !(type.nil? || task.nil?) + if !(type.nil? || task.nil?) # we have task and type if [:discussion, :pdf, :comment].include? type - dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s) << '/' + dst = project_work_root(task.project) << sanitized_path(type.to_s) << '/' elsif [:done, :plagarism].include? type - dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s) << '/' + dst = project_work_root(task.project) << sanitized_path(type.to_s, task.id.to_s) << '/' else # new and in_process -- just have task id # Add task id to dst if we want task dst << "#{type}/#{task.id}/" end - elsif !type.nil? + elsif !type.nil? # have type but not task if [:in_process, :new].include? type - # Add task id to dst if we want task dst << "#{type}/" else raise 'Error in request to student work directory' @@ -211,19 +232,40 @@ def student_work_dir(type = nil, task = nil, create = true) dst end - def unit_dir(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/" # trust the server config and passed in type for paths - dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' + def dir_for_unit_code_and_id(unit_code, unit_id, create: true, archived: false) + dst = if archived + "#{archive_root}/" + else + "#{student_work_root}/" + end - FileUtils.mkdir_p dst if create && (!Dir.exist? dst) + dst << sanitized_path("#{unit_code}-#{unit_id}") + + FileUtils.mkdir_p dst if create && !Dir.exist?(dst) dst end - def unit_portfolio_dir(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/portfolio/" # trust the server config and passed in type for paths + def unit_dir(unit, create: true, archived: true) + dir_for_unit_code_and_id(unit.code, unit.id, create: create, archived: archived == :force || (archived && unit.archived)) + end + + def root_portfolio_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/portfolio/" # trust the server config and passed in type for paths + end + + def unit_portfolio_dir(unit, create: true, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/portfolio/" + else + "#{student_work_root}/portfolio/" + end dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' @@ -235,8 +277,8 @@ def unit_portfolio_dir(unit, create = true) # # Generates a path for storing student portfolios # - def student_portfolio_dir(unit, username, create = true) - dst = unit_portfolio_dir(unit, create) + def student_portfolio_dir(unit, username, create: true, archived: true) + dst = unit_portfolio_dir(unit, create: create, archived: archived) dst << sanitized_path(username.to_s) @@ -245,8 +287,8 @@ def student_portfolio_dir(unit, username, create = true) dst end - def student_portfolio_path(unit, username, create = true) - File.join(student_portfolio_dir(unit, username, create), FileHelper.sanitized_filename("#{username}-portfolio.pdf")) + 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 comment_attachment_path(task_comment, attachment_extension) @@ -357,7 +399,13 @@ def qpdf(path) # - 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 - pwd = FileUtils.pwd + begin + pwd = FileUtils.pwd + rescue + # if no pwd, reset to the root + pwd = Rails.root + end + begin FileUtils.mkdir_p(to_path) Dir.chdir(from_path) @@ -366,7 +414,7 @@ def move_files(from_path, to_path, retain_from = false, only_before = nil) begin # remove from_path as files are now "in process" # these can be retained when the old folder wants to be kept - FileUtils.rm_r(from_path) unless retain_from + FileUtils.rm_rf(from_path) unless retain_from rescue logger.warn "failed to rm #{from_path}" end @@ -537,12 +585,50 @@ def move_compressed_task_to_new(task) task.extract_file_from_done student_work_dir(:new), '*', ->(_task, to_path, name) { "#{to_path}#{name}" } end + REPLACEMENTS_PERL_COMMAND = [ + ['[\\\\]u0000','[NUL]'], + ['[\\\\]u0001','[SOH]'], + ['[\\\\]u0002','[STX]'], + ['[\\\\]u0003','[ETX]'], + ['[\\\\]u0004','[EOT]'], + ['[\\\\]u0005','[ENQ]'], + ['[\\\\]u0006','[ACK]'], + ['[\\\\]u0007','[BEL]'], + ['[\\\\]u0008','[BS]'], + ['(? "#{tmp_filename}"` end + # Remove utf8 control character sequences + `perl -i -pe '#{FileHelper::REPLACEMENTS_PERL_COMMAND}' "#{tmp_filename}"` + # Move into place FileUtils.mv(tmp_filename, output_filename) end @@ -569,13 +658,39 @@ def latest_submission_timestamp_entry_in_dir(path) sorted_timestamp_entries_in_dir(path)[0] end + def root_submission_history_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/submission_history/" # trust the server config and passed in type for paths + end + + def unit_submission_history_dir(unit, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/" + else + "#{student_work_root}/" + end + + dst << sanitized_path('submission_history', "#{unit.code}-#{unit.id}") + end + + def project_submission_history_dir(project, username: nil, archived: true) + username = project.student.username.to_s if username.nil? + dst = unit_submission_history_dir(project.unit, archived: archived) + + File.join(dst, sanitized_path(username)) + end + def task_submission_identifier_path(type, task) - file_server = Doubtfire::Application.config.student_work_dir - "#{file_server}/submission_history/#{sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s)}" + "#{project_submission_history_dir(task.project)}/#{sanitized_path(type.to_s, task.id.to_s)}" end def task_submission_identifier_path_with_timestamp(type, task, timestamp) - "#{task_submission_identifier_path(type, task)}/#{timestamp.to_s}" + "#{task_submission_identifier_path(type, task)}/#{sanitized_path(timestamp.to_s)}" end # Apply line wrapping to a given file, returns true when line wrapping is necessary. @@ -624,8 +739,13 @@ def line_wrap(path, width: 160) module_function :student_group_work_dir module_function :student_work_dir module_function :student_work_root + module_function :archive_root + module_function :dir_for_unit_code_and_id module_function :unit_dir + module_function :root_portfolio_dir module_function :unit_portfolio_dir + 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 @@ -653,6 +773,9 @@ def line_wrap(path, width: 160) module_function :process_audio module_function :sorted_timestamp_entries_in_dir module_function :latest_submission_timestamp_entry_in_dir + module_function :root_submission_history_dir + module_function :unit_submission_history_dir + module_function :project_submission_history_dir module_function :task_submission_identifier_path module_function :task_submission_identifier_path_with_timestamp module_function :known_extension? diff --git a/app/helpers/file_stream_helper.rb b/app/helpers/file_stream_helper.rb index c0d9d7898..1644b3fe4 100644 --- a/app/helpers/file_stream_helper.rb +++ b/app/helpers/file_stream_helper.rb @@ -3,6 +3,8 @@ module FileStreamHelper # file_path is the path to the file to be streamed # this will set the headers and return the content def stream_file(file_path) + # Ensure we have a file path string + file_path = file_path.to_s # Work out what part to return file_size = File.size(file_path) begin_point = 0 @@ -31,13 +33,15 @@ 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') sendfile file_path return end # Return the requested content - content_length = end_point - begin_point + 1 + 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' header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" header['Content-Length'] = content_length.to_s header['Accept-Ranges'] = 'bytes' diff --git a/app/helpers/timeout_helper.rb b/app/helpers/timeout_helper.rb index e620f3002..2131014e3 100644 --- a/app/helpers/timeout_helper.rb +++ b/app/helpers/timeout_helper.rb @@ -25,12 +25,10 @@ def try_within(sec, timeout_message = 'operation') # def system_try_within(sec, timeout_message, command) # shell script to kill command after timeout - timeout_exec = Rails.root.join('lib', 'shell', 'timeout.sh') - result = false - try_within sec, timeout_message do - result = system "#{timeout_exec} -t #{sec} nice -n 10 #{command}" - end - result + system "timeout -k 2 #{sec} nice -n 10 #{command}" + rescue + logger.error "Timeout when #{timeout_message} after #{sec}s" + false end # Export functions as module functions diff --git a/app/helpers/turn_it_in.rb b/app/helpers/turn_it_in.rb index b77bcb53f..7bf73e4f7 100644 --- a/app/helpers/turn_it_in.rb +++ b/app/helpers/turn_it_in.rb @@ -3,22 +3,28 @@ # Class to interact with the Turn It In similarity api # class TurnItIn - @instance = TurnItIn.new - # rubocop:disable Style/ClassVars @@x_turnitin_integration_name = 'formatif-tii' @@x_turnitin_integration_version = '1.0' - @@global_error = nil @@delay_call_until = nil cattr_reader :x_turnitin_integration_name, :x_turnitin_integration_version + def self.enabled? + Doubtfire::Application.config.tii_enabled + end + + def self.register_webhooks? + Doubtfire::Application.config.tii_register_webhook + end + def self.load_config(config) config.tii_enabled = ENV['TII_ENABLED'].present? && (ENV['TII_ENABLED'].to_s.downcase == "true" || ENV['TII_ENABLED'].to_i == 1) - config.tii_add_submissions_to_index = ENV['TII_INDEX_SUBMISSIONS'].present? && (ENV['TII_INDEX_SUBMISSIONS'].to_s.downcase == "true" || ENV['TII_INDEX_SUBMISSIONS'].to_i == 1) - if config.tii_enabled + config.tii_add_submissions_to_index = ENV['TII_INDEX_SUBMISSIONS'].present? && (ENV['TII_INDEX_SUBMISSIONS'].to_s.downcase == "true" || ENV['TII_INDEX_SUBMISSIONS'].to_i == 1) + config.tii_register_webhook = ENV['TII_REGISTER_WEBHOOK'].present? && (ENV['TII_REGISTER_WEBHOOK'].to_s.downcase == "true" || ENV['TII_REGISTER_WEBHOOK'].to_i == 1) + # Turn-it-in TII configuration require 'tca_client' @@ -38,7 +44,7 @@ def self.load_config(config) # Launch the tii background jobs def self.launch_tii(with_webhooks: true) - TiiRegisterWebHookJob.perform_async if with_webhooks + TiiRegisterWebHookJob.perform_async if with_webhooks && TurnItIn.register_webhooks? load_tii_features load_tii_eula rescue StandardError => e @@ -57,41 +63,6 @@ def self.load_tii_features feature_job.fetch_features_enabled end - # A global error indicates that tii is not configured correctly or a change in the - # environment requires that the configuration is updated - def self.global_error - return nil unless Doubtfire::Application.config.tii_enabled - - Rails.cache.fetch("tii.global_error") do - @@global_error - end - end - - # Update the global error, when present this will block calls to tii until resolved - def self.global_error=(value) - return unless Doubtfire::Application.config.tii_enabled - - @@global_error = value - - if value.present? - Rails.cache.write("tii.global_error", value) - else - Rails.cache.delete("tii.global_error") - end - end - - # Indicates if there is a global error that indicates that things should not call tii until resolved - def self.global_error? - return false unless Doubtfire::Application.config.tii_enabled - - Rails.cache.exist?("tii.global_error") || @@global_error.present? - end - - # Indicates that tii can be called, that it is configured and there are no global errors - def self.functional? - Doubtfire::Application.config.tii_enabled && !TurnItIn.global_error? - end - # Indicates that the service is rate limited def self.rate_limited? @@delay_call_until.present? && DateTime.now < @@delay_call_until @@ -111,8 +82,12 @@ def self.handle_tii_error(action, error) case error.code when 429 # rate limit @@delay_call_until = DateTime.now + 1.minute - when 403 # forbidden, issue with authentication... do not attempt more tii requests - TurnItIn.global_error = [403, error.message] + when 403 # forbidden, issue with authentication... notify admin + begin + ErrorLogMailer.error_message('TII Credentials', "TII Error: #{error.message}", error).deliver + rescue StandardError => e + Rails.logger.error "Failed to send error email: #{e}" + end end end @@ -120,7 +95,7 @@ def self.handle_tii_error(action, error) # Get the current eula - value is refreshed every 24 hours def self.eula_version - return nil unless Doubtfire::Application.config.tii_enabled + return nil unless TurnItIn.enabled? action = TiiActionFetchEula.last || TiiActionFetchEula.create action.fetch_eula_version unless action.eula? @@ -132,7 +107,7 @@ def self.eula_version # Return the html for the eula def self.eula_html - return nil unless Doubtfire::Application.config.tii_enabled + return nil unless TurnItIn.enabled? Rails.cache.fetch("tii.eula_html.#{TurnItIn.eula_version}") end @@ -160,7 +135,7 @@ def self.webhook_url # @param unit [Unit] the unit to create or get the group context for # @return [TCAClient::GroupContext] the group context for the unit def self.create_or_get_group_context(unit) - unless unit.tii_group_context_id.present? + if unit.tii_group_context_id.blank? unit.tii_group_context_id = SecureRandom.uuid unit.save end @@ -185,6 +160,15 @@ def self.tii_user_for(user) ) end + def self.tii_user_for_group(grp) + TCAClient::Users.new( + id: "group-#{grp.id}", + family_name: 'Submission', + given_name: 'Group', + email: user.email + ) + end + def self.tii_role_for(task, user) user_role = task.role_for(user) if [:tutor].include?(user_role) || (user_role.nil? && user.role_id == Role.admin_id) @@ -194,6 +178,16 @@ def self.tii_role_for(task, user) end end + # Check and retry any failed tii submissions, where it was due to no accepted EULA + def self.check_and_retry_submissions_with_updated_eula + TiiActionUploadSubmission + .where( + complete: false, + custom_error_message: TiiActionUploadSubmission::NO_USER_ACCEPTED_EULA_ERROR + ) + .find_each(&:attempt_retry_on_no_eula) + end + private def logger diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..ead50cd96 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,2 @@ +class ApplicationMailer < ActionMailer::Base +end diff --git a/app/mailers/convenor_contact_mailer.rb b/app/mailers/convenor_contact_mailer.rb index bb57b8cc0..5859399bd 100644 --- a/app/mailers/convenor_contact_mailer.rb +++ b/app/mailers/convenor_contact_mailer.rb @@ -1,4 +1,4 @@ -class ConvenorContactMailer < ActionMailer::Base +class ConvenorContactMailer < ApplicationMailer def request_project_membership(user, _convenor, unit, _first_name, _last_name) @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] diff --git a/app/mailers/d2l_result_mailer.rb b/app/mailers/d2l_result_mailer.rb new file mode 100644 index 000000000..b50ff6bdb --- /dev/null +++ b/app/mailers/d2l_result_mailer.rb @@ -0,0 +1,20 @@ +class D2lResultMailer < ApplicationMailer + def result_message(unit, user, result_message: 'completed', success: true) + email = user.email + return nil if email.blank? + + path = D2lIntegration.result_file_path(unit) + + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @user = user + @unit = unit + @result_message = result_message + @has_file = success && File.exist?(path) + + if @has_file + attachments['result.csv'] = File.read(path) + end + + mail(to: email, from: email, subject: "#{@doubtfire_product_name} #{unit.code} - D2L Grade Transfer Result") + end +end diff --git a/app/mailers/error_log_mailer.rb b/app/mailers/error_log_mailer.rb new file mode 100644 index 000000000..94f8fcae6 --- /dev/null +++ b/app/mailers/error_log_mailer.rb @@ -0,0 +1,15 @@ +class ErrorLogMailer < ApplicationMailer + def error_message(subject, message, exception) + email = Doubtfire::Application.config.email_errors_to + return nil if email.blank? + + if exception.instance_of?(Task::LatexError) + attachments['log.txt'] = exception.log_message + end + + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @error_log = "#{message}\n\n#{exception.message}\n\n#{exception.backtrace.join("\n")}" + + mail(to: email, from: email, subject: "#{@doubtfire_product_name} Error Log - #{subject}") + end +end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 741fa18b8..3a56ceeae 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -1,4 +1,4 @@ -class NotificationsMailer < ActionMailer::Base +class NotificationsMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 61b729eb6..59d1a4eea 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -1,4 +1,4 @@ -class PortfolioEvidenceMailer < ActionMailer::Base +class PortfolioEvidenceMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] diff --git a/app/models/activity_type.rb b/app/models/activity_type.rb index 87e92c966..ffdbb6b5a 100644 --- a/app/models/activity_type.rb +++ b/app/models/activity_type.rb @@ -1,5 +1,5 @@ class ActivityType < ApplicationRecord - has_many :tutorial_streams + has_many :tutorial_streams, dependent: :restrict_with_exception # Callbacks - methods called are private before_destroy :can_destroy? @@ -32,10 +32,6 @@ def self.find_by(*args) end end - def self.find_by_abbr_or_name(data) - ActivityType.find_by(abbreviation: data) || ActivityType.find_by(name: data) - end - private def invalidate_cache diff --git a/app/models/auth_token.rb b/app/models/auth_token.rb index 5ce9a48a7..78cd3951f 100644 --- a/app/models/auth_token.rb +++ b/app/models/auth_token.rb @@ -6,16 +6,23 @@ class AuthToken < ApplicationRecord validates :authentication_token, presence: true validate :ensure_token_unique_for_user, on: :create - def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours) + enum token_type: { + general: 0, + login: 1, + scorm: 2 + } + + def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours, token_type = :general) # Loop until new unique auth token is found token = loop do token = Devise.friendly_token - break token unless user.token_for_text?(token) + break token unless user.token_for_text?(token, token_type) end # Create a new AuthToken with this value result = AuthToken.new(user_id: user.id) result.authentication_token = token + result.token_type = token_type result.extend_token(remember, expiry_time, false) result.save! result @@ -53,7 +60,7 @@ def extend_token(remember, expiry_time = Time.zone.now + 2.hours, save = true) end def ensure_token_unique_for_user - if user.token_for_text?(authentication_token) + if user.token_for_text?(authentication_token, nil) errors.add(:authentication_token, 'already exists for the selected user') end end diff --git a/app/models/campus.rb b/app/models/campus.rb index 21c373dda..edd3de515 100644 --- a/app/models/campus.rb +++ b/app/models/campus.rb @@ -1,7 +1,7 @@ class Campus < ApplicationRecord # Relationships - has_many :tutorials - has_many :projects + has_many :tutorials, dependent: :restrict_with_exception + has_many :projects, dependent: :restrict_with_exception # Callbacks - methods called are private before_destroy :can_destroy? @@ -12,7 +12,7 @@ class Campus < ApplicationRecord validates :mode, presence: true validates :abbreviation, presence: true, uniqueness: true - validates_inclusion_of :active, :in => [true, false] + validates :active, inclusion: { :in => [true, false] } after_destroy :invalidate_cache after_save :invalidate_cache @@ -39,10 +39,6 @@ def self.find_by(*args) end end - def self.find_by_abbr_or_name(data) - Campus.find_by(abbreviation: data) || Campus.find_by(name: data) - end - private def invalidate_cache diff --git a/app/models/comments/assessment_comment.rb b/app/models/comments/assessment_comment.rb index 4944bf5ae..5c281d32c 100644 --- a/app/models/comments/assessment_comment.rb +++ b/app/models/comments/assessment_comment.rb @@ -1,13 +1,11 @@ class AssessmentComment < TaskComment - belongs_to :overseer_assessment, optional: false - before_create do self.content_type = :assessment end def serialize(user) json = super(user) - json[:overseer_assessment_id] = self.overseer_assessment_id + json[:overseer_assessment_id] = self.commentable_id json end end diff --git a/app/models/comments/scorm_comment.rb b/app/models/comments/scorm_comment.rb new file mode 100644 index 000000000..df7bcc9f5 --- /dev/null +++ b/app/models/comments/scorm_comment.rb @@ -0,0 +1,14 @@ +class ScormComment < TaskComment + before_create do + self.content_type = :scorm + end + + def serialize(user) + json = super(user) + json[:test_attempt] = { + id: self.commentable_id, + success_status: self.commentable.success_status + } + json + end +end diff --git a/app/models/comments/scorm_extension_comment.rb b/app/models/comments/scorm_extension_comment.rb new file mode 100644 index 000000000..74bc9d0c8 --- /dev/null +++ b/app/models/comments/scorm_extension_comment.rb @@ -0,0 +1,45 @@ +class ScormExtensionComment < TaskComment + belongs_to :assessor, class_name: 'User', optional: true + + def serialize(user) + json = super(user) + json[:granted] = extension_granted + json[:assessed] = date_extension_assessed.present? + json[:date_assessed] = date_extension_assessed + json + end + + def assessed? + self.date_extension_assessed.present? + end + + # 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) + super if assessed? || user == project.student || user != recipient + end + + def assess_scorm_extension(user, granted) + if self.assessed? + self.errors[:scorm_extension] << 'has already been assessed' + return false + end + + self.assessor = user + self.date_extension_assessed = Time.zone.now + self.extension_granted = granted + + if self.extension_granted + self.task.grant_scorm_extension(user) + end + + # Now make sure to read it by the main tutor - even if assessed by someone else + super_mark_as_read(project.tutor_for(task.task_definition)) + save! + end +end diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index 4dd3aa811..c74883d01 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -20,6 +20,9 @@ class TaskComment < ApplicationRecord # Can optionally be a reply to a comment belongs_to :task_comment, optional: true + # Can be a comment for different types of entities e.g. Test Attempt, Overseer Assessment + belongs_to :commentable, polymorphic: true, optional: true + validates :task, presence: true validates :user, presence: true validates :recipient, presence: true @@ -38,8 +41,8 @@ def valid_reply_to? if reply_to_id.present? originalTaskComment = TaskComment.find(reply_to_id) replyProject = originalTaskComment.project - errors.add(:task_comment, "Not a reply to a valid task comment") unless originalTaskComment.present? - errors.add(:task_comment, "Original comment is not in this task") unless task.all_comments.find(reply_to_id).present? + errors.add(:task_comment, "Not a reply to a valid task comment") if originalTaskComment.blank? + errors.add(:task_comment, "Original comment is not in this task") if task.all_comments.find(reply_to_id).blank? errors.add(:task_comment, "Not authorised to reply to comment") unless authorise?(user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(user) != nil) end end diff --git a/app/models/d2l/d2l_assessment_mapping.rb b/app/models/d2l/d2l_assessment_mapping.rb new file mode 100644 index 000000000..fffe6d6b1 --- /dev/null +++ b/app/models/d2l/d2l_assessment_mapping.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class D2lAssessmentMapping < ApplicationRecord + belongs_to :unit + + # Ensure only one D2L mapping per unit + validates :unit_id, uniqueness: true + + # Ensure org_unit_id is present + validates :org_unit_id, presence: true + +end diff --git a/app/models/d2l/user_oauth_state.rb b/app/models/d2l/user_oauth_state.rb new file mode 100644 index 000000000..e4b2c3ce3 --- /dev/null +++ b/app/models/d2l/user_oauth_state.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class UserOauthState < ApplicationRecord + belongs_to :user + + # Ensure unique states + validates :state, uniqueness: true + + def self.destroy_old_states + UserOauthState.where('created_at < ?', Time.zone.now - 15.minutes).delete_all + end +end diff --git a/app/models/d2l/user_oauth_token.rb b/app/models/d2l/user_oauth_token.rb new file mode 100644 index 000000000..c7d739f8c --- /dev/null +++ b/app/models/d2l/user_oauth_token.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# This is an oauth access token to connect to an external service +class UserOauthToken < ApplicationRecord + belongs_to :user + + encrypts :token + + validates :token, presence: true + + # Ensure a known provider - what the token gives access to + enum provider: { + d2l: 0 + } + + # Get the provider as a symbol + def provider_sym + provider.to_sym + end + + # Get access token - used to make http requests + def access_token + case provider_sym + when :d2l + client = D2lIntegration.oauth_client + else + raise "Unknown provider" + end + + OAuth2::AccessToken.new(client, token) + end + + def self.destroy_old_tokens + UserOauthToken.where('expires_at < ?', Time.zone.now).delete_all + end +end diff --git a/app/models/group.rb b/app/models/group.rb index e075c04b4..fec42947a 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,9 +3,11 @@ class Group < ApplicationRecord belongs_to :tutorial, optional: false has_many :group_memberships, dependent: :destroy - has_many :group_submissions + has_many :group_submissions, dependent: :destroy + has_many :projects, -> { where('group_memberships.active = :value and projects.enrolled = true', value: true) }, through: :group_memberships has_many :past_projects, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'project' + has_one :unit, through: :group_set has_one :tutor, through: :tutorial diff --git a/app/models/group_set.rb b/app/models/group_set.rb index b4731579c..3fd43ee59 100644 --- a/app/models/group_set.rb +++ b/app/models/group_set.rb @@ -1,6 +1,6 @@ class GroupSet < ApplicationRecord belongs_to :unit, optional: false - has_many :task_definitions + has_many :task_definitions, dependent: :nullify has_many :groups, dependent: :destroy validates :name, uniqueness: { diff --git a/app/models/group_submission.rb b/app/models/group_submission.rb index ba5d23bd4..5016aab97 100644 --- a/app/models/group_submission.rb +++ b/app/models/group_submission.rb @@ -4,7 +4,7 @@ class GroupSubmission < ApplicationRecord belongs_to :group, optional: false belongs_to :task_definition, optional: false - belongs_to :submitted_by_project, class_name: 'Project', foreign_key: 'submitted_by_project_id', optional: false + belongs_to :submitted_by_project, class_name: 'Project', optional: false has_many :tasks, dependent: :nullify has_many :projects, through: :tasks @@ -18,10 +18,9 @@ class GroupSubmission < ApplicationRecord FileHelper.delete_group_submission(group_submission) # also remove evidence from group members - tasks.each do |t| - t.portfolio_evidence_path = nil - t.save - end + # rubocop:disable Rails/SkipsModelValidations + tasks.where('portfolio_evidence IS NOT NULL').update_all(portfolio_evidence: nil) + # rubocop:enable Rails/SkipsModelValidations rescue => e logger.error "Failed to delete group submission #{group_submission.id}. Error: #{e.message}" end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index cd843235d..bfbb8cb4b 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -1,14 +1,15 @@ +# rubocop:disable Rails/Output class OverseerAssessment < ApplicationRecord belongs_to :task, optional: false has_one :project, through: :task - has_many :assessment_comments, dependent: :destroy + has_many :assessment_comments, as: :commentable, dependent: :destroy validates :status, presence: true validates :task_id, presence: true validates :submission_timestamp, presence: true - validates_uniqueness_of :submission_timestamp, scope: :task_id + validates :submission_timestamp, uniqueness: { scope: :task_id } enum status: { pre_queued: 0, queued: 1, queue_failed: 2, done: 3 } @@ -25,10 +26,7 @@ def self.create_for(task) task_definition = task.task_definition unit = task_definition.unit - return nil unless unit.assessment_enabled - return nil unless task_definition.assessment_enabled - return nil unless task_definition.has_task_assessment_resources? - return nil unless task.has_new_files? || task.has_done_file? + return nil unless task.overseer_enabled? docker_image_name_tag = task_definition.docker_image_name_tag || unit.docker_image_name_tag assessment_resources_path = task_definition.task_assessment_resources @@ -83,7 +81,7 @@ def output_path def add_assessment_comment(text = 'Automated Assessment Started') text.strip! - return nil if text.nil? || text.empty? + return nil if text.blank? tutor = project.tutor_for(task.task_definition) @@ -95,7 +93,7 @@ def add_assessment_comment(text = 'Automated Assessment Started') comment.user = tutor comment.comment = text comment.recipient = project.student - comment.overseer_assessment = self + comment.commentable = self comment.save! comment @@ -103,7 +101,7 @@ def add_assessment_comment(text = 'Automated Assessment Started') def update_assessment_comment(text) text.strip! - return nil if text.nil? || text.empty? + return nil if text.blank? assessment_comment = assessment_comments.last @@ -264,3 +262,4 @@ def delete_associated_files FileUtils.rm_rf output_path end end +# rubocop:enable Rails/Output diff --git a/app/models/overseer_image.rb b/app/models/overseer_image.rb index 11321fe6d..99edfc703 100644 --- a/app/models/overseer_image.rb +++ b/app/models/overseer_image.rb @@ -4,8 +4,8 @@ class OverseerImage < ApplicationRecord # Callbacks - methods called are private before_destroy :can_destroy? - has_many :units - has_many :task_definitions + has_many :units, dependent: :nullify + has_many :task_definitions, dependent: :nullify # Always add a unique index with uniqueness constraint # This is to prevent new records from passing the validations when checked at the same time before being written diff --git a/app/models/pdf_generation/project_compile_portfolio_module.rb b/app/models/pdf_generation/project_compile_portfolio_module.rb index 33e72bca4..159fe1fc9 100644 --- a/app/models/pdf_generation/project_compile_portfolio_module.rb +++ b/app/models/pdf_generation/project_compile_portfolio_module.rb @@ -2,9 +2,9 @@ module PdfGeneration module ProjectCompilePortfolioModule def projects_awaiting_auto_generation Project.joins(:unit) - .where(units: { active: true, end_date: Date.today..Float::INFINITY }) + .where(units: { active: true, end_date: Time.zone.today..Float::INFINITY }) .where(projects: { enrolled: true, portfolio_production_date: nil }) - .where("units.portfolio_auto_generation_date < ?", Date.today) + .where("units.portfolio_auto_generation_date < ?", Time.zone.today) .where(compile_portfolio: false) .reject(&:portfolio_available) end @@ -55,7 +55,7 @@ def init(project, is_retry) @learning_summary_report = project.learning_summary_report_path @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') + @image_path = Rails.root.join('public/assets/images') @ordered_tasks = project.tasks.joins(:task_definition).order('task_definitions.start_date, task_definitions.abbreviation').where("task_definitions.target_grade <= #{project.target_grade}") @portfolio_tasks = project.portfolio_tasks @task_defs = project.unit.task_definitions.order(:start_date) @@ -108,11 +108,15 @@ def create_portfolio log_file = e.message.scan(%r{/.*\.log}).first if log_file && File.exist?(log_file) begin + # rubocop:disable Rails/Output puts "--- Latex Log ---\n" puts File.read(log_file) puts "--- End ---\n\n" + # rubocop:enable Rails/Output rescue StandardError + # rubocop:disable Rails/Output puts "Failed to read log file: #{log_file}" + # rubocop:enable Rails/Output end end false @@ -173,8 +177,8 @@ def learning_summary_report_exists? # Portfolio production code # def portfolio_temp_path - portfolio_dir = FileHelper.student_portfolio_dir(self.unit, self.student.username, false) - portfolio_tmp_dir = File.join(portfolio_dir, 'tmp') + portfolio_dir = FileHelper.student_portfolio_dir(self.unit, self.student.username, create: false) + File.join(portfolio_dir, 'tmp') end def portfolio_tmp_file_name(dict) @@ -266,7 +270,7 @@ def remove_portfolio_file(idx, kind, name) end def portfolio_path - FileHelper.student_portfolio_path(self.unit, self.student.username, true) + FileHelper.student_portfolio_path(self.unit, self.student.username, create: true) end def portfolio_exists? diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 5e0790593..65d1d481b 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -22,7 +22,7 @@ def self.move_to_pid_folder pid_folder = File.join(student_work_dir(:in_process), "pid_#{Process.pid}") # 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 - 1.minute) + FileHelper.move_files(student_work_dir(:new), pid_folder, true, DateTime.now - 30.minutes) pid_folder end @@ -50,9 +50,6 @@ def self.process_new_to_pdf(my_source) logger.error "Failed to process folder_id = #{folder_id}. #{message}" if task - task.add_text_comment task.project.tutor_for(task.task_definition), "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{message}" - task.trigger_transition trigger: 'fix', by_user: task.project.tutor_for(task.task_definition) - errors[task.project] = [] if errors[task.project].nil? errors[task.project] << task end @@ -60,7 +57,7 @@ def self.process_new_to_pdf(my_source) begin logger.info "creating pdf for task #{task.id}" - success = task.convert_submission_to_pdf(my_source) + success = task.convert_submission_to_pdf(source_folder: my_source, log_to_stdout: true) if success done[task.project] = [] if done[task.project].nil? @@ -73,20 +70,15 @@ def self.process_new_to_pdf(my_source) end end - # Remove email of task notification success - only email on fail - # done.each do |project, tasks| - # logger.info "checking email for project #{project.id}" - # if project.student.receive_task_notifications - # logger.info "emailing task notification to #{project.student.name}" - # PortfolioEvidenceMailer.task_pdf_ready_message(project, tasks).deliver - # end - # end - errors.each do |project, tasks| - logger.info "checking email for project #{project.id}" - if project.student.receive_task_notifications - logger.info "emailing task notification to #{project.student.name}" + logger.debug "checking email for project #{project.id}" + next unless project.student.receive_task_notifications + + logger.info "emailing task notification to #{project.student.name}" + begin PortfolioEvidenceMailer.task_pdf_failed(project, tasks).deliver + rescue StandardError => e + logger.error "Failed to send task pdf failed email for project #{project.id}!\n#{e.message}" end end end diff --git a/app/models/project.rb b/app/models/project.rb index c0770cf87..a22f25b18 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -22,13 +22,12 @@ class Project < ApplicationRecord # has_one :user, through: :student has_many :tasks, dependent: :destroy # Destroying a project will also nuke all of its tasks - has_many :group_memberships, dependent: :destroy + has_many :tutorial_enrolments, dependent: :destroy + has_many :groups, -> { where('group_memberships.active = :value', value: true) }, through: :group_memberships has_many :task_engagements, through: :tasks has_many :comments, through: :tasks - has_many :tutorial_enrolments, dependent: :destroy - has_many :learning_outcome_task_links, through: :tasks # Callbacks - methods called are private @@ -225,9 +224,7 @@ def tutor_for(task_definition) (tutorial.present? and tutorial.tutor.present?) ? tutorial.tutor : main_convenor_user end - def main_convenor_user - unit.main_convenor_user - end + delegate :main_convenor_user, to: :unit def user_role(user) if user == student then :student @@ -292,6 +289,7 @@ def task_details_for_shallow_serializer(user) num_new_comments: r.number_unread, similarity_flag: AuthorisationHelpers.authorise?(user, t, :view_plagiarism) ? r.similar_to_count > 0 : false, extensions: t.extensions, + scorm_extensions: t.scorm_extensions, due_date: t.due_date, submission_date: t.submission_date, completion_date: t.completion_date @@ -659,7 +657,18 @@ def send_weekly_status_email(summary_stats, middle_of_unit) return unless student.receive_feedback_notifications return if portfolio_exists? && !middle_of_unit - NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now + begin + NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now + rescue StandardError => e + logger.error "Failed to send weekly status email for project #{id}!\n#{e.message}" + end + end + + def archive_submissions(out) + out.puts " - Archiving submissions for project #{id}" + tasks.each(&:archive_submission) + + FileUtils.rm_f(portfolio_path) if portfolio_available end private @@ -679,7 +688,7 @@ def check_withdraw_from_groups group_memberships.each do |gm| next unless gm.active - if !gm.valid? || gm.group.beyond_capacity? + if gm.invalid? || gm.group.beyond_capacity? gm.update(active: false) end end diff --git a/app/models/similarity/unit_similarity_module.rb b/app/models/similarity/unit_similarity_module.rb index c69897f78..6b5d5c09b 100644 --- a/app/models/similarity/unit_similarity_module.rb +++ b/app/models/similarity/unit_similarity_module.rb @@ -51,7 +51,7 @@ def check_moss_similarity(force: false) logger.debug 'Contacting MOSS for new checks' # Create the MossRuby object - moss_key = Doubtfire::Application.secrets.secret_key_moss + 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) @@ -99,7 +99,7 @@ def check_moss_similarity(force: false) end def update_plagiarism_stats - moss_key = Doubtfire::Application.secrets.secret_key_moss + 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) diff --git a/app/models/task.rb b/app/models/task.rb index e75815f90..c1d84c5e3 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -18,7 +18,9 @@ def self.permissions :start_discussion, :get_discussion, :make_discussion_reply, + :request_scorm_extension, # :request_extension -- depends on settings in unit. See specific_permission_hash method + # :make_scorm_attempt -- depends on task def settings. See specific_permission_hash method ] # What can tutors do with tasks? tutor_role_permissions = [ @@ -34,7 +36,9 @@ def self.permissions :delete_discussion, :get_discussion, :assess_extension, - :request_extension + :assess_scorm_extension, + :request_extension, + :request_scorm_extension ] # What can convenors do with tasks? convenor_role_permissions = [ @@ -47,7 +51,9 @@ def self.permissions :delete_plagiarism, :get_discussion, :assess_extension, - :request_extension + :assess_scorm_extension, + :request_extension, + :request_scorm_extension ] # What can admins do with tasks? admin_role_permissions = [ @@ -94,12 +100,15 @@ def role_for(user) end # Used to adjust the request extension permission in units that do not - # allow students to request extensions + # allow students to request extensions and the make scorm attempt permission def specific_permission_hash(role, perm_hash, _other) result = perm_hash[role] unless perm_hash.nil? if result && role == :student && unit.allow_student_extension_requests result << :request_extension end + if result && role == :student && task_definition.scorm_enabled + result << :make_scorm_attempt + end result end @@ -123,6 +132,7 @@ def specific_permission_hash(role, perm_hash, _other) has_many :task_submissions, dependent: :destroy has_many :overseer_assessments, dependent: :destroy has_many :tii_submissions, dependent: :destroy + has_many :test_attempts, dependent: :destroy delegate :unit, to: :project delegate :student, to: :project @@ -227,7 +237,7 @@ def self.for_user(user) Task.joins(:project).where('projects.user_id = ?', user.id) end - def processing_pdf? + def folder_exists_in_new? if group_task? && group_submission File.exist? File.join(FileHelper.student_work_dir(:new), group_submission.submitter_task.id.to_s) else @@ -235,6 +245,18 @@ def processing_pdf? end end + def folder_exists_in_process? + if group_task? && group_submission + File.exist? File.join(FileHelper.student_work_dir(:in_process), group_submission.submitter_task.id.to_s) + else + File.exist? File.join(FileHelper.student_work_dir(:in_process), id.to_s) + end + end + + def processing_pdf? + folder_exists_in_new? || folder_exists_in_process? + end + # Get the raw extension date - with extensions representing weeks def raw_extension_date target_date + extensions.weeks @@ -311,6 +333,33 @@ def grant_extension(by_user, weeks) end end + # Applying for a scorm extension will create a scorm extension comment + def apply_for_scorm_extension(user, text) + extension = ScormExtensionComment.create + extension.task = self + extension.user = user + extension.content_type = :scorm_extension + extension.comment = text + extension.recipient = unit.main_convenor_user + extension.save! + + # Check and apply those requested by staff + if role_for(user) == :tutor + extension.assess_scorm_extension user, true + end + + extension + end + + # Add a scorm extension to the task + def grant_scorm_extension(by_user) + if update(scorm_extensions: self.scorm_extensions + task_definition.scorm_attempt_limit) + return true + else + return false + end + end + def due_date return target_date if extensions == 0 @@ -350,7 +399,7 @@ def ready_or_complete? end def submitted_status? - ![:working_on_it, :not_started, :fix_and_resubmit, :redo, :need_help].include? status + [:working_on_it, :not_started, :fix_and_resubmit, :redo, :need_help].exclude? status end def fix_and_resubmit? @@ -382,7 +431,7 @@ def status end def has_pdf - !portfolio_evidence_path.nil? && File.exist?(portfolio_evidence_path) && !processing_pdf? + !final_pdf_path.nil? && File.exist?(final_pdf_path) && !processing_pdf? end def log_details @@ -592,7 +641,7 @@ def engage(engagement_status) end def submitted_before_due? - return true unless due_date.present? + return true if due_date.blank? to_same_day_anywhere_on_earth(due_date) >= self.submission_date end @@ -670,7 +719,7 @@ def add_text_comment(user, text, reply_to_id = nil) def individual_task_or_submitter_of_group_task? return true if !group_task? # its individual - return true unless group.present? # no group yet... so individual + return true if group.blank? # no group yet... so individual ensured_group_submission.submitted_by? self.project # return true if submitted by this project end @@ -832,12 +881,10 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: zip_file = zip_file_path || zip_file_path_for_done_task return false if zip_file.nil? || (!Dir.exist? task_dir) - FileUtils.rm_f(zip_file) - - # compress image files + # compress image files - convert to jpg image_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(image)/) == 0 } image_files.each do |img| - # Ensure all images in submissions are not jpg + # 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) @@ -845,9 +892,20 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: FileUtils.rm("#{task_dir}#{img}") unless dest_file == "#{task_dir}#{img}" end - # copy all files into zip input_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(cover|document|code|image)/) == 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}" + logger.error "Files found: #{input_files}" + return false + end + + logger.info "Creating new zip file for task #{id} in #{zip_file}" + + # We have what looks like a good submission, remove old zip + FileUtils.rm_f(zip_file) + + # copy all files into zip zip_dir = File.dirname(zip_file) FileUtils.mkdir_p zip_dir @@ -878,9 +936,12 @@ 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(FileUtils.student_work_dir) if FileUtils.pwd == in_process_dir + Dir.chdir(FileHelper.student_work_root) if FileUtils.pwd == in_process_dir FileUtils.rm_rf in_process_dir end + + rescue StandardError => e + logger.error "Error clearing in process directory for task #{log_details} - #{e.message}" end # @@ -923,7 +984,10 @@ def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) from_dir = File.join(source_folder, id.to_s) + "/" if Dir.exist?(from_dir) # save new files in done folder - return false unless compress_new_to_done(task_dir: from_dir) + unless compress_new_to_done(task_dir: from_dir) + logger.error "Error processing task #{log_details} - failed to compress new files" + return false + end end # Get the zip file path... @@ -939,6 +1003,17 @@ def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) end end + def move_files_on_abbreviation_change(old_abbreviation) + # Move files from old abbreviation to new abbreviation + old_path = final_pdf_path(abbr: old_abbreviation) + new_path = final_pdf_path(ignore_portfolio_evidence: true) + + return if old_path == new_path || !File.exist?(old_path) + + FileUtils.mv(old_path, new_path) + update(portfolio_evidence: nil) unless portfolio_evidence.nil? + end + def __output_filename__(in_dir, idx, type) pwd = FileUtils.pwd Dir.chdir(in_dir) @@ -1008,7 +1083,7 @@ def init(task, is_retry) @task = task @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') + @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 @@ -1035,7 +1110,7 @@ def self.pygments_lang(extn) elsif ['cpp', 'hpp', 'c++', 'h++', 'cc', 'cxx', 'cp'].include?(extn) then 'cpp' elsif ['java'].include?(extn) then 'java' elsif %w(js json ts).include?(extn) then 'js' - elsif ['html', 'rhtml'].include?(extn) then 'html' + elsif ['html', 'rhtml', 'vue'].include?(extn) then 'html' elsif %w(css scss).include?(extn) then 'css' elsif ['rb'].include?(extn) then 'ruby' elsif ['coffee'].include?(extn) then 'coffeescript' @@ -1053,33 +1128,76 @@ def self.pygments_lang(extn) end end + def move_to_final_pdf_path + if portfolio_evidence.present? + # Move the portfolio evidence to the final pdf path + if File.exist?(portfolio_evidence_path) + new_path = final_pdf_path(ignore_portfolio_evidence: true) + FileUtils.mv(portfolio_evidence_path, new_path) + end + update(portfolio_evidence: nil) + end + end + def portfolio_evidence_path # Add the student work dir to the start of the portfolio evidence - File.join(FileHelper.student_work_dir, self.portfolio_evidence) if self.portfolio_evidence.present? + if unit.archived + base = FileHelper.archive_root + else + base = FileHelper.student_work_dir + end + File.join(base, self.portfolio_evidence) if self.portfolio_evidence.present? end - def portfolio_evidence_path=(value) - # Strip the student work directory to store in database as relative path - self.portfolio_evidence = value.present? ? value.sub(FileHelper.student_work_dir, '') : nil + # The path to the PDF for this task's submission + def final_pdf_path(abbr: nil, ignore_portfolio_evidence: false) + result = if group_task? + return nil if group_submission.nil? || group_submission.task_definition.nil? + + abbr = group_submission.task_definition.abbreviation if abbr.nil? + + File.join( + FileHelper.student_group_work_dir(:pdf, group_submission, task = nil, create = true), + FileHelper.sanitized_filename(FileHelper.sanitized_path("#{abbr}-#{group_submission.id}") + '.pdf') + ) + else + abbr = task_definition.abbreviation if abbr.nil? + File.join(student_work_dir(:pdf), FileHelper.sanitized_filename(FileHelper.sanitized_path("#{abbr}-#{id}") + '.pdf')) + end + + # see if we need to use the portfolio evidence + if portfolio_evidence.present? && !ignore_portfolio_evidence + evidence_loc = portfolio_evidence_path + + # Remove portfolio evidence if possible + if evidence_loc == result || !File.exist?(evidence_loc) + update(portfolio_evidence: nil) + else + result = evidence_loc + end + end + + result end - # The path to the PDF for this task's submission - def final_pdf_path - if group_task? - return nil if group_submission.nil? || group_submission.task_definition.nil? + # A custom error to capture the log message from the latex error + class LatexError < StandardError + attr_reader :log_message - File.join( - FileHelper.student_group_work_dir(:pdf, group_submission, task = nil, create = true), - FileHelper.sanitized_filename(FileHelper.sanitized_path("#{group_submission.task_definition.abbreviation}-#{group_submission.id}") + '.pdf') - ) - else - File.join(student_work_dir(:pdf), FileHelper.sanitized_filename(FileHelper.sanitized_path("#{task_definition.abbreviation}-#{id}") + '.pdf')) + def initialize(log_message) + super + @log_message = log_message end end # Convert a submission to pdf - the source folder is the root folder in which the submission folder will be found (not the submission folder itself) - def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) - return false unless move_files_to_in_process(source_folder) + 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 tac = TaskAppController.new @@ -1088,8 +1206,11 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) begin pdf_text = tac.make_pdf rescue => e - # Try again... with convert to ascic - # + # Try again... + # Without newpax + # Ensure latex aux file is removed + Dir.glob(Rails.root.join('tmp/rails-latex/**/input.aux')).each { |f| File.delete(f) } + tac2 = TaskAppController.new tac2.init(self, true) @@ -1099,56 +1220,55 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) logger.error "Failed to create PDF for task #{log_details}. Error: #{e.message}" log_file = e.message.scan(/\/.*\.log/).first - # puts "log file is ... #{log_file}" if log_file && File.exist?(log_file) - # puts "exists" - begin - puts "--- Latex Log ---\n" - puts File.read(log_file) - puts "--- End ---\n\n" - rescue + log_message = File.read(log_file) + + # puts "log file is ... #{log_file}" + if log_to_stdout + # puts "exists" + begin + # rubocop:disable Rails/Output + puts "--- Latex Log ---\n" + puts log_message + puts "--- End ---\n\n" + # rubocop:enable Rails/Output + rescue + end end end - raise 'Failed to convert your submission to PDF. Check code files submitted for invalid characters, that documents are valid pdfs, and that images are valid.' - end - end - - # save the final pdf path to portfolio evidence - relative to student work folder - if group_task? - group_submission.tasks.each do |t| - t.portfolio_evidence_path = final_pdf_path - t.save + 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.' end - reload - else - self.portfolio_evidence_path = final_pdf_path end # Save the file... now using the full path! - File.open(portfolio_evidence_path, 'w') do |fout| + File.open(final_pdf_path, 'w') do |fout| fout.puts pdf_text end - FileHelper.compress_pdf(portfolio_evidence_path) + FileHelper.compress_pdf(final_pdf_path) + + logger.info("PDF created for task #{self.id}") # if the task is the draft learning summary task if task_definition_id == unit.draft_task_definition_id # if there is a learning summary, execute, if there isn't and a learning summary exists, don't execute if project.uses_draft_learning_summary || !project.learning_summary_report_exists? - project.save_as_learning_summary_report portfolio_evidence_path + project.save_as_learning_summary_report final_pdf_path end end save - - clear_in_process return true rescue => e - clear_in_process - 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}" raise e + ensure + # Ensure latex aux file is removed - if broken will cause issues for next submission in sidekiq + Dir.glob(Rails.root.join('tmp/rails-latex/**/input.aux')).each { |f| File.delete(f) } + + clear_in_process end end @@ -1207,7 +1327,17 @@ def create_alignments_from_submission(alignments) # # Checks to make sure that the files match what we expect # - def accept_submission(current_user, files, _student, ui, contributions, trigger, alignments, accepted_tii_eula: false) + def accept_submission(current_user, files, ui, contributions, trigger, alignments, accepted_tii_eula: false) + # 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 + + # 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) + end + # # Ensure that each file in files has the following attributes: # id, name, filename, type, tempfile @@ -1269,7 +1399,10 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, # # Set portfolio_evidence_path to nil while it gets processed # - self.portfolio_evidence_path = nil + if portfolio_evidence.present? + FileUtils.rm_f(portfolio_evidence_path) + update(portfolio_evidence: nil) + end files.each_with_index.map do |file, idx| output_filename = File.join(tmp_dir, "#{idx.to_s.rjust(3, '0')}-#{file[:type]}#{File.extname(file[:filename]).downcase}") @@ -1362,6 +1495,17 @@ def read_file_from_done(idx) nil end + def archive_submission + FileUtils.rm_f(final_pdf_path) if has_pdf + end + + def overseer_enabled? + return unit.assessment_enabled && + task_definition.assessment_enabled && + task_definition.has_task_assessment_resources? && + (has_new_files? || has_done_file?) + end + private def delete_associated_files @@ -1371,8 +1515,8 @@ def delete_associated_files zip_file = zip_file_path_for_done_task FileUtils.rm(zip_file) if zip_file && File.exist?(zip_file) - - FileUtils.rm(portfolio_evidence_path) if portfolio_evidence_path.present? && File.exist?(portfolio_evidence_path) + path = final_pdf_path + FileUtils.rm(path) if path.present? && File.exist?(path) new_path = FileHelper.student_work_dir(:new, self, false) FileUtils.rm_rf(new_path) if new_path.present? && File.directory?(new_path) diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7e78bd2a7..44047405f 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -19,7 +19,7 @@ class TaskDefinition < ApplicationRecord has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes has_many :learning_outcomes, -> { where('learning_outcome_task_links.task_id is NULL') }, through: :learning_outcome_task_links # only link staff relations - has_many :tii_group_attachments, dependent: :destroy + has_many :tii_group_attachments, dependent: :destroy # destroy uploaded files to tii - after the tasks has_many :tii_actions, as: :entity, dependent: :destroy serialize :upload_requirements, coder: JSON @@ -95,6 +95,10 @@ def copy_to(other_unit) new_td.add_task_resources(task_resources, copy: true) end + if has_scorm_data? + new_td.add_scorm_data(task_scorm_data, copy: true) + end + new_td.save! new_td @@ -122,17 +126,25 @@ def detailed_name def move_files_on_abbreviation_change old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation - if File.exist? task_sheet_with_abbreviation(old_abbr) + if File.exist? task_sheet_with_abbreviation(old_abbr, false) FileUtils.mv(task_sheet_with_abbreviation(old_abbr), task_sheet()) end - if File.exist? task_resources_with_abbreviation(old_abbr) + if File.exist? task_resources_with_abbreviation(old_abbr, false) FileUtils.mv(task_resources_with_abbreviation(old_abbr), task_resources()) end - if File.exist? task_assessment_resources_with_abbreviation(old_abbr) + if File.exist? task_assessment_resources_with_abbreviation(old_abbr, false) FileUtils.mv(task_assessment_resources_with_abbreviation(old_abbr), task_assessment_resources()) end + + if File.exist? task_scorm_data_with_abbreviation(old_abbr, false) + FileUtils.mv(task_scorm_data_with_abbreviation(old_abbr), task_scorm_data()) + end + + tasks.find_each do |task| + task.move_files_on_abbreviation_change(old_abbr) + end end def docker_image_name_tag @@ -176,6 +188,26 @@ def check_upload_requirements_format errors.add(:upload_requirements, "has additional values for item #{i + 1} --> #{req.keys.join(' ')}.") end + # Check the name matches a valid filename format + unless req['name'].match?(/^[a-zA-Z0-9_\- \.]+$/) + 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'] + errors.add(:upload_requirements, "the type for item #{i + 1} is not valid --> #{req['type']}.") + end + + # Check that tii check is a boolean + unless req['tii_check'].blank? || [true, false].include?(req['tii_check']) + errors.add(:upload_requirements, "the tii_check for item #{i + 1} is not a boolean --> #{req['tii_check']}.") + end + + # Check that tii_pct is a non-negative number + unless req['tii_pct'].blank? || (req['tii_pct'].is_a?(Numeric) && req['tii_pct'] >= 0) + errors.add(:upload_requirements, "the tii_pct for item #{i + 1} is not a non-negative number --> #{req['tii_pct']}.") + end + i += 1 end end @@ -278,7 +310,7 @@ def to_csv_row .map { |column| attributes[column.to_s] } + [ group_set.nil? ? "" : group_set.name, - upload_requirements.to_s, + upload_requirements.to_json, start_week, start_day, target_week, @@ -292,7 +324,10 @@ def to_csv_row end def self.csv_columns - [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, :is_graded, :plagiarism_warn_pct, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :tutorial_stream] + [: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] end def self.task_def_for_csv_row(unit, row) @@ -301,7 +336,7 @@ def self.task_def_for_csv_row(unit, row) new_task = false abbreviation = row[:abbreviation].strip name = row[:name].strip - tutorial_stream = unit.tutorial_streams.find_by_abbr_or_name("#{row[:tutorial_stream]}".strip) + tutorial_stream = unit.tutorial_streams.find_by('abbreviation = :name OR name = :name', name: "#{row[:tutorial_stream]}".strip) target_date = unit.date_for_week_and_day row[:target_week].to_i, "#{row[:target_day]}".strip return [nil, false, "Unable to determine target date for #{abbreviation} -- need week number, and day short text eg. 'Wed'"] if target_date.nil? @@ -338,6 +373,12 @@ def self.task_def_for_csv_row(unit, row) result.upload_requirements = JSON.parse(row[:upload_requirements]) unless row[:upload_requirements].nil? result.due_date = due_date + result.scorm_enabled = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_enabled]}".strip + result.scorm_allow_review = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_allow_review]}".strip + result.scorm_bypass_test = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_bypass_test]}".strip + result.scorm_time_delay_enabled = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_time_delay_enabled]}".strip + result.scorm_attempt_limit = row[:scorm_attempt_limit].to_i + result.plagiarism_warn_pct = row[:plagiarism_warn_pct].to_i if row[:group_set].present? @@ -373,15 +414,39 @@ def is_group_task? end def has_task_resources? - File.exist? task_resources + File.exist? task_resources(false) end def has_task_assessment_resources? - File.exist? task_assessment_resources + File.exist? task_assessment_resources(false) end def has_task_sheet? - File.exist? task_sheet + File.exist? task_sheet(false) + end + + def has_scorm_data? + File.exist? task_scorm_data + end + + def scorm_enabled? + scorm_enabled + end + + def scorm_allow_review? + scorm_allow_review + end + + def scorm_bypass_test? + scorm_bypass_test + end + + def scorm_time_delay_enabled? + scorm_time_delay_enabled + end + + def scorm_attempt_limit? + scorm_attempt_limit end def is_graded? @@ -436,17 +501,37 @@ def remove_task_assessment_resources() end end + def add_scorm_data(file, copy: false) + if copy + FileUtils.cp file, task_scorm_data + else + FileUtils.mv file, task_scorm_data + end + end + + def remove_scorm_data() + if has_scorm_data? + FileUtils.rm task_scorm_data + end + + reset_scorm_config() + end + # Get the path to the task sheet - using the current abbreviation - def task_sheet - task_sheet_with_abbreviation(abbreviation) + def task_sheet(create = true) + task_sheet_with_abbreviation(abbreviation, create) end - def task_resources - task_resources_with_abbreviation(abbreviation) + def task_resources(create = true) + task_resources_with_abbreviation(abbreviation, create) end - def task_assessment_resources - task_assessment_resources_with_abbreviation(abbreviation) + def task_assessment_resources(create = true) + task_assessment_resources_with_abbreviation(abbreviation, create) + end + + def task_scorm_data(create = true) + task_scorm_data_with_abbreviation(abbreviation, create) end def related_tasks_with_files(consolidate_groups = true) @@ -460,7 +545,7 @@ def related_tasks_with_files(consolidate_groups = true) if t.group.nil? result = false else - result = !seen_groups.include?(t.group) + result = seen_groups.exclude?(t.group) seen_groups << t.group if result end result @@ -491,13 +576,14 @@ def delete_associated_files() remove_task_sheet() remove_task_resources() remove_task_assessment_resources() + remove_scorm_data() end # Calculate the path to the task sheet using the provided abbreviation # This allows the path to be calculated on abbreviation change to allow files to # be moved - def task_sheet_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_sheet_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.pdf" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.pdf" @@ -512,8 +598,8 @@ def task_sheet_with_abbreviation(abbr) # Calculate the path to the task sheet using the provided abbreviation # This allows the path to be calculated on abbreviation change to allow files to # be moved - def task_resources_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_resources_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.zip" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.zip" @@ -525,8 +611,8 @@ def task_resources_with_abbreviation(abbr) end end - def task_assessment_resources_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_assessment_resources_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}-assessment.zip" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}-assessment.zip" @@ -537,4 +623,28 @@ def task_assessment_resources_with_abbreviation(abbr) result_with_sanitised_file end end + + # Calculate the path to the SCORM containzer zip file using the provided abbreviation + # This allows the path to be calculated on abbreviation change to allow files to + # be moved + def task_scorm_data_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.scorm.zip" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.scorm.zip" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + def reset_scorm_config() + self.scorm_enabled = false + self.scorm_allow_review = false + self.scorm_bypass_test = false + self.scorm_time_delay_enabled = false + self.scorm_attempt_limit = 0 + end end diff --git a/app/models/task_status.rb b/app/models/task_status.rb index 825fc5c25..14f59225f 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -5,7 +5,7 @@ class TaskStatus < ApplicationRecord # TODO: Consider refactoring this class. Is there any point to having this in the database? Could this become an enum? # Model associations - has_many :tasks + has_many :tasks, dependent: :restrict_with_exception # # Override find to ensure that task status objects are cached - these do not change diff --git a/app/models/task_submission.rb b/app/models/task_submission.rb index 178f565c4..7b07f7ce3 100644 --- a/app/models/task_submission.rb +++ b/app/models/task_submission.rb @@ -1,4 +1,4 @@ class TaskSubmission < ApplicationRecord belongs_to :task, optional: false - belongs_to :assessor, class_name: 'User', foreign_key: 'assessor_id', optional: true + belongs_to :assessor, class_name: 'User', optional: true end diff --git a/app/models/teaching_period.rb b/app/models/teaching_period.rb index 09020ae0e..eaf5d6b2c 100644 --- a/app/models/teaching_period.rb +++ b/app/models/teaching_period.rb @@ -1,6 +1,6 @@ class TeachingPeriod < ApplicationRecord # Relationships - has_many :units + has_many :units, dependent: :restrict_with_exception has_many :breaks, dependent: :delete_all # Callbacks - methods called are private @@ -132,37 +132,6 @@ def future_teaching_periods TeachingPeriod.where("start_date > :end_date", end_date: end_date) end - def rollover(rollover_to, search_forward = true, rollover_inactive = false) - if rollover_to.start_date < Time.zone.now || rollover_to.start_date <= start_date - self.errors.add(:base, "Units can only be rolled over to future teaching periods") - - false - else - units_to_rollover = units - - unless rollover_inactive - units_to_rollover = units_to_rollover.where(active: true) - end - - if search_forward - ftp = future_teaching_periods.where("start_date < :date", date: rollover_to.start_date).order(start_date: "desc") - - units_to_rollover = units_to_rollover.map do |u| - ftp.map { |tp| tp.units.where(code: u.code).first }.select { |u| u.present? }.first || u - end - end - - for unit in units_to_rollover do - # skip if the unit already exists in the teaching period - next if rollover_to.units.where(code: unit.code).count > 0 - - unit.rollover(rollover_to, nil, nil) - end - - true - end - end - private def can_destroy? diff --git a/app/models/test_attempt.rb b/app/models/test_attempt.rb new file mode 100644 index 000000000..991841425 --- /dev/null +++ b/app/models/test_attempt.rb @@ -0,0 +1,160 @@ +require 'json' +require 'time' + +class TestAttempt < ApplicationRecord + belongs_to :task, optional: false + + has_one :task_definition, through: :task + + has_one :scorm_comment, as: :commentable, dependent: :destroy + + delegate :role_for, to: :task + delegate :student, to: :task + + validates :task_id, presence: true + + def self.permissions + student_role_permissions = [ + :update_attempt + # :review_own_attempt -- depends on task def settings. See specific_permission_hash method + ] + + tutor_role_permissions = [ + :review_other_attempt, + :override_success_status, + :delete_attempt + ] + + convenor_role_permissions = [ + :review_other_attempt, + :override_success_status, + :delete_attempt + ] + + nil_role_permissions = [] + + { + student: student_role_permissions, + tutor: tutor_role_permissions, + convenor: convenor_role_permissions, + nil: nil_role_permissions + } + end + + # Used to adjust the review own attempt permission based on task def setting + def specific_permission_hash(role, perm_hash, _other) + result = perm_hash[role] unless perm_hash.nil? + if result && role == :student && task_definition.scorm_allow_review + result << :review_own_attempt + end + result + end + + # task + # t.references :task + + # extra non-cmi metadata + # t.datetime :attempted_time, null:false + # t.boolean :terminated, default: false + + # fields that must be synced from cmi data whenever it's updated + # t.boolean :completion_status, default: false + # t.boolean :success_status, default: false + # t.float :score_scaled, default: 0 + + # scorm datamodel + # t.text :cmi_datamodel + + after_initialize if: :new_record? do + self.attempted_time = Time.zone.now + task = Task.find(self.task_id) + learner_name = task.project.student.name + learner_id = task.project.student.student_id + + init_state = { + "cmi.completion_status": 'not attempted', + "cmi.entry": 'ab-initio', # init state + "cmi.objectives._count": '0', # this counter will be managed on the frontend + "cmi.interactions._count": '0', # this counter will be managed on the frontend + "cmi.mode": 'normal', + "cmi.learner_name": learner_name, + "cmi.learner_id": learner_id + } + self.cmi_datamodel = init_state.to_json + end + + def cmi_datamodel=(data) + new_data = JSON.parse(data) + + if self.terminated == true + raise "Terminated entries should not be updated" + end + + # set cmi.entry to resume if the attempt is in progress + if new_data['cmi.completion_status'] == 'incomplete' + new_data['cmi.entry'] = 'resume' + end + + # IMPORTANT: always sync any model attributes with cmi values here to ensure consistency! + # attributes derived from cmi keys: completion_status, success_status, score_scaled + self.completion_status = new_data['cmi.completion_status'] == 'completed' + self.success_status = new_data['cmi.success_status'] == 'passed' + self.score_scaled = new_data['cmi.score.scaled'] + + write_attribute(:cmi_datamodel, new_data.to_json) + end + + def review + dm = JSON.parse(self.cmi_datamodel) + if dm['cmi.completion_status'] != 'completed' + raise StandardError, 'Cannot review incomplete attempts!' + end + + # when review is requested change the mode to review + dm['cmi.mode'] = 'review' + self[:cmi_datamodel] = dm.to_json + end + + def override_success_status(new_success_status) + dm = JSON.parse(self.cmi_datamodel) + dm['cmi.success_status'] = (new_success_status ? 'passed' : 'failed') + self[:cmi_datamodel] = dm.to_json + self.success_status = dm['cmi.success_status'] == 'passed' + self.save! + self.update_scorm_comment + end + + def add_scorm_comment + comment = ScormComment.create + comment.task = task + comment.user = task.tutor + comment.comment = success_status_description + comment.recipient = task.student + comment.commentable = self + comment.save! + + comment + end + + def update_scorm_comment + if self.scorm_comment.present? + self.scorm_comment.comment = success_status_description + self.scorm_comment.save! + + return self.scorm_comment + end + + logger.warn "WARN: Unexpected need to create scorm comment for test attempt: #{self.id}" + add_scorm_comment + end + + def success_status_description + if self.success_status && self.score_scaled == 1 + "Passed without mistakes" + elsif self.success_status && self.score_scaled < 1 + "Passed" + else + "Unsuccessful" + end + 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 79ea6fda1..8e9d9e309 100644 --- a/app/models/turn_it_in/task_definition_tii_module.rb +++ b/app/models/turn_it_in/task_definition_tii_module.rb @@ -19,13 +19,13 @@ def tii_match_pct(idx) # # @return [Boolean] true if there are any Turnitin checks def tii_checks? - Doubtfire::Application.config.tii_enabled && + TurnItIn.enabled? && !upload_requirements.empty? && ((0..upload_requirements.length - 1).map { |i| use_tii?(i) }.inject(:|) || false) end def had_tii_checks_before_last_save? - Doubtfire::Application.config.tii_enabled && + TurnItIn.enabled? && upload_requirements_before_last_save.present? && !upload_requirements_before_last_save.empty? && ((0..upload_requirements_before_last_save.length - 1).map { |i| use_tii?(i, upload_requirements_before_last_save) }.inject(:|) || false) @@ -34,9 +34,11 @@ def had_tii_checks_before_last_save? # Send all doc and docx files from the task resources to turn it in # as group attachments. def send_group_attachments_to_tii - return unless tii_group_id.present? + return if tii_group_id.blank? return unless has_task_resources? + count = 0 + # loop through files in the task resources zip file Zip::File.open(task_resources) do |zip_file| zip_file.each do |entry| @@ -45,6 +47,11 @@ def send_group_attachments_to_tii next if entry.name.include?('__MACOSX') next if entry.size < 50 + # TODO: This is a hack as TII limits the number of attachments to 3 + # We need to merge documents into a single file... + count += 1 + break if count > 3 + TiiGroupAttachment.find_or_create_from_task_definition(self, entry.name) end end @@ -56,7 +63,7 @@ def send_group_attachments_to_tii # @return [TCAClient::Group] the group for the task definition def create_or_get_tii_group # if there is no group id, create one (but not register with tii) - unless self.tii_group_id.present? + if self.tii_group_id.blank? self.tii_group_id = SecureRandom.uuid self.save end @@ -77,7 +84,7 @@ def check_and_update_tii_status TurnItIn.create_or_get_group_context(unit) if tii_group_id.present? - # We already have the group - so just create the attachments + # We already have the group - so just create/send the attachments send_group_attachments_to_tii else # Trigger the update - which creates action if needed @@ -88,9 +95,10 @@ def check_and_update_tii_status end def update_tii_group - return unless tii_group_id.present? + return if tii_group_id.blank? action = TiiActionUpdateTiiGroup.find_or_create_by(entity: self) + action.params = { update_due_date: true } action.perform end end diff --git a/app/models/turn_it_in/task_tii_module.rb b/app/models/turn_it_in/task_tii_module.rb index b88e318d8..3589d9a99 100644 --- a/app/models/turn_it_in/task_tii_module.rb +++ b/app/models/turn_it_in/task_tii_module.rb @@ -21,7 +21,7 @@ def send_documents_to_tii(submitter, accepted_tii_eula: false) filename: filename_for_upload(idx), submitted_at: Time.zone.now, status: :created, - submitted_by_user: submitter + submitted_by: submitter ) # and start its processing diff --git a/app/models/turn_it_in/tii_action.rb b/app/models/turn_it_in/tii_action.rb index d20d5c0d6..7c852a316 100644 --- a/app/models/turn_it_in/tii_action.rb +++ b/app/models/turn_it_in/tii_action.rb @@ -50,7 +50,7 @@ def perform self.error_code = nil if self.retry && error? self.custom_error_message = nil - self.log = [] if self.log.nil? || self.log.empty? || self.complete # reset log if complete... and performing again + self.log = [] if self.log.blank? || self.complete # reset log if complete... and performing again self.log << { date: Time.zone.now, message: "Started #{type}" } self.last_run = Time.zone.now @@ -60,6 +60,12 @@ def perform result = run self.log << { date: Time.zone.now, message: "#{type} Ended" } + + # Ensure log does not get too long! + if self.log.size > 25 + self.log = self.log.last(25) + end + save result @@ -67,7 +73,7 @@ def perform save_and_log_custom_error e&.to_s if Rails.env.development? || Rails.env.test? - puts e.inspect + Rails.logger.debug e.inspect end nil @@ -122,8 +128,14 @@ def error? error_code.present? end + def perform_retry + save_and_reschedule + perform_async + end + def save_and_reschedule(reset_retry: true) self.retries = 0 if reset_retry + self.error_code = nil # reset error code self.retry = true save end @@ -227,8 +239,8 @@ def log_error # @param description [String] the description of the action that is being performed # @param block [Proc] the block that will be called to perform the call def exec_tca_call(description, codes = [], &block) - unless TurnItIn.functional? - raise TCAClient::ApiError, code: 0, message: "Turn It In not functiona: #{description}" + unless TurnItIn.enabled? + raise TCAClient::ApiError, code: 0, message: "Turn It In not enabled: #{description}" end if TurnItIn.rate_limited? raise TCAClient::ApiError, code: 429, message: "Turn It In rate limited: #{description}" diff --git a/app/models/turn_it_in/tii_action_delete_submission.rb b/app/models/turn_it_in/tii_action_delete_submission.rb index 53c17ebc8..acdadda6f 100644 --- a/app/models/turn_it_in/tii_action_delete_submission.rb +++ b/app/models/turn_it_in/tii_action_delete_submission.rb @@ -9,7 +9,7 @@ def description def run submission_id = params["submission_id"] - unless submission_id.present? + if submission_id.blank? save_and_log_custom_error "Group Attachment id or Group id does not exist - cannot delete group attachment" return end diff --git a/app/models/turn_it_in/tii_action_register_webhook.rb b/app/models/turn_it_in/tii_action_register_webhook.rb index b004125f6..e897eaabb 100644 --- a/app/models/turn_it_in/tii_action_register_webhook.rb +++ b/app/models/turn_it_in/tii_action_register_webhook.rb @@ -6,10 +6,24 @@ def description "Register webhooks" end - private + def remove_webhooks + # Get all webhooks + webhooks = list_all_webhooks + + # Delete each of the webhooks + webhooks.each do |webhook| + exec_tca_call 'delete webhook' do + TCAClient::WebhookApi.new.delete_webhook( + TurnItIn.x_turnitin_integration_name, + TurnItIn.x_turnitin_integration_version, + webhook.id + ) + end + end + end def run - register_webhook if need_to_register_webhook? + register_webhook if TurnItIn.register_webhooks? && need_to_register_webhook? self.complete = true end @@ -27,8 +41,11 @@ def need_to_register_webhook? end def register_webhook + key = ENV.fetch('TCA_SIGNING_KEY', nil) + raise "TCA_SIGNING_KEY is not set" if key.nil? + data = TCAClient::WebhookWithSecret.new( - signing_secret: ENV.fetch('TCA_SIGNING_KEY', nil), + signing_secret: Base64.encode64(key).tr("\n", ''), url: TurnItIn.webhook_url, event_types: %w[ SIMILARITY_COMPLETE @@ -39,8 +56,6 @@ def register_webhook ] ) # WebhookWithSecret | - raise "TCA_SIGNING_KEY is not set" if data.signing_secret.nil? - exec_tca_call 'register webhook' do TCAClient::WebhookApi.new.webhooks_post( TurnItIn.x_turnitin_integration_name, diff --git a/app/models/turn_it_in/tii_action_update_tii_group.rb b/app/models/turn_it_in/tii_action_update_tii_group.rb index 87caf41bd..ad7f2b96c 100644 --- a/app/models/turn_it_in/tii_action_update_tii_group.rb +++ b/app/models/turn_it_in/tii_action_update_tii_group.rb @@ -8,7 +8,7 @@ def description def run # Generate id but do not save until put is complete - entity.tii_group_id = SecureRandom.uuid unless entity.tii_group_id.present? + entity.tii_group_id = SecureRandom.uuid if entity.tii_group_id.blank? data = TCAClient::AggregateGroup.new( id: entity.tii_group_id, @@ -24,6 +24,7 @@ def run ] exec_tca_call "create or update group #{entity.tii_group_id} for task definition #{entity.id}", error_code do + # Update the due date TCAClient::GroupsApi.new.groups_group_id_put( TurnItIn.x_turnitin_integration_name, TurnItIn.x_turnitin_integration_version, diff --git a/app/models/turn_it_in/tii_action_upload_submission.rb b/app/models/turn_it_in/tii_action_upload_submission.rb index 8c707170d..151a481b5 100644 --- a/app/models/turn_it_in/tii_action_upload_submission.rb +++ b/app/models/turn_it_in/tii_action_upload_submission.rb @@ -4,6 +4,8 @@ class TiiActionUploadSubmission < TiiAction delegate :status_sym, :status, :submission_id, :submitted_by_user, :task, :idx, :similarity_pdf_id, :similarity_pdf_path, :filename, to: :entity + NO_USER_ACCEPTED_EULA_ERROR = 'None of the student, tutor, or unit lead have accepted the EULA for Turnitin'.freeze + def description "Upload #{self.filename} for #{self.task.student.username} from #{self.task.task_definition.abbreviation} (#{self.status} - #{self.next_step})" end @@ -15,8 +17,8 @@ def update_from_pdf_report_status(response) case response when 'FAILED' # The report failed to be generated error_message = 'similarity PDF failed to be created' - when 'SUCCESS' # Similarity report is complete - entity.status = :similarity_pdf_requested + when 'SUCCESS' # Similarity report is complete - pdf is available + entity.status = :similarity_pdf_available entity.save save_progress download_similarity_report_pdf(skip_check: true) @@ -62,17 +64,15 @@ def update_from_similarity_status(response) # when 'PROCESSING' # Similarity report is being generated # return when 'COMPLETE' # Similarity report is complete - entity.overall_match_percentage = response.overall_match_percentage - - flag = response.overall_match_percentage.present? && response.overall_match_percentage.to_i > task.tii_match_pct(idx) - # Update the status of the entity - entity.update(status: flag ? :similarity_report_complete : :complete_low_similarity) + entity.overall_match_percentage = response.overall_match_percentage.present? ? response.overall_match_percentage.to_i : -1 + flag = entity.should_flag? + entity.status = flag ? :similarity_report_complete : :complete_low_similarity + entity.save - # Create the similarity record - TiiTaskSimilarity.find_or_initialize_by task: entity.task do |similarity| - similarity.pct = response.overall_match_percentage - similarity.tii_submission = entity + # Create the similarity record - for task and this turn it in submission + TiiTaskSimilarity.find_or_initialize_by task: entity.task, tii_submission: entity do |similarity| + similarity.pct = entity.overall_match_percentage # record percentage similarity.flagged = flag similarity.save end @@ -106,7 +106,7 @@ def next_step "awaiting similarity report" when :similarity_pdf_available "downloading similarity report" - when "similarity_pdf_downloaded" + when :similarity_pdf_downloaded "complete - report available" when :to_delete "awaiting deletion" @@ -163,7 +163,7 @@ def fetch_tii_submission_id data = tii_submission_data # If we don't have data, then we can't create a submission - fail as no one accepted EULA - return false unless data.present? + return false if data.blank? exec_tca_call "TiiSubmission #{entity.id} - fetching id" do # Check to ensure it is a new upload @@ -199,8 +199,9 @@ def tii_submission_data # Setup the task owners if task.group_task? - result.owner = task.group_submission.submitter_task.student.username - result.metadata.owners = task.group_submission.tasks.map { |t| @instance.tii_user_for(t.student) } + grp = Task.group + result.owner = "group-#{grp.id}" + result.metadata.owners = [TurnItIn.tii_user_for_group(task.group_submission.submitter_task.student.email)] else result.owner = task.student.username result.metadata.owners = [TurnItIn.tii_user_for(task.student)] @@ -213,7 +214,7 @@ def tii_submission_data result.submitter = submitted_by_user.username unless submitted_by_user.accepted_tii_eula? || (params.key?("accepted_tii_eula") && params["accepted_tii_eula"]) - save_and_log_custom_error "None of the student, tutor, or unit lead have accepted the EULA for Turnitin" + save_and_log_custom_error NO_USER_ACCEPTED_EULA_ERROR return nil end @@ -334,7 +335,7 @@ def request_similarity_report # # @return [TCAClient::SimilarityMetadata] the similarity report status def fetch_tii_similarity_status - return nil unless submission_id.present? + return nil if submission_id.blank? exec_tca_call "TiiSubmission #{entity.id} - fetching similarity report status" do # Get Similarity Report Status @@ -381,7 +382,7 @@ def request_similarity_report_pdf # # @param [Boolean] skip_check - skip the check to see if the report is ready def download_similarity_report_pdf(skip_check: false) - return false unless similarity_pdf_id.present? + return false if similarity_pdf_id.blank? return false unless skip_check || fetch_tii_similarity_pdf_status == 'SUCCESS' error_codes = [ @@ -442,4 +443,26 @@ def fetch_tii_similarity_pdf_status result.status end end + + # If this submission is not progressing due to a user not accepting the EULA, then + # check if the user has accepted the EULA now and retry + def attempt_retry_on_no_eula + if self.retry == false && status_sym == :created && error_message == NO_USER_ACCEPTED_EULA_ERROR + # If the student has now submitted the eula... + unless entity.submitted_by.accepted_tii_eula? + # Try reassigning the submitted_by so that it checks for tutor + # or convenor eula + entity.submitted_by = entity.submitted_by_user + end + + # If we can submit from someone... + if submitted_by_user.accepted_tii_eula? + # Save any changes to the entity + entity.save + save_and_reschedule + end + + end + end + end diff --git a/app/models/turn_it_in/tii_action_upload_task_resources.rb b/app/models/turn_it_in/tii_action_upload_task_resources.rb index 0dec131a8..7501a90c4 100644 --- a/app/models/turn_it_in/tii_action_upload_task_resources.rb +++ b/app/models/turn_it_in/tii_action_upload_task_resources.rb @@ -25,7 +25,7 @@ def update_from_attachment_status(response) private def run - unless tii_group_id.present? + if tii_group_id.blank? save_and_log_custom_error "Group id does not exist for task definition #{task_definition.id} - cannot upload group attachments" return end diff --git a/app/models/turn_it_in/tii_group_attachment.rb b/app/models/turn_it_in/tii_group_attachment.rb index b15f56dd3..6bfa76083 100644 --- a/app/models/turn_it_in/tii_group_attachment.rb +++ b/app/models/turn_it_in/tii_group_attachment.rb @@ -53,7 +53,7 @@ def self.find_or_create_from_task_definition(task_definition, filename) private def delete_attachment - return unless group_attachment_id.present? + return if group_attachment_id.blank? TiiActionDeleteGroupAttachment.create( entity: nil, diff --git a/app/models/turn_it_in/tii_submission.rb b/app/models/turn_it_in/tii_submission.rb index c12eee75a..6df74cbf9 100644 --- a/app/models/turn_it_in/tii_submission.rb +++ b/app/models/turn_it_in/tii_submission.rb @@ -68,6 +68,13 @@ def create_viewer_url(user) ).perform end + # Should we flag this task for high similarity? + # + # @return [Boolean] true if the task should be flagged, false otherwise + def should_flag? + overall_match_percentage > task.tii_match_pct(idx) + end + private # Delete the turn it in submission for a task diff --git a/app/models/turn_it_in/user_tii_module.rb b/app/models/turn_it_in/user_tii_module.rb index bb183f4f4..59fcb5c56 100644 --- a/app/models/turn_it_in/user_tii_module.rb +++ b/app/models/turn_it_in/user_tii_module.rb @@ -18,7 +18,7 @@ def accept_tii_eula(eula_version = TurnItIn.eula_version) end def accepted_tii_eula? - return false unless Doubtfire::Application.config.tii_enabled + return false unless TurnItIn.enabled? return true unless TiiActionFetchFeaturesEnabled.eula_required? tii_eula_version == TurnItIn.eula_version diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index a27c3fede..6c31fe19b 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -7,7 +7,7 @@ class Tutorial < ApplicationRecord has_one :tutor, through: :unit_role, source: :user - has_many :groups + has_many :groups, dependent: :restrict_with_exception has_many :tutorial_enrolments, dependent: :destroy has_many :projects, through: :tutorial_enrolments diff --git a/app/models/tutorial_enrolment.rb b/app/models/tutorial_enrolment.rb index 2f5fc832d..29c2a8883 100644 --- a/app/models/tutorial_enrolment.rb +++ b/app/models/tutorial_enrolment.rb @@ -8,7 +8,7 @@ class TutorialEnrolment < ApplicationRecord validates :project, presence: true # Always add a unique index to the DB to prevent new records from passing the validations when checked at the same time before being written - validates_uniqueness_of :tutorial, :scope => :project, message: 'already exists for the selected student' + validates :tutorial, uniqueness: { :scope => :project, message: 'already exists for the selected student' } # Ensure only one tutorial stream per stream validate :ensure_only_one_tutorial_per_stream, on: :create @@ -90,7 +90,7 @@ def action_on_student_leave_tutorial(for_tutorial_id = nil) result = :none_can_leave # Now get the group - project.groups.where(tutorial_id: for_tutorial_id || tutorial_id).each do |grp| + project.groups.where(tutorial_id: for_tutorial_id || tutorial_id).find_each do |grp| # You can move if the tutorial allows it next unless grp.limit_members_to_tutorial? @@ -129,7 +129,7 @@ def validate_tutorial_change abbr = Tutorial.find(id_from).abbreviation errors.add(:groups, "require #{project.student.name} to be in tutorial #{abbr}") else # leave after remove from group - project.groups.where(tutorial_id: id_from).each do |grp| + project.groups.where(tutorial_id: id_from).find_each do |grp| # Skip groups that can be in other tutorials next unless grp.limit_members_to_tutorial? @@ -145,7 +145,7 @@ def validate_tutorial_change # Check group removal on delete def remove_from_groups_on_destroy - project.groups.where(tutorial_id: tutorial_id).each do |grp| + project.groups.where(tutorial_id: tutorial_id).find_each do |grp| # Skip groups that can be in other tutorials next unless grp.limit_members_to_tutorial? diff --git a/app/models/tutorial_stream.rb b/app/models/tutorial_stream.rb index 2b0bb3820..4cd8584f6 100644 --- a/app/models/tutorial_stream.rb +++ b/app/models/tutorial_stream.rb @@ -7,7 +7,9 @@ class TutorialStream < ApplicationRecord before_destroy :can_destroy?, prepend: true has_many :tutorials, dependent: :destroy - has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' } + has_many :task_definitions, dependent: :restrict_with_exception, inverse_of: :tutorial_stream + + # Validations - methods called are private validates :unit, presence: true validates :activity_type, presence: true @@ -17,10 +19,6 @@ class TutorialStream < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit" } validates :abbreviation, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit" } - def self.find_by_abbr_or_name(data) - TutorialStream.find_by(abbreviation: data) || TutorialStream.find_by(name: data) - end - private def can_destroy? @@ -31,7 +29,7 @@ def can_destroy? throw :abort elsif unit.tutorial_streams.count.eql? 2 other_tutorial_stream = (self.eql? unit.tutorial_streams.first) ? unit.tutorial_streams.second : unit.tutorial_streams.first - task_definitions.update_all(tutorial_stream_id: other_tutorial_stream.id) + task_definitions.find_each { |td| td.update(tutorial_stream_id: other_tutorial_stream.id) } task_definitions.clear true elsif unit.tutorial_streams.count.eql? 1 @@ -45,7 +43,7 @@ def handle_associated_task_defs return if unit.task_definitions.empty? or unit.tutorial_streams.count > 1 if unit.task_definitions.exists? and unit.tutorial_streams.count.eql? 1 - unit.task_definitions.update_all(tutorial_stream_id: id) + unit.task_definitions.find_each { |td| td.update(tutorial_stream_id: id) } end end end diff --git a/app/models/unit.rb b/app/models/unit.rb index 175e62c79..1c44a9145 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -98,7 +98,7 @@ def role_for(user) elsif active_projects.where('projects.user_id=:id', id: user.id).count == 1 Role.student elsif user.has_auditor_capability? && - start_date >= Date.today - Doubtfire::Application.config.auditor_unit_access_years && + start_date >= Time.zone.today - Doubtfire::Application.config.auditor_unit_access_years && end_date < DateTime.now Role.auditor elsif user.has_admin_capability? @@ -111,23 +111,24 @@ def role_for(user) # Ensure before destroy is above relations - as this needs to clear main convenor before unit roles are deleted before_destroy do update(main_convenor_id: nil) - delete_associated_files end + after_destroy :delete_associated_files + + 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? # Model associations. # When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed. - has_many :projects, dependent: :destroy # projects first to remove tasks - has_many :active_projects, -> { where enrolled: true }, class_name: 'Project' - has_many :group_sets, dependent: :destroy # group sets next to remove groups - has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' }, dependent: :destroy - has_many :tutorials, dependent: :destroy # tutorials need groups and tasks deleted before it... - has_many :tutorial_streams, dependent: :destroy - has_many :unit_roles, dependent: :destroy - has_many :learning_outcomes, dependent: :destroy - has_many :comments, through: :projects + has_many :projects, dependent: :destroy, inverse_of: :unit # projects first to remove tasks + has_many :group_sets, dependent: :destroy, inverse_of: :unit # group sets next to remove groups + has_many :task_definitions, dependent: :destroy, inverse_of: :unit + has_many :tutorials, dependent: :destroy, inverse_of: :unit # tutorials need groups and tasks deleted before it... + has_many :tutorial_streams, dependent: :destroy, inverse_of: :unit + has_many :unit_roles, dependent: :destroy, inverse_of: :unit + has_many :learning_outcomes, dependent: :destroy, inverse_of: :unit + has_many :comments, through: :projects has_many :tasks, through: :projects has_many :groups, through: :group_sets has_many :tutorial_enrolments, through: :tutorials @@ -139,8 +140,7 @@ def role_for(user) has_many :tii_group_attachments, through: :task_definitions has_many :campuses, through: :tutorials - has_many :convenors, -> { joins(:role).where('roles.name = :role', role: 'Convenor') }, class_name: 'UnitRole' - has_many :staff, -> { joins(:role).where('roles.name = :role_convenor or roles.name = :role_tutor', role_convenor: 'Convenor', role_tutor: 'Tutor') }, class_name: 'UnitRole' + has_one :d2l_assessment_mapping, dependent: :destroy # Unit has a teaching period belongs_to :teaching_period, optional: true @@ -164,7 +164,7 @@ def role_for(user) validate :validate_end_date_after_start_date validate :ensure_teaching_period_dates_match, if: :has_teaching_period? - validate :ensure_main_convenor_is_appropriate + validate :ensure_main_convenor_is_appropriate, if: :main_convenor_id_changed? # Portfolio autogen date validations, must be after start date and before or equal to end date validate :autogen_date_within_unit_active_period, if: -> { start_date_changed? || end_date_changed? || teaching_period_id_changed? || portfolio_auto_generation_date_changed? } @@ -183,6 +183,22 @@ def detailed_name "#{name} #{teaching_period.present? ? teaching_period.detailed_name : start_date.strftime('%Y-%m-%d')}" end + def active_projects + projects.where(enrolled: true) + end + + def ordered_task_definitions + task_definitions.order('start_date ASC, abbreviation ASC') + end + + def convenors + unit_roles.where(role_id: Role.convenor_id) + end + + def staff + unit_roles.where(role_id: [Role.convenor_id, Role.tutor_id]) + end + def docker_image_name_tag return nil if overseer_image.nil? @@ -218,9 +234,9 @@ def teaching_period_id=(tp_id) def teaching_period=(tp) if tp.present? - write_attribute(:start_date, tp.start_date) - write_attribute(:end_date, tp.end_date) - write_attribute(:teaching_period_id, tp.id) + self[:start_date] = tp.start_date + self[:end_date] = tp.end_date + self[:teaching_period_id] = tp.id end super(tp) end @@ -230,10 +246,10 @@ def has_teaching_period? end def ensure_teaching_period_dates_match - if read_attribute(:start_date) != teaching_period.start_date + if self[:start_date] != teaching_period.start_date errors.add(:start_date, "should match teaching period date") end - if read_attribute(:end_date) != teaching_period.end_date + if self[:end_date] != teaching_period.end_date errors.add(:end_date, "should match teaching period date") end end @@ -258,16 +274,22 @@ def autogen_date_within_unit_active_period end end - def rollover(teaching_period, start_date, end_date) + def rollover(teaching_period, start_date, end_date, new_code) new_unit = self.dup + new_unit.code = new_code if new_code.present? + if teaching_period.present? new_unit.teaching_period = teaching_period else + new_unit.teaching_period = nil new_unit.start_date = start_date new_unit.end_date = end_date end + # Clear archived + new_unit.archived = false + if self.portfolio_auto_generation_date.present? # Update the portfolio auto generation date to be the same day of the week and week number as the old date new_unit.portfolio_auto_generation_date = new_unit.date_for_week_and_day(week_number(self.portfolio_auto_generation_date), Date::ABBR_DAYNAMES[self.portfolio_auto_generation_date.wday]) @@ -331,7 +353,7 @@ def self.for_user_admin(user) Unit.all elsif user.has_auditor_capability? # Limit range of units that the auditor has access to - earliest_unit_start_date = Date.today - Doubtfire::Application.config.auditor_unit_access_years + earliest_unit_start_date = Time.zone.today - Doubtfire::Application.config.auditor_unit_access_years Unit.all.where('start_date >= :earliest_unit_start_date AND end_date < :today', earliest_unit_start_date: earliest_unit_start_date, today: DateTime.now) else Unit.joins(:unit_roles).where('unit_roles.user_id = :user_id AND unit_roles.role_id = :convenor_role', user_id: user.id, convenor_role: Role.convenor.id) @@ -343,7 +365,7 @@ def self.default unit.name = 'New Unit' unit.description = 'Enter a description for this unit.' - unit.start_date = Date.today + unit.start_date = Time.zone.today unit.end_date = 13.weeks.from_now unit @@ -356,9 +378,7 @@ def tutors User.teaching(self) end - def main_convenor_user - main_convenor.user - end + delegate :user, to: :main_convenor, prefix: true def students projects @@ -557,7 +577,6 @@ def import_users_from_csv(file) 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 body.nil? }]) - # Read the header row to determine what kind of file it is if csv.header_row? csv.shift @@ -788,7 +807,7 @@ def update_student_enrolments(changes, import_settings, result) end # Find the campus - campus = campus_data.present? ? Campus.find_by_abbr_or_name(campus_data) : nil + campus = campus_data.present? ? Campus.find_by('abbreviation = :name OR name = :name', name: campus_data) : nil if campus_data.present? && campus.nil? errors << { row: row, message: "Unable to find campus (#{campus_data})" } next @@ -815,7 +834,7 @@ def update_student_enrolments(changes, import_settings, result) # if project_participant.persisted? # Add in the student id if it was supplied... - if (project_participant.student_id.nil? || project_participant.student_id.empty? || project_participant.student_id != student_id) && student_id.present? + if (project_participant.student_id.blank? || project_participant.student_id != student_id) && student_id.present? project_participant.student_id = student_id project_participant.save! end @@ -1155,7 +1174,7 @@ def import_groups_from_csv(group_set, file) change += ' Created new tutorial.' campus_data = row['campus'].strip unless row['campus'].nil? - campus = Campus.find_by_abbr_or_name(campus_data) + campus = Campus.find_by('abbreviation = :name OR name = :name', name: campus_data) tutorial = add_tutorial( 'Monday', @@ -1452,7 +1471,7 @@ def task_completion_csv "#{row['first_name']} #{row['last_name']}", GradeHelper.grade_for(row['target_grade']), row['email'], - row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], true)), + 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, row['grade_rationale'] ] + [1].map do @@ -1549,7 +1568,7 @@ def get_task_submissions_pdf_zip(current_user, td) task.student.username.to_s end - FileUtils.cp task.portfolio_evidence_path, File.join(dir, path_part.to_s) + '.pdf' + FileUtils.cp task.final_pdf_path, File.join(dir, path_part.to_s) + '.pdf' end # each task # Copy files into zip @@ -1881,7 +1900,7 @@ def _student_task_completion_data_base def _calculate_task_completion_stats(data) values = data.map { |r| r[:num] } - if values && !values.empty? + if values.present? values.sort! median_value = if values.length.even? @@ -2146,7 +2165,7 @@ def check_mark_csv_headers end def readme_text - path = Rails.root.join('public', 'resources', 'marking_package_readme.txt') + path = Rails.root.join("public/resources/marking_package_readme.txt") File.read path end @@ -2180,9 +2199,9 @@ def generate_batch_task_zip(user, tasks) csv_str << "\n#{student.username.tr(',', '_')},#{student.name.tr(',', '_')},#{task.project.tutorial_for(task.task_definition).abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_by(task.project.student).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",#{mark_col},,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence_path + src_path = task.final_pdf_path - next if src_path.nil? || src_path.empty? + next if src_path.blank? next unless File.exist? src_path # make dst path of "/.pdf" @@ -2203,9 +2222,9 @@ def generate_batch_task_zip(user, tasks) csv_str << "\nGRP_#{grp.id}_#{subm.id},#{grp.name.tr(',', '_')},#{grp.tutorial.abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_not_by(user).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",rff,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence_path + src_path = task.final_pdf_path - next if src_path.nil? || src_path.empty? + next if src_path.blank? next unless File.exist? src_path # make dst path of "/.pdf" @@ -2321,7 +2340,7 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) 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 - if task_entry['new comment'].nil? || task_entry['new comment'].empty? + 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'] @@ -2470,15 +2489,14 @@ def upload_batch_task_zip_or_csv(user, file) next end - # Read into the task's portfolio_evidence path the new file + # Read into the task's final pdf path the new file tmp_file = File.join(tmp_dir, File.basename(file[:name])) - task.portfolio_evidence_path = task.final_pdf_path # 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.portfolio_evidence_path) + if FileHelper.copy_pdf(tmp_file, task.final_pdf_path) if task.group.nil? success << { row: "File #{file[:name]}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } else @@ -2539,11 +2557,57 @@ def send_weekly_status_emails(summary_stats) summary_stats[:staff] = {} end + def archive_submissions(out) + out.puts "Unit: #{code} - #{name}" + projects.each do |project| + project.archive_submissions(out) + end + end + + def move_files_to_archive + FileUtils.mkdir_p FileHelper.archive_root + FileUtils.mkdir_p FileHelper.root_portfolio_dir(archived: true) + + # Indicate unit is now archived + update(archived: true) + + # Move work + archive_work_path = FileHelper.unit_work_root(self, archived: :force) + original_work_path = FileHelper.unit_work_root(self, archived: false) + + if File.exist?(original_work_path) && ! File.exist?(archive_work_path) + FileUtils.mv(original_work_path, archive_work_path) + end + + # Move portfolios + archive_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false, archived: :force) + original_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false, archived: false) + + if File.exist?(original_portfolio_path) && ! File.exist?(archive_portfolio_path) + FileUtils.mv(original_portfolio_path, archive_portfolio_path) + end + + # Move submission history + archive_submission_history_path = FileHelper.unit_submission_history_dir(self, archived: :force) + original_submission_history_path = FileHelper.unit_submission_history_dir(self, archived: false) + + if File.exist?(original_submission_history_path) && ! File.exist?(archive_submission_history_path) + FileUtils.mkdir_p(FileHelper.root_submission_history_dir(archived: true)) + FileUtils.mv(original_submission_history_path, archive_submission_history_path) + end + end + private def delete_associated_files - FileUtils.rm_rf FileHelper.unit_dir(self) - FileUtils.rm_rf FileHelper.unit_portfolio_dir(self) + 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) + + FileUtils.rm_rf unit_path + FileUtils.rm_rf unit_portfolio_path + FileUtils.rm_rf submission_history_path + FileUtils.cd FileHelper.student_work_dir end @@ -2559,4 +2623,18 @@ def propogate_date_changes_to_tasks td.propogate_date_changes date_diff end end + + def move_files_on_code_change + return unless saved_change_to_code? + + old_dir = FileHelper.dir_for_unit_code_and_id(saved_change_to_code[0], id, create: false, archived: archived) + if File.exist? old_dir + new_dir = FileHelper.unit_dir(self, create: false) + FileUtils.mv(old_dir, new_dir) unless File.exist?(new_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 + end end diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 9985a5174..b0e7fad92 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -66,7 +66,7 @@ def self.permissions end def self.tasks_to_review(user) - Tutorial.find_by_user(user) + Tutorial.find_by(user: user) .map(&:projects) .flatten .map(&:tasks) @@ -145,7 +145,11 @@ def populate_summary_stats(summary_stats) def send_weekly_status_email(summary_stats) return unless user.receive_feedback_notifications - NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now + begin + NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now + rescue StandardError => e + Rails.logger.error "Failed to send weekly staff summary email to #{user.email} - #{e.message}" + end end def ensure_valid_user_for_role diff --git a/app/models/user.rb b/app/models/user.rb index 6e016badf..8ac08e883 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,8 @@ class User < ApplicationRecord include UserTiiModule + after_update :move_files_on_username_change, if: :saved_change_to_username? + ### # Authentication ### @@ -92,19 +94,24 @@ def authenticate?(data) # Force-generates a new authentication token, regardless of whether or not # it is actually expired # - def generate_authentication_token!(remember = false) + def generate_authentication_token!(remember: false, expiry: Time.zone.now + 2.hours, token_type: :general) # Ensure this user is saved... so it has an id self.save unless self.persisted? - AuthToken.generate(self, remember) + AuthToken.generate(self, remember, expiry, token_type) end # # Generate an authentication token that will expire in 30 seconds # def generate_temporary_authentication_token! - # Ensure this user is saved... so it has an id - self.save unless self.persisted? - AuthToken.generate(self, false, Time.zone.now + 30.seconds) + generate_authentication_token!(expiry: Time.zone.now + 30.seconds, token_type: :login) + end + + # + # Generate an authentication token for scorm asset retrieval + # + def generate_scorm_authentication_token! + generate_authentication_token!(token_type: :scorm) end # @@ -117,8 +124,11 @@ def authentication_token_expired? # # Returns authentication of the user # - def token_for_text?(a_token) - self.auth_tokens.each do |token| + def token_for_text?(a_token, token_type) + tokens_to_check = self.auth_tokens + tokens_to_check = tokens_to_check.where(token_type: token_type) if token_type.present? + + tokens_to_check.each do |token| if a_token == token.authentication_token return token end @@ -132,10 +142,12 @@ def token_for_text?(a_token) # Model associations belongs_to :role, optional: false # Foreign Key - has_many :unit_roles, dependent: :destroy - has_many :projects, dependent: :destroy - has_many :auth_tokens, dependent: :destroy - has_one :webcal, dependent: :destroy + has_many :unit_roles, dependent: :destroy, inverse_of: :user + has_many :projects, dependent: :restrict_with_exception, inverse_of: :user + has_many :auth_tokens, 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 # Model validations/constraints validates :first_name, presence: true @@ -301,7 +313,9 @@ def self.permissions :get_teaching_periods, :admin_overseer, - :use_overseer + :use_overseer, + + :get_scorm_token ] # What can auditors do with users? @@ -315,11 +329,13 @@ def self.permissions :audit_units, :get_teaching_periods, - :use_overseer + :use_overseer, + :get_scorm_token ] # What can convenors do with users? convenor_role_permissions = [ + :get_all_units, :promote_user, :list_users, :create_user, @@ -332,20 +348,22 @@ def self.permissions :convene_units, :get_staff_list, :get_teaching_periods, - :use_overseer + :use_overseer, + :get_scorm_token ] # What can tutors do with users? tutor_role_permissions = [ :get_unit_roles, :download_unit_csv, - :get_teaching_periods + :get_teaching_periods, + :get_scorm_token ] # What can students do with users? student_role_permissions = [ - :get_teaching_periods - + :get_teaching_periods, + :get_scorm_token ] # Return the permissions hash @@ -396,6 +414,40 @@ def name "#{fn} #{sn}" end + def move_files_on_username_change + old_username = saved_change_to_username[0] + + # Move all files to the new username + projects.find_each do |project| + # Move the task files + old_path = FileHelper.project_work_root(project, username: old_username) + new_path = FileHelper.project_work_root(project, username: username) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + # rubocop:disable Rails/SkipsModelValidations + project.tasks.where('portfolio_evidence IS NOT NULL').update_all("portfolio_evidence = REPLACE(portfolio_evidence, '#{FileHelper.sanitized_path(old_username)}', '#{FileHelper.sanitized_path(username)}')") + # rubocop:enable Rails/SkipsModelValidations + + # Now move submission history files + old_path = FileHelper.project_submission_history_dir(project, username: old_username) + new_path = FileHelper.project_submission_history_dir(project, username: username) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + + # Now move the portfolio folder + old_path = FileHelper.student_portfolio_dir(project.unit, old_username, create: false) + new_path = FileHelper.student_portfolio_dir(project.unit, username, create: false) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + + # Lastly move the portfolio file + old_path = "#{new_path}/#{old_username}-portfolio.pdf" + new_path = "#{new_path}/#{username}-portfolio.pdf" + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + end + end + def self.export_to_csv exportables = csv_columns.map { |col| col == 'role' ? 'role_id' : col } CSV.generate do |row| @@ -461,7 +513,7 @@ def self.import_from_csv(current_user, file) pass_checks = true %w(username email role first_name).each do |col| - next unless row[col].nil? || row[col].empty? + next if row[col].present? errors << { row: row, message: "The #{col} cannot be blank or empty" } pass_checks = false diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index 86235f2c2..140c19d8c 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -3,17 +3,54 @@ class AcceptSubmissionJob include LogHelper def perform(task_id, user_id, accepted_tii_eula) - task = Task.find(task_id) - user = User.find(user_id) + begin + # Ensure cwd is valid... + FileUtils.cd(Rails.root) + rescue StandardError => e + logger.error e + end + + begin + task = Task.find(task_id) + user = User.find(user_id) + rescue StandardError => e + logger.error e + return + end + + 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) + rescue StandardError => e + # Send email to student if task pdf failed + 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}" + end + end - # Convert submission to PDF - task.convert_submission_to_pdf + begin + # Notify system admin + mail = ErrorLogMailer.error_message('Accept Submission', "Failed to convert submission to PDF for task #{task.log_details}", e) + mail.deliver if mail.present? + + logger.error e + rescue StandardError => e + logger.error "Failed to send error log to admin" + end + + return + end # When converted, we can now send documents to turn it in for checking - if TurnItIn.functional? + if TurnItIn.enabled? task.send_documents_to_tii(user, accepted_tii_eula: accepted_tii_eula) end rescue StandardError => e # to raise error message to avoid unnecessary retry logger.error e + task.clear_in_process end end diff --git a/app/sidekiq/archive_old_units_job.rb b/app/sidekiq/archive_old_units_job.rb new file mode 100644 index 000000000..7ebb68e66 --- /dev/null +++ b/app/sidekiq/archive_old_units_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Move old units to archive folder +class ArchiveOldUnitsJob + include Sidekiq::Job + + def perform + return unless Doubtfire::Application.config.archive_units + + archive_period = Doubtfire::Application.config.unit_archive_after_period + + archive_period = 1.year if archive_period < 1.year + + units = Unit.where(archived: false).where('end_date < :archive_before', archive_before: DateTime.now - archive_period) + + units.find_each(&:move_files_to_archive) + rescue StandardError => e + begin + # Notify system admin + mail = ErrorLogMailer.error_message('Archive Units', "Failed to move old units to archive", e) + mail.deliver if mail.present? + + logger.error e + rescue StandardError => e + logger.error "Failed to send error log to admin" + end + end +end diff --git a/app/sidekiq/clear_access_tokens_job.rb b/app/sidekiq/clear_access_tokens_job.rb new file mode 100644 index 000000000..b9a4524bb --- /dev/null +++ b/app/sidekiq/clear_access_tokens_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Remove auth tokens and oauth state and tokens that have expired +class ClearAccessTokensJob + include Sidekiq::Job + + def perform + UserOauthToken.destroy_old_tokens + UserOauthState.destroy_old_states + + AuthToken.destroy_old_tokens + end +end diff --git a/app/sidekiq/d2l_post_grades_job.rb b/app/sidekiq/d2l_post_grades_job.rb new file mode 100644 index 000000000..10bbf80e5 --- /dev/null +++ b/app/sidekiq/d2l_post_grades_job.rb @@ -0,0 +1,42 @@ +require 'csv' + +class D2lPostGradesJob + include Sidekiq::Job + include LogHelper + + sidekiq_options lock: :until_executed + + def perform(unit_id, user_id) + unit = Unit.find(unit_id) + user = User.find(user_id) + + logger.info "Posting grades for unit #{unit.id} by user #{user.id}" + + result = D2lIntegration.post_grades(unit, user) + + CSV.open(D2lIntegration.result_file_path(unit), "wb") do |csv| + csv << %w[Status Id Grade Message] + result.each do |r| + csv << r.split(",") + end + end + + logger.info "Finished posting grades for unit #{unit.id} by user #{user.id}" + + mail = D2lResultMailer.result_message(unit, user) + mail.deliver if mail.present? + + logger.info "Sent email to user #{user.id} for unit #{unit.id} grade transfer result" + rescue StandardError => e + logger.error e + + begin + mail = D2lResultMailer.result_message(unit, user, result_message: "failed. Please check the D2L settings for the unit, and your permissions within D2L to upload results. #{e.message}", success: false) + mail.deliver if mail.present? + + logger.info "Sent fail email to user #{user.id} for unit #{unit.id} grade transfer result" + rescue StandardError => exception + logger.error exception + end + end +end diff --git a/app/sidekiq/tii_check_progress_job.rb b/app/sidekiq/tii_check_progress_job.rb index c4db36268..ca8084bee 100644 --- a/app/sidekiq/tii_check_progress_job.rb +++ b/app/sidekiq/tii_check_progress_job.rb @@ -7,6 +7,10 @@ class TiiCheckProgressJob include Sidekiq::Job def perform + return unless TurnItIn.enabled? + + TurnItIn.check_and_retry_submissions_with_updated_eula + run_waiting_actions TurnItIn.check_and_update_eula TurnItIn.check_and_update_features @@ -16,7 +20,7 @@ def run_waiting_actions # Get the actions waiting to retry, where last run is more than 30 minutes ago, and run them TiiAction.where(retry: true, complete: false) .where('(last_run IS NULL AND created_at < :date) OR last_run < :date', date: DateTime.now - 30.minutes) - .each do |action| + .find_each do |action| action.perform # Stop if the service is not available diff --git a/app/sidekiq/tii_register_web_hook_job.rb b/app/sidekiq/tii_register_web_hook_job.rb index f2149bfcd..f61de7bea 100644 --- a/app/sidekiq/tii_register_web_hook_job.rb +++ b/app/sidekiq/tii_register_web_hook_job.rb @@ -6,6 +6,8 @@ class TiiRegisterWebHookJob include Sidekiq::Job def perform + return unless TurnItIn.enabled? + (TiiActionRegisterWebhook.last || TiiActionRegisterWebhook.create).perform end end diff --git a/app/views/d2l_result_mailer/result_message.text.erb b/app/views/d2l_result_mailer/result_message.text.erb new file mode 100644 index 000000000..b844550bc --- /dev/null +++ b/app/views/d2l_result_mailer/result_message.text.erb @@ -0,0 +1,10 @@ +Hi <%= @user.first_name %>, + +The grade transfer to D2L for <%= @unit.code %> has <%= @result_message %>. + +<% if @has_file %> +Please see the attached file for details on the results transferred. + +<% end %> +cheers, +The <%= @doubtfire_product_name %> Team diff --git a/app/views/error_log_mailer/error_message.text.erb b/app/views/error_log_mailer/error_message.text.erb new file mode 100644 index 000000000..e25167d08 --- /dev/null +++ b/app/views/error_log_mailer/error_message.text.erb @@ -0,0 +1,3 @@ +Something went wrong with <%= @doubtfire_product_name %>, and the following log entry was created: + +<%= @error_log %> diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex index ab70189c8..982bcda26 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -33,12 +33,16 @@ filecolor=black, urlcolor=blue, citecolor=black} - +<% +if @include_pax +%> \usepackage{newpax} \newpaxsetup{usefileattributes=true, addannots=true} \directlua{require("newpax")} <%= yield :preamble_newpax %> - +<% +end +%> \epstopdfDeclareGraphicsRule{.tif}{png}{.png}{convert #1 \OutputFile} \AppendGraphicsExtensions{.tif} \epstopdfDeclareGraphicsRule{.tiff}{png}{.png}{convert #1 \OutputFile} diff --git a/app/views/layouts/jupynotex.py b/app/views/layouts/jupynotex.py index b3ce34cd3..525a424f4 100644 --- a/app/views/layouts/jupynotex.py +++ b/app/views/layouts/jupynotex.py @@ -25,7 +25,7 @@ # markdown start/end MARKDOWN_BEGIN = [r"\begin{markdown}"] -MARKDOWN_END = [r"\end{markdown}"] +MARKDOWN_END = [r"\end{markdown}"+"\n"] # highlighers for different languages (block beginning and ending) HIGHLIGHTERS = { diff --git a/app/views/portfolio/portfolio_pdf.pdf.erb b/app/views/portfolio/portfolio_pdf.pdf.erb index 039c8036d..a1ee50728 100644 --- a/app/views/portfolio/portfolio_pdf.pdf.erb +++ b/app/views/portfolio/portfolio_pdf.pdf.erb @@ -237,20 +237,20 @@ No Tutor <% end %> \end{tabular} <% end %> - -<% if File.exist? task.portfolio_evidence_path - # add task evidence to the document list for annotation extraction - max_pages = FileHelper.pages_in_pdf(task.portfolio_evidence_path) +<% if File.exist? task.final_pdf_path + max_pages = FileHelper.pages_in_pdf(task.final_pdf_path) # Limit to 100 pages if max_pages > 100 max_pages = 100 else - document_list.append(task.portfolio_evidence_path) unless @is_retry + # add task evidence to the document list for annotation extraction + # skip long files + document_list.append(task.final_pdf_path) unless @is_retry end for page_idx in 1..max_pages do %> -\includepdf[pages={<%= page_idx %>-<%= page_idx %>},fitpaper]{<%= task.portfolio_evidence_path %>} +\includepdf[pages={<%= page_idx %>-<%= page_idx %>},fitpaper]{<%= task.final_pdf_path %>} <% end # end for end # end if file exists end # portfolio tasks do diff --git a/config/application.rb b/config/application.rb index df21df01b..a6265bb49 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,7 +29,36 @@ class Application < Rails::Application # File server location for storing student's work. Defaults to `student_work` # directory under root but is overridden using DF_STUDENT_WORK_DIR environment # variable. - config.student_work_dir = ENV['DF_STUDENT_WORK_DIR'] || "#{Rails.root}/student_work" + config.student_work_dir = ENV['DF_STUDENT_WORK_DIR'] || Rails.root.join('student_work').to_s + + # ==> Archive directory + # File server location for storing archived student work. Defaults to a subfolder of student work + # Set using DF_ARCHIVE_DIR environment variable. + config.archive_dir = ENV.fetch('DF_ARCHIVE_DIR', "#{config.student_work_dir}/archive") + + # Allows for the archiving of units to be automated + config.archive_units = ENV['DF_ARCHIVE_UNITS'].present? && (ENV['DF_ARCHIVE_UNITS'].to_s.downcase == "true" || ENV['DF_ARCHIVE_UNITS'].to_i == 1) + + # Period for which to keep units + config.unit_archive_after_period = ENV.fetch('DF_UNIT_ARCHIVE_PERIOD', 2).to_f * 1.year + + # Limit number of pdf generators to run at once + config.pdfgen_max_processes = ENV['DF_MAX_PDF_GEN_PROCESSES'] || 2 + + # Date range for auditors to view + config.auditor_unit_access_years = ENV.fetch('DF_AUDITOR_UNIT_ACCESS_YEARS', 2).to_f * 1.year + + config.student_import_weeks_before = ENV.fetch('DF_IMPORT_STUDENTS_WEEKS_BEFPRE', 1).to_f * 1.week + + def self.fetch_boolean_env(name) + %w'true 1'.include?(ENV.fetch(name, 'false').downcase) + 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) # ==> Load credentials from env credentials.secret_key_base = ENV.fetch('DF_SECRET_KEY_BASE', Rails.env.production? ? nil : '9e010ee2f52af762916406fd2ac488c5694a6cc784777136e657511f8bbc7a73f96d59c0a9a778a0d7cf6406f8ecbf77efe4701dfbd63d8248fc7cc7f32dea97') @@ -38,27 +67,26 @@ class Application < Rails::Application 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) - # Limit number of pdf generators to run at once - config.pdfgen_max_processes = ENV['DF_MAX_PDF_GEN_PROCESSES'] || 2 - - # Date range for auditors to view - config.auditor_unit_access_years = ENV.fetch('DF_AUDITOR_UNIT_ACCESS_YEARS', 2).years - # ==> Institution settings # Institution YAML and ENV (override) config load - config.institution = YAML.load_file("#{Rails.root}/config/institution.yml").with_indifferent_access + config.institution = YAML.load_file(Rails.root.join('config/institution.yml').to_s).with_indifferent_access 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[: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'] + config.institution[:logo_url] = ENV['DF_INSTITUTION_LOGO_URL'] if ENV['DF_INSTITUTION_LOGO_URL'] + config.institution[:logo_link_url] = ENV['DF_INSTITUTION_LOGO_LINK_URL'] if ENV['DF_INSTITUTION_LOGO_LINK_URL'] + config.institution[:privacy] = ENV['DF_INSTITUTION_PRIVACY'] if ENV['DF_INSTITUTION_PRIVACY'] config.institution[:plagiarism] = ENV['DF_INSTITUTION_PLAGIARISM'] if ENV['DF_INSTITUTION_PLAGIARISM'] # Institution host becomes localhost in development - config.institution[:host] ||= 'http://localhost:3000' if Rails.env.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' - require "#{Rails.root}/config/#{config.institution[:settings]}" unless config.institution[:settings].nil? + require Rails.root.join("config/#{config.institution[:settings]}").to_s unless config.institution[:settings].nil? # ==> SAML2.0 authentication if config.auth_method == :saml @@ -90,11 +118,11 @@ class Application < Rails::Application config.saml[:entity_id].nil? || config.saml[:idp_sso_target_url].nil? raise "Invalid values specified to saml, check the following environment variables: \n " \ - "key => variable set?\n " \ - "DF_SAML_CONSUMER_SERVICE_URL => #{!ENV['DF_SAML_CONSUMER_SERVICE_URL'].nil?}\n " \ + "key => variable set?\n " \ + "DF_SAML_CONSUMER_SERVICE_URL => #{!ENV['DF_SAML_CONSUMER_SERVICE_URL'].nil?}\n " \ "DF_SAML_SP_ENTITY_ID => #{!ENV['DF_SAML_SP_ENTITY_ID'].nil?}\n " \ - "DF_SAML_IDP_SIGNOUT_URL => #{!ENV['DF_SAML_IDP_SIGNOUT_URL'].nil?}\n " \ - "DF_SAML_IDP_TARGET_URL => #{!ENV['DF_SAML_IDP_TARGET_URL'].nil?}\n" + "DF_SAML_IDP_SIGNOUT_URL => #{!ENV['DF_SAML_IDP_SIGNOUT_URL'].nil?}\n " \ + "DF_SAML_IDP_TARGET_URL => #{!ENV['DF_SAML_IDP_TARGET_URL'].nil?}\n" end # If there's no XML url, we need the cert @@ -136,7 +164,7 @@ class Application < Rails::Application "DF_AAF_CALLBACK_URL => #{!ENV['DF_AAF_CALLBACK_URL'].nil?}\n " \ "DF_AAF_IDENTITY_PROVIDER_URL => #{!ENV['DF_AAF_IDENTITY_PROVIDER_URL'].nil?}\n " \ "DF_AAF_UNIQUE_URL => #{!ENV['DF_AAF_UNIQUE_URL'].nil?}\n " \ - "DF_SECRET_KEY_AAF => #{!secrets.secret_key_aaf.nil?}\n" + "DF_SECRET_KEY_AAF => #{!credentials.secret_key_aaf.nil?}\n" end end # Check secrets set for DF_SECRET_KEY_BASE, DF_SECRET_KEY_ATTR, DF_SECRET_KEY_DEVISE @@ -166,15 +194,17 @@ class Application < Rails::Application config.autoload_paths << Rails.root.join('app') << - Rails.root.join('app', 'models', 'comments') << - Rails.root.join('app', 'models', 'turn_it_in') << - Rails.root.join('app', 'models', 'similarity') + Rails.root.join('app/models/comments') << + Rails.root.join('app/models/turn_it_in') << + Rails.root.join('app/models/similarity') << + Rails.root.join('app/models/d2l') config.eager_load_paths << Rails.root.join('app') << - Rails.root.join('app', 'models', 'comments') << - Rails.root.join('app', 'models', 'turn_it_in') << - Rails.root.join('app', 'models', 'similarity') + Rails.root.join('app/models/comments') << + Rails.root.join('app/models/turn_it_in') << + Rails.root.join('app/models/similarity') << + Rails.root.join('app/models/d2l') # CORS config config.middleware.insert_before Warden::Manager, Rack::Cors do diff --git a/config/deakin.rb b/config/deakin.rb index 1eb9ae9a1..1185bcd00 100644 --- a/config/deakin.rb +++ b/config/deakin.rb @@ -84,10 +84,6 @@ def activity_type_for_group_code(activity_group_code, description) result end - def default_online_campus_abbr - 'Online-01' - end - # Multi code units have a stream for unit - and do not sync with star def setup_multi_code_streams unit logger.info("Setting up multi unit for #{unit.code}") @@ -135,7 +131,19 @@ def sync_streams_from_star(unit) url = "#{@star_url}/#{server}/rest/activities" logger.info("Fetching #{unit.name} timetable from #{url}") - response = RestClient.post(url, { username: @star_user, password: @star_secret, where_clause: "subject_code LIKE '#{unit.code}%_#{tp.period.last}'" }) + + # Try to contact the server up to 3 times... + for i in 0..2 do + begin + response = RestClient.post(url, { username: @star_user, password: @star_secret, where_clause: "subject_code LIKE '#{unit.code}%_#{tp.period.last}'" }) + break if response.code == 200 + logger.error "Error in sync #{unit.code} - #{response.code}" + rescue StandardError => e + logger.error "Error in sync #{unit.code} - #{e.message}" + end + + sleep(5 + (5 * i)) # wait 5+ seconds before retrying + end if response.code == 200 jsonData = JSON.parse(response.body) @@ -265,37 +273,36 @@ def sync_student_user_from_callista(row_data) username_user elsif username_user.present? && student_id_user.present? + + # Check if the username user student id contains the student id + unless username_user.student_id.blank? || username_user.student_id.include?(student_id_user.student_id) + logger.error("Unable to fix user #{row_data} - username user has an unrelated student id. Cannot merge records - Need manual fix.") + return nil + end + # Both present, but different... - # Most likely updated username with existing student id - if username_user.projects.count == 0 && student_id_user.projects.count > 0 - # Change the student id user to use the new username and email - student_id_user.username = username_user.username - student_id_user.email = username_user.email - student_id_user.login_id = nil - student_id_user.auth_tokens.destroy_all - - # Correct the new username user record - so we mark this as a duplicate and move to the old record - username_user.username = "OLD-#{username_user.username}" - username_user.email = "DUP-#{username_user.email}" - username_user.login_id = nil - - unless username_user.save - logger.error("Unable to fix user #{row_data} - username_user.save failed") - return nil - end - username_user.auth_tokens.destroy_all + # Merge them into the username user, as the student id user does not have the new username - unless student_id_user.save - logger.error("Unable to fix user #{row_data} - student_id_user.save failed") - return nil - end + # Change the username user... + username_user.student_id = student_id_user.student_id - # We keep the student id user... so return this - student_id_user - else - logger.error("Unable to fix user #{row_data} - both username and student id users present. Need manual fix.") - nil + # Correct the older student id record + student_id_user.student_id = "DUP-#{student_id_user.student_id}" + + # Save student id user first - free student id from duplicate error + unless student_id_user.save + logger.error("Unable to fix user #{row_data} - student_id_user.save failed") + return nil + end + + # Update the username user + unless username_user.save + logger.error("Unable to fix user #{row_data} - username_user.save failed") + return nil end + + # We keep the student id user... so return this + username_user else logger.error("Unable to fix user #{row_data} - Need manual fix.") nil @@ -312,7 +319,7 @@ def find_online_tutorial(unit, tutorial_stats) # Get the first one # Return its abbreviation list = tutorial_stats.sort_by { |r| - capacity = r[:capacity].present? ? r[:capacity] : 0 + capacity = r[:capacity].presence || 0 capacity = 10000 if capacity <= 0 (r[:enrolment_count] + r[:added]) / capacity } @@ -340,7 +347,7 @@ def sync_enrolments(unit) # subsequently withdrawn already_enrolled = {} - unless tp.present? + if tp.blank? logger.error "Failing to sync unit #{unit.code} as not in teaching period" return end @@ -354,13 +361,28 @@ def sync_enrolments(unit) timetable_data = {} end + # Get the list of students + student_list = [] + for code in codes do # Get URL to enrolment data for this code url = "#{@base_url}?academicYear=#{tp.year}&periodType=trimester&period=#{tp.period.last}&unitCode=#{code}" logger.info("Requesting #{url}") # Get json from enrolment server - response = RestClient.get(url, headers = { "client_id" => @client_id, "client_secret" => @client_secret }) + + # Try to contact the server up to 3 times... + for i in 0..2 do + begin + response = RestClient.get(url, headers = { "client_id" => @client_id, "client_secret" => @client_secret }) + break if response.code == 200 + logger.error "Error in sync #{unit.code} - #{response.code}" + sleep(5 + (5 * i)) # wait 5+ seconds before retrying + rescue StandardError => e + logger.error "Error in sync #{unit.code} - #{e.message}" + sleep(5) + end + end # Check we get a valid response if response.code == 200 @@ -385,9 +407,6 @@ def sync_enrolments(unit) logger.info "Syncing enrolment for #{code} - #{tp.year} #{tp.period}" - # Get the list of students - student_list = [] - # Get the timetable data () if multi_unit # We just enrol people in a "tutorial" associated with the unit code @@ -416,39 +435,46 @@ def sync_enrolments(unit) # We need to determine the stats here before the enrolments. # This is not needed for multi unit as we do not setup the tutorials for multi units - if is_online && !multi_unit && unit.enable_sync_timetable - if unit.tutorials.where(campus_id: campus.id).count == 0 - unit.add_tutorial( - 'Asynchronous', # day - '', # time - 'Online', # location - unit.main_convenor_user, # tutor - online_campus, # campus - -1, # capacity - default_online_campus_abbr, # abbrev - nil # tutorial_stream=nil - ) - end - - # Get stats for distribution of students across tutorials - for enrolment of online students - tutorial_stats = unit.tutorials - .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.tutorial_id = tutorials.id') - .where(campus_id: campus.id) - .select( - 'tutorials.abbreviation AS abbreviation', - 'capacity', - 'COUNT(tutorial_enrolments.id) AS enrolment_count' - ) - .group('tutorials.abbreviation', 'capacity') - .map { |row| - { - abbreviation: row.abbreviation, - enrolment_count: row.enrolment_count, - added: 0.0, # float to force float division in % full calc - capacity: row.capacity - } - } - end # is online + # TODO: redesign online tutorial enrolements + # if is_online && !multi_unit && unit.enable_sync_timetable + # if unit.tutorials.where(campus_id: campus.id).count == 0 + # # Add an online campus tutorial to each tutorial stream that has an allocated task + + # streams_to_add = unit.tutorial_streams.select { |ts| ts.tutorials.count > 0 } + + # streams_to_add.each do |stream| + # unit.add_tutorial( + # 'Asynchronous', # day + # '', # time + # 'Online', # location + # unit.main_convenor_user, # tutor + # online_campus, # campus + # -1, # capacity + # "#{stream.abbreviation}-online-01", # abbrev + # stream # tutorial_stream=nil + # ) + # end + # end + + # # Get stats for distribution of students across tutorials - for enrolment of online students + # tutorial_stats = unit.tutorials + # .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.tutorial_id = tutorials.id') + # .where(campus_id: campus.id) + # .select( + # 'tutorials.abbreviation AS abbreviation', + # 'capacity', + # 'COUNT(tutorial_enrolments.id) AS enrolment_count' + # ) + # .group('tutorials.abbreviation', 'capacity') + # .map { |row| + # { + # abbreviation: row.abbreviation, + # enrolment_count: row.enrolment_count, + # added: 0.0, # float to force float division in % full calc + # capacity: row.capacity + # } + # } + # end # is online # For each of the enrolments... location['enrolments'].each do |enrolment| @@ -490,6 +516,11 @@ def sync_enrolments(unit) # Record details for students already enrolled to work with multi-units if row_data[:enrolled] already_enrolled[row_data[:username]] = true + + if multi_unit + # Ensure student list does not already contain this student as a withdrawal + student_list.delete_if { |student| student[:username] == row_data[:username] } + end elsif already_enrolled[row_data[:username]] # skip to the next enrolment... this person was enrolled in an earlier unit nested within this unit... so skip this row as it would result in withdrawal next @@ -497,32 +528,34 @@ def sync_enrolments(unit) user = sync_student_user_from_callista(row_data) - # if they are enrolled, but not timetabled and online... - if is_online && row_data[:enrolled] && !multi_unit && unit.enable_sync_timetable && timetable_data[enrolment['studentId']].nil? # Is this an online user that we have the user data for? - # try to get their exising data - project = unit.projects.where(user_id: user.id).first unless user.nil? + # TODO: redesign online tutorial enrolements + # # if they are enrolled, but not timetabled and online... + # if is_online && row_data[:enrolled] && !multi_unit && unit.enable_sync_timetable && timetable_data[enrolment['studentId']].nil? # Is this an online user that we have the user data for? + # # try to get their exising data + # project = unit.projects.where(user_id: user.id).first unless user.nil? - if project.nil? || project.tutorial_enrolments.count == 0 - # not present (so new), or has no enrolment... so we can enrol it into the online tutorial - tutorial = find_online_tutorial(unit, tutorial_stats) - row_data[:tutorials] = [tutorial] unless tutorial.nil? - end - end + # if project.nil? || project.tutorial_enrolments.count == 0 + # # not present (so new), or has no enrolment... so we can enrol it into the online tutorial + # tutorial = find_online_tutorial(unit, tutorial_stats) + # row_data[:tutorials] = [tutorial] unless tutorial.nil? + # end + # end student_list << row_data end end - import_settings = { - replace_existing_tutorial: false - } - - # Now get unit to sync - unit.sync_enrolment_with(student_list, import_settings, result) else logger.error "Failed to sync #{unit.code} - #{response}" end # if response 200 end # for each code + + import_settings = { + replace_existing_tutorial: false + } + + # Now get unit to sync + unit.sync_enrolment_with(student_list, import_settings, result) rescue Exception => e logger.error "Failed to sync unit: #{e.message}" end @@ -547,7 +580,17 @@ def fetch_timetable_data(unit) unit.tutorial_streams.each do |tutorial_stream| logger.info("Fetching #{tutorial_stream.abbreviation} from #{url}") - response = RestClient.post(url, { username: @star_user, password: @star_secret, where_clause: "subject_code LIKE '#{unit.code}%' AND activity_group_code LIKE '#{tutorial_stream.abbreviation}'" }) + for i in 0..2 do + begin + response = RestClient.post(url, { username: @star_user, password: @star_secret, where_clause: "subject_code LIKE '#{unit.code}%' AND activity_group_code LIKE '#{tutorial_stream.abbreviation}'" }) + break if response.code == 200 + logger.error "Error in sync #{unit.code} - #{response.code}" + rescue StandardError => e + logger.error "Error in sync #{unit.code} - #{e.message}" + end + + sleep(5 + (5 * i)) # wait 5+ seconds before retrying + end if response.code == 200 jsonData = JSON.parse(response.body) diff --git a/config/environments/development.rb b/config/environments/development.rb index 0b4ebb164..7dd6f8112 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -15,13 +15,15 @@ # 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' || Rails.root.join('tmp/caching-dev.txt').exist? skip_first = true ActiveSupport::Reloader.to_prepare do if skip_first skip_first = false else + # rubocop:disable Rails/Output puts "CLEARING CACHE" + # rubocop:enable Rails/Output Rails.cache.clear end end @@ -45,7 +47,7 @@ # Ensure cache is cleared on reload unless Rails.application.config.cache_classes - Rails.autoloaders.main.on_unload do |klass, _abspath| + Rails.autoloaders.main.on_unload do |_klass, _abspath| Rails.cache.clear end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 6bb6af878..5863c6be7 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -45,6 +45,9 @@ authentication: ENV.fetch('DF_SMTP_AUTHENTICATION', 'plain'), enable_starttls_auto: true } + + # reset authentication to nil if it is set to 'no_auth' or 'none' + 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) diff --git a/config/environments/test.rb b/config/environments/test.rb index a497efc06..24fcb3d4c 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,4 +46,15 @@ ENV.store('TII_ENABLED', '1') ENV.store('TCA_API_KEY', '1234') ENV.store('TCA_HOST', 'localhost') + + # Setup D2L integration environment + ENV.store('D2L_ENABLED', '1') + ENV.store('D2L_CLIENT_ID', '1234') + ENV.store('D2L_CLIENT_SECRET', '1234') + ENV.store('D2L_REDIRECT_URI', 'https://our/api/d2l/callback') # 'https://vast-lands-jam.loca.lt/api/d2l/callback') + ENV.store('D2L_OAUTH_SITE', 'https://auth.brightspace.com/') + ENV.store('D2L_OAUTH_SITE_AUTHORIZE_URL', 'oauth2/auth') + 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') end diff --git a/config/initializers/d2l_integration_initializer.rb b/config/initializers/d2l_integration_initializer.rb new file mode 100644 index 000000000..3c2017bb4 --- /dev/null +++ b/config/initializers/d2l_integration_initializer.rb @@ -0,0 +1,5 @@ +require_relative '../../app/helpers/d2l_integration' +config = Doubtfire::Application.config + +# Initialise TurnItIn API +D2lIntegration.load_config(config) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 7df78e997..97cd80260 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -252,7 +252,7 @@ # ==> AAF via JWT OmniAuth # Devise method for JWT # if Doubtfire::Application.config.auth_method == :jwt - # aaf_secret = Doubtfire::Application.secrets.secret_key_aaf + # aaf_secret = Doubtfire::Application.credentials.secret_key_aaf # aaf_config = Doubtfire::Application.config.aaf # config.omniauth :jwt, # aaf_secret, @@ -268,7 +268,7 @@ # ==> Devise secret key # Secret key to be used by devise in prod. - config.secret_key = Doubtfire::Application.secrets.secret_key_devise if Rails.env.production? + config.secret_key = Doubtfire::Application.credentials.secret_key_devise if Rails.env.production? config.ldap_use_admin_to_bind = ENV.fetch('DF_LDAP_USE_ADMIN_TO_BIND', 'false').to_s.downcase != 'false' diff --git a/config/initializers/log_initializer.rb b/config/initializers/log_initializer.rb index 9748142b7..1ee180e30 100644 --- a/config/initializers/log_initializer.rb +++ b/config/initializers/log_initializer.rb @@ -1,6 +1,6 @@ -# Ensure log outputs to stdout in all but test environments -unless Rails.env.test? - Rails.logger.broadcast_to(ActiveSupport::Logger.new($stdout)) +# Ensure log outputs to stdout in development +if Rails.env.development? || Doubtfire::Application.config.log_to_stdout + Rails.logger.broadcast_to(ActiveSupport::Logger.new($stdout, level: Rails.logger.level)) end class FormatifFormatter < Logger::Formatter diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index a4c9ab0ce..cd904cdcd 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,9 +1,23 @@ Sidekiq.configure_server do |config| config.redis = { url: ENV.fetch('DF_REDIS_SIDEKIQ_URL', 'redis://localhost:6379/1') } config.logger = Rails.logger + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end + + config.server_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Server + end + + SidekiqUniqueJobs::Server.configure(config) end Sidekiq.configure_client do |config| config.redis = { url: ENV.fetch('DF_REDIS_SIDEKIQ_URL', 'redis://localhost:6379/1') } config.logger = Rails.logger + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end end diff --git a/config/institution.yml b/config/institution.yml index 702edfc23..03fa2795b 100644 --- a/config/institution.yml +++ b/config/institution.yml @@ -1,7 +1,10 @@ -name: Doubtfire University -email_domain: doubtfire.com -host: localhost:3000 -product_name: Doubtfire -settings: no_institution_setting.rb -privacy: By clicking on the Upload button, I certify that the attached work is entirely my own (or where submitted to meet the requirements of an approved group assignment is the work of the group), except where work quoted or paraphrased is acknowledged in the text. I also certify that it has not been previously submitted for assessment in this or any other unit or course unless permission for this has been granted by the teaching staff coordinating this unit. I agree that the University may make and retain copies of this work for the purposes of marking and review, and may submit this work to an external plagiarism and collusion detection service who may retain a copy for future plagiarism and collusion detection but will not release it or use it for any other purpose. -plagiarism: Plagiarism and collusion constitute extremely serious academic misconduct. They are forms of cheating, and severe penalties are associated with them, including cancellation of marks for a specific assignment, for a specific unit or even exclusion from the course. If you are ever in doubt about how to cite a reference properly, consult your lecturer or the Study Support website Plagiarism occurs when a student passes off as the student’s own work, or copies without acknowledgement as to its authorship, the work of any other person. Collusion occurs when a student obtains the agreement of another person for a fraudulent purpose, with the intent of obtaining an advantage in submitting an assignment or other work. Work submitted may be reproduced and/or communicated by the university for the purpose of detecting plagiarism and collusion. Students are reminded that assessment work, or parts of assessment work, cannot be re-submitted for a different assessment task in the same unit or any other unit, without the approval from the teaching staff involved. This includes work submitted for assessment at another academic institution. If students wish to reuse or extend parts of previously submitted work then they should discuss this with the teaching staff prior to the submission date. Depending on the nature of the task, the teaching staff may permit or decline the request. \ No newline at end of file +name: Doubtfire University +email_domain: doubtfire.com +host: localhost:3000 +product_name: Doubtfire +has_logo: false +logo_url: /assets/images/institution-logo.png +logo_link_url: / +settings: no_institution_setting.rb +privacy: By clicking on the Upload button, I certify that the attached work is entirely my own (or where submitted to meet the requirements of an approved group assignment is the work of the group), except where work quoted or paraphrased is acknowledged in the text. I also certify that it has not been previously submitted for assessment in this or any other unit or course unless permission for this has been granted by the teaching staff coordinating this unit. I agree that the University may make and retain copies of this work for the purposes of marking and review, and may submit this work to an external plagiarism and collusion detection service who may retain a copy for future plagiarism and collusion detection but will not release it or use it for any other purpose. +plagiarism: Plagiarism and collusion constitute extremely serious academic misconduct. They are forms of cheating, and severe penalties are associated with them, including cancellation of marks for a specific assignment, for a specific unit or even exclusion from the course. If you are ever in doubt about how to cite a reference properly, consult your lecturer or the Study Support website Plagiarism occurs when a student passes off as the student’s own work, or copies without acknowledgement as to its authorship, the work of any other person. Collusion occurs when a student obtains the agreement of another person for a fraudulent purpose, with the intent of obtaining an advantage in submitting an assignment or other work. Work submitted may be reproduced and/or communicated by the university for the purpose of detecting plagiarism and collusion. Students are reminded that assessment work, or parts of assessment work, cannot be re-submitted for a different assessment task in the same unit or any other unit, without the approval from the teaching staff involved. This includes work submitted for assessment at another academic institution. If students wish to reuse or extend parts of previously submitted work then they should discuss this with the teaching staff prior to the submission date. Depending on the nature of the task, the teaching staff may permit or decline the request. diff --git a/config/no_institution_setting.rb b/config/no_institution_setting.rb index 0ef7be746..2a8d5ccd8 100644 --- a/config/no_institution_setting.rb +++ b/config/no_institution_setting.rb @@ -16,7 +16,9 @@ def extract_user_from_row(row) end def sync_enrolments(unit) + # rubocop:disable Rails/Output puts 'Unit sync not enabled' + # rubocop:enable Rails/Output end def details_for_next_tutorial_stream(unit, activity_type) diff --git a/config/schedule.yml b/config/schedule.yml index 22c423b2c..2dcf867a9 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -7,3 +7,11 @@ register_webhooks: progress_turn_it_in_jobs: cron: "every 30 minutes" class: "TiiCheckProgressJob" + +clean_up_auth_tokens: + cron: "every 30 minutes" + class: "ClearAccessTokensJob" + +# archive_old_units: +# cron: "every 6 months" +# class: "ArchiveOldUnitsJob" diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 000000000..0515ae218 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1 @@ +:concurrency: 1 diff --git a/db/migrate/20231205011842_create_test_attempts.rb b/db/migrate/20231205011842_create_test_attempts.rb new file mode 100644 index 000000000..c1a313eb2 --- /dev/null +++ b/db/migrate/20231205011842_create_test_attempts.rb @@ -0,0 +1,13 @@ +class CreateTestAttempts < ActiveRecord::Migration[7.0] + def change + create_table :test_attempts do |t| + t.references :task + t.datetime :attempted_time, null: false + t.boolean :terminated, default: false + t.boolean :completion_status, default: false + t.boolean :success_status, default: false + t.float :score_scaled, default: 0 + t.text :cmi_datamodel + end + end +end diff --git a/db/migrate/20240603111953_add_name_uniq_idx.rb b/db/migrate/20240603111953_add_name_uniq_idx.rb new file mode 100644 index 000000000..203f75bc0 --- /dev/null +++ b/db/migrate/20240603111953_add_name_uniq_idx.rb @@ -0,0 +1,15 @@ +class AddNameUniqIdx < ActiveRecord::Migration[7.0] + def change + add_index :group_sets, [:name, :unit_id], unique: true + add_index :groups, [:name, :group_set_id], unique: true + add_index :learning_outcomes, [:abbreviation, :unit_id], unique: true + add_index :overseer_images, :name, unique: true + add_index :overseer_images, :tag, unique: true + add_index :task_definitions, [:abbreviation, :unit_id], unique: true + add_index :task_definitions, [:name, :unit_id], unique: true + add_index :tutorials, [:abbreviation, :unit_id], unique: true + add_index :users, :email, unique: true + add_index :users, :username, unique: true + add_index :users, :student_id, unique: true + end +end diff --git a/db/migrate/20240618135038_add_auth_token_type.rb b/db/migrate/20240618135038_add_auth_token_type.rb new file mode 100644 index 000000000..da6613a17 --- /dev/null +++ b/db/migrate/20240618135038_add_auth_token_type.rb @@ -0,0 +1,6 @@ +class AddAuthTokenType < ActiveRecord::Migration[7.1] + def change + add_column :auth_tokens, :token_type, :integer, null: false, default: 0 + add_index :auth_tokens, :token_type + end +end diff --git a/db/migrate/20240701221318_add_archive_unit_flag.rb b/db/migrate/20240701221318_add_archive_unit_flag.rb new file mode 100644 index 000000000..daae66653 --- /dev/null +++ b/db/migrate/20240701221318_add_archive_unit_flag.rb @@ -0,0 +1,5 @@ +class AddArchiveUnitFlag < ActiveRecord::Migration[7.1] + def change + add_column :units, :archived, :boolean, default: false + end +end diff --git a/db/migrate/20240920052508_convert_task_def_filenames.rb b/db/migrate/20240920052508_convert_task_def_filenames.rb new file mode 100644 index 000000000..b807b2f6a --- /dev/null +++ b/db/migrate/20240920052508_convert_task_def_filenames.rb @@ -0,0 +1,32 @@ +class ConvertTaskDefFilenames < ActiveRecord::Migration[7.1] + + # Check filenames in the upload requirements for each task definition + # and replace any invalid characters using sanitize filename + def change + TaskDefinition.find_in_batches do |group| + group.each do |task_def| + next if task_def.valid? + + upload_req = task_def.upload_requirements + + change = false + upload_req.each do |req| + unless req['name'].match?(/^[a-zA-Z0-9_\- \.]+$/) + req['name'] = FileHelper.sanitized_filename(req['name']) + change = true + end + + if req['name'].blank? + req['name'] = 'file' + change = true + end + end + + unless change && task_def.valid? && task_def.save + puts "Remaining issue with task definition #{task_def.id}" + end + puts '.' + end + end + end +end diff --git a/db/migrate/20241025050957_add_scorm_feat.rb b/db/migrate/20241025050957_add_scorm_feat.rb new file mode 100644 index 000000000..e965984ec --- /dev/null +++ b/db/migrate/20241025050957_add_scorm_feat.rb @@ -0,0 +1,24 @@ +class AddScormFeat < ActiveRecord::Migration[7.1] + def change + # Record scorm extensions added to a task + add_column :tasks, :scorm_extensions, :integer, null: false, default: 0 + + change_table :task_definitions do |t| + t.boolean :scorm_enabled, default: false + t.boolean :scorm_allow_review, default: false + t.boolean :scorm_bypass_test, default: false + t.boolean :scorm_time_delay_enabled, default: false + t.integer :scorm_attempt_limit, default: 0 + end + + # Enable polymorphic relationships for task comments + remove_index :task_comments, :overseer_assessment_id + + add_column :task_comments, :commentable_type, :string + rename_column :task_comments, :overseer_assessment_id, :commentable_id + + TaskComment.where('NOT commentable_id IS NULL').in_batches.update_all(commentable_type: 'OverseerAssessment') + + add_index :task_comments, [:commentable_type, :commentable_id] + end +end diff --git a/db/migrate/20241217091744_add_d2l.rb b/db/migrate/20241217091744_add_d2l.rb new file mode 100644 index 000000000..d1ca3cfb7 --- /dev/null +++ b/db/migrate/20241217091744_add_d2l.rb @@ -0,0 +1,30 @@ +class AddD2l < ActiveRecord::Migration[7.1] + def change + # Create a table linked to the units table, + # that captures the org unit id, and the grade object id for D2L + create_table :d2l_assessment_mappings do |t| + t.bigint :unit_id, null: false + t.string :org_unit_id + t.integer :grade_object_id + t.timestamps + + t.index :unit_id, unique: true + end + + create_table :user_oauth_tokens do |t| + t.references :user, null: false, foreign_key: true + t.integer :provider, default: 0, null: false + t.text :token + t.datetime :expires_at + t.timestamps + end + + create_table :user_oauth_states do |t| + t.references :user, null: false, foreign_key: true + t.string :state + t.timestamps + + t.index :state, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6daa71ebf..79b612a18 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[7.1].define(version: 2024_05_28_223908) do +ActiveRecord::Schema[7.1].define(version: 2024_12_17_091744) do create_table "activity_types", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -24,6 +24,8 @@ t.datetime "auth_token_expiry", null: false t.bigint "user_id" t.string "authentication_token", null: false + t.integer "token_type", default: 0, null: false + t.index ["token_type"], name: "index_auth_tokens_on_token_type" t.index ["user_id"], name: "index_auth_tokens_on_user_id" end @@ -54,6 +56,15 @@ t.index ["user_id"], name: "index_comments_read_receipts_on_user_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" + t.integer "grade_object_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id"], name: "index_d2l_assessment_mappings_on_unit_id", unique: true + end + create_table "discussion_comments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.datetime "time_started" t.datetime "time_completed" @@ -82,6 +93,7 @@ t.datetime "updated_at" t.integer "capacity" t.boolean "locked", default: false, null: false + t.index ["name", "unit_id"], name: "index_group_sets_on_name_and_unit_id", unique: true t.index ["unit_id"], name: "index_group_sets_on_unit_id" end @@ -106,6 +118,7 @@ t.integer "capacity_adjustment", default: 0, null: false t.boolean "locked", default: false, null: false t.index ["group_set_id"], name: "index_groups_on_group_set_id" + t.index ["name", "group_set_id"], name: "index_groups_on_name_and_group_set_id", unique: true t.index ["tutorial_id"], name: "index_groups_on_tutorial_id" end @@ -128,6 +141,7 @@ t.string "name" t.string "description", limit: 4096 t.string "abbreviation" + t.index ["abbreviation", "unit_id"], name: "index_learning_outcomes_on_abbreviation_and_unit_id", unique: true t.index ["unit_id"], name: "index_learning_outcomes_on_unit_id" end @@ -158,6 +172,8 @@ t.text "pulled_image_text" t.integer "pulled_image_status" t.datetime "last_pulled_date" + t.index ["name"], name: "index_overseer_images_on_name", unique: true + t.index ["tag"], name: "index_overseer_images_on_tag", unique: true end create_table "projects", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| @@ -214,10 +230,11 @@ t.integer "extension_weeks" t.string "extension_response" t.bigint "reply_to_id" - t.bigint "overseer_assessment_id" + t.bigint "commentable_id" + t.string "commentable_type" 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" - t.index ["overseer_assessment_id"], name: "index_task_comments_on_overseer_assessment_id" t.index ["recipient_id"], name: "fk_rails_1dbb49165b" t.index ["reply_to_id"], name: "index_task_comments_on_reply_to_id" t.index ["task_id"], name: "index_task_comments_on_task_id" @@ -250,7 +267,14 @@ t.bigint "overseer_image_id" t.string "tii_group_id" t.string "moss_language" + t.boolean "scorm_enabled", default: false + t.boolean "scorm_allow_review", default: false + t.boolean "scorm_bypass_test", default: false + t.boolean "scorm_time_delay_enabled", default: false + t.integer "scorm_attempt_limit", default: 0 + 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 t.index ["overseer_image_id"], name: "index_task_definitions_on_overseer_image_id" t.index ["tutorial_stream_id"], name: "index_task_definitions_on_tutorial_stream_id" t.index ["unit_id"], name: "index_task_definitions_on_unit_id" @@ -328,6 +352,7 @@ t.integer "contribution_pts", default: 3 t.integer "quality_pts", default: -1 t.integer "extensions", default: 0, null: false + t.integer "scorm_extensions", default: 0, null: false 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" @@ -344,6 +369,17 @@ t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true end + create_table "test_attempts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "task_id" + t.datetime "attempted_time", null: false + t.boolean "terminated", default: false + t.boolean "completion_status", default: false + t.boolean "success_status", default: false + t.float "score_scaled", default: 0.0 + t.text "cmi_datamodel" + t.index ["task_id"], name: "index_test_attempts_on_task_id" + end + create_table "tii_actions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "entity_type" t.bigint "entity_id" @@ -431,6 +467,7 @@ t.integer "capacity", default: -1 t.bigint "campus_id" t.bigint "tutorial_stream_id" + 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" t.index ["unit_id"], name: "index_tutorials_on_unit_id" @@ -474,12 +511,32 @@ t.bigint "overseer_image_id" t.datetime "portfolio_auto_generation_date" t.string "tii_group_context_id" + t.boolean "archived", default: 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" end + create_table "user_oauth_states", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "state" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["state"], name: "index_user_oauth_states_on_state", unique: true + t.index ["user_id"], name: "index_user_oauth_states_on_user_id" + end + + create_table "user_oauth_tokens", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "provider", default: 0, null: false + t.text "token" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_user_oauth_tokens_on_user_id" + end + create_table "users", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -509,8 +566,11 @@ t.string "tii_eula_version" t.datetime "tii_eula_date" t.boolean "tii_eula_version_confirmed", default: false, null: false + 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" + t.index ["student_id"], name: "index_users_on_student_id", unique: true + t.index ["username"], name: "index_users_on_username", unique: true end create_table "webcal_unit_exclusions", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| @@ -531,4 +591,6 @@ t.index ["user_id"], name: "index_webcals_on_user_id", unique: true end + add_foreign_key "user_oauth_states", "users" + add_foreign_key "user_oauth_tokens", "users" end diff --git a/deployAppSvr.Dockerfile b/deployAppSvr.Dockerfile index 424924957..aa224e236 100644 --- a/deployAppSvr.Dockerfile +++ b/deployAppSvr.Dockerfile @@ -31,6 +31,7 @@ RUN apt-get update \ docker-ce \ docker-ce-cli \ containerd.io \ + librsvg2-bin \ && apt-get clean # Setup the folder where we will deploy the code diff --git a/lib/assets/ontrack_receive_action.rb b/lib/assets/ontrack_receive_action.rb index 50a9dd0a8..0c6f8cf35 100644 --- a/lib/assets/ontrack_receive_action.rb +++ b/lib/assets/ontrack_receive_action.rb @@ -19,7 +19,7 @@ def receive(_subscriber_instance, channel, _results_publisher, delivery_info, _p overseer_assessment_id = params['overseer_assessment_id'] overseer_assessment = OverseerAssessment.find(overseer_assessment_id) - unless overseer_assessment.present? + if overseer_assessment.blank? logger.error "No overseer_assessment found for id: #{overseer_assessment_id}" channel.reject(delivery_info.delivery_tag) return diff --git a/lib/helpers/database_populator.rb b/lib/helpers/database_populator.rb index 1d197a60c..7d2f98a88 100644 --- a/lib/helpers/database_populator.rb +++ b/lib/helpers/database_populator.rb @@ -402,7 +402,7 @@ def generate_tutorials_and_enrol_students_for_unit(unit, unit_details) if @user_cache.present? tutor = @user_cache[user_details[:user]] else - tutor = User.find_by_username(user_details[:user]) + tutor = User.find_by(username: user_details[:user]) end echo_line "----> Enrolling tutor #{tutor.name} with #{user_details[:num]} tutorials" @@ -532,10 +532,9 @@ def self.assess_task(proj, task, tutor, status, complete_date) pdf_path = task.final_pdf_path if pdf_path && !File.exist?(pdf_path) - FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-student-submission.pdf'), pdf_path) + FileUtils.ln_s(Rails.root.join('test_files/unit_files/sample-student-submission.pdf'), pdf_path) end - task.portfolio_evidence_path = pdf_path task.save end @@ -543,8 +542,8 @@ def self.generate_portfolio(project) portfolio_tmp_dir = project.portfolio_temp_path FileUtils.mkdir_p(portfolio_tmp_dir) - lsr_path = File.join(portfolio_tmp_dir, "000-document-LearningSummaryReport.pdf") - FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-learning-summary.pdf'), lsr_path) unless File.exist? lsr_path + lsr_path = File.join(portfolio_tmp_dir, '000-document-LearningSummaryReport.pdf') + FileUtils.ln_s(Rails.root.join('test_files/unit_files/sample-learning-summary.pdf'), lsr_path) unless File.exist? lsr_path project.compile_portfolio = true project.create_portfolio end @@ -553,11 +552,15 @@ def self.generate_portfolio(project) # Output def echo *args + # rubocop:disable Rails/Output print(*args) if @echo + # rubocop:enable Rails/Output end def echo_line *args + # rubocop:disable Rails/Output puts(*args) if @echo + # rubocop:enable Rails/Output end # @@ -579,11 +582,9 @@ def generate_tasks_for_unit(unit, unit_details) unless result[:errors].empty? raise("----> Task files import failed with the following errors: #{result[:errors]} \n") end - unless result[:ignored].empty? echo "----> Task files import ignored the following files: #{result[:ignored]} \n" end - return end diff --git a/lib/helpers/find_or_create_students.rb b/lib/helpers/find_or_create_students.rb index 36011e18b..19995782a 100644 --- a/lib/helpers/find_or_create_students.rb +++ b/lib/helpers/find_or_create_students.rb @@ -20,7 +20,7 @@ def find_or_create_student(username) user_created = User.create!(profile) @user_cache[username] = user_created if using_cache else - user_created = User.find_by_username(username) + user_created = User.find_by(username: username) end user_created || @user_cache[username] end diff --git a/lib/shell/check_plagiarism.sh b/lib/shell/check_plagiarism.sh index 43847f2f8..c64035dd9 100755 --- a/lib/shell/check_plagiarism.sh +++ b/lib/shell/check_plagiarism.sh @@ -8,4 +8,4 @@ ROOT_PATH=`cd "$APP_PATH"/../..; pwd` cd "$ROOT_PATH" -bundle exec rake submission:check_plagiarism +DF_LOG_TO_STDOUT=true rails submission:check_plagiarism diff --git a/lib/shell/generate_pdfs.sh b/lib/shell/generate_pdfs.sh index a3a95cc84..6daa5592b 100755 --- a/lib/shell/generate_pdfs.sh +++ b/lib/shell/generate_pdfs.sh @@ -7,8 +7,8 @@ APP_PATH=`cd "$APP_PATH"; pwd` ROOT_PATH=`cd "$APP_PATH"/../..; pwd` cd "$ROOT_PATH" -TERM=xterm-256color bundle exec rake submission:generate_pdfs -bundle exec rake maintenance:cleanup +DF_LOG_TO_STDOUT=true TERM=xterm-256color rails submission:generate_pdfs +DF_LOG_TO_STDOUT=true rails maintenance:cleanup #Delete tmp files that may not be cleaned up by image magick and ghostscript find /tmp -maxdepth 1 -name magick* -type f -delete diff --git a/lib/shell/portfolio_autogen_check.sh b/lib/shell/portfolio_autogen_check.sh index 1ad011282..73e1b04d2 100755 --- a/lib/shell/portfolio_autogen_check.sh +++ b/lib/shell/portfolio_autogen_check.sh @@ -7,4 +7,4 @@ ROOT_PATH=`cd "$APP_PATH"/../..; pwd` cd "$ROOT_PATH" -bundle exec rake submission:portfolio_autogen_check +DF_LOG_TO_STDOUT=true rails submission:portfolio_autogen_check diff --git a/lib/shell/send_weekly_emails.sh b/lib/shell/send_weekly_emails.sh index 5237ccd3b..d56ab71b6 100755 --- a/lib/shell/send_weekly_emails.sh +++ b/lib/shell/send_weekly_emails.sh @@ -8,4 +8,4 @@ ROOT_PATH=`cd "$APP_PATH"/../..; pwd` cd "$ROOT_PATH" -bundle exec rake mailer:send_status_emails +DF_LOG_TO_STDOUT=true rails mailer:send_status_emails diff --git a/lib/shell/sync_enrolments.sh b/lib/shell/sync_enrolments.sh index 696914f1b..3e5610855 100755 --- a/lib/shell/sync_enrolments.sh +++ b/lib/shell/sync_enrolments.sh @@ -6,4 +6,4 @@ ROOT_PATH=`cd "$APP_PATH"/../..; pwd` cd "$ROOT_PATH" -bundle exec rake db:sync_enrolments +DF_LOG_TO_STDOUT=true rails db:sync_enrolments diff --git a/lib/shell/timeout.sh b/lib/shell/timeout.sh deleted file mode 100755 index dfb7808a1..000000000 --- a/lib/shell/timeout.sh +++ /dev/null @@ -1,86 +0,0 @@ -########################################################################## -# Shellscript: timeout - set timeout for a command -# Author : Heiner Steven -# Date : 29.07.1999 -# Category : File Utilities -# Requires : -# SCCS-Id. : @(#) timeout 1.3 03/03/18 -########################################################################## -# Description -# o Runs a command, and terminates it (by sending a signal) after -# a specified time period -# o This command first starts itself as a "watchdog" process in the -# background, and then runs the specified command. -# If the command did not terminate after the specified -# number of seconds, the "watchdog" process will terminate -# the command by sending a signal. -# -# Notes -# o Uses the internal command line argument "-p" to specify the -# PID of the process to terminate after the timeout to the -# "watchdog" process. -# o The "watchdog" process is invoked by the name "$0", so -# "$0" must be a valid path to the script. -# o If this script runs in the environment of the login shell -# (i.e. it was invoked using ". timeout command...") it will -# terminate the login session. -########################################################################## - -PN=`basename "$0"` # Program name -VER='1.3' - -TIMEOUT=5 # Default [seconds] - -Usage () { - echo >&2 "$PN - set timeout for a command, $VER -usage: $PN [-t timeout] command [argument ...] - -t: timeout (in seconds, default is $TIMEOUT)" - exit 1 -} - -Msg () { - for MsgLine - do echo "$PN: $MsgLine" >&2 - done -} - -Fatal () { Msg "$@"; exit 1; } - -while [ $# -gt 0 ] -do - case "$1" in - -p) ParentPID=$2; shift;; # Used internally! - -t) Timeout="$2"; shift;; - --) shift; break;; - -h) Usage;; - -*) Usage;; - *) break;; # First file name - esac - shift -done - -: ${Timeout:=$TIMEOUT} # Set default [seconds] - -if [ -z "$ParentPID" ] -then - # This is the first invokation of this script. - # Start "watchdog" process, and then run the command. - [ $# -lt 1 ] && Fatal "please specify a command to execute" - "$0" -p $$ -t $Timeout & # Start watchdog - #echo >&2 "DEBUG: process id is $$" - exec "$@" # Run command - exit 2 # NOT REACHED -else - # We run in "watchdog" mode, $ParentPID contains the PID - # of the process we should terminate after $Timeout seconds. - [ $# -ne 0 ] && Fatal "please do not use -p option interactively" - - #echo >&2 "DEBUG: $$: parent PID to terminate is $ParentPID" - - exec >/dev/null 0<&1 2>&1 # Suppress error messages - sleep $Timeout - kill $ParentPID && # Give process time to terminate - (sleep 2; kill -1 $ParentPID) && - (sleep 2; kill -9 $ParentPID) - exit 0 -fi diff --git a/lib/tasks/checks.rake b/lib/tasks/checks.rake index 812a42ecf..72f16af50 100644 --- a/lib/tasks/checks.rake +++ b/lib/tasks/checks.rake @@ -70,6 +70,7 @@ namespace :submission do puts " Starting Plagiarism Check for #{unit.name}" puts ' ------------------------------------------------------------ ' unit.check_moss_similarity + unit.update_plagiarism_stats end puts ' ------------------------------------------------------------ ' puts ' done.' diff --git a/lib/tasks/compress_pdfs.rake b/lib/tasks/compress_pdfs.rake index 671870b8f..564264ddd 100644 --- a/lib/tasks/compress_pdfs.rake +++ b/lib/tasks/compress_pdfs.rake @@ -9,11 +9,12 @@ namespace :submission do logger.info 'Starting compress pdf' puts 'Starting compress pdf' - Unit.where('active').each do |u| - u.tasks.where('portfolio_evidence is not NULL').each do |t| - if File.exist?(t.portfolio_evidence_path) && File.size?(t.portfolio_evidence_path) >= 2_200_000 - puts "Compressing #{t.portfolio_evidence_path}" - FileHelper.compress_pdf(t.portfolio_evidence_path) + Unit.where('active').find_each do |u| + u.tasks.find_each(batch_size: 5000) do |t| + path = t.final_pdf_path + if File.exist?(path) && File.size?(path) >= 2_200_000 + puts "Compressing #{path}" + FileHelper.compress_pdf(path) end end end @@ -25,7 +26,7 @@ namespace :submission do logger.info 'Starting compress portfolios' puts 'Starting compress portfolios' - Unit.where('active').each do |u| + Unit.where('active').find_each do |u| puts "Unit #{u.name}" u.projects.select { |p| p.portfolio_exists? && File.exist?(p.portfolio_path) && File.size?(p.portfolio_path) >= 20_000_000 }.each do |p| puts " Compressing #{p.portfolio_path}" @@ -44,12 +45,12 @@ namespace :submission do start_executing begin - Unit.where('active').each do |u| - u.tasks.where('portfolio_evidence is not NULL').each do |t| + Unit.where('active').find_each do |u| + u.tasks.find_each(batch_size: 5000) do |t| pdf_file = t.final_pdf_path next unless pdf_file && File.exist?(pdf_file) && File.size?(pdf_file) >= 2_200_000 - puts " Recreating #{t.portfolio_evidence_path} was #{File.size?(pdf_file)}" + puts " Recreating #{pdf_file} was #{File.size?(pdf_file)}" t.move_done_to_new t.convert_submission_to_pdf puts " ... now #{File.size?(pdf_file)}" diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index c20c30e80..4daf14bf9 100644 --- a/lib/tasks/generate_pdfs.rake +++ b/lib/tasks/generate_pdfs.rake @@ -75,7 +75,7 @@ namespace :submission do end task create_missing_portfolios: :environment do - TeachingPeriod.where("start_date < :today && active_until > :today", today: Date.today).each do |teaching_period| + TeachingPeriod.where("start_date < :today && active_until > :today", today: Time.zone.today).find_each do |teaching_period| teaching_period.units.each do |unit| unit.projects.each do |project| # We have a learning summary but not a portfolio @@ -107,13 +107,15 @@ namespace :submission do next if is_process_running?(pid) # That process is not running... so pick up portfolios here - Project.where(portfolio_generation_pid: pid).update_all(portfolio_generation_pid: Process.pid) + Project.where(portfolio_generation_pid: pid).find_each { |p| p.update(portfolio_generation_pid: Process.pid) } end # Secure portfolios Project.where(compile_portfolio: true, portfolio_generation_pid: nil) .limit(10) - .update_all(portfolio_generation_pid: Process.pid) + .find_each do |p| + p.update(portfolio_generation_pid: Process.pid) + end # Clean up any old failed runs - now after I have the files I need :) clean_up_failed_runs @@ -127,7 +129,7 @@ namespace :submission do PortfolioEvidence.process_new_to_pdf(my_source) # Now compile the portfolios - Project.where(compile_portfolio: true, portfolio_generation_pid: Process.pid).each do |project| + Project.where(compile_portfolio: true, portfolio_generation_pid: Process.pid).find_each do |project| next unless project.portfolio_generation_pid == Process.pid begin @@ -142,19 +144,25 @@ namespace :submission do logger.info "emailing portfolio notification to #{project.student.name}" - if success - PortfolioEvidenceMailer.portfolio_ready(project).deliver_now - else - PortfolioEvidenceMailer.portfolio_failed(project).deliver_now + begin + if success + PortfolioEvidenceMailer.portfolio_ready(project).deliver_now + else + PortfolioEvidenceMailer.portfolio_failed(project).deliver_now + end + rescue StandardError => e + logger.error "Failed to send portfolio email for project #{project.id}!\n#{e.message}" end end ensure # Ensure that we clear the pid from the projects so that they can be processed again - Project.where(portfolio_generation_pid: Process.pid).update_all(portfolio_generation_pid: nil) + Project.where(portfolio_generation_pid: Process.pid).find_each do |p| + p.update(portfolio_generation_pid: nil) + end # Remove the processing directory if Dir.entries(my_source).count == 2 # . and .. - FileUtils.rmdir my_source + FileUtils.rmdir(my_source) end logger.info "Ending generate pdf - #{Process.pid}" @@ -173,10 +181,12 @@ namespace :submission do task check_task_pdfs: :environment do logger.info 'Starting check of PDF tasks' - Unit.where('active').each do |u| - u.tasks.where('portfolio_evidence is not NULL').each do |t| - unless FileHelper.validate_pdf(t.portfolio_evidence_path)[:valid] - puts t.portfolio_evidence_path + Unit.where('active').find_each do |u| + u.tasks.find_each(batch_size: 5000) do |t| + path = t.final_pdf_path + next unless File.exist?(path) + unless FileHelper.validate_pdf(path)[:valid] + puts path end end end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index 1bdf4b7fe..5f68a071c 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -27,13 +27,44 @@ namespace :maintenance do AuthToken.destroy_old_tokens end - desc 'Export auth tokens for migration from 5.x to 6.x' - task export_auth_tokens: [:environment] do - User.all - .map { |u| { token: u.auth_token, user: u.id, expiry: u.auth_token_expiry } } - .select { |d| d[:token].present? } - .each do |d| - puts "AuthToken.create!(authentication_token: '#{d[:token].strip}', auth_token_expiry: DateTime.parse('#{d[:expiry]}'), user_id: '#{d[:user]}')" + desc 'Remove PDFs from old submissions and archive units' + task archive_submissions: [:environment] do + archive_period = Doubtfire::Application.config.unit_archive_after_period + # Next returns from rake tasks + next if archive_period <= 1.year + + units = Unit.where(archived: false).where('end_date < :archive_before', archive_before: DateTime.now - archive_period) + unit_ids = units.pluck(:id) + + loop do + puts "Are you happy to archive the following units?" + units.find_each do |unit| + puts("#{unit.id}: #{unit.detailed_name}") if unit_ids.include?(unit.id) + end + + puts "Please enter any unit IDs you would like to remove from the list, separated by commas" + response = $stdin.gets.chomp + break if response.blank? + unit_ids_to_exclude = response.split(',').map(&:to_i) + + unit_ids = unit_ids.excluding(unit_ids_to_exclude) + + break if unit_ids.empty? end + + # Next returns from rake tasks + next if unit_ids.empty? + + puts "Proceed? (Yes/No): " + response = $stdin.gets.chomp + next unless response == 'Yes' + + Unit.where(id: unit_ids).preload(projects: [:user, { tasks: :task_definition }]).find_each do |unit| + unit.archive_submissions($stdout) + unit.update(archived: true) + end + + puts "Removing old portfolio PDFs" + `find #{FileHelper.root_portfolio_dir} -name "*pdf.old" -exec rm {} \;` end end diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake index 15d2f7fa6..92103a6ff 100644 --- a/lib/tasks/populate.rake +++ b/lib/tasks/populate.rake @@ -15,7 +15,7 @@ namespace :db do desc 'Mark off some of the due tasks' task simulate_signoff: [:log_info, :skip_prod, :environment] do - Unit.all.each do |unit| + Unit.all.find_each do |unit| current_week = ((Time.zone.now - unit.start_date) / 1.week).floor unit.students.each do |proj| @@ -159,7 +159,7 @@ namespace :db do pdf_path = task.final_pdf_path if pdf_path - FileUtils.ln_s(Rails.root.join('test_files', 'unit_files', 'sample-student-submission.pdf'), pdf_path) + FileUtils.ln_s(Rails.root.join('test_files/unit_files/sample-student-submission.pdf'), pdf_path) end end end diff --git a/lib/tasks/send_status_emails.rake b/lib/tasks/send_status_emails.rake index ca36946d2..057432f27 100644 --- a/lib/tasks/send_status_emails.rake +++ b/lib/tasks/send_status_emails.rake @@ -2,12 +2,12 @@ namespace :mailer do task send_status_emails: :environment do summary_stats = {} - summary_stats[:week_end] = Date.today + summary_stats[:week_end] = Time.zone.today summary_stats[:week_start] = summary_stats[:week_end] - 7.days summary_stats[:weeks_comments] = TaskComment.where("created_at >= :start AND created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count summary_stats[:weeks_engagements] = TaskEngagement.where("engagement_time >= :start AND engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count - Unit.where(active: true).each do |unit| + Unit.where(active: true).find_each do |unit| next unless summary_stats[:week_end] > unit.start_date && summary_stats[:week_start] < unit.end_date unit.send_weekly_status_emails(summary_stats) diff --git a/lib/tasks/sync.rake b/lib/tasks/sync.rake index d1e03fa14..af6e40198 100644 --- a/lib/tasks/sync.rake +++ b/lib/tasks/sync.rake @@ -3,7 +3,7 @@ require_all 'lib/helpers' namespace :db do desc 'Synchronise enrolments in the active units within the current teaching period' task sync_enrolments: [:environment] do - TeachingPeriod.where('? >= start_date', Time.zone.now + 2.weeks).where('? <= end_date', Time.zone.now).each do |tp| + TeachingPeriod.where('? >= start_date', Time.zone.now + Doubtfire::Application.config.student_import_weeks_before).where('? <= end_date', Time.zone.now).find_each do |tp| tp.units.each do |unit| unit.sync_enrolments sleep(1) diff --git a/public/resources/AwaitingProcessing.pdf b/public/resources/AwaitingProcessing.pdf new file mode 100644 index 000000000..be6c7c8a2 Binary files /dev/null and b/public/resources/AwaitingProcessing.pdf differ diff --git a/public/resources/FileNotFound.pdf b/public/resources/FileNotFound.pdf index 12eb714db..2a2bfa421 100644 Binary files a/public/resources/FileNotFound.pdf and b/public/resources/FileNotFound.pdf differ diff --git a/test/api/auth_test.rb b/test/api/auth_test.rb index ec837e921..786f0adba 100644 --- a/test/api/auth_test.rb +++ b/test/api/auth_test.rb @@ -44,6 +44,10 @@ def test_auth_post # Check other values returned assert_equal expected_auth.role.name, response_user_data['system_role'], 'Roles match' + token = User.first.token_for_text? actual_auth['auth_token'], :general + assert token.present? + 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 end @@ -265,4 +269,88 @@ def test_token_signout_works_with_multiple end # End DELETE tests # --------------------------------------------------------------------------- # + + # # --------------------------------------------------------------------------- # + # # SCORM auth test + + def test_scorm_auth + admin = FactoryBot.create(:user, :admin) + + add_auth_header_for(user: admin) + + # All users can access scorm resources + get "api/auth/scorm" + assert_equal 200, last_response.status + assert_equal 1, admin.auth_tokens.where(token_type: :scorm).count + + student = FactoryBot.create(:user, :student) + + student.auth_tokens.where(token_type: :scorm).destroy_all + + add_auth_header_for(user: student) + + # When user is authorised and no prior scorm tokens exist + get "api/auth/scorm" + assert_equal 200, last_response.status + assert last_response_body["scorm_auth_token"] + assert 2, student.auth_tokens.where(token_type: :scorm).count + + first_token = last_response_body["scorm_auth_token"] + + add_auth_header_for(user: student) + + # When previous valid scorm token exists + get "api/auth/scorm" + assert_equal 200, last_response.status + assert last_response_body["scorm_auth_token"] == first_token + + old_token = student.auth_tokens.find_by(token_type: :scorm) + old_token.auth_token_expiry = Time.zone.now - 1.day + old_token.save! + + add_auth_header_for(user: student) + + # When previous expired scorm token exists + get "api/auth/scorm" + assert_equal 200, last_response.status + assert last_response_body["scorm_auth_token"] != first_token + assert_raises ActiveRecord::RecordNotFound do + student.auth_tokens.find(old_token.id) + end + end + + # End SCORM auth test + # --------------------------------------------------------------------------- # + + def test_login_token + unit = FactoryBot.create :unit, with_students: false + user = unit.main_convenor_user + + token = user.generate_temporary_authentication_token! + + add_auth_header_for(user: user, auth_token: token) + + get 'api/units' + + assert 403, last_response.status + + post 'api/auth' + ensure + unit.destroy + end + + def test_scorm_token + unit = FactoryBot.create :unit, with_students: false + user = unit.main_convenor_user + + token = user.generate_scorm_authentication_token! + + add_auth_header_for(user: user, auth_token: token) + + get '/api/units' + + assert 403, last_response.status + ensure + unit.destroy + end end diff --git a/test/api/comments/scorm_extension_test.rb b/test/api/comments/scorm_extension_test.rb new file mode 100644 index 000000000..f206b8f0f --- /dev/null +++ b/test/api/comments/scorm_extension_test.rb @@ -0,0 +1,253 @@ +require 'test_helper' + +class ScormExtensionTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_scorm_extension_request + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Scorm extension request', + description: 'Scorm extension request', + 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.week, + abbreviation: 'ScormExtensionRequest', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + + data_to_post = { + comment: 'I need more attempts please' + } + + add_auth_header_for(user: user) + + # When there is no attempt limit + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 400, last_response.status + + td.scorm_attempt_limit = 1 + td.save! + + add_auth_header_for(user: user) + + # When there is an attempt limit + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 201, last_response.status + assert last_response_body["type"] == "scorm_extension" + + admin = FactoryBot.create(:user, :admin) + + add_auth_header_for(user: admin) + + # When the user is unauthorised + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 403, last_response.status + + td.destroy! + unit.destroy! + end + + # Test that extension requests are not read by main tutor until they are assessed + def test_read_by_main_tutor + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + other_tutor = unit.main_convenor_user + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Scorm extension request', + description: 'Scorm extension request', + 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.week, + abbreviation: 'ScormExtensionRequest', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 1 + } + ) + td.save! + + main_tutor = project.tutor_for(td) + data_to_post = { + comment: 'I need more attempts please' + } + + add_auth_header_for(user: user) + + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 201, last_response.status + assert last_response_body["type"] == "scorm_extension" + + 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 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" + + # Check it is read after grant by another user + tc.assess_scorm_extension other_tutor, true + assert tc.read_by?(main_tutor), "Error: Should be read by main tutor after assess" + + td.destroy! + unit.destroy! + end + + def test_auto_grant_for_tutor + unit = FactoryBot.create(:unit) + project = unit.projects.first + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Scorm extension request', + description: 'Scorm extension request', + 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.week, + abbreviation: 'ScormExtensionRequest', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 1 + } + ) + td.save! + + main_tutor = project.tutor_for(td) + data_to_post = { + comment: 'I need more attempts please' + } + + # Scorm extension request made by tutor + add_auth_header_for(user: main_tutor) + + post_json "/api/projects/#{project.id}/task_def_id/#{td.id}/request_scorm_extension", data_to_post + assert_equal 201, last_response.status + assert last_response_body["type"] == "scorm_extension" + + tc = ScormExtensionComment.find(last_response_body["id"]) + + # Check if it is granted automatically + assert tc.read_by?(main_tutor), "Error: Should be read by main tutor after assess" + assert tc.extension_granted, "Error: Should be granted" + + td.destroy! + unit.destroy! + end + + def test_scorm_extension_assessment + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Scorm extension', + description: 'Scorm extension', + 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.week, + abbreviation: 'ScormExtension', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 2 + } + ) + td.save! + + main_tutor = project.tutor_for(td) + task = project.task_for_task_definition(td) + initial_extension_count = task.scorm_extensions + + tc = task.apply_for_scorm_extension(user, "I need more attempts please") + + data_to_put = { + granted: true + } + + add_auth_header_for(user: user) + + # When the user is unauthorised + put_json "/api/projects/#{project.id}/task_def_id/#{td.id}/assess_scorm_extension/#{tc.id}", data_to_put + assert_equal 403, last_response.status + + add_auth_header_for(user: main_tutor) + + # Grant extension + put_json "/api/projects/#{project.id}/task_def_id/#{td.id}/assess_scorm_extension/#{tc.id}", data_to_put + assert_equal 200, last_response.status + + tc = ScormExtensionComment.find(last_response_body["id"]) + task = project.task_for_task_definition(td) + + # Check scorm extension count + assert tc.extension_granted, "Error: Should be granted" + assert tc.assessed?, "Error: Should be assessed" + assert task.scorm_extensions == initial_extension_count + td.scorm_attempt_limit + + new_extension_count = task.scorm_extensions + + add_auth_header_for(user: main_tutor) + + # Duplicate assessment + put_json "/api/projects/#{project.id}/task_def_id/#{td.id}/assess_scorm_extension/#{tc.id}", data_to_put + assert_equal 403, last_response.status + + task = project.task_for_task_definition(td) + + assert task.scorm_extensions == new_extension_count + + td.destroy! + unit.destroy! + end +end diff --git a/test/api/csv_test.rb b/test/api/csv_test.rb index d61c2fd20..8ab2b0e0a 100644 --- a/test/api/csv_test.rb +++ b/test/api/csv_test.rb @@ -668,6 +668,7 @@ def test_csv_upload_students_un_enroll_in_unit_empty_unit_id 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' unit_id_to_test = unit.id @@ -717,8 +718,8 @@ def test_csv_upload_students_un_enroll_in_unit_incorrect_file_pdf assert_equal true, Project.where(user_id: user_id_check).last.enrolled end - #38: Testing for CSV upload failure due to no file - #POST /api/csv/units/{id}/withdraw + # 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) @@ -865,8 +866,8 @@ def test_download_csv_all_student_tasks_in_unit_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/units/#{unit_id_to_test}/task_completion" @@ -877,10 +878,9 @@ def test_download_csv_all_student_tasks_in_unit_with_empty_auth_token # #####--------------GET tests - Download stats related to the number of tasks assessed by each tutor------------###### - #46: Testing for CSV download of stats related to number of tasks assessed by each tutor - #GET /api/csv/units/{id}/tutor_assessments + # 46: Testing for CSV download of stats related to number of tasks assessed by each tutor + # GET /api/csv/units/{id}/tutor_assessments def test_download_csv_stats_tutor_assessed - unit_id_to_test = '1' # Add authentication token to header diff --git a/test/api/d2l_test.rb b/test/api/d2l_test.rb new file mode 100644 index 000000000..0f72d5698 --- /dev/null +++ b/test/api/d2l_test.rb @@ -0,0 +1,455 @@ +require 'test_helper' + +class D2lTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + end + + def test_can_add_d2l_details_to_unit + unit = FactoryBot.create(:unit, with_students: false) + + add_auth_header_for(user: User.first) + + initial_count = D2lAssessmentMapping.count + + post "/api/units/#{unit.id}/d2l", { org_unit_id: '12345' } + assert_equal 201, last_response.status, last_response.inspect + + assert_equal initial_count + 1, D2lAssessmentMapping.count + assert_equal '12345', D2lAssessmentMapping.last.org_unit_id + assert_equal unit.id, D2lAssessmentMapping.last.unit_id + + assert_equal unit.d2l_assessment_mapping, D2lAssessmentMapping.last + end + + def test_ensure_only_one_d2l_mapping_per_unit + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + add_auth_header_for(user: User.first) + + initial_count = D2lAssessmentMapping.count + + post "/api/units/#{unit.id}/d2l", { org_unit_id: '54321' } + assert_equal 400, last_response.status, last_response.inspect + + assert_equal initial_count, D2lAssessmentMapping.count + end + + def test_convenor_needed_for_d2l_details + unit = FactoryBot.create(:unit, with_students: false) + user = FactoryBot.create(:user, :student) + add_auth_header_for(user: user) + + post "/api/units/#{unit.id}/d2l", { org_unit_id: '12345' } + assert_equal 403, last_response.status, last_response.inspect + + user = FactoryBot.create(:user, :tutor) + add_auth_header_for(user: user) + + post "/api/units/#{unit.id}/d2l", { org_unit_id: '12345' } + assert_equal 403, last_response.status, last_response.inspect + + user = FactoryBot.create(:user, :auditor) + add_auth_header_for(user: user) + + post "/api/units/#{unit.id}/d2l", { org_unit_id: '12345' } + assert_equal 403, last_response.status, last_response.inspect + end + + def test_can_get_d2l_details_for_unit + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + add_auth_header_for(user: unit.main_convenor_user) + + get "/api/units/#{unit.id}/d2l" + assert_equal 200, last_response.status, last_response.inspect + + assert_equal '12345', last_response_body['org_unit_id'], last_response_body + assert_equal d2l.id, last_response_body['id'] + end + + def test_can_delete_d2l_details_for_unit + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + add_auth_header_for(user: unit.main_convenor_user) + + initial_count = D2lAssessmentMapping.count + + delete "/api/units/#{unit.id}/d2l/#{d2l.id}" + assert_equal 204, last_response.status, last_response.inspect + + assert_equal initial_count - 1, D2lAssessmentMapping.count + + unit.reload + assert_nil unit.d2l_assessment_mapping + end + + def test_can_update_d2l_details_for_unit + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + add_auth_header_for(user: unit.main_convenor_user) + + initial_count = D2lAssessmentMapping.count + + put "/api/units/#{unit.id}/d2l/#{d2l.id}", { org_unit_id: '54321' } + assert_equal 200, last_response.status, last_response.inspect + + assert_equal initial_count, D2lAssessmentMapping.count + + unit.reload + assert_equal '54321', unit.d2l_assessment_mapping.org_unit_id + end + + def test_can_login_to_d2l + user = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: user) + + init_states = UserOauthState.count + + stub_request(:post, "https://auth.brightspace.com/core/connect/token") + .to_return( + status: 200, + body: { + 'access_token' => "blah", + 'expires_at' => '1734493629', + 'refresh_token' => "blee.bloo", + 'scope' => "core:*:* enrollment:orgunit:read grades:*:*", + 'token_type' => "Bearer" + }.to_json, + headers: { 'Content-Type' => 'application/json;charset=UTF-8' } + ) + + post '/api/d2l/login_url' + assert_equal 201, last_response.status, last_response.inspect + + # State is created for callback + assert_equal init_states + 1, UserOauthState.count + + state = UserOauthState.last.state + + init_tokens = user.user_oauth_tokens.count + + # When the user logs in, they are redirected to the callback + get '/api/d2l/callback', { code: '12345', state: state } + assert_equal 302, last_response.status, last_response.inspect + assert_equal "#{Doubtfire::Application.config.institution[:host]}/success-close", last_response.headers['Location'] + + # The user should now have an oauth token + user.reload + assert_equal init_tokens + 1, user.user_oauth_tokens.count + end + + def test_login_to_d2l_exposed_over_api + unit = FactoryBot.create(:unit, with_students: false) + + add_auth_header_for(user: unit.main_convenor_user) + + post '/api/d2l/login_url' + assert_equal 201, last_response.status, last_response.inspect + + assert_equal unit.main_convenor_user, UserOauthState.last.user + end + + def test_old_state_and_oauth_tokens_are_destroyed + user = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: user) + + state = UserOauthState.create(user: user, state: '12345') + state.created_at = 31.minutes.ago + state.save + + UserOauthState.destroy_old_states + + assert_nil UserOauthState.find_by(id: state.id) + + token = UserOauthToken.create(user: user, provider: :d2l, token: 'test', expires_at: 31.minutes.ago) + + UserOauthToken.destroy_old_tokens + + assert_nil UserOauthToken.find_by(id: token.id) + end + + def test_post_grades_requires_org_unit_id + unit = FactoryBot.create(:unit, with_students: false) + + assert_raises(StandardError) do + D2lIntegration.post_grades(unit, unit.main_convenor_user) + end + end + + def test_post_grades_requires_user_oauth_token + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + assert_raise(StandardError) do + D2lIntegration.post_grades(unit, unit.main_convenor_user) + end + + UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 30.minutes.from_now) + + assert_raises(StandardError) do + D2lIntegration.post_grades(unit, User.first) + end + end + + def test_does_grade_item_exist + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345', grade_object_id: 54321) + UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 30.minutes.from_now) + + grade_request = stub_request(:get, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/54321") + .to_return( + { status: 404, headers: {} }, + { status: 200, body: { id: 54321 }.to_json, headers: { 'Content-Type' => 'application/json;charset=UTF-8' } } + ) + + assert_not D2lIntegration.does_grade_item_exist?(d2l, UserOauthToken.last.access_token) + assert_requested(grade_request, times: 1) + + # restore grade object id + d2l.grade_object_id = 54321 + d2l.save + + assert D2lIntegration.does_grade_item_exist?(d2l, UserOauthToken.last.access_token) + assert_requested(grade_request, times: 2) + end + + def test_create_grade_item + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 30.minutes.from_now) + + post_grade_request = stub_request(:post, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/") + .to_return( + status: 200, + body: { + "Id" => 127 + }.to_json, + headers: { 'Content-Type' => 'application/json;charset=UTF-8' } + ) + + D2lIntegration.create_grade_item(d2l, UserOauthToken.last.access_token) + + assert_requested post_grade_request, times: 1 + assert_equal 127, d2l.grade_object_id + assert d2l.persisted? + end + + def test_get_class_list + unit = FactoryBot.create(:unit, with_students: false) + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 30.minutes.from_now) + + class_list_request = stub_request(:get, "https://api.brightspace.com/d2l/api/le/1.47/12345/classlist/") + .to_return( + status: 200, + body: [ + { + "Identifier" => "12345", + "FirstName" => "John", + "LastName" => "Doe", + "UserName" => "johndoe", + "OrgDefinedId" => "s12345", + "Email" => "s12345@test.com" + }, + { + "Identifier" => "12346", + "FirstName" => "Jane", + "LastName" => "Doe", + "UserName" => "johndoe", + "OrgDefinedId" => "s12346", + "Email" => "s12346@test.com" + } + ].to_json, + headers: { 'Content-Type' => 'application/json;charset=UTF-8' } + ) + + list = D2lIntegration.get_class_list(d2l, UserOauthToken.last.access_token) + + assert_requested class_list_request, times: 1 + assert_equal 2, list.count + end + + def test_post_grades + # Create unit, d2l mapping, and user oauth token + unit = FactoryBot.create(:unit, with_students: false) + + p1 = unit.enrol_student(FactoryBot.create(:user, :student), Campus.first) + p2 = unit.enrol_student(FactoryBot.create(:user, :student), Campus.first) + p3 = unit.enrol_student(FactoryBot.create(:user, :student), Campus.first) + p4 = unit.enrol_student(FactoryBot.create(:user, :student), Campus.first) + + s1 = other_student = FactoryBot.create(:user, :student) + + p1.update(grade: 50) + p2.update(grade: 60) + p3.update(enrolled: false) + p4.update(grade: 70) + + assert_equal 3, unit.active_projects.count + assert_equal 4, unit.projects.count + + post_grade_request = stub_request(:post, "https://api.brightspace.com/d2l/api/le/1.47/12345/grades/") + .to_return( + status: 200, + body: { + "Id" => 98701 + }.to_json, + headers: { 'Content-Type' => 'application/json;charset=UTF-8' } + ) + + class_list_request = stub_request(:get, "https://api.brightspace.com/d2l/api/le/1.47/12345/classlist/") + .to_return( + status: 200, + body: [ + { + 'Identifier' => '12345', + 'FirstName' => p1.student.first_name, + 'LastName' => p1.student.last_name, + 'UserName' => p1.student.username, + 'OrgDefinedId' => p1.student.student_id, + 'Email' => p1.student.email, + 'ClasslistRoleDisplayName' => 'Student' + }, + { + "Identifier" => "12346", + "FirstName" => p2.student.first_name, + "LastName" => p2.student.last_name, + "UserName" => p2.student.username, + "OrgDefinedId" => "#{p2.student.student_id} - somehow mismatch", + "Email" => p2.student.email, + 'ClasslistRoleDisplayName' => 'Student' + }, + { + "Identifier" => "12347", + "FirstName" => p3.student.first_name, + "LastName" => p3.student.last_name, + "UserName" => "#{p3.student.username} - somehow mismatch", + "OrgDefinedId" => "#{p3.student.student_id} - somehow mismatch", + "Email" => p3.student.email, + 'ClasslistRoleDisplayName' => 'Student' + }, + { + "Identifier" => "12348", + "FirstName" => s1.first_name, + "LastName" => s1.last_name, + "UserName" => s1.username, + "OrgDefinedId" => s1.student_id, + "Email" => s1.email, + 'ClasslistRoleDisplayName' => 'Student' + } + ].to_json, + 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' } ) + + 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} + ).to_return( + status: 200, + headers: { + 'X-Rate-Limit-Remaining' => 2, + 'X-Request-Cost' => 1, + 'X-Rate-Limit-Reset' => 1 + } + ) + + 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 }, + ).to_return(status: 200, headers: {}) + + D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 30.minutes.from_now) + + # result = D2lIntegration.post_grades(unit, unit.main_convenor_user) + D2lPostGradesJob.perform_async(unit.id, unit.main_convenor_user.id) + D2lPostGradesJob.drain + + assert_equal 1, ActionMailer::Base.deliveries.count + mail = ActionMailer::Base.deliveries.first + assert_equal [unit.main_convenor_user.email], mail.to + assert_includes mail.body.parts[0].body.raw_source, "has completed" + + assert File.exist?(D2lIntegration.result_file_path(unit)) + result = File.read(D2lIntegration.result_file_path(unit)).split("\n") + + assert_requested post_grade_request, times: 1 + assert_requested class_list_request, times: 1 + + assert_equal 6, result.count, result + + assert_includes result[1], "Success,#{p1.student.student_id},#{p1.grade},Posted grade for #{p1.student.username}" + assert_includes result[2], "Success,#{p2.student.student_id} - somehow mismatch,#{p2.grade},Posted grade for #{p2.student.username}" + assert_includes result[3], "Skipped,#{p3.student.student_id} - somehow mismatch,\"\",No grade for #{p3.student.username}" + assert_includes result[4], "Error,#{s1.student_id},\"\",No OnTrack result for" + assert_includes result[5], "Error,#{p4.student.username},#{p4.grade},Not found in D2L" + + add_auth_header_for(user: unit.main_convenor_user) + get "/api/units/#{unit.id}/d2l/grades" + assert_equal 200, last_response.status, last_response.inspect + + assert_equal 'text/csv', last_response.headers['Content-Type'] + result = last_response.body.split("\n") + assert_equal 6, result.count, result + assert_includes result[1], "Success,#{p1.student.student_id},#{p1.grade},Posted grade for #{p1.student.username}" + end + + def test_mail_on_fail + unit = FactoryBot.create(:unit, with_students: false) + D2lAssessmentMapping.create(unit: unit, org_unit_id: '54321') + + D2lPostGradesJob.perform_async(unit.id, unit.main_convenor_user.id) + D2lPostGradesJob.drain + + assert_equal 1, ActionMailer::Base.deliveries.count + mail = ActionMailer::Base.deliveries.first + assert_equal [unit.main_convenor_user.email], mail.to + assert_includes mail.body.raw_source, "has failed" + end + + def test_request_grade_transfer + unit = FactoryBot.create(:unit, with_students: false) + + add_auth_header_for(user: unit.main_convenor_user) + + # Call without d2l mapping + post "/api/units/#{unit.id}/d2l/grades" + assert_equal 403, last_response.status, last_response.inspect + + d2l = D2lAssessmentMapping.create(unit: unit, org_unit_id: '12345') + + # Call without user oauth token + + post "/api/units/#{unit.id}/d2l/grades" + assert_equal 403, last_response.status, last_response.inspect + + token = UserOauthToken.create(user: unit.main_convenor_user, provider: :d2l, token: 'test', expires_at: 2.minutes.from_now) + + # Call with old token + post "/api/units/#{unit.id}/d2l/grades" + assert_equal 403, last_response.status, last_response.inspect + + # Call with everything set up + token.update(expires_at: 30.minutes.from_now) + + post "/api/units/#{unit.id}/d2l/grades" + assert_equal 202, last_response.status, last_response.inspect + + assert_equal 1, D2lPostGradesJob.jobs.count + end +end diff --git a/test/api/groups_api_test.rb b/test/api/groups_api_test.rb index a2759f227..9c7138951 100644 --- a/test/api/groups_api_test.rb +++ b/test/api/groups_api_test.rb @@ -81,6 +81,9 @@ def test_group_submission_with_extensions assert_equal TaskStatus.ready_for_feedback, task.task_status end + # ensure groupset has groups to destroy + group_set.reload + td.destroy group_set.destroy end @@ -433,4 +436,27 @@ def test_group_switch_tutorial_unenrolled_students refute 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 + + gs = unit.group_sets.first + group1 = gs.groups.first + + p1 = group1.tutorial.projects.first + p2 = group1.tutorial.projects.last + + group1.add_member p1 + group1.add_member p2 + + group1.update(locked: true) + + add_auth_header_for(user: p1.user) + delete "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}/members/#{p1.id}" + + assert_equal 403, last_response.status + + post "/api/units/#{unit.id}/group_sets/#{gs.id}/groups/#{group1.id}/members/#{unit.active_projects.last.id}" + assert_equal 403, last_response.status + end end diff --git a/test/api/overseer_image_api_test.rb b/test/api/overseer_image_api_test.rb new file mode 100644 index 000000000..65025a880 --- /dev/null +++ b/test/api/overseer_image_api_test.rb @@ -0,0 +1,363 @@ +require 'test_helper' + +class OverseerImageApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::OverseerTestHelper + + def app + Rails.application + end + + def setup + setup_overseer_enabled + end + + def test_get_all_overseer_images + FactoryBot.create_list(:overseer_image, 5) + expected_data = OverseerImage.all + + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + get '/api/admin/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] + + last_response_body.each do |data| + expected_data = OverseerImage.find(data['id']) + assert_json_matches_model(expected_data, data, response_keys) + end + end + + def test_get_all_overseer_images_for_convenor + FactoryBot.create_list(:overseer_image, 5) + expected_data = OverseerImage.all + convenor = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: convenor) + + get '/api/admin/overseer_images' + + assert_equal 200, last_response.status + assert_equal expected_data.count, last_response_body.count, last_response_body + end + + def test_no_get_for_students_or_tutors + FactoryBot.create_list(:overseer_image, 5) + expected_data = OverseerImage.all + + student = FactoryBot.create(:user, :student) + add_auth_header_for(user: student) + + get '/api/admin/overseer_images' + + assert_equal 403, last_response.status + + tutor = FactoryBot.create(:user, :tutor) + add_auth_header_for(user: tutor) + + get '/api/admin/overseer_images' + + assert_equal 403, last_response.status + end + + def test_get_single_overseer_image + overseer_image = FactoryBot.create(:overseer_image) + + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + get "/api/admin/overseer_images/#{overseer_image.id}" + + assert_equal 200, last_response.status + + response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date] + assert_json_matches_model(overseer_image, last_response_body, response_keys) + end + + def test_get_single_overseer_image_for_convenor + overseer_image = FactoryBot.create(:overseer_image) + + convenor = FactoryBot.create(:user, :convenor) + add_auth_header_for(user: convenor) + + get "/api/admin/overseer_images/#{overseer_image.id}" + + assert_equal 200, last_response.status + + response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date] + assert_json_matches_model(overseer_image, last_response_body, response_keys) + end + + def test_no_get_single_overseer_image_for_students_or_tutors + overseer_image = FactoryBot.create(:overseer_image) + + student = FactoryBot.create(:user, :student) + add_auth_header_for(user: student) + + get "/api/admin/overseer_images/#{overseer_image.id}" + + assert_equal 403, last_response.status + + tutor = FactoryBot.create(:user, :tutor) + + add_auth_header_for(user: tutor) + + get "/api/admin/overseer_images/#{overseer_image.id}" + + assert_equal 403, last_response.status + end + + # POST tests + # 1: Admin can create a new overseer image + def test_admin_can_post_overseer_image + # Admin user + admin = FactoryBot.create(:user, :admin) + + # the number of images before post + no_images = OverseerImage.count + + # the data that we want to post/create + data_to_post = { + overseer_image: FactoryBot.build(:overseer_image) + } + + # auth_token and username added to header + add_auth_header_for(user: admin) + + # perform the POST + post_json '/api/admin/overseer_images', data_to_post + + # check if the request get through + assert_equal 201, last_response.status, "Failed to add image: #{data_to_post}" + + # check if the details posted match as expected + response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date] + overseer_image = OverseerImage.find(last_response_body['id']) + assert_json_matches_model(overseer_image, last_response_body, response_keys) + + assert_nil overseer_image.pulled_image_text + assert_nil overseer_image.pulled_image_status + + # check if the details in the newly created match as pre-set data + assert_equal data_to_post[:overseer_image]['name'], overseer_image.name + assert_equal data_to_post[:overseer_image]['tag'], overseer_image.tag + + # check if one more image is created + assert_equal no_images + 1, OverseerImage.count + end + + # 2: Convenor cannot create a new overseer image + def test_convenor_and_student_cannot_post_overseer_image + # Convenor user + convenor = FactoryBot.create(:user, :convenor) + student = FactoryBot.create(:user, :student) + + # the number of teaching period before post + overseer_image_type = OverseerImage.count + + # the data that we want to post/create + data_to_post = { + overseer_image: FactoryBot.build(:overseer_image) + } + + # auth_token and username added to header + add_auth_header_for(user: convenor) + + # perform the POST + post_json '/api/admin/overseer_images', data_to_post + + # check if the request get through + assert_equal 403, last_response.status + + # auth_token and username added to header + add_auth_header_for(user: student) + + # perform the POST + post_json '/api/admin/overseer_images', data_to_post + + # check if the request get through + assert_equal 403, last_response.status + + # check if no more images is created + assert_equal overseer_image_type, OverseerImage.count + end + + # PUT tests + # 1: Admin can replace an image + def test_admin_can_put_overseer_image + # Admin user + admin = FactoryBot.create(:user, :admin) + + # The overseer image to be replaced + overseer_image = FactoryBot.create(:overseer_image) + + # Data to replace + data_to_put = { + overseer_image: FactoryBot.build(:overseer_image) + } + + # auth_token and username added to header + add_auth_header_for(user: admin) + + # Update overseer_image with data_to_put + put_json "/api/admin/overseer_images/#{overseer_image.id}", data_to_put + + # check if the request get through + assert_equal 200, last_response.status, "Failed to update image: #{data_to_put} for #{overseer_image.inspect}" + + # check if the details posted match as expected + response_keys = %w[name tag pulled_image_text pulled_image_status last_pulled_date] + overseer_image_updated = overseer_image.reload + assert_json_matches_model(overseer_image_updated, last_response_body, response_keys) + + # check if the details in the replaced teaching period match as data set to replace + assert_equal data_to_put[:overseer_image]['name'], overseer_image_updated.name + assert_equal data_to_put[:overseer_image]['tag'], overseer_image_updated.tag + + # Check other attributes are cleared + assert_nil overseer_image_updated.pulled_image_text + assert_nil overseer_image_updated.pulled_image_status + end + + # 2: Convenor cannot replace an overseer image + def test_non_admin_cannot_put_overseer_images + # Convenor user + convenor = FactoryBot.create(:user, :convenor) + tutor = FactoryBot.create(:user, :tutor) + student = FactoryBot.create(:user, :student) + auditor = FactoryBot.create(:user, :auditor) + + users_to_test = [convenor, tutor, student, auditor] + + # The overseer image to be replaced + overseer_image = FactoryBot.create(:overseer_image) + + # Data to replace + data_to_put = { + overseer_image: FactoryBot.build(:overseer_image) + } + + users_to_test.each do |user| + # auth_token and username added to header + add_auth_header_for(user: user) + + # Update overseer_image with data_to_put + put_json "/api/admin/overseer_images/#{overseer_image.id}", data_to_put + + # check if the request get through + assert_equal 403, last_response.status, "User: #{user.role} updated overseer image" + end + end + + def test_delete_overseer_image + # Create a overseer image + overseer_image = FactoryBot.create(:overseer_image) + + # number of overseer image before delete + number_of_images = OverseerImage.count + + # auth_token and username added to header + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + # perform the delete + delete_json "/api/admin/overseer_images/#{overseer_image.id}" + + # Check if the delete get through + assert_equal 200, last_response.status + + # Check delete if success + assert_equal OverseerImage.count, number_of_images - 1 + + # Check that you can't find the deleted id + assert_not OverseerImage.exists?(overseer_image.id) + end + + def test_non_admin_cannot_delete_overseer_image + # A user with student role which does not have permision to delete a overseer image + users = [ + FactoryBot.create(:user, :student), + FactoryBot.create(:user, :tutor), + FactoryBot.create(:user, :convenor), + FactoryBot.create(:user, :auditor) + ] + + # create a overseer image to delete + overseer_image = FactoryBot.create (:overseer_image) + + # number of overseer image before delete + number_of_images = OverseerImage.count + + users.each do |user| + # auth_token and username added to header + add_auth_header_for(user: user) + + # perform the delete + delete_json "/api/admin/overseer_images/#{overseer_image.id}" + + # check if the delete does not get through + assert_equal 403, last_response.status, "User: #{user.role} deleted overseer image" + + # check if the number of ativity_type is still the same + assert_equal OverseerImage.count, number_of_images + end + + # Check that you still can find the deleted id + assert OverseerImage.exists?(overseer_image.id) + end + + def test_delete_overseer_image_with_associated_units + unit = FactoryBot.create(:unit, with_students: false) + overseer_image = FactoryBot.create(:overseer_image, units: [unit]) + unit.update(overseer_image: overseer_image) + + # number of overseer image before delete + number_of_images = OverseerImage.count + + # auth_token and username added to header + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + # perform the delete + delete_json "/api/admin/overseer_images/#{overseer_image.id}" + + # Check if the delete get through + assert_equal 403, last_response.status + + # Check delete if success + assert_equal OverseerImage.count, number_of_images + + # Check that you can't find the deleted id + assert OverseerImage.exists?(overseer_image.id) + end + + def test_delete_overseer_image_with_associated_task_definitions + unit = FactoryBot.create(:unit, with_students: false) + overseer_image = FactoryBot.create(:overseer_image, units: [unit]) + unit.task_definitions.first.update(overseer_image: overseer_image) + + # number of overseer image before delete + number_of_images = OverseerImage.count + + # auth_token and username added to header + admin = FactoryBot.create(:user, :admin) + add_auth_header_for(user: admin) + + # perform the delete + delete_json "/api/admin/overseer_images/#{overseer_image.id}" + + # Check if the delete get through + assert_equal 403, last_response.status + + # Check delete if success + assert_equal OverseerImage.count, number_of_images + + # Check that you can't find the deleted id + assert OverseerImage.exists?(overseer_image.id) + end +end diff --git a/test/api/projects_api_test.rb b/test/api/projects_api_test.rb index 6d4ab3087..faacfa02b 100644 --- a/test/api/projects_api_test.rb +++ b/test/api/projects_api_test.rb @@ -165,7 +165,7 @@ def test_download_portfolio get "/api/submission/project/#{project.id}/portfolio", data_to_put assert_equal 200, last_response.status assert last_response.headers['Content-Disposition'].starts_with?('attachment; filename=') - assert last_response.headers['Access-Control-Expose-Headers'] == 'Content-Disposition' + assert_equal 'Content-Disposition', last_response.headers['Access-Control-Expose-Headers'] assert last_response.headers['Content-Type'] == 'application/pdf' assert 10_485_760, last_response.length @@ -185,7 +185,7 @@ def test_download_portfolio assert 500, last_response.length assert_equal 206, last_response.status assert_nil last_response.headers['Content-Disposition'] - assert_nil last_response.headers['Access-Control-Expose-Headers'] + assert_equal 'Content-Range,Accept-Ranges', last_response.headers['Access-Control-Expose-Headers'] assert last_response.headers['Content-Type'] == 'application/pdf' unit.destroy! diff --git a/test/api/scorm_api_test.rb b/test/api/scorm_api_test.rb new file mode 100644 index 000000000..b590393ec --- /dev/null +++ b/test/api/scorm_api_test.rb @@ -0,0 +1,89 @@ +require 'test_helper' + +class ScormApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + end + + def scorm_path(task_def, user, file, token_type = :scorm) + "/api/scorm/#{task_def.id}/#{user.username.gsub('.', '%2e')}/#{auth_token(user, token_type)}/#{file}" + end + + def test_serve_scorm_content + unit = FactoryBot.create(:unit) + user = unit.projects.first.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task scorm', + description: 'Task with scorm test', + 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.week, + abbreviation: 'TaskScorm', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_allow_review: true, + scorm_bypass_test: false, + scorm_time_delay_enabled: false, + scorm_attempt_limit: 0 + } + ) + td.save! + + # When the task def does not have SCORM data + get scorm_path(td, user, 'index.html') + assert_equal 404, last_response.status + + td.add_scorm_data(test_file_path('numbas.zip'), copy: true) + td.save! + + # When the file is missing + get scorm_path(td, user, 'index1.html') + assert_equal 404, last_response.status + + # When the file is present - html + get scorm_path(td, user, 'index.html') + assert_equal 200, last_response.status + assert_equal 'text/html', last_response.content_type + + # Cannot access with the wrong token type + get scorm_path(td, user, 'index.html', :general) + assert_equal 419, last_response.status + + get scorm_path(td, user, 'index.html', :login) + assert_equal 419, last_response.status + + # When the file is present - css + get scorm_path(td, user, 'styles.css') + assert_equal 200, last_response.status + assert_equal 'text/css', last_response.content_type + + # When the file is present - js + get scorm_path(td, user, 'scripts.js') + assert_equal 200, last_response.status + assert_equal 'text/javascript', last_response.content_type + + tutor = FactoryBot.create(:user, :tutor, username: :test_tutor) + + # When the user is unauthorised + get scorm_path(td, tutor, 'index.html') + assert_equal 403, last_response.status + + tutor.destroy! + td.destroy! + unit.destroy! + end +end diff --git a/test/api/tasks_api_test.rb b/test/api/tasks_api_test.rb index 1e84f3f3c..bc7cef74a 100644 --- a/test/api/tasks_api_test.rb +++ b/test/api/tasks_api_test.rb @@ -400,11 +400,100 @@ def test_can_submit_ipynb assert_equal 201, last_response.status, last_response_body task = project.task_for_task_definition(td) - task.convert_submission_to_pdf + task.convert_submission_to_pdf(log_to_stdout: false) assert File.exist? task.final_pdf_path - td.destroy + unit.destroy end + def test_invalid_latex_in_ipynb + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Code task', + description: 'Code task', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'CodeTask', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], + plagiarism_warn_pct: 0.8, + is_graded: true, + max_quality_pts: 0 + }) + + 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' + } + + data_to_post = with_file('test_files/submissions/invalid_notebook.ipynb', 'application/json', data_to_post) + + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + + assert_equal 201, last_response.status, last_response_body + task = project.task_for_task_definition(td) + task.convert_submission_to_pdf(log_to_stdout: true) + assert File.exist? task.final_pdf_path + + unit.destroy + end + + def test_download_task_pdf + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.create!({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Code task', + description: 'Code task', + weighting: 4, + target_grade: 0, + start_date: Time.zone.now - 2.weeks, + target_date: Time.zone.now + 1.week, + abbreviation: 'CodeTask', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'code' } ], + plagiarism_warn_pct: 0.8, + is_graded: true, + max_quality_pts: 0 + }) + + project = unit.active_projects.first + task = project.task_for_task_definition(td) + + # Add username and auth_token to Header + add_auth_header_for(user: project.user) + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/submission" + + assert_equal 200, last_response.status + assert_equal File.size(Rails.root.join('public/resources/FileNotFound.pdf')), last_response.length + + dir = FileHelper.student_work_dir(:new, task, true) + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/submission" + + assert_equal 200, last_response.status + assert_equal File.size(Rails.root.join('public/resources/AwaitingProcessing.pdf')), last_response.length + + FileUtils.rm_r dir + + src_file = Rails.root.join('test_files/submissions/1.2P.pdf') + FileUtils.cp src_file, task.final_pdf_path + + get "/api/projects/#{project.id}/task_def_id/#{td.id}/submission" + + assert_equal 200, last_response.status + assert_equal File.size(src_file), last_response.length + + unit.destroy + end end diff --git a/test/api/test_attempts_test.rb b/test/api/test_attempts_test.rb new file mode 100644 index 000000000..be0c02ae5 --- /dev/null +++ b/test/api/test_attempts_test.rb @@ -0,0 +1,498 @@ +require 'test_helper' + +class TestAttemptsTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_get_task_attempts + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + + add_auth_header_for(user: user) + + # When no attempts exist + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 200, last_response.status + assert_empty last_response_body + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + td1 = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts new', + description: 'Test attempts new', + 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.week, + abbreviation: 'TestAttemptsNew', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td1.save! + + task1 = project.task_for_task_definition(td1) + attempt1 = TestAttempt.create({ task_id: task1.id }) + + add_auth_header_for(user: user) + + response_keys = %w[id task_id terminated completion_status success_status score_scaled cmi_datamodel] + + # When attempts exists + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 200, last_response.status + assert_equal 1, last_response_body.size + assert_json_matches_model attempt, last_response_body.first, response_keys + + user1 = FactoryBot.create(:user, :student) + + add_auth_header_for(user: user1) + + # When user is unauthorised + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 403, last_response.status + + user1.destroy! + td.destroy! + td1.destroy! + unit.destroy! + end + + def test_get_latest + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + + add_auth_header_for(user: user) + + # When no attempts exist + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts/latest" + assert_equal 404, last_response.status + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + attempt.terminated = true + attempt.completion_status = true + attempt.save! + attempt1 = TestAttempt.create({ task_id: task.id }) + + add_auth_header_for(user: user) + + response_keys = %w[id task_id terminated completion_status success_status score_scaled cmi_datamodel] + + # When attempts exist + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts/latest" + assert_equal 200, last_response.status + assert_json_matches_model attempt1, last_response_body, response_keys + + add_auth_header_for(user: user) + + # Get completed latest + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts/latest?completed=true" + assert_equal 200, last_response.status + assert_json_matches_model attempt, last_response_body, response_keys + + user1 = FactoryBot.create(:user, :student) + + add_auth_header_for(user: user1) + + # When user is unauthorised + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts/latest" + assert_equal 403, last_response.status + + td.destroy! + unit.destroy! + end + + def test_review_attempt + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0, + scorm_allow_review: true + } + ) + td.save! + + add_auth_header_for(user: user) + + # When attempt id is invalid + get "api/test_attempts/0/review" + assert_equal 404, last_response.status + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + td.scorm_allow_review = false + td.save! + + add_auth_header_for(user: user) + + # When review is disabled + get "api/test_attempts/#{attempt.id}/review" + assert_equal 403, last_response.status + + td.scorm_allow_review = true + td.save! + + add_auth_header_for(user: user) + + # When attempt is incomplete + get "api/test_attempts/#{attempt.id}/review" + assert_equal 403, last_response.status + + dm = JSON.parse(attempt.cmi_datamodel) + dm['cmi.completion_status'] = 'completed' + attempt.cmi_datamodel = dm.to_json + attempt.completion_status = true + attempt.terminated = true + attempt.save! + + add_auth_header_for(user: user) + + # When attempt can be reviewed + get "api/test_attempts/#{attempt.id}/review" + assert_equal 200, last_response.status + + attempt.review + attempt.save! + + response_keys = %w[id task_id terminated completion_status success_status score_scaled cmi_datamodel] + assert_json_matches_model attempt, last_response_body, response_keys + + tutor = project.tutor_for(td) + + add_auth_header_for(user: tutor) + + # When user is tutor + get "api/test_attempts/#{attempt.id}/review" + assert_equal 200, last_response.status + assert_json_matches_model attempt, last_response_body, response_keys + + user1 = FactoryBot.create(:user, :student) + + add_auth_header_for(user: user1) + + # When user is unauthorised + get "api/test_attempts/#{attempt.id}/review" + assert_equal 403, last_response.status + + td.destroy! + unit.destroy! + end + + def test_post_attempt + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: false, + scorm_attempt_limit: 1 + } + ) + td.save! + + add_auth_header_for(user: user) + + # When scorm is disabled + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 403, last_response.status + + td.scorm_enabled = true + td.save! + + tutor = project.tutor_for(td) + + add_auth_header_for(user: tutor) + + # When user is unauthorised + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 403, last_response.status + + task = project.task_for_task_definition(td) + + add_auth_header_for(user: user) + + # When new attempt can be made + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 201, last_response.status + assert last_response_body["task_id"] == task.id + + attempt = TestAttempt.find(last_response_body["id"]) + + add_auth_header_for(user: user) + + # When last attempt is incomplete + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 400, last_response.status + + attempt.terminated = true + attempt.success_status = true + attempt.save! + + add_auth_header_for(user: user) + + # When last attempt is a pass + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 400, last_response.status + + attempt.success_status = false + attempt.save! + + add_auth_header_for(user: user) + + # When attempt limit is reached + post "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 400, last_response.status + + td.destroy! + unit.destroy! + end + + def test_update_attempt + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + + tutor = project.tutor_for(td) + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + dm = JSON.parse(attempt.cmi_datamodel) + dm["cmi.completion_status"] = "completed" + dm["cmi.score.scaled"] = "0.1" + + data_to_patch = { + cmi_datamodel: dm.to_json, + terminated: true + } + + add_auth_header_for(user: tutor) + + # When user is unauthorised + patch "api/test_attempts/#{attempt.id}", data_to_patch + assert_equal 403, last_response.status + + add_auth_header_for(user: user) + + # When attempt is terminated + patch "api/test_attempts/#{attempt.id}", data_to_patch + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert attempt.terminated == true + assert JSON.parse(attempt.cmi_datamodel)["cmi.completion_status"] == "completed" + + tc = ScormComment.find_by(commentable_id: attempt.id) + + assert_not_nil tc + + add_auth_header_for(user: user) + + # When unauthorised user tries to override pass status + patch "api/test_attempts/#{attempt.id}", { success_status: true } + assert_equal 403, last_response.status + + add_auth_header_for(user: tutor) + + # When authorised user tries to override pass status + patch "api/test_attempts/#{attempt.id}", { success_status: true } + assert_equal 200, last_response.status + + attempt = TestAttempt.find(attempt.id) + + assert attempt.success_status == true + assert JSON.parse(attempt.cmi_datamodel)["cmi.success_status"] == "passed" + + tc = ScormComment.find_by(commentable_id: attempt.id) + + assert tc.comment == attempt.success_status_description + + add_auth_header_for(user: tutor) + + # When attempt id is invalid + patch "api/test_attempts/0", { success_status: true } + assert_equal 404, last_response.status + + td.destroy! + unit.destroy! + end + + def test_delete_attempt + unit = FactoryBot.create(:unit) + project = unit.projects.first + user = project.student + + td = TaskDefinition.new( + { + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test attempts', + description: 'Test attempts', + 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.week, + abbreviation: 'TestAttempts', + restrict_status_updates: false, + upload_requirements: [], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0, + scorm_enabled: true, + scorm_attempt_limit: 0 + } + ) + td.save! + + task = project.task_for_task_definition(td) + attempt = TestAttempt.create({ task_id: task.id }) + + add_auth_header_for(user: user) + + # When user is unauthorised + delete "api/test_attempts/#{attempt.id}" + assert_equal 403, last_response.status + + tutor = project.tutor_for(td) + + add_auth_header_for(user: tutor) + + # When user is authorised + delete "api/test_attempts/#{attempt.id}" + assert_equal 200, last_response.status + + add_auth_header_for(user: tutor) + + # When attempt id is invalid + delete "api/test_attempts/0" + assert_equal 404, last_response.status + + td.destroy! + unit.destroy! + end +end diff --git a/test/api/tii/tii_action_api_test.rb b/test/api/tii/tii_action_api_test.rb index c81cd2b27..8a34265e9 100644 --- a/test/api/tii/tii_action_api_test.rb +++ b/test/api/tii/tii_action_api_test.rb @@ -15,21 +15,26 @@ def app setup do TiiAction.delete_all - + setup_tii_features_enabled setup_tii_eula # Create a task definition with two attachments @unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - @task_def = FactoryBot.create(:task_definition, unit: @unit, upload_requirements: [ - { - 'key' => 'file0', - 'name' => 'My document', - 'type' => 'document', - 'tii_check' => 'true', - 'tii_pct' => '10' - } - ]) + @task_def = FactoryBot.create( + :task_definition, + unit: @unit, + upload_requirements: + [ + { + 'key' => 'file0', + 'name' => 'My document', + 'type' => 'document', + 'tii_check' => true, + 'tii_pct' => 10 + } + ] + ) ga1 = TiiGroupAttachment.create( task_definition: @task_def, diff --git a/test/api/tutorials_test.rb b/test/api/tutorials_test.rb index 379c0e301..0fefb2e87 100644 --- a/test/api/tutorials_test.rb +++ b/test/api/tutorials_test.rb @@ -1194,11 +1194,11 @@ def test_delete_tutorials_with_string_tutorial_id delete_json "/api/tutorials/#{tutorial_id}" # Check number of tutorials does not change - assert_equal number_of_tutorials , Tutorial.all.length + assert_equal number_of_tutorials, Tutorial.all.length # Check on error of incorrect tutorial ID - assert_equal 400, last_response.status - assert_equal 'id is invalid', last_response_body['error'] + assert_equal 404, last_response.status + assert last_response.body.include?('Not Found'), last_response.body end def test_delete_tutorials_with_empty_auth_token diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 656d2d67c..aa13dcea5 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -63,10 +63,10 @@ def test_task_definition_cud td = unit.task_definitions.first assert_json_matches_model td, last_response_body, all_task_def_keys + assert_equal [{ "key" => "file0", "name" => "Shape Class", "type" => "document" }], td.upload_requirements assert_equal unit.tutorial_streams.first.id, td.tutorial_stream_id assert_equal 4, td.weighting - data_to_put = { task_def: { tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, @@ -97,6 +97,7 @@ def test_task_definition_cud assert_json_matches_model td, last_response_body, all_task_def_keys assert_equal unit.tutorial_streams.last.id, td.tutorial_stream_id + assert_equal [{ "key" => "file0", "name" => "Other Class", "type" => "document" }], td.upload_requirements assert_equal 2, td.weighting end @@ -218,6 +219,25 @@ def test_post_task_resources assert_requested delete_stub, times: 1 end + def test_post_scorm + test_unit = Unit.first + test_task_definition = TaskDefinition.first + + data_to_post = { + file: upload_file('test_files/numbas.zip', 'application/zip') + } + + # Add auth_token and username to header + add_auth_header_for(user: Unit.first.main_convenor_user) + + post "/api/units/#{test_unit.id}/task_definitions/#{test_task_definition.id}/scorm_data", data_to_post + + assert_equal 201, last_response.status + assert test_task_definition.task_scorm_data + + assert_equal File.size(data_to_post[:file]), File.size(TaskDefinition.first.task_scorm_data) + end + def test_submission_creates_folders unit = Unit.first td = TaskDefinition.new({ @@ -308,7 +328,7 @@ def test_change_to_group_after_submissions assert_equal 201, last_response.status task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index bdd73fc84..fc53e1045 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -49,6 +49,82 @@ def test_units_post assert_equal expected_unit[:name], Unit.last.name end + # Test POST for creating new unit + def test_units_post_other_main_convenor + data_to_post = { + unit: { + name: 'Intro to Social Skills', + code: 'JRRW40003', + start_date: '2016-05-14', + end_date: '2017-05-14', + main_convenor_user_id: 2 + } + } + expected_unit = data_to_post[:unit] + unit_count = Unit.all.length + + # Add username and auth_token to Header + add_auth_header_for(user: User.first) + + # The post that we will be testing. + post_json '/api/units.json', data_to_post + + assert_equal 201, last_response.status, last_response_body + + # Check to see if the unit's name matches what was expected + actual_unit = last_response_body + + assert_equal expected_unit[:name], actual_unit['name'] + assert_equal expected_unit[:code], actual_unit['code'] + assert_equal expected_unit[:start_date], actual_unit['start_date'] + assert_equal expected_unit[:end_date], actual_unit['end_date'] + + assert_equal unit_count + 1, Unit.all.count + assert_equal expected_unit[:name], Unit.last.name + + assert_equal 2, Unit.last.main_convenor_user.id + end + + # Test POST for creating new unit - but with student main convenor + def test_units_post_other_main_convenor_not_permitted + data_to_post = { + unit: { + name: 'Intro to Social Skills', + code: 'JRRW40003', + start_date: '2016-05-14', + end_date: '2017-05-14', + main_convenor_user_id: User.where(role: Role.student).first.id + } + } + + # Add username and auth_token to Header + add_auth_header_for(user: User.first) + + # The post that we will be testing. + post_json '/api/units.json', data_to_post + assert_equal 403, last_response.status, last_response_body + end + + # Test POST for creating new unit + def test_units_post_other_main_convenor_not_permitted_for_student + data_to_post = { + unit: { + name: 'Intro to Social Skills', + code: 'JRRW40003', + start_date: '2016-05-14', + end_date: '2017-05-14', + main_convenor_user_id: User.where(role: Role.convenor).first.id + } + } + + # Add username and auth_token to Header + add_auth_header_for(user: User.where(role: Role.student).first) + + # The post that we will be testing. + post_json '/api/units.json', data_to_post + assert_equal 403, last_response.status, last_response_body + end + def create_unit { name:'Intro to Social Skills', @@ -233,7 +309,7 @@ def test_permissions_on_get # Test convenor can not get all add_auth_header_for(user: aconvenor) get '/api/units' - assert_equal 403, last_response.status + assert_equal 200, last_response.status # Test tutor can not get all add_auth_header_for(user: atutor) diff --git a/test/api/webcal_api_test.rb b/test/api/webcal_api_test.rb index f06667d10..ae7f6c37a 100644 --- a/test/api/webcal_api_test.rb +++ b/test/api/webcal_api_test.rb @@ -1,6 +1,6 @@ require 'test_helper' -class UnitsTest < ActiveSupport::TestCase +class WebcalApiTest < ActiveSupport::TestCase include Rack::Test::Methods include TestHelpers::AuthHelper include TestHelpers::JsonHelper @@ -14,6 +14,7 @@ def app end teardown do + @student.projects.find_each { |project| project.destroy } @student.destroy end diff --git a/test/config/deakin_config_test.rb b/test/config/deakin_config_test.rb index 4649cb190..25da492ee 100644 --- a/test/config/deakin_config_test.rb +++ b/test/config/deakin_config_test.rb @@ -46,7 +46,7 @@ def test_sync_deakin_unit result = unit.sync_enrolments() assert_equal 3, unit.projects.count, result # 3 students and others skipped - assert_equal 2, unit.tutorials.count, result # campus + assert_equal 1, unit.tutorials.count, result # campus assert_requested enrolment_stub assert_requested timetable_stub @@ -85,6 +85,85 @@ def test_sync_deakin_unit_without_timetable unit.destroy end + def test_sync_deakin_retry_requests + WebMock.reset_executed_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']}.*/) + .to_return([ + { body: "Too many requests", status: 429 }, + { body: "Internal server error", status: 500 }, + { 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: "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 } + ]) + + 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) + + unit.sync_enrolments + + assert_requested enrolment_stub, times: 3 + assert_requested timetable_stub, times: 3 + assert_requested timetable_activity_stub, times: 3 + + unit.destroy + end + + def test_sync_deakin_multi_unit + WebMock.reset_executed_requests! + + # Setup enrolments stubs + raw_enrolment_file_1 = File.new(test_file_path("deakin/enrol_multi_1.json")) + raw_enrolment_file_2 = File.new(test_file_path("deakin/enrol_multi_2.json")) + 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 } + ]) + + 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) + + unit.enable_sync_timetable = false + unit.save + + result = unit.sync_enrolments + + assert_equal 2, unit.tutorials.count # none created + + assert_requested enrolment_stub, times: 2 + + assert_equal 2, unit.active_projects.count + + unit.reload + result = unit.sync_enrolments + + assert_equal 2, unit.active_projects.count + + unit.destroy + end + def test_sync_deakin_unit_disabled WebMock.reset_executed_requests! diff --git a/test/factories/overseer_image_factory.rb b/test/factories/overseer_image_factory.rb new file mode 100644 index 000000000..bdfee6797 --- /dev/null +++ b/test/factories/overseer_image_factory.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :overseer_image do + name { Faker::Lorem.unique.word } + sequence(:tag) { |n| "host/testtag:v#{n}-#{rand(0..100)}" } + pulled_image_text { Faker::Lorem.sentence } + pulled_image_status { rand(0..1) } + last_pulled_date { Faker::Date.between(from: 2.days.ago, to: Time.zone.today) } + end +end diff --git a/test/factories/users_factory.rb b/test/factories/users_factory.rb index d706bf031..5185bdb07 100644 --- a/test/factories/users_factory.rb +++ b/test/factories/users_factory.rb @@ -8,11 +8,16 @@ email { Faker::Internet.unique.email } password { "password" } 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 + + while User.where(student_id: user.student_id).count > 0 + user.student_id = user.student_id.to_i + 1 + end end trait :student do diff --git a/test/helpers/auth_helper.rb b/test/helpers/auth_helper.rb index 42537449b..c60348949 100644 --- a/test/helpers/auth_helper.rb +++ b/test/helpers/auth_helper.rb @@ -13,11 +13,11 @@ def app # # Gets an auth token for the provided user # - def auth_token(user = User.first) - token = user.valid_auth_tokens().first + 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!().authentication_token + return user.generate_authentication_token!(token_type: token_type).authentication_token end # diff --git a/test/helpers/overseer_test_helper.rb b/test/helpers/overseer_test_helper.rb new file mode 100644 index 000000000..54be769f3 --- /dev/null +++ b/test/helpers/overseer_test_helper.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +module TestHelpers + # + # Turn It In Test Helpers + # + module OverseerTestHelper + module_function + + def setup_overseer_enabled + Doubtfire::Application.config.overseer_enabled = true + end + end +end diff --git a/test/mailers/error_log_mailer_test.rb b/test/mailers/error_log_mailer_test.rb new file mode 100644 index 000000000..ed990597f --- /dev/null +++ b/test/mailers/error_log_mailer_test.rb @@ -0,0 +1,33 @@ +require 'test_helper' +require 'grade_helper' + +class ErrorLogMailerTest < ActionMailer::TestCase + + def test_can_send_error_log_mail + Doubtfire::Application.config.email_errors_to = 'test ' + begin + raise 'test' + rescue StandardError => e + mail = ErrorLogMailer.error_message('test', 'test message', e) + end + + assert mail.present? + assert mail.to.include? 'test@test.com' + assert mail.body.include? e.message + assert mail.body.include? e.backtrace.join("\n") + end + + def test_latex_error_logs_are_attached + Doubtfire::Application.config.email_errors_to = 'test ' + begin + raise Task::LatexError.new('this is the content of the log'), 'test' + rescue StandardError => e + mail = ErrorLogMailer.error_message('test', 'test message', e) + end + + assert mail.present? + assert mail.to.include? 'test@test.com' + assert mail.attachments['log.txt'].present? + assert mail.attachments['log.txt'].body.include? 'this is the content of the log' + end +end diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index c0290ee08..04d207a07 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -2,20 +2,19 @@ require 'grade_helper' class UnitMailTest < ActionMailer::TestCase - def test_send_summary_email unit = FactoryBot.create :unit summary_stats = {} - summary_stats[:week_end] = Date.today + summary_stats[:week_end] = Time.zone.today summary_stats[:week_start] = summary_stats[:week_end] - 7.days summary_stats[:weeks_comments] = TaskComment.where("created_at >= :start AND created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count summary_stats[:weeks_engagements] = TaskEngagement.where("engagement_time >= :start AND engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count unit.send_weekly_status_emails(summary_stats) - assert_equal unit.active_projects.count + 1, ActionMailer::Base.deliveries.count + assert_equal unit.active_projects.count + unit.staff.count, ActionMailer::Base.deliveries.count unit.destroy! end diff --git a/test/models/file_helper_test.rb b/test/models/file_helper_test.rb index 717149f35..a2f7fa62b 100644 --- a/test/models/file_helper_test.rb +++ b/test/models/file_helper_test.rb @@ -10,4 +10,19 @@ def test_convert_use_with_gif assert File.exist? dest_file end end + + def test_archive_paths + unit = FactoryBot.create(:unit, with_students: false) + + archive_work_path = FileHelper.unit_work_root(unit, archived: :force) + original_work_path = FileHelper.unit_work_root(unit, archived: false) + + archive_portfolio_path = FileHelper.unit_portfolio_dir(unit, create: false, archived: :force) + original_portfolio_path = FileHelper.unit_portfolio_dir(unit, create: false, archived: false) + + assert_match %r{^#{FileHelper.archive_root}/}, archive_work_path + assert_match %r{^#{FileHelper.archive_root}/portfolio/}, archive_portfolio_path + assert_match %r{^#{FileHelper.student_work_root}/}, original_work_path + assert_match %r{^#{FileHelper.student_work_root}/portfolio/}, original_portfolio_path + end end diff --git a/test/models/task_definition_test.rb b/test/models/task_definition_test.rb index d20634d8c..578b0e895 100644 --- a/test/models/task_definition_test.rb +++ b/test/models/task_definition_test.rb @@ -145,13 +145,14 @@ def test_export_task_definitions_csv task_defs_csv = CSV.parse unit.task_definitions_csv, headers: true task_defs_csv.each do |task_def_csv| task_def = unit.task_definitions.find_by(abbreviation: task_def_csv['abbreviation']) - keys_to_ignore = ['tutorial_stream', 'start_week', 'start_day', 'target_week', 'target_day', 'due_week', 'due_day'] + keys_to_ignore = ['tutorial_stream', 'start_week', 'start_day', 'target_week', 'target_day', 'due_week', 'due_day', 'upload_requirements'] task_def_csv.each do |key, value| unless keys_to_ignore.include?(key) assert_equal(task_def[key].to_s, value) end end + assert_equal task_def.upload_requirements.to_json, task_def_csv['upload_requirements'] assert_equal task_def.start_week.to_s, task_def_csv['start_week'] assert_equal task_def.start_day.to_s, task_def_csv['start_day'] assert_equal task_def.target_week.to_s, task_def_csv['target_week'] @@ -265,8 +266,130 @@ def test_delete_unneeded_group_submission_on_group_set_change t1.reload assert_nil t1.group_submission - + ensure unit.destroy end + def test_upload_req_format + u = FactoryBot.create :unit, task_count: 0, with_students: false + td = FactoryBot.create :task_definition, unit: u, upload_requirements: [], start_date: Time.zone.now + 1.day + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document', + "tii_check" => true, + "tii_pct" => 5 + } + ] + assert td.valid? + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document' + } + ] + assert td.valid?, 'tii check and pct not required' + + td.upload_requirements = + [ + { + "name" => 'Document 1', + "type" => 'document', + "tii_check" => true, + "tii_pct" => 5 + } + ] + + assert_not td.valid?, 'missing key' + + td.upload_requirements = + [ + { + "key" => 'file0', + "type" => 'document', + "tii_check" => true, + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'missing name' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "tii_check" => true, + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'missing type' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document', + "other" => true, + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'unknown key' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'other', + "tii_check" => true, + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'unknown type' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document', + "tii_check" => 'test', + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'tii_check not boolean' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document', + "tii_check" => true, + "tii_pct" => 'test' + } + ] + assert_not td.valid?, 'tii_pct not integer' + + td.upload_requirements = + [ + { + "key" => 'file0', + "name" => "\tnot a filename", + "type" => 'document', + "tii_check" => true, + "tii_pct" => 5 + } + ] + assert_not td.valid?, 'name not valid filename' + ensure + u.destroy + end end diff --git a/test/models/task_test.rb b/test/models/task_test.rb index d88a28ee6..76cfcef61 100644 --- a/test/models/task_test.rb +++ b/test/models/task_test.rb @@ -10,6 +10,15 @@ class TaskDefinitionTest < ActiveSupport::TestCase include TestHelpers::AuthHelper include TestHelpers::JsonHelper + def error!(msg, _code) + raise StandardError, msg + end + + def clear_submission(task) + FileUtils.rm_rf(FileHelper.student_work_dir(:new, task, false)) + FileUtils.rm_rf(FileHelper.student_work_dir(:in_process, task, false)) + end + def app Rails.application end @@ -74,7 +83,7 @@ def test_pdf_creation_with_gif assert_equal 201, last_response.status, last_response_body task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -161,7 +170,7 @@ def test_pdf_creation_with_jpg assert_equal 201, last_response.status task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -205,7 +214,7 @@ def test_pdf_with_quotes_in_task_title task = project.task_for_task_definition(td) - task.convert_submission_to_pdf + task.convert_submission_to_pdf(log_to_stdout: false) path = task.final_pdf_path assert File.exist? path @@ -251,7 +260,7 @@ def test_copy_draft_learning_summary assert project_task.processing_pdf? # Generate pdf for task - assert project_task.convert_submission_to_pdf + assert project_task.convert_submission_to_pdf(log_to_stdout: false) # Check if pdf was copied over project.reload @@ -308,7 +317,7 @@ def test_draft_learning_summary_wont_copy assert project_task.processing_pdf? # Generate pdf for task - assert project_task.convert_submission_to_pdf + assert project_task.convert_submission_to_pdf(log_to_stdout: false) # Check if the file was moved to portfolio assert_not project.uses_draft_learning_summary @@ -352,7 +361,7 @@ def test_ipynb_to_pdf assert_equal 201, last_response.status, last_response_body task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -360,13 +369,13 @@ def test_ipynb_to_pdf # Test if latex math was rendered properly reader = PDF::Reader.new(task.final_pdf_path) - assert reader.pages.last.text.include?("weight\n") + assert reader.pages.last.text.include?("BMI: bmi ="), reader.pages.last.text # ensure the notice is not included when the notebook doesn't have long lines source code cells # and no errors reader.pages.each do |page| assert_not page.text.include? 'The rest of this line has been truncated by the system to improve readability.' - assert_not page.text.include? 'ERROR when parsing' + assert_not page.text.include?('ERROR when parsing'), page.text end # test line wrapping in jupynotex @@ -377,7 +386,7 @@ def test_ipynb_to_pdf assert_equal 201, last_response.status, last_response_body # test submission generation - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -394,7 +403,7 @@ def test_ipynb_to_pdf assert_equal 201, last_response.status, last_response_body # test submission generation - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -446,7 +455,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -469,7 +478,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -520,7 +529,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -543,7 +552,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -557,6 +566,81 @@ def test_code_submission_with_long_lines assert_not File.exist? path unit.destroy! end + + def test_code_submission_with_long_lines + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.new({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task with super ling lines in code submission', + description: 'Code task', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'Long', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'long.py', "type" => 'code' } ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + td.save! + + data_to_post = { + trigger: 'ready_for_feedback' + } + + data_to_post = with_file('test_files/submissions/long.py', 'application/json', data_to_post) + + project = unit.active_projects.first + + add_auth_header_for user: unit.main_convenor_user + + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + + assert_equal 201, last_response.status, last_response_body + + # test submission generation + task = project.task_for_task_definition(td) + assert task.convert_submission_to_pdf(log_to_stdout: false) + path = task.zip_file_path_for_done_task + assert path + assert File.exist? path + assert File.exist? task.final_pdf_path + + # ensure the notice is included when rendered files are truncated + reader = PDF::Reader.new(task.final_pdf_path) + assert reader.pages[1].text.include? "This file has additional line breaks applied" + + # submit a normal file and ensure the notice is not included in the PDF + data_to_post = { + trigger: 'ready_for_feedback' + } + + data_to_post = with_file('test_files/submissions/normal.py', 'application/json', data_to_post) + project = unit.active_projects.first + add_auth_header_for user: unit.main_convenor_user + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + assert_equal 201, last_response.status, last_response_body + + # test submission generation + task = project.task_for_task_definition(td) + assert task.convert_submission_to_pdf(log_to_stdout: false) + path = task.zip_file_path_for_done_task + assert path + assert File.exist? path + assert File.exist? task.final_pdf_path + + # ensure the notice is not included + reader = PDF::Reader.new(task.final_pdf_path) + assert_not reader.pages[1].text.include? "This file has additional line breaks applied" + + td.destroy + assert_not File.exist? path + unit.destroy! + end + def test_pdf_validation_on_submit unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) td = TaskDefinition.new({ @@ -615,7 +699,106 @@ def test_pdf_validation_on_submit assert_equal 201, last_response.status, last_response_body task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) + path = task.zip_file_path_for_done_task + assert path + assert File.exist? path + assert File.exist? task.final_pdf_path + + td.destroy + assert_not File.exist? path + unit.destroy! + end + + def test_pdf_creation_fails_on_invalid_pdf + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.new({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'PDF Test Task', + description: 'Test task', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'PDFTestTask', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'A pdf file', "type" => 'code' } ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + td.save! + + data_to_post = { + trigger: 'ready_for_feedback' + } + + project = unit.active_projects.first + + task = project.task_for_task_definition(td) + + folder = FileHelper.student_work_dir(:new, task) + + # Copy the file in + FileUtils.cp(Rails.root.join('test_files/submissions/corrupted.pdf'), "#{folder}/001-code.cs") + + begin + assert_not task.convert_submission_to_pdf(log_to_stdout: false) + rescue StandardError => e + task.reload + + assert_equal 2, task.comments.count + assert task.comments.last.comment.starts_with?('**Automated Comment**:') + assert task.comments.last.comment.include?(e.message.to_s) + + td.destroy + unit.destroy! + end + end + + def test_pax_crash_does_not_stop_pdf_creation + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.new({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'PDF Test Task', + description: 'Test task', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'PDFTestTask', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'A pdf file', "type" => 'document' } ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + td.save! + + data_to_post = { + trigger: 'ready_for_feedback' + } + + # submit an encrypted (but valid) PDF file and ensure it's rejected immediately + data_to_post = with_file('test_files/submissions/valid.pdf', 'application/json', data_to_post) + + project = unit.active_projects.first + + add_auth_header_for user: unit.main_convenor_user + + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + + assert_equal 201, last_response.status, last_response_body + + task = project.task_for_task_definition(td) + + rails_latex_path = Rails.root.join("tmp/rails-latex/#{Process.pid}-#{Thread.current.hash}") + FileUtils.mkdir_p(rails_latex_path) + FileUtils.cp(Rails.root.join('test_files/latex/input-broken.aux'), "#{rails_latex_path}/input.aux") + + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -625,4 +808,381 @@ def test_pdf_validation_on_submit assert_not File.exist? path unit.destroy! end + + def test_accept_files_checks_they_all_exist + project = FactoryBot.create(:project) + unit = project.unit + user = project.student + convenor = unit.main_convenor_user + task_definition = unit.task_definitions.first + + task_definition.upload_requirements = [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document' + }, + { + "key" => 'file1', + "name" => 'Document 2', + "type" => 'document' + }, + { + "key" => 'file2', + "name" => 'Code 1', + "type" => 'code' + }, + { + "key" => 'file3', + "name" => 'Document 3', + "type" => 'document' + }, + { + "key" => 'file4', + "name" => 'Document 4', + "type" => 'document' + } + ] + + # Saving task def + task_definition.save! + + # Test that the task def is setup correctly + assert_equal 5, task_definition.number_of_uploaded_files + + # Now... lets upload a submission + task = project.task_for_task_definition(task_definition) + + # Create a submission - but no files! + begin + task.accept_submission user, [], self, nil, 'ready_for_feedback', nil + assert false, 'Should have raised an error with no files submitted' + rescue StandardError => e + assert_equal :not_started, task.status + end + + # Create a submission + task.accept_submission user, [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + }, + { + id: 'file1', + name: 'Document 2', + type: 'document', + filename: 'file1.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + }, + { + id: 'file2', + name: 'Code 1', + type: 'code', + filename: 'code.cs', + "tempfile" => File.new(test_file_path('submissions/program.cs')) + }, + { + id: 'file3', + name: 'Document 3', + type: 'document', + filename: 'file3.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + }, + { + id: 'file4', + name: 'Document 4', + type: 'document', + filename: 'file4.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil, accepted_tii_eula: true + + assert_equal :ready_for_feedback, task.status + + task_definition.upload_requirements = [] + task_definition.save! + + task.task_status = TaskStatus.not_started + task.save! + task.reload + + clear_submission(task) + + # Now... lets upload a submission with no files + task.accept_submission user, [], self, nil, 'ready_for_feedback', nil + assert_equal :ready_for_feedback, task.status + + task.task_status = TaskStatus.not_started + task.save! + + # Now... lets upload a submission with too many files + begin + task.accept_submission user, + [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil + assert false, 'Should have raised an error with too many files submitted' + rescue StandardError => e + assert_equal :not_started, task.status + end + end + + def test_cannot_upload_with_existing_upload_in_process + project = FactoryBot.create(:project) + unit = project.unit + user = project.student + convenor = unit.main_convenor_user + task_definition = unit.task_definitions.first + + task_definition.upload_requirements = [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document' + } + ] + + task_definition.target_date = Time.zone.now + 1.day + task_definition.due_date = task_definition.target_date + 1.week + + # Saving task def + task_definition.save! + + # Now... lets upload a submission + task = project.task_for_task_definition(task_definition) + + # Create a submission + task.accept_submission user, [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil, accepted_tii_eula: true + + assert_equal :ready_for_feedback, task.status + + # Now... try uploading again + begin + task.accept_submission user, + [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil + assert false, 'Should have raised an error with existing upload in process' + rescue StandardError => e + assert_includes e.message, 'A submission is already being processed. Please wait for the current submission process to complete.' + assert_equal :ready_for_feedback, task.status + end + + FileHelper.move_files(FileHelper.student_work_dir(:new, task, false), FileHelper.student_work_dir(:in_process, task, false), false) + + begin + task.accept_submission user, + [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil + assert false, 'Should have raised an error with existing upload in process' + rescue StandardError => e + assert_includes e.message, 'A submission is already being processed. Please wait for the current submission process to complete.' + assert_equal :ready_for_feedback, task.status + end + + FileUtils.rm_rf(FileHelper.student_work_dir(:in_process, task, false)) + + assert_not task.processing_pdf? + + # Create a submission + task.accept_submission user, [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil, accepted_tii_eula: true + + assert_equal :ready_for_feedback, task.status + ensure + unit.destroy + end + + def test_check_files_on_task_move + project = FactoryBot.create(:project) + unit = project.unit + user = project.student + convenor = unit.main_convenor_user + task_definition = unit.task_definitions.first + + task_definition.upload_requirements = [ + { + "key" => 'file0', + "name" => 'Document 1', + "type" => 'document' + } + ] + + # Saving task def + task_definition.save! + + # Now... lets upload a submission + task = project.task_for_task_definition(task_definition) + + # Create a submission + task.accept_submission user, [ + { + id: 'file0', + name: 'Document 1', + type: 'document', + filename: 'file0.pdf', + "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) + } + ], self, nil, 'ready_for_feedback', nil, accepted_tii_eula: true + + # Test that we can move to in process + assert task.move_files_to_in_process + assert_not File.exist? FileHelper.student_work_dir(:new, task, false) + assert File.exist? FileHelper.student_work_dir(:in_process, task, false) + + # Test that we can move back to new + FileHelper.move_files(FileHelper.student_work_dir(:in_process, task, false), FileHelper.student_work_dir(:new, task, false), false) + assert File.exist? FileHelper.student_work_dir(:new, task, false) + assert_not File.exist? FileHelper.student_work_dir(:in_process, task, false) + + # Delete a file and try to compress + FileUtils.rm("#{FileHelper.student_work_dir(:new, task)}/000-document.pdf") + + assert_not task.compress_new_to_done + + FileHelper.student_work_dir(:new, task, true) + assert_not task.move_files_to_in_process + ensure + unit.destroy + end + + def test_portfolio_evidence_path + unit = FactoryBot.create(:unit, student_count: 1, task_count: 0) + td = TaskDefinition.new({ + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Test task', + description: 'Code task', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'ABBR', + restrict_status_updates: false, + upload_requirements: [ { "key" => 'file0', "name" => 'Some Code', "type" => 'code' } ], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) + td.save! + + data_to_post = { + trigger: 'ready_for_feedback' + } + + data_to_post = with_file('test_files/submissions/program.cs', 'application/json', data_to_post) + + project = unit.active_projects.first + + add_auth_header_for user: unit.main_convenor_user + + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + + assert_equal 201, last_response.status, last_response_body + + task = project.task_for_task_definition(td) + assert task.convert_submission_to_pdf(log_to_stdout: false) + path = task.zip_file_path_for_done_task + assert path + assert File.exist? path + assert File.exist? task.final_pdf_path + + assert_nil task.portfolio_evidence + + new_path = task.final_pdf_path.gsub(/\.pdf$/, '-evidence.pdf') + + FileUtils.mv task.final_pdf_path(ignore_portfolio_evidence: true), new_path + + task.portfolio_evidence = new_path.gsub(/#{FileHelper.student_work_root}/, '') + task.save + + assert_equal new_path, task.final_pdf_path + assert_not_equal new_path, task.final_pdf_path(ignore_portfolio_evidence: true) + + assert_not File.exist?(task.final_pdf_path(ignore_portfolio_evidence: true)) + assert File.exist?(task.final_pdf_path) + + user = project.student + user.update(username: 'student') + task.reload + assert File.exist?(task.final_pdf_path), "File does not exist #{task.final_pdf_path}" + + td.update(abbreviation: 'ABBR2') + task.reload + assert_not_equal new_path, task.final_pdf_path + assert File.exist?(task.final_pdf_path) + assert File.exist?(task.final_pdf_path(ignore_portfolio_evidence: true)) + + # Rename again... + new_path = task.final_pdf_path.gsub(/\.pdf$/, '-evidence.pdf') + FileUtils.mv task.final_pdf_path, new_path + task.portfolio_evidence = new_path.gsub(/#{FileHelper.student_work_root}/, '') + task.save + + post "/api/projects/#{project.id}/task_def_id/#{td.id}/submission", data_to_post + + # Check it has moved to the new path and removed the portfolio_evidence attribute + task.reload + assert_nil task.portfolio_evidence + assert_not File.exist?(new_path), "File exists #{new_path} after upload" + task.convert_submission_to_pdf(log_to_stdout: false) + assert File.exist?(task.final_pdf_path), task.final_pdf_path + + # Check after archive + + # Rename again... + new_path = task.final_pdf_path.gsub(/\.pdf$/, '-evidence.pdf') + FileUtils.mv task.final_pdf_path, new_path + task.portfolio_evidence = new_path.gsub(/#{FileHelper.student_work_root}/, '') + task.save + + # Move to archive + unit.move_files_to_archive + + # Check it has moved to the new path and removed the portfolio_evidence attribute + task.reload + assert task.portfolio_evidence.present? + assert_not File.exist?(new_path) + assert File.exist?(task.final_pdf_path), "File does not exist #{task.final_pdf_path} after archive" + + td.destroy + unit.destroy + end end diff --git a/test/models/teaching_period_test.rb b/test/models/teaching_period_test.rb index 9ac87c69c..863507e2e 100644 --- a/test/models/teaching_period_test.rb +++ b/test/models/teaching_period_test.rb @@ -199,7 +199,7 @@ def test_create_teaching_period_with_invalid_dates assert tp.units.count > 0 tp.destroy - + rescue assert_not tp.destroyed? end @@ -220,160 +220,4 @@ def test_create_teaching_period_with_invalid_dates tp.destroy assert tp.destroyed? end - - test 'cannot roll over to past teaching periods' do - tp = TeachingPeriod.first - tp2 = TeachingPeriod.last - - assert_not tp.rollover(tp2) - assert_equal 1, tp.errors.count - end - - test 'can roll over to future teaching periods' do - tp = TeachingPeriod.first - - data = { - year: 2019, - period: 'TN', - start_date: Time.zone.now + 1.week, - end_date: Time.zone.now + 13.week, - active_until: Time.zone.now + 15.week - } - - tp2 = TeachingPeriod.create!(data) - - assert tp.rollover(tp2) - assert_equal 0, tp.errors.count - end - - test 'can update teaching period dates' do - data = { - year: 2019, - period: 'T1', - start_date: Date.parse('2018-01-01'), - end_date: Date.parse('2018-02-01'), - active_until: Date.parse('2018-03-01') - } - - tp = TeachingPeriod.create(data) - assert tp.valid? - - unit_data = { - name: 'Unit with TP - to update', - code: 'TEST113', - teaching_period: tp, - description: 'Unit in TP to update dates', - } - - unit = Unit.create(unit_data) - - assert unit.valid? - - tp.update!(start_date: Date.parse('2018-01-02')) - - assert tp.valid? - - unit = Unit.includes(:teaching_period).find(unit.id) - assert unit.valid?, unit.errors.inspect - - tp.update(end_date: Date.parse('2018-02-02')) - - assert tp.valid? - unit.reload - assert unit.valid? - end - - def test_search_forward_occurs_in_rollover - tp1 = FactoryBot.create :teaching_period, start_date: Time.zone.now - tp2 = FactoryBot.create :teaching_period, start_date: Time.zone.now + 20.weeks - tp3 = FactoryBot.create :teaching_period, start_date: Time.zone.now + 40.weeks - - u1 = FactoryBot.create :unit, with_students: false, code: 'SIT111', task_count: 1, teaching_period: tp1 - - assert_equal 1, tp1.units.count - assert_equal 0, tp2.units.count - - tp1.rollover tp2, false - - assert_equal 1, tp2.units.count - assert_equal 0, tp3.units.count - - u1.reload - - u2 = tp2.units.first - u2.reload - u2.task_definitions.first.update(name: u2.task_definitions.first.name + "A") - u1.reload - - refute_equal u1.task_definitions.first.name, u2.task_definitions.first.name - - tp1.rollover tp3, true - - assert_equal 1, tp3.units.count - - u3 = tp3.units.first - - u1.reload - u2.reload - u3.reload - - u1.task_definitions.reload - u2.task_definitions.reload - u3.task_definitions.reload - - assert_equal u2.task_definitions.first.name, u3.task_definitions.first.name - refute_equal u1.task_definitions.first.name, u3.task_definitions.first.name - end - - def test_rollover_active_only - tp1 = FactoryBot.create :teaching_period, start_date: Time.zone.now - tp2 = FactoryBot.create :teaching_period, start_date: Time.zone.now + 20.weeks - - u1 = FactoryBot.create :unit, with_students: false, code: 'SIT111', task_count: 0, teaching_period: tp1 - u2 = FactoryBot.create :unit, with_students: false, code: 'SIT112', task_count: 0, teaching_period: tp1 - - u1.active = false - u1.save - - assert_equal 2, tp1.units.count - assert_equal 0, tp2.units.count - - tp1.rollover tp2, false - - assert_equal 1, tp2.units.count - end - - def test_can_opt_to_rollover_inactive - tp1 = FactoryBot.create :teaching_period, start_date: Time.zone.now - tp2 = FactoryBot.create :teaching_period, start_date: Time.zone.now + 20.weeks - - u1 = FactoryBot.create :unit, with_students: false, code: 'SIT111', task_count: 0, teaching_period: tp1 - u2 = FactoryBot.create :unit, with_students: false, code: 'SIT112', task_count: 0, teaching_period: tp1 - - u1.active = false - u1.save - - assert_equal 2, tp1.units.count - assert_equal 0, tp2.units.count - - tp1.rollover tp2, false, true - - assert_equal 2, tp2.units.count - end - - def test_rollover_detects_existing_units - tp1 = FactoryBot.create :teaching_period, start_date: Time.zone.now - tp2 = FactoryBot.create :teaching_period, start_date: Time.zone.now + 20.weeks - - u1 = FactoryBot.create :unit, with_students: false, code: 'SIT111', task_count: 0, teaching_period: tp1 - u2 = FactoryBot.create :unit, with_students: false, code: 'SIT111', task_count: 0, teaching_period: tp2 - - assert_equal 1, tp1.units.count - assert_equal 1, tp2.units.count - - tp1.rollover tp2 - - assert_equal 1, tp2.units.count - end - end diff --git a/test/models/tii_model_test.rb b/test/models/tii_model_test.rb index b6c8a4565..e2a4450c2 100644 --- a/test/models/tii_model_test.rb +++ b/test/models/tii_model_test.rb @@ -7,7 +7,7 @@ class TiiModelTest < ActiveSupport::TestCase include TestHelpers::TestFileHelper def test_fetch_eula - skip "TurnItIn Integration Tests Skipped" unless Doubtfire::Application.config.tii_enabled + skip "TurnItIn Integration Tests Skipped" unless TurnItIn.enabled? clear_tii_eula refute Rails.cache.fetch('tii.eula_version').present? @@ -85,7 +85,7 @@ def test_fetch_eula end def test_fetch_eula_error_handling - skip "TurnItIn Integration Tests Skipped" unless Doubtfire::Application.config.tii_enabled + skip "TurnItIn Integration Tests Skipped" unless TurnItIn.enabled? clear_tii_eula eula_version_stub = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/eula/latest"). @@ -104,7 +104,7 @@ def test_fetch_eula_error_handling end def test_tii_features_enabled - skip "TurnItIn Integration Tests Skipped" unless Doubtfire::Application.config.tii_enabled + skip "TurnItIn Integration Tests Skipped" unless TurnItIn.enabled? clear_tii_festures_enabled body = '{ @@ -167,7 +167,7 @@ def test_tii_features_enabled end def test_tii_process - skip "TurnItIn Integration Tests Skipped" unless Doubtfire::Application.config.tii_enabled + skip "TurnItIn Integration Tests Skipped" unless TurnItIn.enabled? setup_tii_features_enabled @@ -311,7 +311,7 @@ def test_tii_process "tempfile" => File.new(test_file_path('submissions/1.2P.pdf')) }, - ], user, nil, nil, 'ready_for_feedback', nil, accepted_tii_eula: true + ], nil, nil, 'ready_for_feedback', nil, accepted_tii_eula: true # Check that the submission is going to be progressed assert_equal 1, AcceptSubmissionJob.jobs.count diff --git a/test/models/tii_user_accept_eula_test.rb b/test/models/tii_user_accept_eula_test.rb index 6d156a053..567b6af2f 100644 --- a/test/models/tii_user_accept_eula_test.rb +++ b/test/models/tii_user_accept_eula_test.rb @@ -4,6 +4,7 @@ class TiiUserAcceptEulaTest < ActiveSupport::TestCase include TestHelpers::TiiTestHelper def test_can_accept_tii_eula + setup_tii_features_enabled setup_tii_eula assert TurnItIn.eula_version.present? @@ -15,7 +16,7 @@ def test_can_accept_tii_eula assert user.tii_eula_date.present? assert_equal TurnItIn.eula_version, user.tii_eula_version - refute user.tii_eula_version_confirmed + assert_not user.tii_eula_version_confirmed assert_equal 1, TiiActionJob.jobs.count @@ -35,6 +36,8 @@ def test_can_accept_tii_eula end def test_eula_accept_will_retry + TiiAction.destroy_all + setup_tii_features_enabled setup_tii_eula user = FactoryBot.create(:user) @@ -45,10 +48,10 @@ def test_eula_accept_will_retry # Get the action tracking this progress... action = TiiActionAcceptEula.last - refute action.complete + assert_not action.complete assert action.retry - refute user.tii_eula_version_confirmed + assert_not user.tii_eula_version_confirmed assert_equal 1, TiiActionJob.jobs.count assert_equal user, action.entity @@ -67,7 +70,7 @@ def test_eula_accept_will_retry action.reload assert_requested accept_stub, times: 1 - refute action.complete + assert_not action.complete assert action.retry # Reset to retry with check progress sweep @@ -77,11 +80,11 @@ def test_eula_accept_will_retry check_job.perform # Second fails action.reload - refute user.reload.tii_eula_version_confirmed + assert_not user.reload.tii_eula_version_confirmed assert_requested accept_stub, times: 2 - refute action.complete - refute action.retry + assert_not action.complete + assert_not action.retry # Reset to retry with check progress sweep action.update(last_run: DateTime.now - 31.minutes, retry: true) @@ -91,7 +94,7 @@ def test_eula_accept_will_retry assert_requested accept_stub, times: 3 assert action.complete - refute action.retry + assert_not action.retry # Reload our copy of user user.reload @@ -101,6 +104,7 @@ def test_eula_accept_will_retry end def test_eula_accept_rate_limit + setup_tii_features_enabled setup_tii_eula # Prepare stub for call when eula is accepted and it fails @@ -134,46 +138,4 @@ def test_eula_accept_rate_limit action.perform assert_requested accept_stub, times: 2 end - - def test_eula_respects_global_errors - setup_tii_eula - - # Prepare stub for call when eula is accepted and it fails - accept_stub = stub_request(:post, "https://#{ENV['TCA_HOST']}/api/v1/eula/v1beta/accept"). - with(tii_headers). - to_return( - {status: 403, body: "", headers: {} }, - {status: 200, body: "", headers: {} }, # should not occur, until end - ) - - user = FactoryBot.create(:user) - # Queue job to accept eula - user.accept_tii_eula - - action = TiiActionAcceptEula.last - - # Make sure we have the right action - assert_equal user, action.entity - - # Perform manually - TiiActionJob.jobs.clear - action.perform - - assert_requested accept_stub, times: 1 - refute TurnItIn.functional? - - refute action.retry - - action.perform - # Call does not go to tii as limit applied - assert_requested accept_stub, times: 1 - - # Clear global error - TurnItIn.global_error = nil - assert TurnItIn.functional? - - # When cleared, the job will run - action.perform - assert_requested accept_stub, times: 2 - end end diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 5ac3abe08..9bb524dc6 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -113,7 +113,7 @@ def test_rollover_of_task_files @unit.import_tasks_from_csv File.open(Rails.root.join('test_files', "#{@unit.code}-Tasks.csv")) @unit.import_task_files_from_zip Rails.root.join('test_files', "#{@unit.code}-Tasks.zip") - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil unit2.task_definitions.each do |td| assert File.exist?(td.task_sheet), 'task sheet is absent' @@ -130,7 +130,7 @@ def test_rollover_of_learning_summary @unit.draft_task_definition = lsr @unit.save - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil assert_not_nil unit2.draft_task_definition refute_equal lsr, unit2.draft_task_definition @@ -139,7 +139,7 @@ def test_rollover_of_learning_summary end def test_rollover_of_portfolio_generation - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil assert unit2.portfolio_auto_generation_date.present? assert unit2.portfolio_auto_generation_date > unit2.start_date && unit2.portfolio_auto_generation_date < unit2.end_date @@ -157,7 +157,7 @@ def test_rollover_of_group_tasks groups: [ { gs: 0, students: 2} ], group_tasks: [ { idx: 0, gs: 0 }] ) - unit2 = unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = unit.rollover TeachingPeriod.find(2), nil, nil, nil assert_equal 1, unit2.group_sets.count assert_not_equal unit2.group_sets.first, unit.group_sets.first @@ -172,7 +172,7 @@ def test_rollover_of_task_ilo_links @unit.import_outcomes_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Outcomes.csv")) @unit.import_task_alignment_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Alignment.csv")), nil - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil assert @unit.task_outcome_alignments.count > 0 assert_equal @unit.task_outcome_alignments.count, unit2.task_outcome_alignments.count @@ -192,7 +192,7 @@ def test_rollover_of_task_ilo_links def test_rollover_of_tasks_have_same_start_week_and_day @unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Tasks.csv")) - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil assert_equal 3, @unit.teaching_period_id assert_equal 2, unit2.teaching_period_id @@ -210,7 +210,7 @@ def test_rollover_of_tasks_have_same_start_week_and_day def test_rollover_of_tasks_have_same_target_week_and_day @unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Tasks.csv")) - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil @unit.task_definitions.each do |td| td2 = unit2.task_definitions.find_by_abbreviation(td.abbreviation) @@ -247,7 +247,7 @@ def test_updating_unit_dates_propogates_to_tasks test 'rollover of tasks have same due week and day' do @unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Tasks.csv")) - unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil + unit2 = @unit.rollover TeachingPeriod.find(2), nil, nil, nil @unit.task_definitions.each do |td| td2 = unit2.task_definitions.find_by_abbreviation(td.abbreviation) @@ -256,7 +256,6 @@ def test_updating_unit_dates_propogates_to_tasks end end - test 'ensure valid response from unit ilo data' do @unit.import_tasks_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Tasks.csv")) @unit.import_outcomes_from_csv File.open(Rails.root.join('test_files',"#{@unit.code}-Outcomes.csv")) @@ -417,9 +416,9 @@ def check_task_completion_csv unit, col_count = nil # Test basic details assert_equal project.student.username, entry['username'], entry.inspect if project.student.student_id.present? - assert_equal project.student.student_id, entry['student_id'], entry.inspect + assert_equal project.student.student_id, entry['student id'], entry.inspect else - assert_nil entry['student_id'], entry.inspect + assert_nil entry['student id'], entry.inspect end assert_equal project.student.email, entry['email'], entry.inspect @@ -503,7 +502,7 @@ def test_export_users assert_json_matches_model(user, entry, %w( username student_id first_name last_name email)) - campus = Campus.find_by_abbr_or_name entry['campus'] + campus = Campus.find_by('abbreviation = :name OR name = :name', name: entry['campus']) assert campus.present?, entry assert_equal project.campus, campus, entry @@ -715,4 +714,220 @@ def test_portfolio_zip end end + def test_change_unit_code_moves_files + unit = FactoryBot.create :unit, student_count: 1, unenrolled_student_count: 0, inactive_student_count: 0, task_count: 1, tutorials: 1, outcome_count: 0, staff_count: 0, campus_count: 1 + + td = unit.task_definitions.first + assert_not File.exist?(td.task_sheet) + FileUtils.touch(td.task_sheet) + assert File.exist?(td.task_sheet) + + old_path = td.task_sheet + + # also check tasks + p = unit.projects.first + task = p.task_for_task_definition(td) + task_pdf = task.final_pdf_path + FileUtils.touch(task_pdf) + + assert File.exist?(task_pdf) + assert task_pdf.include?(unit.code) + assert task_pdf.include?(unit.id.to_s) + + unit.code = "New-#{unit.code}" + unit.save! + + td.reload + task.reload + + assert_not_equal old_path, td.task_sheet + assert_not File.exist?(old_path), "Old file still exists" + assert File.exist?(td.task_sheet), "New file does not exist" + + assert_not_equal task.final_pdf_path, task_pdf + assert_not File.exist?(task_pdf), "Old task file still exists" + assert File.exist?(task.final_pdf_path), "New task file does not exist" + + assert File.exist?(task.final_pdf_path), "Portfolio evidence file does not exist = #{task.final_pdf_path}" + assert task.has_pdf + + unit.destroy! + end + + test 'rollover to set dates' do + start_date = Time.zone.now + end_date = start_date + 14.weeks + + unit2 = @unit.rollover(nil, start_date, end_date, nil) + + assert_equal @unit.code, unit2.code + assert_in_delta start_date, unit2.start_date, 1.hour + assert_in_delta end_date, unit2.end_date, 1.hour + + unit2.destroy + end + + test 'rollover to new code with dates' do + start_date = Time.zone.now + end_date = start_date + 14.weeks + + unit2 = @unit.rollover(nil, start_date, end_date, 'NEWCODE-1') + + assert_not_equal @unit.code, unit2.code + assert_equal 'NEWCODE-1', unit2.code + assert_in_delta start_date, unit2.start_date, 1.hour + assert_in_delta end_date, unit2.end_date, 1.hour + + unit2.destroy + end + + test 'rollover to new code with teaching period' do + @unit.import_tasks_from_csv File.open(Rails.root.join('test_files', "#{@unit.code}-Tasks.csv")) + @unit.import_task_files_from_zip Rails.root.join('test_files', "#{@unit.code}-Tasks.zip") + + tp = TeachingPeriod.find(2) + + unit2 = @unit.rollover(tp, nil, nil, 'NEWCODE-1') + + assert_not_equal @unit.code, unit2.code + assert_equal 'NEWCODE-1', unit2.code + assert_equal tp, unit2.teaching_period + + unit2.task_definitions.each do |td| + assert File.exist?(td.task_sheet), 'task sheet is absent' + end + + assert File.exist?(unit2.task_definitions.first.task_resources), 'task resource is absent' + + # can rollover in the same teaching period with a new code + unit3 = unit2.rollover(tp, nil, nil, 'NEWCODE-2') + + assert_not_equal unit2.code, unit3.code + assert_equal 'NEWCODE-2', unit3.code + assert_equal tp, unit3.teaching_period + + unit3.task_definitions.each do |td| + assert File.exist?(td.task_sheet), 'task sheet is absent' + end + + assert File.exist?(unit3.task_definitions.first.task_resources), 'task resource is absent' + + unit2.destroy + unit3.destroy + end + + def test_archive_unit + Doubtfire::Application.config.archive_units = true + unit = FactoryBot.create :unit, student_count: 1, unenrolled_student_count: 0, inactive_student_count: 0, task_count: 1, tutorials: 1, outcome_count: 0, staff_count: 0, campus_count: 1 + + td = unit.task_definitions.first + assert_not File.exist?(td.task_sheet) + FileUtils.touch(td.task_sheet) + assert File.exist?(td.task_sheet) + + old_path = td.task_sheet + + # also check tasks + p = unit.projects.first + task = p.task_for_task_definition(td) + task_pdf = task.final_pdf_path + FileUtils.touch(task_pdf) + + DatabasePopulator.generate_portfolio(p) + old_portfolio_path = p.portfolio_path + + old_submission_history_path = FileHelper.task_submission_identifier_path_with_timestamp(:done, task, '123/45') + FileUtils.mkdir_p(old_submission_history_path) + FileUtils.touch(File.join(old_submission_history_path, 'output.txt')) + + assert File.exist?(old_path) + assert File.exist?(task_pdf) + assert File.exist?(old_portfolio_path) + assert File.exist?(old_submission_history_path) + assert File.exist?(File.join(old_submission_history_path, 'output.txt')) + + unit.move_files_to_archive + unit.archived = true + unit.save! + + td.reload + task.reload + + assert_not File.exist?(old_path), "Old file still exists" + assert File.exist?(td.task_sheet), "New file does not exist - #{td.task_sheet}" + assert_not File.exist?(task_pdf), "Old task file still exists" + assert File.exist?(task.final_pdf_path), "New task file does not exist" + assert_not File.exist?(old_portfolio_path), "Old portfolio file still exists - #{old_portfolio_path}" + assert File.exist?(p.portfolio_path), "New portfolio file does not exist" + assert_not File.exist?(old_submission_history_path), "Old submission history still exists - #{old_submission_history_path}" + assert File.exist?(FileHelper.task_submission_identifier_path(:done, task)) + assert File.exist?(File.join(FileHelper.task_submission_identifier_path_with_timestamp(:done, task, '123_45'), 'output.txt')) + + assert File.exist?(task.final_pdf_path), "Portfolio evidence file does not exist - #{task.final_pdf_path}" + + td.abbreviation = 'NEW' + td.save + task.reload + + # File exists after rename + assert File.exist?(task.final_pdf_path), "Portfolio evidence file does not exist - #{task.final_pdf_path}" + assert File.exist?(FileHelper.task_submission_identifier_path(:done, task)) + assert File.exist?(File.join(FileHelper.task_submission_identifier_path_with_timestamp(:done, task, '123_45'), 'output.txt')) + + p.student.update(username: 'NEW_USERNAME') + task.reload + assert File.exist?(task.final_pdf_path), "Portfolio evidence file does not exist after username change - #{task.final_pdf_path}" + assert File.exist?(p.portfolio_path), "New portfolio file does not exist" + assert File.exist?(FileHelper.task_submission_identifier_path(:done, task)) + assert File.exist?(File.join(FileHelper.task_submission_identifier_path_with_timestamp(:done, task, '123_45'), 'output.txt')) + + new_tp = FactoryBot.create :teaching_period + new_unit = unit.rollover(new_tp, nil, nil, nil) + + assert_not new_unit.archived + + unit.destroy! + + assert_not File.exist?(td.task_sheet), "New file exists after delete - #{td.task_sheet}" + assert_not File.exist?(task.final_pdf_path), "New task file exists after delete - #{task.final_pdf_path}" + assert_not File.exist?(p.portfolio_path), "New portfolio exists after delete - #{p.portfolio_path}" + assert_not File.exist?(FileHelper.task_submission_identifier_path(:done, task)) + assert_not File.exist?(File.join(FileHelper.task_submission_identifier_path_with_timestamp(:done, task, '123_45'), 'output.txt')) + ensure + Doubtfire::Application.config.archive_units = false + end + + def test_archive_unit_job + assert_not Doubtfire::Application.config.archive_units, 'Archive units should be off by default' + + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + + unit.end_date = Time.zone.now - Doubtfire::Application.config.unit_archive_after_period - 1.day + unit.start_date = unit.end_date - 14.weeks + unit.save! + + unit2 = FactoryBot.create :unit, with_students: false, task_count: 0 + + assert_not unit.archived + assert_not unit2.archived + + job = ArchiveOldUnitsJob.new + job.perform + + unit.reload + unit2.reload + + assert_not unit.archived + assert_not unit2.archived + + Doubtfire::Application.config.archive_units = true + + job.perform + unit.reload + unit2.reload + + assert unit.archived + assert_not unit2.archived + end + end diff --git a/test/models/webcal_test.rb b/test/models/webcal_test.rb index 31b58159d..7ef2ae319 100644 --- a/test/models/webcal_test.rb +++ b/test/models/webcal_test.rb @@ -33,10 +33,11 @@ class WebcalTest < ActiveSupport::TestCase teardown do @webcal.destroy - @student.destroy + @old_project.destroy @old_unit.destroy @current_unit_1.destroy @current_unit_2.destroy + @student.destroy @campus.destroy end @@ -110,11 +111,11 @@ class WebcalTest < ActiveSupport::TestCase end test 'Includes events with extended date if available' do - # Apply for an extension on one task td = @current_unit_1.task_definitions.first task = @current_project_1.task_for_task_definition(td) comment = task.apply_for_extension(@student, 'extension', 1) + comment.assess_extension(task.tutor, true) # Detect corresponding Ical event cal = @webcal.to_ical @@ -159,7 +160,7 @@ class WebcalTest < ActiveSupport::TestCase checks.each do |check| @webcal.update(reminder_time: time, reminder_unit: check[:unit]) cal = @webcal.to_ical - + per_task_def.call do |td, ev| assert_equal 1, ev.alarms.count, 'Error: Specified alarm does not exist.' diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index 9b725fad8..43260fef2 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,10 +6,12 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.load_from_hash YAML.load_file("#{Rails.root}/config/schedule.yml") Sidekiq::Cron::Job.all.each(&:enque!) - assert_equal 2, Sidekiq::Cron::Job.all.count + assert_equal 3, Sidekiq::Cron::Job.all.count assert_equal 1, TiiRegisterWebHookJob.jobs.count assert_equal 1, TiiCheckProgressJob.jobs.count + assert_equal 1, ClearAccessTokensJob.jobs.count + # assert_equal 1, ArchiveOldUnitsJob.jobs.count end end diff --git a/test/sidekiq/tii_check_progress_job_test.rb b/test/sidekiq/tii_check_progress_job_test.rb index e3b960b4c..ed200c20a 100644 --- a/test/sidekiq/tii_check_progress_job_test.rb +++ b/test/sidekiq/tii_check_progress_job_test.rb @@ -4,8 +4,220 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase include TestHelpers::TiiTestHelper + def test_check_eula_change + TiiAction.delete_all + setup_tii_features_enabled + setup_tii_eula + + # Create a task definition with two attachments + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, stream_count: 0) + + task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [ + { + 'key' => 'file0', + 'name' => 'My document', + 'type' => 'document', + 'tii_check' => true, + 'tii_pct' => 10 + } + ]) + + # Setup users + convenor = unit.main_convenor_user + tutor = FactoryBot.create(:user, :tutor) + student = FactoryBot.create(:user, :student) + + # Add users to unit + tutor_unit_role = unit.employ_staff(tutor, Role.tutor) + project = unit.enrol_student(student, Campus.first) + + # Create tutorial and enrol + tutorial = FactoryBot.create(:tutorial, unit: unit, campus: Campus.first, unit_role: tutor_unit_role) + + project.enrol_in tutorial + + task = project.task_for_task_definition(task_def) + + # Create a submission + sub1 = TiiSubmission.create( + task: task, + idx: 0, + filename: 'test.doc', + status: :created, + submitted_by_user: student + ) + sub2 = TiiSubmission.create( + task: task, + idx: 0, + filename: 'test.doc', + status: :created, + submitted_by_user: student + ) + sub3 = TiiSubmission.create( + task: task, + idx: 0, + filename: 'test.doc', + status: :created, + submitted_by_user: student + ) + + action = TiiActionUploadSubmission.find_or_create_by(entity: sub1) + + # Test fail as not EULA accepted + action.perform + + assert_not action.retry + assert_not action.complete + assert_equal TiiActionUploadSubmission::NO_USER_ACCEPTED_EULA_ERROR, action.custom_error_message + + # Now have convenor accept EULA + convenor.tii_eula_date = DateTime.now + convenor.tii_eula_version = TurnItIn.eula_version + convenor.save + + # Check the convenor has accepted + assert convenor.accepted_tii_eula? + + # See if we can retry + action.attempt_retry_on_no_eula + + assert action.retry + assert_not action.complete + assert_equal convenor, sub1.submitted_by + + convenor.tii_eula_version = nil + convenor.tii_eula_date = nil + convenor.save + assert_not convenor.accepted_tii_eula? + + # Reset... to try with tutor + action = TiiActionUploadSubmission.find_or_create_by(entity: sub2) + action.perform + + # Tutor accepts eula + tutor.tii_eula_date = DateTime.now + tutor.tii_eula_version = TurnItIn.eula_version + tutor.save + + # Check the tutor has accepted + assert tutor.accepted_tii_eula? + + # See if we can retry + action.attempt_retry_on_no_eula + + assert action.retry + assert_not action.complete + assert_equal tutor, sub2.submitted_by + + tutor.tii_eula_version = nil + tutor.tii_eula_date = nil + tutor.save + assert_not tutor.accepted_tii_eula? + + # Reset... to try with student + action = TiiActionUploadSubmission.find_or_create_by(entity: sub3) + action.perform + + # Student accepts eula + student.tii_eula_date = DateTime.now + student.tii_eula_version = TurnItIn.eula_version + student.save + + # Check the student has accepted + assert student.accepted_tii_eula? + + # See if we can retry + action.attempt_retry_on_no_eula + + assert action.retry + assert_not action.complete + assert_equal student, sub3.submitted_by + ensure + unit.destroy + end + + def test_that_progress_checks_eula_change + TiiAction.delete_all + + setup_tii_eula + setup_tii_features_enabled + + # Create a task definition with two attachments + unit = FactoryBot.create(:unit, with_students: false, task_count: 0, stream_count: 0) + + task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: [ + { + 'key' => 'file0', + 'name' => 'My document', + 'type' => 'document', + 'tii_check' => true, + 'tii_pct' => 10 + } + ]) + + # Setup users + convenor = unit.main_convenor_user + tutor = FactoryBot.create(:user, :tutor) + student = FactoryBot.create(:user, :student) + + # Add users to unit + tutor_unit_role = unit.employ_staff(tutor, Role.tutor) + project = unit.enrol_student(student, Campus.first) + + # Create tutorial and enrol + tutorial = FactoryBot.create(:tutorial, unit: unit, campus: Campus.first, unit_role: tutor_unit_role) + + project.enrol_in tutorial + + task = project.task_for_task_definition(task_def) + + # Create a submission + sub1 = TiiSubmission.create( + task: task, + idx: 0, + filename: 'test.doc', + status: :created, + submitted_by_user: student + ) + + action = TiiActionUploadSubmission.find_or_create_by(entity: sub1) + + # Test fail as not EULA accepted + action.perform + + assert_not action.retry + assert_not action.complete + assert_equal TiiActionUploadSubmission::NO_USER_ACCEPTED_EULA_ERROR, action.custom_error_message + + # Get the job + job = TiiCheckProgressJob.new + + # Performing the job does not chaange the action - no eula change + job.perform + + action.reload + assert_not action.retry + assert_not action.complete + + # Now have convenor accept EULA + convenor.tii_eula_date = DateTime.now + convenor.tii_eula_version = TurnItIn.eula_version + convenor.save + + # Perform progress check job + job.perform + + # Will trigger retry of action, but wont perform as it is not old + action.reload + assert action.retry + assert_not action.complete + + unit.destroy + end + def test_waits_to_process_action setup_tii_eula + setup_tii_features_enabled # Will test with user eula user = FactoryBot.create(:user) @@ -76,7 +288,7 @@ def test_waits_to_process_action assert_requested accept_request, times: 2 assert action.reload.retry - refute action.complete + assert_not action.complete action.update(last_run: DateTime.now - 31.minutes) job.perform # attempt 3 - but rate limited @@ -92,7 +304,7 @@ def test_waits_to_process_action # Check it was all success assert action.reload.complete - refute action.retry + assert_not action.retry assert user.reload.accepted_tii_eula? assert user.tii_eula_version_confirmed diff --git a/test/sidekiq/tii_webhooks_job_test.rb b/test/sidekiq/tii_webhooks_job_test.rb index 217878dac..2381029ca 100644 --- a/test/sidekiq/tii_webhooks_job_test.rb +++ b/test/sidekiq/tii_webhooks_job_test.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true require 'test_helper' -class TiiCWebhooksJobTest < ActiveSupport::TestCase +class TiiWebhooksJobTest < ActiveSupport::TestCase include TestHelpers::TiiTestHelper def test_register_webhooks + setup_tii_eula + setup_tii_features_enabled + + Doubtfire::Application.config.tii_register_webhook = true + # Will ask for current webhooks list_webhooks_stub = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/webhooks"). with(tii_headers). @@ -30,25 +35,28 @@ def test_register_webhooks ] ) ].to_json, - headers: {}) + headers: {} + ) + + ENV['TCA_SIGNING_KEY'] = 'TESTING' # and will register the webhooks - register_webhooks_stub = stub_request(:post, "https://#{ENV['TCA_HOST']}/api/v1/webhooks"). - with(tii_headers). - with( - body: TCAClient::WebhookWithSecret.new( - signing_secret: ENV.fetch('TCA_SIGNING_KEY', nil), - url: TurnItIn.webhook_url, - event_types: [ - 'SIMILARITY_COMPLETE', - 'SUBMISSION_COMPLETE', - 'SIMILARITY_UPDATED', - 'PDF_STATUS', - 'GROUP_ATTACHMENT_COMPLETE' - ] - ).to_json, - ). - to_return(status: 200, body: "", headers: {}) + register_webhooks_stub = stub_request(:post, "https://#{ENV['TCA_HOST']}/api/v1/webhooks") + .with(tii_headers) + .with( + body: TCAClient::WebhookWithSecret.new( + signing_secret: Base64.encode64(ENV.fetch('TCA_SIGNING_KEY', nil)).tr("\n", ''), + url: TurnItIn.webhook_url, + event_types: [ + 'SIMILARITY_COMPLETE', + 'SUBMISSION_COMPLETE', + 'SIMILARITY_UPDATED', + 'PDF_STATUS', + 'GROUP_ATTACHMENT_COMPLETE' + ] + ).to_json + ) + .to_return(status: 200, body: "", headers: {}) job = TiiRegisterWebHookJob.new job.perform @@ -58,6 +66,8 @@ def test_register_webhooks end def test_do_not_register_if_registered + Doubtfire::Application.config.tii_register_webhook = true + # Will ask for current webhooks list_webhooks_stub = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/webhooks"). with(tii_headers). @@ -109,4 +119,49 @@ def test_do_not_register_if_registered assert_requested list_webhooks_stub, times: 1 assert_requested register_webhooks_stub, times: 0 end + + def test_can_remove_webhooks + # Will ask for current webhooks + list_webhooks_stub = stub_request(:get, "https://#{ENV['TCA_HOST']}/api/v1/webhooks"). + with(tii_headers). + to_return( + status: 200, + body: [ + TCAClient::Webhook.new( + "id" => "f5d62573-277d-4725-b557-c64877ddf6c7", + "url" => "https://myschool.sweetlms.com/turnitin-callbacks", + "description" => "my first webhook", + "created_time" => "2017-10-20T13:39:53.816Z", + "event_types" => [ + "SUBMISSION_COMPLETE" + ] + ), + TCAClient::Webhook.new( + "id" => "another-id", + "url" => TurnItIn.webhook_url, + "description" => "my second webhook", + "created_time" => "2017-10-20T13:39:53.816Z", + "event_types" => [ + "SUBMISSION_COMPLETE" + ] + ) + ].to_json, + headers: {} + ) + + delete_webhook_1_stub = stub_request(:delete, "https://#{ENV['TCA_HOST']}/api/v1/webhooks/f5d62573-277d-4725-b557-c64877ddf6c7") + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) + + delete_webhook_2_stub = stub_request(:delete, "https://#{ENV['TCA_HOST']}/api/v1/webhooks/another-id") + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) + + action = TiiActionRegisterWebhook.last || TiiActionRegisterWebhook.create + action.remove_webhooks + + assert_requested list_webhooks_stub, times: 1 + assert_requested delete_webhook_1_stub, times: 1 + assert_requested delete_webhook_2_stub, times: 1 + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6912a190a..8b3494080 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -73,7 +73,6 @@ class ActiveSupport::TestCase # Ensure turn it in states is cleared TurnItIn.reset_rate_limit - TurnItIn.global_error = nil TestHelpers::TiiTestHelper.setup_tii_eula TestHelpers::TiiTestHelper.setup_tii_features_enabled @@ -90,5 +89,6 @@ class ActiveSupport::TestCase DatabaseCleaner.clean Faker::UniqueGenerator.clear + ActionMailer::Base.deliveries.clear end end diff --git a/test_files/COS10001-ImportTasksWithTutorialStream.csv b/test_files/COS10001-ImportTasksWithTutorialStream.csv index d31f822b0..b36339a21 100644 --- a/test_files/COS10001-ImportTasksWithTutorialStream.csv +++ b/test_files/COS10001-ImportTasksWithTutorialStream.csv @@ -1,37 +1,37 @@ -name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream -Pass Task 1.1 - Hello World,1.1P,"As a first step, create the classic 'Hello World' program. This will help ensure that you have all of the software installed correctly, and are ready to move on with creating other,,, programs.",1,0,FALSE,"[{""key"":""file0"",""name"":""HelloWorld.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks -Pass Task 1.2 - Picture Drawing,1.2P,Create a program that calls procedures to draw a picture to a window (something other than a house which we use as the example).,2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks -Pass Task 1.3 - Creating a Procedure,1.3P,"Now that you have created a program that uses procedures, you can learn how to create your own procedures. Creating procedures will allow you to group your program's actions into procedures that perform meaningful tasks.",2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks -Credit Task 1.4 - Concept Map,1.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",1,Tue,2,Tue,,,0,FALSE,90,,,import-tasks -Pass Task 2.1 - Hand Execute Assignment,2.1P,"Using the assignment statement, you can assign a value to a variable. In this task you will demonstrate how this action works within the computer.",2,0,FALSE,"[{""key"":""file0"",""name"":""Program Execution 1"",""type"":""image""},{""key"":""file1"",""name"":""Program Execution 2"",""type"":""image""},{""key"":""file2"",""name"":""Program Execution 3"",""type"":""image""},{""key"":""file3"",""name"":""Program Execution 4"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks -Pass Task 2.2 - Hello User,2.2P,Now that we have variables we can create a program that reads in the users name from the Terminal and echoes back a welcome message.,4,0,FALSE,"[{""key"":""file0"",""name"":""HelloUser.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks -Pass Task 2.3 - My Drawing Procedure,2.3P,Procedures are a great way of encapsulating the instructions needed to perform a task. In most cases the task will need some input data for it to work with. Use parameters to provide data to your procedures.,2,0,FALSE,"[{""key"":""file0"",""name"":""Shape Drawing Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks -Pass Task 2.4 - My Functions,2.4P,Using functions you can now create artefacts to encapsulate the steps needed to calculate a value.,4,0,FALSE,"[{""key"":""file0"",""name"":""My Function Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks -Credit Task 2.5 - Concept Maps,2.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",2,Tue,3,Tue,,,5,FALSE,90,,,import-tasks -Pass Task 3.1 - Hand Execution of Control Flow,3.1P,In this task you will use the hand execution process to demonstrate how the control flow constructs operate within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Button Code"",""type"":""code""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks -Pass Task 3.2 - Name Tester,3.2P,Control flow enables you to easily add conditions and loops to your programs. In this task you will create a small program that uses conditions and loops to output custom messages to users.,4,0,FALSE,"[{""key"":""file0"",""name"":""Name Tester code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks -Pass Task 3.3 - Circle Moving,3.3P,In this task you will create a small program that allows the user to move a circle around on the screen.,4,0,FALSE,"[{""key"":""file0"",""name"":""Circle Mover code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks -Credit Task 3.4 - User Input Functions,3.4C,So far we have provided you with a unit to read and check values entered by the user: the Terminal User Input unit. In this task you will extend this library so that it has a number of additional functions.,4,1,FALSE,"[{""key"":""file0"",""name"":""User Input unit code"",""type"":""code""},{""key"":""file1"",""name"":""Program code"",""type"":""code""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks -Credit Task 3.5 - Concept Map,3.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks -Distinction Task 3.6 - Mandelbrot,3.6D,The Mandelbrot provides an interesting challenge in order to determine how to zoom in to and out of the section of the Mandelbrot being shown to the user.,4,2,FALSE,"[{""key"":""file0"",""name"":""Mandelbrot code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks -Pass Task 4.1 - Using Records and Enumerations,4.1P,Effectively organising your data makes programs much easier to develop. By using records and enumerations you can start to model the entities associated with your programs.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,7,Mon,0,FALSE,90,,,import-tasks -Credit Task 4.2 - Fruit Punch,4.2C,Create a program using the concepts covered so far.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,,,0,FALSE,90,,,import-tasks -Credit Task 4.3 - Concept Map,4.3C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",4,Tue,5,Tue,,,0,FALSE,90,,,import-tasks -Test 1,T1,Test 1 covers weeks 1 to 3,1,0,TRUE,[],5,Fri,5,Fri,,,0,FALSE,90,,,import-tasks -Pass Task 5.1 - Hand Execution of Arrays,5.1P,Demonstrate how arrays work within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,import-tasks -Pass Task 5.2 - Arrays of Records,5.2P,Add an array of records to your program that uses records.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,import-tasks -Credit Task 5.3 - Food Hunter,5.3C,Extend a small game to make use of arrays.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks -Credit Task 5.4 - Concept Map,5.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks -Distinction Task 5.5 - Sort Visualiser,5.5D,Create a program to demonstrate sorting working within the computer.,4,2,FALSE,"[{""key"":""file0"",""name"":""Sort Visualiser"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks -Pass Task 6.1 - Structure Charts,6.1P,Illustrate the structure of your program using a structure chart.,2,0,FALSE,"[{""key"":""file0"",""name"":""Program structrue chart"",""type"":""image""}]",6,Tue,7,Tue,10,Mon,0,FALSE,90,,,import-tasks -Pass Task 7.1 - Programming Principles,7.1P,"Describe the principles of structured, procedural, programming.",4,0,FALSE,"[{""key"":""file0"",""name"":""Program Principles Description"",""type"":""document""}]",7,Tue,8,Tue,10,Mon,0,FALSE,90,,,import-tasks -Distinction Task 7.2 - Game of Life,7.2D,Create the Game of Life,4,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",7,Tue,8,Tue,,,0,FALSE,90,,,import-tasks -Pass Task 8.1 - Language Reference Sheet,8.1P,Create a reference sheet for C or C#,4,0,FALSE,"[{""key"":""file0"",""name"":""Reference Sheet"",""type"":""document""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,import-tasks -Pass Task 8.2 - Circle Moving 2,8.2P,Recreate your circle moving program using C,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,import-tasks -Test 2,T2,Covers all core concepts.,1,0,TRUE,[],9,Fri,9,Fri,,,0,FALSE,90,,,import-tasks -Pass Task 9.1 - Reading Another Language,9.1P,Demonstrate how programs written in C work within the computer,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,import-tasks -Credit Task 9.2 - Another Language,9.2C,Create a program with C using the concepts covered.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,import-tasks -High Distinction Task 10.1 - Custom Program,10.1H,Extend your custom program to meet the High Distinction criteria.,4,3,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",10,Tue,13,Tue,,,0,FALSE,90,,,import-tasks -High Distinction Task 10.2 - Research Report,10.2H,Start working on a research project,8,3,FALSE,"[{""key"":""file0"",""name"":""Research Report Document"",""type"":""document""}]",10,Tue,13,Tue,,,0,FALSE,90,,,import-tasks -Pass Task 11.1 - Learning Summary Report,11.1P,Summarise your learning from the unit.,4,0,FALSE,"[{""key"":""file0"",""name"":""Learning Summary Report"",""type"":""document""}]",11,Tue,12,Tue,,,0,FALSE,90,,,import-tasks -Distinction Task 6.2 - Custom Program,6.2D,Start working on your custom program!,16,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",6,Tue,13,Tue,,,5,TRUE,90,,,import-tasks \ No newline at end of file +name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream,scorm_enabled,scorm_allow_review,scorm_bypass_test,scorm_time_delay_enabled,scorm_attempt_limit +Pass Task 1.1 - Hello World,1.1P,"As a first step, create the classic 'Hello World' program. This will help ensure that you have all of the software installed correctly, and are ready to move on with creating other,,, programs.",1,0,FALSE,"[{""key"":""file0"",""name"":""HelloWorld.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 1.2 - Picture Drawing,1.2P,Create a program that calls procedures to draw a picture to a window (something other than a house which we use as the example).,2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 1.3 - Creating a Procedure,1.3P,"Now that you have created a program that uses procedures, you can learn how to create your own procedures. Creating procedures will allow you to group your program's actions into procedures that perform meaningful tasks.",2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Credit Task 1.4 - Concept Map,1.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",1,Tue,2,Tue,,,0,FALSE,90,,,import-tasks,,, +Pass Task 2.1 - Hand Execute Assignment,2.1P,"Using the assignment statement, you can assign a value to a variable. In this task you will demonstrate how this action works within the computer.",2,0,FALSE,"[{""key"":""file0"",""name"":""Program Execution 1"",""type"":""image""},{""key"":""file1"",""name"":""Program Execution 2"",""type"":""image""},{""key"":""file2"",""name"":""Program Execution 3"",""type"":""image""},{""key"":""file3"",""name"":""Program Execution 4"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 2.2 - Hello User,2.2P,Now that we have variables we can create a program that reads in the users name from the Terminal and echoes back a welcome message.,4,0,FALSE,"[{""key"":""file0"",""name"":""HelloUser.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 2.3 - My Drawing Procedure,2.3P,Procedures are a great way of encapsulating the instructions needed to perform a task. In most cases the task will need some input data for it to work with. Use parameters to provide data to your procedures.,2,0,FALSE,"[{""key"":""file0"",""name"":""Shape Drawing Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 2.4 - My Functions,2.4P,Using functions you can now create artefacts to encapsulate the steps needed to calculate a value.,4,0,FALSE,"[{""key"":""file0"",""name"":""My Function Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,import-tasks,,, +Credit Task 2.5 - Concept Maps,2.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",2,Tue,3,Tue,,,5,FALSE,90,,,import-tasks,,, +Pass Task 3.1 - Hand Execution of Control Flow,3.1P,In this task you will use the hand execution process to demonstrate how the control flow constructs operate within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Button Code"",""type"":""code""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 3.2 - Name Tester,3.2P,Control flow enables you to easily add conditions and loops to your programs. In this task you will create a small program that uses conditions and loops to output custom messages to users.,4,0,FALSE,"[{""key"":""file0"",""name"":""Name Tester code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 3.3 - Circle Moving,3.3P,In this task you will create a small program that allows the user to move a circle around on the screen.,4,0,FALSE,"[{""key"":""file0"",""name"":""Circle Mover code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,import-tasks,,, +Credit Task 3.4 - User Input Functions,3.4C,So far we have provided you with a unit to read and check values entered by the user: the Terminal User Input unit. In this task you will extend this library so that it has a number of additional functions.,4,1,FALSE,"[{""key"":""file0"",""name"":""User Input unit code"",""type"":""code""},{""key"":""file1"",""name"":""Program code"",""type"":""code""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks,,, +Credit Task 3.5 - Concept Map,3.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks,,, +Distinction Task 3.6 - Mandelbrot,3.6D,The Mandelbrot provides an interesting challenge in order to determine how to zoom in to and out of the section of the Mandelbrot being shown to the user.,4,2,FALSE,"[{""key"":""file0"",""name"":""Mandelbrot code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,import-tasks,,, +Pass Task 4.1 - Using Records and Enumerations,4.1P,Effectively organising your data makes programs much easier to develop. By using records and enumerations you can start to model the entities associated with your programs.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,7,Mon,0,FALSE,90,,,import-tasks,,, +Credit Task 4.2 - Fruit Punch,4.2C,Create a program using the concepts covered so far.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,,,0,FALSE,90,,,import-tasks,,, +Credit Task 4.3 - Concept Map,4.3C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",4,Tue,5,Tue,,,0,FALSE,90,,,import-tasks,,, +Test 1,T1,Test 1 covers weeks 1 to 3,1,0,TRUE,[],5,Fri,5,Fri,,,0,FALSE,90,,,import-tasks,,, +Pass Task 5.1 - Hand Execution of Arrays,5.1P,Demonstrate how arrays work within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 5.2 - Arrays of Records,5.2P,Add an array of records to your program that uses records.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Credit Task 5.3 - Food Hunter,5.3C,Extend a small game to make use of arrays.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks,,, +Credit Task 5.4 - Concept Map,5.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks,,, +Distinction Task 5.5 - Sort Visualiser,5.5D,Create a program to demonstrate sorting working within the computer.,4,2,FALSE,"[{""key"":""file0"",""name"":""Sort Visualiser"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,import-tasks,,, +Pass Task 6.1 - Structure Charts,6.1P,Illustrate the structure of your program using a structure chart.,2,0,FALSE,"[{""key"":""file0"",""name"":""Program structrue chart"",""type"":""image""}]",6,Tue,7,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 7.1 - Programming Principles,7.1P,"Describe the principles of structured, procedural, programming.",4,0,FALSE,"[{""key"":""file0"",""name"":""Program Principles Description"",""type"":""document""}]",7,Tue,8,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Distinction Task 7.2 - Game of Life,7.2D,Create the Game of Life,4,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",7,Tue,8,Tue,,,0,FALSE,90,,,import-tasks,,, +Pass Task 8.1 - Language Reference Sheet,8.1P,Create a reference sheet for C or C#,4,0,FALSE,"[{""key"":""file0"",""name"":""Reference Sheet"",""type"":""document""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Pass Task 8.2 - Circle Moving 2,8.2P,Recreate your circle moving program using C,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,import-tasks,,, +Test 2,T2,Covers all core concepts.,1,0,TRUE,[],9,Fri,9,Fri,,,0,FALSE,90,,,import-tasks,,, +Pass Task 9.1 - Reading Another Language,9.1P,Demonstrate how programs written in C work within the computer,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,import-tasks,,, +Credit Task 9.2 - Another Language,9.2C,Create a program with C using the concepts covered.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,import-tasks,,, +High Distinction Task 10.1 - Custom Program,10.1H,Extend your custom program to meet the High Distinction criteria.,4,3,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",10,Tue,13,Tue,,,0,FALSE,90,,,import-tasks,,, +High Distinction Task 10.2 - Research Report,10.2H,Start working on a research project,8,3,FALSE,"[{""key"":""file0"",""name"":""Research Report Document"",""type"":""document""}]",10,Tue,13,Tue,,,0,FALSE,90,,,import-tasks,,, +Pass Task 11.1 - Learning Summary Report,11.1P,Summarise your learning from the unit.,4,0,FALSE,"[{""key"":""file0"",""name"":""Learning Summary Report"",""type"":""document""}]",11,Tue,12,Tue,,,0,FALSE,90,,,import-tasks,,, +Distinction Task 6.2 - Custom Program,6.2D,Start working on your custom program!,16,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",6,Tue,13,Tue,,,5,TRUE,90,,,import-tasks,,, diff --git a/test_files/COS10001-ImportTasksWithoutTutorialStream.csv b/test_files/COS10001-ImportTasksWithoutTutorialStream.csv index c44b12a4b..d5f7eb1e8 100644 --- a/test_files/COS10001-ImportTasksWithoutTutorialStream.csv +++ b/test_files/COS10001-ImportTasksWithoutTutorialStream.csv @@ -1,37 +1,37 @@ -name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream -Pass Task 1.1 - Hello World,1.1P,"As a first step, create the classic 'Hello World' program. This will help ensure that you have all of the software installed correctly, and are ready to move on with creating other,,, programs.",1,0,FALSE,"[{""key"":""file0"",""name"":""HelloWorld.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,, -Pass Task 1.2 - Picture Drawing,1.2P,Create a program that calls procedures to draw a picture to a window (something other than a house which we use as the example).,2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,, -Pass Task 1.3 - Creating a Procedure,1.3P,"Now that you have created a program that uses procedures, you can learn how to create your own procedures. Creating procedures will allow you to group your program's actions into procedures that perform meaningful tasks.",2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,, -Credit Task 1.4 - Concept Map,1.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",1,Tue,2,Tue,,,0,FALSE,90,,, -Pass Task 2.1 - Hand Execute Assignment,2.1P,"Using the assignment statement, you can assign a value to a variable. In this task you will demonstrate how this action works within the computer.",2,0,FALSE,"[{""key"":""file0"",""name"":""Program Execution 1"",""type"":""image""},{""key"":""file1"",""name"":""Program Execution 2"",""type"":""image""},{""key"":""file2"",""name"":""Program Execution 3"",""type"":""image""},{""key"":""file3"",""name"":""Program Execution 4"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,, -Pass Task 2.2 - Hello User,2.2P,Now that we have variables we can create a program that reads in the users name from the Terminal and echoes back a welcome message.,4,0,FALSE,"[{""key"":""file0"",""name"":""HelloUser.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,, -Pass Task 2.3 - My Drawing Procedure,2.3P,Procedures are a great way of encapsulating the instructions needed to perform a task. In most cases the task will need some input data for it to work with. Use parameters to provide data to your procedures.,2,0,FALSE,"[{""key"":""file0"",""name"":""Shape Drawing Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,, -Pass Task 2.4 - My Functions,2.4P,Using functions you can now create artefacts to encapsulate the steps needed to calculate a value.,4,0,FALSE,"[{""key"":""file0"",""name"":""My Function Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,, -Credit Task 2.5 - Concept Maps,2.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",2,Tue,3,Tue,,,5,FALSE,90,,, -Pass Task 3.1 - Hand Execution of Control Flow,3.1P,In this task you will use the hand execution process to demonstrate how the control flow constructs operate within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Button Code"",""type"":""code""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,, -Pass Task 3.2 - Name Tester,3.2P,Control flow enables you to easily add conditions and loops to your programs. In this task you will create a small program that uses conditions and loops to output custom messages to users.,4,0,FALSE,"[{""key"":""file0"",""name"":""Name Tester code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,, -Pass Task 3.3 - Circle Moving,3.3P,In this task you will create a small program that allows the user to move a circle around on the screen.,4,0,FALSE,"[{""key"":""file0"",""name"":""Circle Mover code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,, -Credit Task 3.4 - User Input Functions,3.4C,So far we have provided you with a unit to read and check values entered by the user: the Terminal User Input unit. In this task you will extend this library so that it has a number of additional functions.,4,1,FALSE,"[{""key"":""file0"",""name"":""User Input unit code"",""type"":""code""},{""key"":""file1"",""name"":""Program code"",""type"":""code""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,, -Credit Task 3.5 - Concept Map,3.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",3,Tue,4,Tue,,,0,FALSE,90,,, -Distinction Task 3.6 - Mandelbrot,3.6D,The Mandelbrot provides an interesting challenge in order to determine how to zoom in to and out of the section of the Mandelbrot being shown to the user.,4,2,FALSE,"[{""key"":""file0"",""name"":""Mandelbrot code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,, -Pass Task 4.1 - Using Records and Enumerations,4.1P,Effectively organising your data makes programs much easier to develop. By using records and enumerations you can start to model the entities associated with your programs.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,7,Mon,0,FALSE,90,,, -Credit Task 4.2 - Fruit Punch,4.2C,Create a program using the concepts covered so far.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,,,0,FALSE,90,,, -Credit Task 4.3 - Concept Map,4.3C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",4,Tue,5,Tue,,,0,FALSE,90,,, -Test 1,T1,Test 1 covers weeks 1 to 3,1,0,TRUE,[],5,Fri,5,Fri,,,0,FALSE,90,,, -Pass Task 5.1 - Hand Execution of Arrays,5.1P,Demonstrate how arrays work within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,, -Pass Task 5.2 - Arrays of Records,5.2P,Add an array of records to your program that uses records.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,, -Credit Task 5.3 - Food Hunter,5.3C,Extend a small game to make use of arrays.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,, -Credit Task 5.4 - Concept Map,5.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",5,Tue,6,Tue,,,0,FALSE,90,,, -Distinction Task 5.5 - Sort Visualiser,5.5D,Create a program to demonstrate sorting working within the computer.,4,2,FALSE,"[{""key"":""file0"",""name"":""Sort Visualiser"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,, -Pass Task 6.1 - Structure Charts,6.1P,Illustrate the structure of your program using a structure chart.,2,0,FALSE,"[{""key"":""file0"",""name"":""Program structrue chart"",""type"":""image""}]",6,Tue,7,Tue,10,Mon,0,FALSE,90,,, -Pass Task 7.1 - Programming Principles,7.1P,"Describe the principles of structured, procedural, programming.",4,0,FALSE,"[{""key"":""file0"",""name"":""Program Principles Description"",""type"":""document""}]",7,Tue,8,Tue,10,Mon,0,FALSE,90,,, -Distinction Task 7.2 - Game of Life,7.2D,Create the Game of Life,4,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",7,Tue,8,Tue,,,0,FALSE,90,,, -Pass Task 8.1 - Language Reference Sheet,8.1P,Create a reference sheet for C or C#,4,0,FALSE,"[{""key"":""file0"",""name"":""Reference Sheet"",""type"":""document""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,, -Pass Task 8.2 - Circle Moving 2,8.2P,Recreate your circle moving program using C,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,, -Test 2,T2,Covers all core concepts.,1,0,TRUE,[],9,Fri,9,Fri,,,0,FALSE,90,,, -Pass Task 9.1 - Reading Another Language,9.1P,Demonstrate how programs written in C work within the computer,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,, -Credit Task 9.2 - Another Language,9.2C,Create a program with C using the concepts covered.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,, -High Distinction Task 10.1 - Custom Program,10.1H,Extend your custom program to meet the High Distinction criteria.,4,3,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",10,Tue,13,Tue,,,0,FALSE,90,,, -High Distinction Task 10.2 - Research Report,10.2H,Start working on a research project,8,3,FALSE,"[{""key"":""file0"",""name"":""Research Report Document"",""type"":""document""}]",10,Tue,13,Tue,,,0,FALSE,90,,, -Pass Task 11.1 - Learning Summary Report,11.1P,Summarise your learning from the unit.,4,0,FALSE,"[{""key"":""file0"",""name"":""Learning Summary Report"",""type"":""document""}]",11,Tue,12,Tue,,,0,FALSE,90,,, -Distinction Task 6.2 - Custom Program,6.2D,Start working on your custom program!,16,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",6,Tue,13,Tue,,,5,TRUE,90,,, \ No newline at end of file +name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream,scorm_enabled,scorm_allow_review,scorm_bypass_test,scorm_time_delay_enabled,scorm_attempt_limit +Pass Task 1.1 - Hello World,1.1P,"As a first step, create the classic 'Hello World' program. This will help ensure that you have all of the software installed correctly, and are ready to move on with creating other,,, programs.",1,0,FALSE,"[{""key"":""file0"",""name"":""HelloWorld.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,,,, +Pass Task 1.2 - Picture Drawing,1.2P,Create a program that calls procedures to draw a picture to a window (something other than a house which we use as the example).,2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,,,, +Pass Task 1.3 - Creating a Procedure,1.3P,"Now that you have created a program that uses procedures, you can learn how to create your own procedures. Creating procedures will allow you to group your program's actions into procedures that perform meaningful tasks.",2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,,,, +Credit Task 1.4 - Concept Map,1.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",1,Tue,2,Tue,,,0,FALSE,90,,,,,, +Pass Task 2.1 - Hand Execute Assignment,2.1P,"Using the assignment statement, you can assign a value to a variable. In this task you will demonstrate how this action works within the computer.",2,0,FALSE,"[{""key"":""file0"",""name"":""Program Execution 1"",""type"":""image""},{""key"":""file1"",""name"":""Program Execution 2"",""type"":""image""},{""key"":""file2"",""name"":""Program Execution 3"",""type"":""image""},{""key"":""file3"",""name"":""Program Execution 4"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,,,, +Pass Task 2.2 - Hello User,2.2P,Now that we have variables we can create a program that reads in the users name from the Terminal and echoes back a welcome message.,4,0,FALSE,"[{""key"":""file0"",""name"":""HelloUser.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,,,, +Pass Task 2.3 - My Drawing Procedure,2.3P,Procedures are a great way of encapsulating the instructions needed to perform a task. In most cases the task will need some input data for it to work with. Use parameters to provide data to your procedures.,2,0,FALSE,"[{""key"":""file0"",""name"":""Shape Drawing Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,,,, +Pass Task 2.4 - My Functions,2.4P,Using functions you can now create artefacts to encapsulate the steps needed to calculate a value.,4,0,FALSE,"[{""key"":""file0"",""name"":""My Function Code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",2,Tue,3,Tue,5,Mon,0,FALSE,90,,,,,, +Credit Task 2.5 - Concept Maps,2.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",2,Tue,3,Tue,,,5,FALSE,90,,,,,, +Pass Task 3.1 - Hand Execution of Control Flow,3.1P,In this task you will use the hand execution process to demonstrate how the control flow constructs operate within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Button Code"",""type"":""code""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,,,, +Pass Task 3.2 - Name Tester,3.2P,Control flow enables you to easily add conditions and loops to your programs. In this task you will create a small program that uses conditions and loops to output custom messages to users.,4,0,FALSE,"[{""key"":""file0"",""name"":""Name Tester code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,,,, +Pass Task 3.3 - Circle Moving,3.3P,In this task you will create a small program that allows the user to move a circle around on the screen.,4,0,FALSE,"[{""key"":""file0"",""name"":""Circle Mover code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,7,Mon,0,FALSE,90,,,,,, +Credit Task 3.4 - User Input Functions,3.4C,So far we have provided you with a unit to read and check values entered by the user: the Terminal User Input unit. In this task you will extend this library so that it has a number of additional functions.,4,1,FALSE,"[{""key"":""file0"",""name"":""User Input unit code"",""type"":""code""},{""key"":""file1"",""name"":""Program code"",""type"":""code""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,,,, +Credit Task 3.5 - Concept Map,3.5C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",3,Tue,4,Tue,,,0,FALSE,90,,,,,, +Distinction Task 3.6 - Mandelbrot,3.6D,The Mandelbrot provides an interesting challenge in order to determine how to zoom in to and out of the section of the Mandelbrot being shown to the user.,4,2,FALSE,"[{""key"":""file0"",""name"":""Mandelbrot code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",3,Tue,4,Tue,,,0,FALSE,90,,,,,, +Pass Task 4.1 - Using Records and Enumerations,4.1P,Effectively organising your data makes programs much easier to develop. By using records and enumerations you can start to model the entities associated with your programs.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,7,Mon,0,FALSE,90,,,,,, +Credit Task 4.2 - Fruit Punch,4.2C,Create a program using the concepts covered so far.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",4,Tue,5,Tue,,,0,FALSE,90,,,,,, +Credit Task 4.3 - Concept Map,4.3C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",4,Tue,5,Tue,,,0,FALSE,90,,,,,, +Test 1,T1,Test 1 covers weeks 1 to 3,1,0,TRUE,[],5,Fri,5,Fri,,,0,FALSE,90,,,,,, +Pass Task 5.1 - Hand Execution of Arrays,5.1P,Demonstrate how arrays work within the computer.,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,,,, +Pass Task 5.2 - Arrays of Records,5.2P,Add an array of records to your program that uses records.,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,10,Mon,0,FALSE,90,,,,,, +Credit Task 5.3 - Food Hunter,5.3C,Extend a small game to make use of arrays.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,,,, +Credit Task 5.4 - Concept Map,5.4C,A concept map visually shows the relationships between concepts. This task aims to help you think through the various relationships between the structured procedural programming concepts and the associated programming artefacts.,4,1,FALSE,"[{""key"":""file0"",""name"":""Concept map"",""type"":""document""}]",5,Tue,6,Tue,,,0,FALSE,90,,,,,, +Distinction Task 5.5 - Sort Visualiser,5.5D,Create a program to demonstrate sorting working within the computer.,4,2,FALSE,"[{""key"":""file0"",""name"":""Sort Visualiser"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",5,Tue,6,Tue,,,0,FALSE,90,,,,,, +Pass Task 6.1 - Structure Charts,6.1P,Illustrate the structure of your program using a structure chart.,2,0,FALSE,"[{""key"":""file0"",""name"":""Program structrue chart"",""type"":""image""}]",6,Tue,7,Tue,10,Mon,0,FALSE,90,,,,,, +Pass Task 7.1 - Programming Principles,7.1P,"Describe the principles of structured, procedural, programming.",4,0,FALSE,"[{""key"":""file0"",""name"":""Program Principles Description"",""type"":""document""}]",7,Tue,8,Tue,10,Mon,0,FALSE,90,,,,,, +Distinction Task 7.2 - Game of Life,7.2D,Create the Game of Life,4,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",7,Tue,8,Tue,,,0,FALSE,90,,,,,, +Pass Task 8.1 - Language Reference Sheet,8.1P,Create a reference sheet for C or C#,4,0,FALSE,"[{""key"":""file0"",""name"":""Reference Sheet"",""type"":""document""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,,,, +Pass Task 8.2 - Circle Moving 2,8.2P,Recreate your circle moving program using C,4,0,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",8,Tue,9,Tue,10,Mon,0,FALSE,90,,,,,, +Test 2,T2,Covers all core concepts.,1,0,TRUE,[],9,Fri,9,Fri,,,0,FALSE,90,,,,,, +Pass Task 9.1 - Reading Another Language,9.1P,Demonstrate how programs written in C work within the computer,2,0,FALSE,"[{""key"":""file0"",""name"":""Execution of Program 1"",""type"":""image""},{""key"":""file1"",""name"":""Execution of Program 2"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,,,, +Credit Task 9.2 - Another Language,9.2C,Create a program with C using the concepts covered.,4,1,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",9,Tue,10,Tue,,,0,FALSE,90,,,,,, +High Distinction Task 10.1 - Custom Program,10.1H,Extend your custom program to meet the High Distinction criteria.,4,3,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",10,Tue,13,Tue,,,0,FALSE,90,,,,,, +High Distinction Task 10.2 - Research Report,10.2H,Start working on a research project,8,3,FALSE,"[{""key"":""file0"",""name"":""Research Report Document"",""type"":""document""}]",10,Tue,13,Tue,,,0,FALSE,90,,,,,, +Pass Task 11.1 - Learning Summary Report,11.1P,Summarise your learning from the unit.,4,0,FALSE,"[{""key"":""file0"",""name"":""Learning Summary Report"",""type"":""document""}]",11,Tue,12,Tue,,,0,FALSE,90,,,,,, +Distinction Task 6.2 - Custom Program,6.2D,Start working on your custom program!,16,2,FALSE,"[{""key"":""file0"",""name"":""Program code"",""type"":""code""},{""key"":""file1"",""name"":""Design overview"",""type"":""document""},{""key"":""file2"",""name"":""Screenshot"",""type"":""image""}]",6,Tue,13,Tue,,,5,TRUE,90,,,,,, diff --git a/test_files/COS10001-Tasks.csv b/test_files/COS10001-Tasks.csv index bc86315bc..ae03608c0 100644 --- a/test_files/COS10001-Tasks.csv +++ b/test_files/COS10001-Tasks.csv @@ -1,4 +1,4 @@ -name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream +name,abbreviation,description,weighting,target_grade,restrict_status_updates,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,tutorial_stream,scorm_enabled,scorm_allow_review,scorm_bypass_test,scorm_time_delay_enabled,scorm_attempt_limit Pass Task 1.1 - Hello World,1.1P,"As a first step, create the classic 'Hello World' program. This will help ensure that you have all of the software installed correctly, and are ready to move on with creating other,,, programs.",1,0,FALSE,"[{""key"":""file0"",""name"":""HelloWorld.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks Pass Task 1.2 - Picture Drawing,1.2P,Create a program that calls procedures to draw a picture to a window (something other than a house which we use as the example).,2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks Pass Task 1.3 - Creating a Procedure,1.3P,"Now that you have created a program that uses procedures, you can learn how to create your own procedures. Creating procedures will allow you to group your program's actions into procedures that perform meaningful tasks.",2,0,FALSE,"[{""key"":""file0"",""name"":""PictureDrawing.pas"",""type"":""code""},{""key"":""file1"",""name"":""Screenshot"",""type"":""image""}]",1,Tue,2,Tue,5,Mon,0,FALSE,90,,,import-tasks diff --git a/test_files/csv_test_files/COS10001-Tasks.csv b/test_files/csv_test_files/COS10001-Tasks.csv index fc9930340..52a9ebe8d 100644 --- a/test_files/csv_test_files/COS10001-Tasks.csv +++ b/test_files/csv_test_files/COS10001-Tasks.csv @@ -1,2 +1,2 @@ -name,abbreviation,description,weighting,target_grade,restrict_status_updates,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day -Assignment 12,A12,rerum ut fugit saepe ipsa in quidem,2,0,FALSE,0,FALSE,50,[],,"[{""key"":""file0"",""name"":""Assumenda accusamus quas"",""type"":""image""}]",-1,Sat,1,Mon,13,Mon \ No newline at end of file +name,abbreviation,description,weighting,target_grade,restrict_status_updates,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,scorm_enabled,scorm_allow_review,scorm_bypass_test,scorm_time_delay_enabled,scorm_attempt_limit +Assignment 12,A12,rerum ut fugit saepe ipsa in quidem,2,0,FALSE,0,FALSE,50,[],,"[{""key"":""file0"",""name"":""Assumenda accusamus quas"",""type"":""image""}]",-1,Sat,1,Mon,13,Mon,,, diff --git a/test_files/csv_test_files/COS10001-Tasks.xlsx b/test_files/csv_test_files/COS10001-Tasks.xlsx index 49839ecf8..5da9ad5ee 100644 Binary files a/test_files/csv_test_files/COS10001-Tasks.xlsx and b/test_files/csv_test_files/COS10001-Tasks.xlsx differ diff --git a/test_files/deakin/enrol_multi_1.json b/test_files/deakin/enrol_multi_1.json new file mode 100644 index 000000000..3a2cfe189 --- /dev/null +++ b/test_files/deakin/enrol_multi_1.json @@ -0,0 +1,43 @@ +{ + "unitEnrolments": [ + { + "unitCode": "SIT724", + "unitTitle": "RESEARCH PROJECT", + "teachingPeriod": { + "type": "trimester", + "period": "2", + "year": "2024" + }, + "locations": [ + { + "name": "Test Sync Campus", + "enrolments": [ + { + "studentId": 11111000, + "title": "MR", + "surname": "TEST", + "givenNames": "TEST", + "preferredName": "TEST", + "email": "test@deakin.edu.au", + "courseCode": "S464", + "unitClass": "X", + "status": "Discontinued" + }, + { + "studentId": 222220000, + "title": "MR", + "surname": "TEST", + "givenNames": "TEST", + "preferredName": "TEST", + "email": "test1@deakin.edu.au", + "courseCode": "S464", + "unitClass": "X", + "status": "Enrolled" + } + ] + } + ] + } + ] +} + diff --git a/test_files/deakin/enrol_multi_2.json b/test_files/deakin/enrol_multi_2.json new file mode 100644 index 000000000..04a021f12 --- /dev/null +++ b/test_files/deakin/enrol_multi_2.json @@ -0,0 +1,43 @@ +{ + "unitEnrolments": [ + { + "unitCode": "SIT746", + "unitTitle": "RESEARCH PROJECT", + "teachingPeriod": { + "type": "trimester", + "period": "2", + "year": "2024" + }, + "locations": [ + { + "name": "Test Sync Campus", + "enrolments": [ + { + "studentId": 11111000, + "title": "MR", + "surname": "TEST", + "givenNames": "TEST", + "preferredName": "TEST", + "email": "test@deakin.edu.au", + "courseCode": "S464", + "unitClass": "X", + "status": "Enrolled" + }, + { + "studentId": 222220000, + "title": "MR", + "surname": "TEST", + "givenNames": "TEST", + "preferredName": "TEST", + "email": "test1@deakin.edu.au", + "courseCode": "S464", + "unitClass": "X", + "status": "Discontinued" + } + ] + } + ] + } + ] +} + diff --git a/test_files/latex/input-broken.aux b/test_files/latex/input-broken.aux new file mode 100644 index 000000000..13a13ad73 --- /dev/null +++ b/test_files/latex/input-broken.aux @@ -0,0 +1,40 @@ +\relax +\providecommand\hyper@newdestlabel[2]{} +\providecommand\HyField@AuxAddToFields[1]{} +\providecommand\HyField@AuxAddToCoFields[2]{} +\providecommand{\NEWPAX@DestReq}[2]{} +\providecommand{\NEWPAX@DestProv}[2]{} +\providecommand\BKM@entry[2]{} +\NEWPAX@DestProv{000-document.newpax}{Congratulations!} +\NEWPAX@DestProv{000-document.newpax}{Decision-Tree} +\NEWPAX@DestProv{000-document.newpax}{Explanation-using-LIME} +\NEWPAX@DestProv{000-document.newpax}{Feature-importance:} +\NEWPAX@DestProv{000-document.newpax}{LIME-for-explaining-prediction-images} +\NEWPAX@DestProv{000-document.newpax}{LIME:} +\NEWPAX@DestProv{000-document.newpax}{Model-Interpretation-Methods} +\NEWPAX@DestProv{000-document.newpax}{Model-Performance} +\NEWPAX@DestProv{000-document.newpax}{Pre-processing} +\NEWPAX@DestProv{000-document.newpax}{Predictions-on-the-test-data} +\NEWPAX@DestProv{000-document.newpax}{Split-Train-and-Test-Datasets} +\NEWPAX@DestProv{000-document.newpax}{Training-the-classification-model} +\NEWPAX@DestProv{000-document.newpax}{Understanding-the-Census-Income-Dataset} +\NEWPAX@DestProv{000-document.newpax}{Visualzing-the-Tree} +\NEWPAX@DestProv{000-document.newpax}{When-a-person's-income-%3C=-$50K} +\NEWPAX@DestProv{000-document.newpax}{When-a-person's-income-%3E-$50K} +\NEWPAX@DestProv{000-document.newpax}{When-a-person's-income-actual-is-different-than-predicted} +\NEWPAX@DestProv{000-document.newpax}{Your-own-test-example} +\NEWPAX@DestProv{000-document.newpax}{sk-container-id-1} +\NEWPAX@DestProv{000-document.newpax}{sk-container-id-2} +\NEWPAX@DestProv{000-document.newpax}{sk-estimator-id-1} +\NEWPAX@DestProv{000-document.newpax}{sk-estimator-id-2} +\NEWPAX@DestProv{000-document.newpax}{top_div8WD77G29ZHEFM8A} +\NEWPAX@DestProv{000-document.newpax}{top_divDR5YA6I6PZOKFNQ} +\NEWPAX@DestProv{000-document.newpax}{top_divGH11HTIE1SXJUDR} +\NEWPAX@DestProv{000-document.newpax}{top_divQ4ALLQ5KXQWP33K} +\NEWPAX@DestProv{000-document.newpax}{top_divR1OKF6JPUWMW89V} +\NEWPAX@DestProv{000-document.newpax}{top_divSJB1S48PH4T84MA} +\NEWPAX@DestProv{000-document.newpax}{top_divWA6RJ9LWIJMH2XT} +\newlabel{LastPage}{{}{27}{}{page.27}{}} +\gdef\lastpage@lastpage{27} +\gdef\lastpage@lastpageHy{27} +\gdef \@abspage@last{28} diff --git a/test_files/numbas.zip b/test_files/numbas.zip new file mode 100644 index 000000000..2ea1ef5a9 Binary files /dev/null and b/test_files/numbas.zip differ diff --git a/test_files/submissions/invalid_notebook.ipynb b/test_files/submissions/invalid_notebook.ipynb new file mode 100644 index 000000000..1b2c83911 --- /dev/null +++ b/test_files/submissions/invalid_notebook.ipynb @@ -0,0 +1,137 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test of invalid notebook" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Testing that two single markdown cells can be adjcent - with heading in the first. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: scipy in /Users/user/opt/anaconda3/lib/python3.12/site-packages (1.13.1)\n", + "Collecting scipy\u0007\u0000\u0007\n", + "\u0000\n", + "\u0001\n", + "\u0002\n", + "\u0003\n", + "\u0004\n", + "\u0005\n", + "\u0006\n", + "\u0007\n", + "\b\n", + "\t\n", + "\n", + "\n", + "\u0008\n", + "\u0009\n", + "\u000a\n", + "\u000b\n", + "\f\n", + "\r\n", + "\u000c\n", + "\u000d\n", + "\u000e\n", + "\u000f\n", + "\u0010\n", + "\u0011\n", + "\u0012\n", + "\u0013\n", + "\u0014\n", + "\u0015\n", + "\u0016\n", + "\u0017\n", + "\u0018\n", + "\u0019\n", + "\u001a\n", + "\u001b\n", + "\u001c\n", + "\u001d\n", + "\u001e\n", + "\u001f\n", + " Downloading scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl.metadata (60 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m60.8/60.8 kB\u001b[0m \u001b[31m929.9 kB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m \u001b[36m0:00:01\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: librosa in /Users/user/opt/anaconda3/lib/python3.12/site-packages (0.10.2.post1)\n", + "Requirement already satisfied: pydub in /Users/user/opt/anaconda3/lib/python3.12/site-packages (0.25.1)\n", + "Requirement already satisfied: matplotlib in /Users/user/opt/anaconda3/lib/python3.12/site-packages (3.8.4)\n", + "Collecting matplotlib\n", + " Downloading matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl.metadata (11 kB)\n", + "Requirement already satisfied: numpy in /Users/user/opt/anaconda3/lib/python3.12/site-packages (1.26.4)\n", + "Collecting numpy\n", + " Downloading numpy-2.1.1-cp312-cp312-macosx_10_9_x86_64.whl.metadata (60 kB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m60.9/60.9 kB\u001b[0m \u001b[31m2.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", + "\u001b[?25hRequirement already satisfied: audioread>=2.1.9 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (3.0.1)\n", + "Requirement already satisfied: scikit-learn>=0.20.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (1.4.2)\n", + "Requirement already satisfied: joblib>=0.14 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (1.4.2)\n", + "Requirement already satisfied: decorator>=4.3.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (5.1.1)\n", + "Requirement already satisfied: numba>=0.51.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (0.59.1)\n", + "Requirement already satisfied: soundfile>=0.12.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (0.12.1)\n", + "Requirement already satisfied: pooch>=1.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (1.8.2)\n", + "Requirement already satisfied: soxr>=0.3.2 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (0.5.0.post1)\n", + "Requirement already satisfied: typing-extensions>=4.1.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (4.11.0)\n", + "Requirement already satisfied: lazy-loader>=0.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (0.4)\n", + "Requirement already satisfied: msgpack>=1.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from librosa) (1.0.3)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (1.2.0)\n", + "Requirement already satisfied: cycler>=0.10 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (0.11.0)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (4.51.0)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (1.4.4)\n", + "Requirement already satisfied: packaging>=20.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (23.2)\n", + "Requirement already satisfied: pillow>=8 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (10.3.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (3.0.9)\n", + "Requirement already satisfied: python-dateutil>=2.7 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from matplotlib) (2.9.0.post0)\n", + "Requirement already satisfied: llvmlite<0.43,>=0.42.0dev0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from numba>=0.51.0->librosa) (0.42.0)\n", + "Requirement already satisfied: platformdirs>=2.5.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from pooch>=1.1->librosa) (3.10.0)\n", + "Requirement already satisfied: requests>=2.19.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from pooch>=1.1->librosa) (2.32.2)\n", + "Requirement already satisfied: six>=1.5 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from python-dateutil>=2.7->matplotlib) (1.16.0)\n", + "Requirement already satisfied: threadpoolctl>=2.0.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from scikit-learn>=0.20.0->librosa) (2.2.0)\n", + "Requirement already satisfied: cffi>=1.0 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from soundfile>=0.12.1->librosa) (1.16.0)\n", + "Requirement already satisfied: pycparser in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from cffi>=1.0->soundfile>=0.12.1->librosa) (2.21)\n", + "Requirement already satisfied: charset-normalizer<4,>=2 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (2.0.4)\n", + "Requirement already satisfied: idna<4,>=2.5 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (3.7)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (2.2.2)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /Users/user/opt/anaconda3/lib/python3.12/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (2024.6.2)\n", + "Downloading scipy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl (39.1 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m39.1/39.1 MB\u001b[0m \u001b[31m19.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hDownloading matplotlib-3.9.2-cp312-cp312-macosx_10_12_x86_64.whl (7.9 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m7.9/7.9 MB\u001b[0m \u001b[31m22.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hInstalling collected packages: scipy, matplotlib\n", + " Attempting uninstall: scipy\n", + " Found existing installation: scipy 1.13.1\n", + " Uninstalling scipy-1.13.1:\n", + " Successfully uninstalled scipy-1.13.1\n", + " Attempting uninstall: matplotlib\n", + " Found existing installation: matplotlib 3.8.4\n", + " Uninstalling matplotlib-3.8.4:\n", + " Successfully uninstalled matplotlib-3.8.4\n", + "Successfully installed matplotlib-3.9.2 scipy-1.14.1\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "pip install --upgrade scipy librosa pydub matplotlib numpy" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/test_files/submissions/test.vue b/test_files/submissions/test.vue new file mode 100644 index 000000000..fcf90b20b --- /dev/null +++ b/test_files/submissions/test.vue @@ -0,0 +1,23 @@ + + + + + + + + This could be e.g. documentation for the component. + diff --git a/test_files/unit_csv_imports/import_group_tasks.csv b/test_files/unit_csv_imports/import_group_tasks.csv index dfef81145..e9782cf7e 100644 --- a/test_files/unit_csv_imports/import_group_tasks.csv +++ b/test_files/unit_csv_imports/import_group_tasks.csv @@ -1,3 +1,3 @@ -name,abbreviation,description,weighting,target_grade,restrict_status_updates,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,tutorial_stream -Group Import 1,1GI,Test Description - Import,16,0,FALSE,0,FALSE,80,[],Group Work,[],0,Mon,1,Sun,2,Wed,group-tasks-test -Missing Group,2GI,Test Description - Import FAIL,16,0,FALSE,0,FALSE,80,[],Group Work1,[],0,Mon,1,Sun,2,Wed,group-tasks-test \ No newline at end of file +name,abbreviation,description,weighting,target_grade,restrict_status_updates,max_quality_pts,is_graded,plagiarism_warn_pct,plagiarism_checks,group_set,upload_requirements,start_week,start_day,target_week,target_day,due_week,due_day,tutorial_stream,scorm_enabled,scorm_allow_review,scorm_bypass_test,scorm_time_delay_enabled,scorm_attempt_limit +Group Import 1,1GI,Test Description - Import,16,0,FALSE,0,FALSE,80,[],Group Work,[],0,Mon,1,Sun,2,Wed,group-tasks-test,,, +Missing Group,2GI,Test Description - Import FAIL,16,0,FALSE,0,FALSE,80,[],Group Work1,[],0,Mon,1,Sun,2,Wed,group-tasks-test,,,