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/.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/CHANGELOG.md b/CHANGELOG.md index 4744417cb..3a1dbc271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,481 @@ 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.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.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)) +* logging of fail to send message in accept submission ([38abe9e](https://github.com/macite/doubtfire-deploy/commit/38abe9eeb7dedf8f7d26b7b1c659be94d9c42d4a)) +* remove default from cmi_datamodel in test attempt ([ccb20dc](https://github.com/macite/doubtfire-deploy/commit/ccb20dc5c1efea2e5d0331026bc17d39dda3db11)) + +### [8.0.24](https://github.com/doubtfire-lms/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/doubtfire-lms/doubtfire-deploy/commit/fce7e7519bb9171726a030b409aee23de65f44fd)) +* add Numbas config options to task def backend ([d53610a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d53610a3f4b0c8077aea34cbfa2924e301914e1f)) +* add numbas task comment on test completion ([3f5aa2b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3f5aa2be6bd69441730375b689751fe881d7617a)) +* add test attempt auth ([7d31f7c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/7d31f7caaae6dc1efa24f78842873e9f55796279)) +* change Numbas time delay config to enable incremental delays ([54c27ce](https://github.com/doubtfire-lms/doubtfire-deploy/commit/54c27cef2b8ff57fd8ac972728ec3d249e2862b8)) +* create unique token for scorm asset retrieval ([fc8134a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/fc8134ab6b734b7daf064a67ad15f3cefba1d7d6)) +* enable reviewing, passing, and deleting test attempts ([8c9a68b](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8c9a68ba6b3914da24ba33ee62f6a5a00e101c76)) +* enable students to request extra scorm attempt ([c5055b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c5055b858c30ba693c535590e1ccff0e8e0b42da)) +* restrict test attempts by limit and comments to when test is completed ([26d75f5](https://github.com/doubtfire-lms/doubtfire-deploy/commit/26d75f51b7fcf11dac0834ddc5a46f40c07407de)) + + +### Bug Fixes + +* add allow review property to task def related files ([3539d95](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3539d957022f0c6310a2939dd6eccad946cb6610)) +* add missing numbas config fields to fix unit tests ([89a6615](https://github.com/doubtfire-lms/doubtfire-deploy/commit/89a66157b4fde887a19912ca40261243b4961e2f)) +* add scorm bypass to excel file ([4139690](https://github.com/doubtfire-lms/doubtfire-deploy/commit/413969069969316f6ea9c515e4ec9da6b332be0a)) +* calculate attempt number and limit instead of using stored int ([28f3279](https://github.com/doubtfire-lms/doubtfire-deploy/commit/28f327964edb0c9326b487a674d19b7da7da8c89)) +* change scorm comment text ([69053ee](https://github.com/doubtfire-lms/doubtfire-deploy/commit/69053ee147503e7916e929aac5c834903c0087ba)) +* check for attempts before accessing properties ([4255347](https://github.com/doubtfire-lms/doubtfire-deploy/commit/42553479eb2a018a9273931e033084e26b3d18d5)) +* check if no old scorm tokens exist ([6108b52](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6108b52bc04d7866548c8738b39d37c30d24f602)) +* consolidate numbas api endpoints ([27253bd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/27253bd1b1d5640d00098f692160dd4b50675640)) +* enforce attempt limit ([d71ea14](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d71ea14d319a59ba1e96bbd5bf34c85a21f0c0f6)) +* expose enable Numbas test config to all users ([20d5265](https://github.com/doubtfire-lms/doubtfire-deploy/commit/20d526533a2ecab592d7d22f3330d37cee7e0f45)) +* expose scorm configs to student ([910eecd](https://github.com/doubtfire-lms/doubtfire-deploy/commit/910eecdc218f52e572d39059e64a0b28acb44dce)) +* grant same number of extra attempts as scorm limit ([3d44ef2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3d44ef2ea57829131cc3c70d1655ccd996154ee2)) +* post scorm comment after test attempt termination ([0812e20](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0812e206a9dadcfe7d575feec04e49b15b412556)) +* preload unit in test attempt and ensure limit flexibility in validation ([8059213](https://github.com/doubtfire-lms/doubtfire-deploy/commit/80592130bfb33bbb74322c5950e62e2663223af1)) +* prevent new attempt if last is incomplete or passed ([1240b3f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1240b3fa853d3a3f3fd1ad061f9cc6f6635c2c37)) +* prevent scorm extensions if no attempt limit ([1ae0347](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1ae03478bb2c55b5e281a876bae37f730206ac3e)) +* refactor numbas config reset logic ([ff5ff62](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ff5ff62061c05e509f15af3048fe047b0d69dc68)) +* rename entity file and add update fields in task spreadsheet ([b498924](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b4989242e37ccd046651bfc8db32934ee94e190a)) +* reorder columns for csv export ([5db5f35](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5db5f35dc6cc1874c50f5891ca7bbd752ea32b55)) +* reset Numbas configs if no zip file has been uploaded ([3f19ffa](https://github.com/doubtfire-lms/doubtfire-deploy/commit/3f19ffa6f4f465ed0691582b5012cf997ec62852)) +* temporarily disable auth and fix test attempt lookup ([b4d3f9d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b4d3f9dc1661b733eaf704c551ceb5836789db22)) +* update auth token to work with scorm and general ([e7a6eed](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e7a6eed53d8e7049b6144e2b07b8018725be01fb)) +* use correct endpoint url and include exam result for numbas test attempts ([ee992f4](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ee992f4218b8ca07c9259d6569c9c946af7701ef)) +* use correct Numbas data path in Numbas api ([5d80830](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5d80830d3564bb7137db3c4adb3b1d906342e851)) +* use custom endpoint for Numbas ([0cc4915](https://github.com/doubtfire-lms/doubtfire-deploy/commit/0cc4915c85d7d55b48ca6832f6779e49362a7870)) +* use project and task def to fix issue where task is undefined on launching scorm test ([2a04a06](https://github.com/doubtfire-lms/doubtfire-deploy/commit/2a04a068282f69b11a6243a590bb25edcdd5c2c1)) +* use test attempt entity in file instead ([a7c4006](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a7c400669bf199f30b627b54c4ed49157ff88222)) +* use unique perms for scorm test retrieval ([08a0090](https://github.com/doubtfire-lms/doubtfire-deploy/commit/08a00906019ce0c2706c34cf053a511b6e5ddca2)) +* validate attempt id ([c5240d8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c5240d8da378b84deb3ac64e1584808b07d5e671)) + +### [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..44f811806 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 diff --git a/Gemfile.lock b/Gemfile.lock index 58ca5d592..6a52b4786 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) @@ -200,10 +200,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) @@ -231,9 +231,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) @@ -245,13 +245,13 @@ GEM multi_json (1.15.0) 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 +263,14 @@ 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) 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 +283,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 +298,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 +329,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 +351,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 +364,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 +376,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 +390,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) @@ -445,7 +453,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 +463,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) @@ -486,10 +494,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 @@ -532,7 +541,10 @@ DEPENDENCIES roo (~> 2.7.0) roo-xls rubocop + rubocop-factory_bot rubocop-faker + rubocop-minitest + rubocop-performance rubocop-rails ruby-filemagic ruby-lsp @@ -551,4 +563,4 @@ RUBY VERSION ruby 3.1.4p223 BUNDLED WITH - 2.4.15 + 2.4.22 diff --git a/app/api/api_root.rb b/app/api/api_root.rb index bde5f0236..c311a1a38 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 @@ -96,6 +104,7 @@ 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 @@ -122,6 +131,8 @@ 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 add_swagger_documentation \ base_path: nil, diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index 3b43b10cf..f9e68ff66 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/) @@ -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]) @@ -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/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..b787c7d53 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -9,7 +9,7 @@ class SettingsApi < Grape::API response = { externalName: Doubtfire::Application.config.institution[:product_name], overseerEnabled: Doubtfire::Application.config.overseer_enabled, - tiiEnabled: Doubtfire::Application.config.tii_enabled + tiiEnabled: TurnItIn.enabled? } present response, with: Grape::Presenters::Presenter 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..bf9f700b1 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 @@ -94,10 +91,10 @@ def self.logger unit = task.project.unit 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') + evidence_loc = Rails.root.join('public/resources/FileNotFound.pdf') filename = 'FileNotFound.pdf' else filename = "#{task.task_definition.abbreviation}.pdf" @@ -105,7 +102,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..71782de82 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -28,6 +28,11 @@ class TaskDefinitionsApi < Grape::API requires :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' optional :upload_requirements, type: String, desc: 'Task file upload requirements' requires :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' + requires :scorm_enabled, type: Boolean, desc: 'Whether SCORM assessment is enabled for this task' + requires :scorm_allow_review, type: Boolean, desc: 'Whether a student is allowed to review their completed test attempts' + requires :scorm_bypass_test, type: Boolean, desc: 'Whether a student is allowed to upload files before passing SCORM test' + requires :scorm_time_delay_enabled, type: Boolean, desc: 'Whether there is an incremental time delay between SCORM test attempts' + requires :scorm_attempt_limit, type: Integer, desc: 'The number of times a SCORM test can be attempted' requires :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' requires :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' @@ -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,20 +149,26 @@ 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? + task_params[:upload_requirements] = params[:task_def][:upload_requirements].present? ? JSON.parse(params[:task_def][:upload_requirements]) : [] - # Ensure changes to a TD defined as a "draft task definition" are validated + # 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" + if task_params[:upload_requirements] + requirements = task_params[: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 end @@ -179,7 +200,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 +215,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 +294,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 +317,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 +371,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 +458,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 +568,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 +598,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 +624,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/file_helper.rb b/app/helpers/file_helper.rb index 2ae3597b2..8f5b0101b 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 # @@ -211,16 +211,20 @@ def student_work_dir(type = nil, task = nil, create = true) dst end - def unit_dir(unit, create = true) + def dir_for_unit_code_and_id(unit_code, unit_id, 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}") << '/' + dst << sanitized_path("#{unit_code}-#{unit_id}") << '/' - FileUtils.mkdir_p dst if create && (!Dir.exist? dst) + FileUtils.mkdir_p dst if create && !Dir.exist?(dst) dst end + def unit_dir(unit, create = true) + dir_for_unit_code_and_id(unit.code, unit.id, create) + 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 @@ -357,7 +361,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 +376,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 @@ -624,6 +634,7 @@ def line_wrap(path, width: 160) module_function :student_group_work_dir module_function :student_work_dir module_function :student_work_root + module_function :dir_for_unit_code_and_id module_function :unit_dir module_function :unit_portfolio_dir module_function :student_portfolio_dir 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/error_log_mailer.rb b/app/mailers/error_log_mailer.rb new file mode 100644 index 000000000..43ed8d8f4 --- /dev/null +++ b/app/mailers/error_log_mailer.rb @@ -0,0 +1,11 @@ +class ErrorLogMailer < ApplicationMailer + def error_message(subject, message, exception) + email = Doubtfire::Application.config.email_errors_to + return nil if email.blank? + + @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/scorm_comment.rb b/app/models/comments/scorm_comment.rb new file mode 100644 index 000000000..16502f50a --- /dev/null +++ b/app/models/comments/scorm_comment.rb @@ -0,0 +1,16 @@ +class ScormComment < TaskComment + belongs_to :test_attempt, optional: false + + before_create do + self.content_type = :scorm + end + + def serialize(user) + json = super(user) + json[:test_attempt] = { + id: self.test_attempt_id, + success_status: self.test_attempt.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..36acb4e89 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -38,8 +38,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/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..f7d0f3b5f 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 diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index cd843235d..afca988ad 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -1,3 +1,4 @@ +# rubocop:disable Rails/Output class OverseerAssessment < ApplicationRecord belongs_to :task, optional: false @@ -8,7 +9,7 @@ class OverseerAssessment < ApplicationRecord 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) @@ -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 a7a69af2b..a13aea350 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 @@ -57,7 +57,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) @@ -112,11 +112,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 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/task.rb b/app/models/task.rb index 2f8172967..1fe030ccb 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? @@ -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... @@ -1010,7 +1074,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 @@ -1037,7 +1101,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' @@ -1080,8 +1144,13 @@ def final_pdf_path 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 @@ -1102,12 +1171,14 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) log_file = e.message.scan(/\/.*\.log/).first # puts "log file is ... #{log_file}" - if log_file && File.exist?(log_file) + if log_to_stdout && log_file && File.exist?(log_file) # puts "exists" begin + # rubocop:disable Rails/Output puts "--- Latex Log ---\n" puts File.read(log_file) puts "--- End ---\n\n" + # rubocop:enable Rails/Output rescue end end @@ -1134,6 +1205,8 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) FileHelper.compress_pdf(portfolio_evidence_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 @@ -1143,14 +1216,13 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) 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 + clear_in_process end end @@ -1209,7 +1281,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 @@ -1364,6 +1446,17 @@ def read_file_from_done(idx) nil end + def archive_submission + FileUtils.rm_f(portfolio_evidence_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 diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7e78bd2a7..0983f83f4 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 @@ -133,6 +137,10 @@ def move_files_on_abbreviation_change if File.exist? task_assessment_resources_with_abbreviation(old_abbr) FileUtils.mv(task_assessment_resources_with_abbreviation(old_abbr), task_assessment_resources()) end + + if File.exist? task_scorm_data_with_abbreviation(old_abbr) + FileUtils.mv(task_scorm_data_with_abbreviation(old_abbr), task_scorm_data()) + end end def docker_image_name_tag @@ -176,6 +184,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 +306,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 +320,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 +332,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 +369,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? @@ -384,6 +421,30 @@ def has_task_sheet? File.exist? task_sheet 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? is_graded end @@ -436,6 +497,22 @@ 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) @@ -449,6 +526,10 @@ def task_assessment_resources task_assessment_resources_with_abbreviation(abbreviation) end + def task_scorm_data + task_scorm_data_with_abbreviation(abbreviation) + end + def related_tasks_with_files(consolidate_groups = true) tasks_with_files = tasks.select(&:has_pdf) @@ -460,7 +541,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,6 +572,7 @@ 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 @@ -537,4 +619,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) + task_path = FileHelper.task_file_dir_for_unit unit, create = true + + 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..9d57c65bf --- /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, 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.test_attempt = 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..802601be7 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? @@ -114,20 +114,20 @@ def role_for(user) delete_associated_files end + 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,9 +139,6 @@ 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' - # Unit has a teaching period belongs_to :teaching_period, optional: true @@ -164,7 +161,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 +180,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 +231,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 +243,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,12 +271,15 @@ 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 @@ -331,7 +347,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 +359,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 +372,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 +571,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 +801,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 +828,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 +1168,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', @@ -1881,7 +1894,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 +2159,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 @@ -2182,7 +2195,7 @@ def generate_batch_task_zip(user, tasks) src_path = task.portfolio_evidence_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" @@ -2205,7 +2218,7 @@ def generate_batch_task_zip(user, tasks) src_path = task.portfolio_evidence_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 +2334,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'] @@ -2539,6 +2552,13 @@ 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 + private def delete_associated_files @@ -2559,4 +2579,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, false) + if File.exist? old_dir + new_dir = FileHelper.unit_dir(self, false) + FileUtils.mv(old_dir, new_dir) unless File.exist?(new_dir) + end + + # rubocop:disable Rails/SkipsModelValidations + tasks.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..d53ca9efc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -92,19 +92,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 +122,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 +140,10 @@ 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_one :webcal, dependent: :destroy, inverse_of: :user # Model validations/constraints validates :first_name, presence: true @@ -301,7 +309,9 @@ def self.permissions :get_teaching_periods, :admin_overseer, - :use_overseer + :use_overseer, + + :get_scorm_token ] # What can auditors do with users? @@ -315,11 +325,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 +344,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 @@ -461,7 +475,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..969c79472 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -3,17 +3,49 @@ class AcceptSubmissionJob include LogHelper def perform(task_id, user_id, accepted_tii_eula) + begin + # Ensure cwd is valid... + FileUtils.cd(Rails.root) + rescue StandardError => e + logger.error e + end + task = Task.find(task_id) user = User.find(user_id) - # Convert submission to PDF - task.convert_submission_to_pdf + 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 + + begin + # Notify system admin + mail = ErrorLogMailer.error_message('Accept Submission', "Failed to convert submission to PDF for task #{task.id} by user #{user.id}", 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/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/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 e678411b8..0ddb2d3f5 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -46,12 +46,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/portfolio/portfolio_pdf.pdf.erb b/app/views/portfolio/portfolio_pdf.pdf.erb index 039c8036d..4fc8bffcb 100644 --- a/app/views/portfolio/portfolio_pdf.pdf.erb +++ b/app/views/portfolio/portfolio_pdf.pdf.erb @@ -237,15 +237,15 @@ 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) # Limit to 100 pages if max_pages > 100 max_pages = 100 else + # add task evidence to the document list for annotation extraction + # skip long files document_list.append(task.portfolio_evidence_path) unless @is_retry end diff --git a/config/application.rb b/config/application.rb index df21df01b..ffa8f13f9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,7 +29,28 @@ 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 + + # 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 + + # Period for which to keep units + config.unit_archive_after_period = ENV.fetch('DF_UNIT_ARCHIVE_PERIOD', 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,15 +59,9 @@ 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'] @@ -54,11 +69,11 @@ class Application < Rails::Application 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 @@ -166,15 +181,15 @@ 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') 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') # 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/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/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/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/20240322021829_add_scorm_config_to_task_def.rb b/db/migrate/20240322021829_add_scorm_config_to_task_def.rb new file mode 100644 index 000000000..04847cb9b --- /dev/null +++ b/db/migrate/20240322021829_add_scorm_config_to_task_def.rb @@ -0,0 +1,21 @@ +class AddScormConfigToTaskDef < ActiveRecord::Migration[7.0] + def change + 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 + end + + def down + change_table :task_definitions do |t| + t.remove :scorm_enabled + t.remove :scorm_allow_review + t.remove :scorm_bypass_test + t.remove :scorm_time_delay_enabled + t.remove :scorm_attempt_limit + end + end +end diff --git a/db/migrate/20240601103707_add_test_attempt_link_to_comment.rb b/db/migrate/20240601103707_add_test_attempt_link_to_comment.rb new file mode 100644 index 000000000..51db18b9e --- /dev/null +++ b/db/migrate/20240601103707_add_test_attempt_link_to_comment.rb @@ -0,0 +1,7 @@ +class AddTestAttemptLinkToComment < ActiveRecord::Migration[7.1] + def change + # Link to corresponding SCORM test attempt for scorm comments + add_column :task_comments, :test_attempt_id, :integer + add_index :task_comments, :test_attempt_id + end +end diff --git a/db/migrate/20240603020127_add_scorm_extensions.rb b/db/migrate/20240603020127_add_scorm_extensions.rb new file mode 100644 index 000000000..0e549611d --- /dev/null +++ b/db/migrate/20240603020127_add_scorm_extensions.rb @@ -0,0 +1,5 @@ +class AddScormExtensions < ActiveRecord::Migration[7.1] + def change + add_column :tasks, :scorm_extensions, :integer, null: false, default: 0 + 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/schema.rb b/db/schema.rb index 6daa71ebf..786065928 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_07_01_221318) 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 @@ -82,6 +84,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 +109,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 +132,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 +163,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| @@ -215,6 +222,7 @@ t.string "extension_response" t.bigint "reply_to_id" t.bigint "overseer_assessment_id" + t.integer "test_attempt_id" t.index ["assessor_id"], name: "index_task_comments_on_assessor_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" @@ -222,6 +230,7 @@ t.index ["reply_to_id"], name: "index_task_comments_on_reply_to_id" t.index ["task_id"], name: "index_task_comments_on_task_id" t.index ["task_status_id"], name: "index_task_comments_on_task_status_id" + t.index ["test_attempt_id"], name: "index_task_comments_on_test_attempt_id" t.index ["user_id"], name: "index_task_comments_on_user_id" end @@ -250,7 +259,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 +344,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 +361,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 +459,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,6 +503,7 @@ 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" @@ -509,8 +539,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| diff --git a/deployAppSvr.Dockerfile b/deployAppSvr.Dockerfile index 748f56a1f..192118282 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..32e1724e3 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,7 +532,7 @@ 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 @@ -543,8 +543,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 +553,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 +583,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/compress_pdfs.rake b/lib/tasks/compress_pdfs.rake index 671870b8f..fdb819f67 100644 --- a/lib/tasks/compress_pdfs.rake +++ b/lib/tasks/compress_pdfs.rake @@ -9,8 +9,8 @@ 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| + Unit.where('active').find_each do |u| + u.tasks.where('portfolio_evidence is not NULL').find_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) @@ -25,7 +25,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,8 +44,8 @@ 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.where('portfolio_evidence is not NULL').find_each do |t| pdf_file = t.final_pdf_path next unless pdf_file && File.exist?(pdf_file) && File.size?(pdf_file) >= 2_200_000 diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index c20c30e80..a5dab5fc4 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,8 +181,8 @@ 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| + Unit.where('active').find_each do |u| + u.tasks.where('portfolio_evidence is not NULL').find_each do |t| unless FileHelper.validate_pdf(t.portfolio_evidence_path)[:valid] puts t.portfolio_evidence_path end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index 1bdf4b7fe..3c6d69143 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -27,13 +27,41 @@ 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 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/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/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..be6808bee --- /dev/null +++ b/test/api/scorm_api_test.rb @@ -0,0 +1,85 @@ +require 'test_helper' + +class ScormApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::TestFileHelper + + def app + Rails.application + 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 "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :scorm)}/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 "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :scorm)}/index1.html" + assert_equal 404, last_response.status + + # When the file is present - html + get "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :scorm)}/index.html" + assert_equal 200, last_response.status + assert_equal 'text/html', last_response.content_type + + # Cannot access with the wrong token type + get "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :general)}/index.html" + assert_equal 419, last_response.status + + get "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :login)}/index.html" + assert_equal 419, last_response.status + + # When the file is present - css + get "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :scorm)}/styles.css" + assert_equal 200, last_response.status + assert_equal 'text/css', last_response.content_type + + # When the file is present - js + get "/api/scorm/#{td.id}/#{user.username}/#{auth_token(user, :scorm)}/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 "/api/scorm/#{td.id}/#{tutor.username}/#{auth_token(tutor, :scorm)}/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..bcb9dcf85 100644 --- a/test/api/tasks_api_test.rb +++ b/test/api/tasks_api_test.rb @@ -400,11 +400,61 @@ 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_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 + task.portfolio_evidence_path = task.final_pdf_path + task.save + + 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..6d8603320 --- /dev/null +++ b/test/api/test_attempts_test.rb @@ -0,0 +1,492 @@ +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) + + # When attempts exists + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts" + assert_equal 200, last_response.status + assert_json_equal last_response_body, [attempt] + + 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) + + # When attempts exist + get "api/projects/#{project.id}/task_def_id/#{td.id}/test_attempts/latest" + assert_equal 200, last_response.status + assert_json_equal last_response_body, attempt1 + + 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_equal last_response_body, attempt + + 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! + + assert_json_equal last_response_body, attempt + + 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_equal last_response_body, attempt + + 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(test_attempt_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(test_attempt_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..ec177a7bf 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -49,7 +49,12 @@ def test_task_definition_cud upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', plagiarism_warn_pct: 80, is_graded: false, - max_quality_pts: 0 + max_quality_pts: 0, + scorm_enabled: false, + scorm_allow_review: false, + scorm_bypass_test: false, + scorm_time_delay_enabled: false, + scorm_attempt_limit: 0 } } @@ -63,10 +68,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 +102,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 +224,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 +333,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/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/mailers/error_log_mailer_test.rb b/test/mailers/error_log_mailer_test.rb new file mode 100644 index 000000000..4ee92cbdd --- /dev/null +++ b/test/mailers/error_log_mailer_test.rb @@ -0,0 +1,19 @@ +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 +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/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 b56644063..117219fd6 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 @@ -363,7 +372,7 @@ def test_ipynb_to_pdf # PDF-reader incorrectly parses "weight (kg) / height (m)^2" as "weight (2g) / height (m)", misplacing the ^2 # Detecting "height" and "weight" confirms correct LaTeX rendering - assert reader.pages.last.text.include?("BMI: bmi =") + assert reader.pages.last.text.include?("BMI: bmi ="), reader.pages.last.text assert reader.pages.last.text.include?("weight") assert reader.pages.last.text.include?("height (m)") @@ -371,7 +380,7 @@ def test_ipynb_to_pdf # 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 @@ -382,7 +391,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 @@ -399,7 +408,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 @@ -451,7 +460,81 @@ 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 + 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_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 @@ -474,7 +557,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 @@ -525,7 +608,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 @@ -548,7 +631,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 @@ -562,6 +645,7 @@ def test_code_submission_with_long_lines 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({ @@ -620,7 +704,7 @@ 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 @@ -630,4 +714,322 @@ def test_pdf_validation_on_submit 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_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' + } + ] + + # 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 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..ba1bcb781 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")) @@ -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,109 @@ 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 + task.portfolio_evidence_path = task_pdf + task.save! + 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_equal task.final_pdf_path, task.portfolio_evidence_path + assert File.exist?(task.portfolio_evidence_path), "Portfolio evidence file does not exist = #{task.portfolio_evidence_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 + 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/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..cc50ccdb5 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 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/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/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,,,