diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd0ad39a..9c0fab27e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,153 @@ 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.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) diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 53435e1e3..c311a1a38 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -33,6 +33,9 @@ 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? @@ -57,6 +60,7 @@ class ApiRoot < Grape::API mount BreaksApi mount DiscussionCommentApi mount ExtensionCommentsApi + mount ScormExtensionCommentsApi mount GroupSetsApi mount LearningOutcomesApi mount LearningAlignmentApi @@ -78,6 +82,8 @@ class ApiRoot < Grape::API mount Tii::TiiGroupAttachmentApi mount Tii::TiiActionApi + mount ScormApi + mount TestAttemptsApi mount CampusesPublicApi mount CampusesAuthenticatedApi mount TutorialsApi @@ -98,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 @@ -124,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 04a4f5e61..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 @@ -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? + if authenticated?(:login) 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? + 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}" @@ -324,7 +325,7 @@ class AuthenticationApi < Grape::API # Find user user = User.find_by(username: user_param) - token = user.token_for_text?(token_param) unless user.nil? + token = user.token_for_text?(token_param, :general) unless user.nil? remember = params[:remember] || false # Token does not match user @@ -359,7 +360,7 @@ 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? + 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/entities/task_definition_entity.rb b/app/api/entities/task_definition_entity.rb index 3dc309f28..637bf51a9 100644 --- a/app/api/entities/task_definition_entity.rb +++ b/app/api/entities/task_definition_entity.rb @@ -26,7 +26,7 @@ def staff?(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 @@ -39,6 +39,12 @@ def staff?(my_role) 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_nil: false 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/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/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/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_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index 63b6ddabb..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 diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 18a7d1035..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' @@ -55,6 +60,11 @@ 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, @@ -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,6 +149,11 @@ 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, @@ -297,7 +317,7 @@ 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' overseer_assessment = OverseerAssessment.create_for(task) @@ -611,4 +631,79 @@ class TaskDefinitionsApi < Grape::API 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 3e14117a6..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 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..c709cc094 --- /dev/null +++ b/app/api/test_attempts_api.rb @@ -0,0 +1,188 @@ +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]}" + test.review + 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/units_api.rb b/app/api/units_api.rb index 5cc93a88d..1dd92a3d1 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -235,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 @@ -253,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) diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index 2841a779c..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 diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index e313a9d4e..f0da92db5 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -361,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) @@ -370,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 diff --git a/app/helpers/file_stream_helper.rb b/app/helpers/file_stream_helper.rb index 6859954d6..1644b3fe4 100644 --- a/app/helpers/file_stream_helper.rb +++ b/app/helpers/file_stream_helper.rb @@ -40,7 +40,7 @@ def stream_file(file_path) 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 diff --git a/app/helpers/turn_it_in.rb b/app/helpers/turn_it_in.rb index b5b2a0c05..7bf73e4f7 100644 --- a/app/helpers/turn_it_in.rb +++ b/app/helpers/turn_it_in.rb @@ -6,17 +6,25 @@ class TurnItIn # 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' @@ -36,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 @@ -55,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 @@ -109,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 @@ -118,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? @@ -130,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 @@ -201,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/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/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/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/overseer_assessment.rb b/app/models/overseer_assessment.rb index 179ac6ccc..afca988ad 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -26,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 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 706a60b58..a22f25b18 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -289,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 @@ -656,7 +657,11 @@ 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) diff --git a/app/models/task.rb b/app/models/task.rb index 22992041d..ee009184e 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 @@ -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,7 +936,7 @@ 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 end @@ -923,7 +981,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... @@ -1078,8 +1139,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 @@ -1100,7 +1166,7 @@ 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 @@ -1134,6 +1200,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 +1211,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 +1276,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 @@ -1368,6 +1445,13 @@ 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 393f6d272..0983f83f4 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -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 @@ -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) @@ -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) @@ -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/teaching_period.rb b/app/models/teaching_period.rb index 965ef5f4b..eaf5d6b2c 100644 --- a/app/models/teaching_period.rb +++ b/app/models/teaching_period.rb @@ -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..88d9761d0 --- /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, default: "{}", null: false + + 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 "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 50a9c72f2..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) 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 93a30792f..7c852a316 100644 --- a/app/models/turn_it_in/tii_action.rb +++ b/app/models/turn_it_in/tii_action.rb @@ -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 @@ -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_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_upload_submission.rb b/app/models/turn_it_in/tii_action_upload_submission.rb index bdd3403a0..e8281c0c1 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 @@ -214,7 +216,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 @@ -443,4 +445,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/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/unit.rb b/app/models/unit.rb index be1f90974..802601be7 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -161,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? } @@ -271,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 diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index e458ca8d8..b0e7fad92 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -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 57e962399..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 @@ -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,7 +325,8 @@ def self.permissions :audit_units, :get_teaching_periods, - :use_overseer + :use_overseer, + :get_scorm_token ] # What can convenors do with users? @@ -333,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 diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index 86235f2c2..c4f660589 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -3,14 +3,45 @@ 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: false) + rescue StandardError => e + # Send email to student if task pdf failed + if task.project.student.receive_task_notifications + begin + PortfolioEvidenceMailer.task_pdf_failed(project, [task]).deliver + rescue StandardError => e + logger.error "Failed to send task pdf failed email for project #{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 diff --git a/app/sidekiq/tii_check_progress_job.rb b/app/sidekiq/tii_check_progress_job.rb index 9754f3950..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 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/error_log.text.erb b/app/views/error_log/error_log.text.erb new file mode 100644 index 000000000..e25167d08 --- /dev/null +++ b/app/views/error_log/error_log.text.erb @@ -0,0 +1,3 @@ +Something went wrong with <%= @doubtfire_product_name %>, and the following log entry was created: + +<%= @error_log %> diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex index ab70189c8..982bcda26 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -33,12 +33,16 @@ filecolor=black, urlcolor=blue, citecolor=black} - +<% +if @include_pax +%> \usepackage{newpax} \newpaxsetup{usefileattributes=true, addannots=true} \directlua{require("newpax")} <%= yield :preamble_newpax %> - +<% +end +%> \epstopdfDeclareGraphicsRule{.tif}{png}{.png}{convert #1 \OutputFile} \AppendGraphicsExtensions{.tif} \epstopdfDeclareGraphicsRule{.tiff}{png}{.png}{convert #1 \OutputFile} diff --git a/config/application.rb b/config/application.rb index a054a5064..ffa8f13f9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,6 +42,16 @@ class Application < Rails::Application 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') credentials.secret_key_attr = ENV.fetch('DF_SECRET_KEY_ATTR', Rails.env.production? ? nil : 'e69fc5960ca0e8700844a3a25fe80373b41c0a265d342eba06950113f3766fd983bad9ec51bf36eb615d9711bfe1dd90b8e35f01841b323f604ffee857e32055') @@ -59,7 +69,7 @@ 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' diff --git a/config/deakin.rb b/config/deakin.rb index 419254ae1..1185bcd00 100644 --- a/config/deakin.rb +++ b/config/deakin.rb @@ -131,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) @@ -261,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 @@ -350,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 @@ -381,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 @@ -493,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 @@ -517,16 +545,17 @@ def sync_enrolments(unit) 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 @@ -551,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/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/db/migrate/20231205011842_create_test_attempts.rb b/db/migrate/20231205011842_create_test_attempts.rb new file mode 100644 index 000000000..3f7004a81 --- /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, default: "{}", null: false + 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/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/schema.rb b/db/schema.rb index b7924c2dc..af59778e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -24,6 +24,7 @@ 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 ["user_id"], name: "index_auth_tokens_on_user_id" end @@ -220,6 +221,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" @@ -227,6 +229,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 @@ -255,6 +258,11 @@ 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 @@ -335,6 +343,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" @@ -351,6 +360,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", default: "{}", null: false + 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" 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/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index 697e3b74f..a5dab5fc4 100644 --- a/lib/tasks/generate_pdfs.rake +++ b/lib/tasks/generate_pdfs.rake @@ -144,10 +144,14 @@ 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 @@ -158,7 +162,7 @@ namespace :submission do # 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}" diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index 7efd1b94c..3c6d69143 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -30,14 +30,36 @@ namespace :maintenance do desc 'Remove PDFs from old submissions and archive units' task archive_submissions: [:environment] do archive_period = Doubtfire::Application.config.unit_archive_after_period - return if archive_period <= 1.year + # Next returns from rake tasks + next if archive_period <= 1.year - Unit.where(archived: false).where('end_date < :archive_before', archive_before: DateTime.now - archive_period).find_each do |unit| - puts "Are you sure you want to archive #{unit.detailed_name}? (Yes to confirm): " + 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? - next unless response == 'Yes' + 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 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/groups_api_test.rb b/test/api/groups_api_test.rb index af200a9af..9c7138951 100644 --- a/test/api/groups_api_test.rb +++ b/test/api/groups_api_test.rb @@ -436,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/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..c3c444c62 --- /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 500, 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/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 218472caf..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 } } @@ -219,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({ @@ -309,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/config/deakin_config_test.rb b/test/config/deakin_config_test.rb index 41baafd7a..25da492ee 100644 --- a/test/config/deakin_config_test.rb +++ b/test/config/deakin_config_test.rb @@ -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/models/task_definition_test.rb b/test/models/task_definition_test.rb index 0ff812661..578b0e895 100644 --- a/test/models/task_definition_test.rb +++ b/test/models/task_definition_test.rb @@ -266,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 22bdda0a9..212e75508 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 @@ -377,7 +386,7 @@ def test_ipynb_to_pdf assert_equal 201, last_response.status, last_response_body # test submission generation - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -394,7 +403,7 @@ def test_ipynb_to_pdf assert_equal 201, last_response.status, last_response_body # test submission generation - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -446,7 +455,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -469,7 +478,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -520,7 +529,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -543,7 +552,7 @@ def test_code_submission_with_long_lines # test submission generation task = project.task_for_task_definition(td) - assert task.convert_submission_to_pdf + assert task.convert_submission_to_pdf(log_to_stdout: false) path = task.zip_file_path_for_done_task assert path assert File.exist? path @@ -594,7 +603,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 @@ -617,7 +626,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 @@ -631,6 +640,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({ @@ -689,7 +699,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 @@ -699,4 +709,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 fc1115e2d..863507e2e 100644 --- a/test/models/teaching_period_test.rb +++ b/test/models/teaching_period_test.rb @@ -220,159 +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 18c13567f..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")) @@ -758,4 +757,66 @@ def test_change_unit_code_moves_files 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/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/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,,,