Skip to content

Commit 84aeb62

Browse files
ClupaimaplefoxgitThirus224849242Tyler2811jmirchh75
committed
feat(submissions): submission processing, DOCX feedback and upload rules
Brings the T2 2026 submissions work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit <s223932052@deakin.edu.au> Co-authored-by: Thirus224849242 <s224849242@deakin.edu.au> Co-authored-by: Tan Tai <s224621011@deakin.edu.au> Co-authored-by: jmirchh75 <jmirch@live.com> Co-authored-by: anaghwadhwa123 <s224458621@deakin.edu.au>
1 parent dc76a5a commit 84aeb62

41 files changed

Lines changed: 5126 additions & 68 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/api/entities/task_definition_entity.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def staff?(my_role)
1212
expose :abbreviation
1313
expose :name
1414
expose :description
15+
expose :resubmission_extensions_enabled
1516
expose :weighting
1617
expose :target_grade
1718

@@ -39,6 +40,7 @@ def staff?(my_role)
3940
expose :restrict_status_updates, if: ->(unit, options) { staff?(options[:my_role]) }
4041
expose :group_set_id, expose_nil: false
4142
expose :has_task_sheet?, as: :has_task_sheet
43+
expose :task_sheet_filename
4244
expose :has_task_resources?, as: :has_task_resources
4345
expose :has_task_assessment_resources?, as: :has_task_assessment_resources, if: ->(unit, options) { staff?(options[:my_role]) }
4446
expose :has_task_assessment_script?, as: :has_task_assessment_script, if: ->(unit, options) { staff?(options[:my_role]) }

‎app/api/entities/task_entity.rb‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class TaskEntity < Grape::Entity
1818
expose :target_start_date, expose_nil: false
1919
end
2020

21+
expose :effective_deadline
22+
expose :effective_deadline_reason
23+
expose :effective_deadline_source_id
24+
expose :effective_deadline_date, format_with: :date_only, expose_nil: false
25+
2126
expose :extensions
2227
expose :scorm_extensions
2328

@@ -32,6 +37,7 @@ class TaskEntity < Grape::Entity
3237
expose :similarity_flag, unless: :update_only
3338

3439
expose :num_new_comments, unless: :update_only
40+
expose :has_feedback, unless: :update_only
3541

3642
# Attributes only included in "update only"
3743

‎app/api/submission/portfolio_api.rb‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ class PortfolioApi < Grape::API
1313

1414
desc "Upload documents for inclusion in a project's portfolio"
1515
params do
16-
requires :name, type: String, desc: 'Name of the part being uploaded'
17-
requires :kind, type: String, desc: 'The kind of file being uploaded: document, code, or image'
16+
requires :name, type: String, desc: 'Name of the part being uploaded'
17+
requires :kind, type: String, values: %w[document code image], desc: 'The kind of file being uploaded: document, code, or image'
1818
requires :file0, type: File, desc: 'file 0.'
1919
end
2020
post '/submission/project/:id/portfolio' do
@@ -34,6 +34,14 @@ class PortfolioApi < Grape::API
3434
error!({ error: "'#{file[:filename]}': #{file_result[:msg]}" }, 403)
3535
end
3636

37+
max_file_size = Doubtfire::Application.config.max_file_size.to_i
38+
max_file_size = 10_000_000 if max_file_size <= 0
39+
size_in_mb = max_file_size / 1_000_000
40+
41+
if File.size(file[:tempfile].path) > max_file_size
42+
error!({ error: "'#{file[:filename]}' exceeds the #{size_in_mb}MB file limit." }, 413)
43+
end
44+
3745
# Move file into place
3846
result = project.move_to_portfolio(file, name, kind) # returns details of file
3947

@@ -43,7 +51,7 @@ class PortfolioApi < Grape::API
4351
desc 'Remove a file from the portfolio files for a unit'
4452
params do
4553
optional :idx, type: Integer, desc: 'The index of the file'
46-
optional :kind, type: String, desc: 'The kind of file being removed: document, code, or image'
54+
optional :kind, type: String, values: %w[document code image], desc: 'The kind of file being removed: document, code, or image'
4755
optional :name, type: String, desc: 'Name of file to remove'
4856
end
4957
delete '/submission/project/:id/portfolio' do

‎app/api/submission/portfolio_evidence_api.rb‎

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ def self.logger
5353
error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403)
5454
end
5555

56+
# A finished task stops accepting new student uploads. Without this the
57+
# upload lands, submission_date and file_uploaded_at are rewritten and the
58+
# assessed pdf is deleted and regenerated, while only the status transition
59+
# is skipped. Staff are still allowed through on purpose, because a tutor
60+
# sometimes has to upload on a student's behalf when a file is corrupt or
61+
# went to the wrong task.
62+
if task.task_submission_closed? && !authorise?(current_user, project, :assess)
63+
error!({ error: 'This task is closed for new submissions.' }, 403)
64+
end
65+
5666
# Check that prerequisite tasks are in the required minimum submitted state
5767
prerequisites = task_definition.task_prerequisites
5868
prerequisites.each do |prerequisite|
@@ -156,16 +166,50 @@ def self.logger
156166
end
157167

158168
task = project.task_for_task_definition(task_definition)
169+
error!({ error: 'A submission for this task was not found.' }, 404) unless task
159170

160-
if task && PortfolioEvidence.recreate_task_pdf(task)
171+
begin
172+
task.regenerate_submission!(current_user)
161173
result = 'done'
162-
else
163-
result = 'false'
174+
rescue ArgumentError => e
175+
error!({ error: e.message }, 409)
176+
rescue StandardError
177+
error!({ error: 'The submitted files are not available to regenerate.' }, 422)
164178
end
165179

166180
present :result, result, with: Grape::Presenters::Presenter
167181
end # put
168182

183+
desc 'Retry a failed or timed-out submission conversion'
184+
post '/projects/:id/task_def_id/:task_definition_id/submission/retry' do
185+
project = Project.find(params[:id])
186+
task_definition = project.unit.task_definitions.find(params[:task_definition_id])
187+
188+
unless authorise? current_user, project, :reprocess_submission
189+
error!({ error: "Not authorised to retry task '#{task_definition.name}'" }, 401)
190+
end
191+
192+
task = project.task_for_task_definition(task_definition)
193+
error!({ error: 'A submission for this task was not found.' }, 404) unless task
194+
195+
begin
196+
task.retry_submission_processing!(current_user)
197+
rescue ArgumentError => e
198+
error!({ error: e.message }, 409)
199+
rescue StandardError
200+
error!({ error: 'The submitted files are not available to retry.' }, 422)
201+
end
202+
203+
# For a group task the new state was written through other instances.
204+
task.reload
205+
present task.submission_processing_snapshot.merge(
206+
submission_date: task.submission_date,
207+
processing_error_code: task.submission_processing_error_code,
208+
processing_attempts: task.submission_processing_attempts,
209+
task_status: task.task_status.status_key
210+
), with: Grape::Presenters::Presenter
211+
end
212+
169213
desc 'Get the timestamps of the last 10 submissions of a task'
170214
get '/projects/:id/task_def_id/:task_definition_id/submissions/timestamps' do
171215
project = Project.find(params[:id])
@@ -187,41 +231,89 @@ def self.logger
187231

188232
desc 'Get all retained submission histories for a task'
189233
get '/projects/:id/task_def_id/:task_definition_id/submission_histories' do
190-
project = Project.find(params[:id])
191-
task_definition = project.unit.task_definitions.find(params[:task_definition_id])
234+
project = Project.find_by(id: params[:id])
235+
error!({ error: 'Submission history is not available' }, 404) unless project
192236

193-
unless authorise? current_user, project, :get_submission
194-
error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401)
237+
unless authorise?(current_user, project, :get_submission)
238+
error!({ error: 'Submission history is not available' }, 404)
195239
end
196240

197-
task = project.task_for_task_definition(task_definition)
198-
unless task
199-
error!({ error: 'A submission for this task definition has never been created' }, 404)
241+
task_definition = project.unit.task_definitions.find_by(id: params[:task_definition_id])
242+
error!({ error: 'Submission history is not available' }, 404) unless task_definition
243+
244+
task = project.tasks.find_by(task_definition: task_definition)
245+
error!({ error: 'Submission history is not available' }, 404) unless task
246+
247+
student_request = project.student == current_user
248+
249+
if student_request && !authorise?(current_user, task, :get_submission)
250+
error!({ error: 'Submission history is not available' }, 404)
200251
end
201252

202-
present task.submission_histories.order(submission_timestamp: :desc),
203-
with: Entities::SubmissionHistoryEntity
253+
histories = task.submission_histories.sort_by { |history| [-history.submission_timestamp.to_i, -history.id] }
254+
255+
if student_request
256+
archive_pending = SubmissionHistory.pending?(task)
257+
latest_submission_at = task.submission_processing_started_at || task.submission_date
258+
student_histories = histories.each_with_index.map do |history, index|
259+
{
260+
id: history.id,
261+
version_order: index + 1,
262+
current: index.zero? && !archive_pending && latest_submission_at.present? &&
263+
history.submission_timestamp.to_i >= latest_submission_at.to_i,
264+
submission_timestamp: history.submission_timestamp,
265+
status: history.has_submission_files? ? 'available' : 'unavailable'
266+
}
267+
end
268+
269+
status 202 if archive_pending
270+
present student_histories
271+
else
272+
present histories, with: Entities::SubmissionHistoryEntity
273+
end
204274
end
205275

206276
desc 'Download a retained submission history archive'
207277
get '/projects/:id/task_def_id/:task_definition_id/submission_histories/:history_id/files' do
208-
project = Project.find(params[:id])
209-
task_definition = project.unit.task_definitions.find(params[:task_definition_id])
278+
project = Project.find_by(id: params[:id])
279+
error!({ error: 'Submission history is not available' }, 404) unless project
210280

211-
unless authorise? current_user, project.unit, :provide_feedback
212-
error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401)
281+
staff_access = authorise?(current_user, project.unit, :provide_feedback)
282+
student_access =
283+
project.student == current_user && authorise?(current_user, project, :get_submission)
284+
285+
unless staff_access || student_access
286+
error!({ error: 'Submission history is not available' }, 404)
213287
end
214288

215-
task = project.task_for_task_definition(task_definition)
216-
history = task&.submission_histories&.find_by(id: params[:history_id])
217-
error!({ error: 'Submission history was not found' }, 404) unless history
218-
error!({ error: 'Submission history files are not available' }, 404) unless history.has_submission_files?
289+
task_definition = project.unit.task_definitions.find_by(id: params[:task_definition_id])
290+
error!({ error: 'Submission history is not available' }, 404) unless task_definition
291+
292+
task = project.tasks.find_by(task_definition: task_definition)
293+
error!({ error: 'Submission history is not available' }, 404) unless task
294+
295+
student_access &&= authorise?(current_user, task, :get_submission)
296+
297+
unless staff_access || student_access
298+
error!({ error: 'Submission history is not available' }, 404)
299+
end
300+
301+
history = task.submission_histories.find_by(id: params[:history_id])
302+
error!({ error: 'Submission history is not available' }, 404) unless history
303+
304+
unless history.has_submission_files?
305+
error!({ error: 'Submission history files are not available' }, 404)
306+
end
219307

220308
filename = "#{project.student.username}-#{task_definition.abbreviation}-#{history.submission_timestamp}.zip"
221309

222310
content_type 'application/octet-stream'
223311
header['Content-Disposition'] = "attachment; filename=#{filename}"
224-
submission_zip_data = history.submission_zip_data
312+
begin
313+
submission_zip_data = history.submission_zip_data
314+
rescue Zip::Error, Errno::ENOENT, Errno::EACCES
315+
error!({ error: 'Submission history files are not available' }, 404)
316+
end
225317
header['Content-Length'] = submission_zip_data.bytesize.to_s
226318
env['api.format'] = :binary
227319
body submission_zip_data
@@ -339,7 +431,11 @@ def self.logger
339431
content_type 'application/octet-stream'
340432
header['Content-Disposition'] = "attachment; filename=#{filename}"
341433

342-
submission_zip_data = history.submission_zip_data
434+
begin
435+
submission_zip_data = history.submission_zip_data
436+
rescue Zip::Error, Errno::ENOENT, Errno::EACCES
437+
error!({ error: 'Submission history files are not available' }, 404)
438+
end
343439
header['Content-Length'] = submission_zip_data.bytesize.to_s
344440
env['api.format'] = :binary
345441
body submission_zip_data
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
# Shared by the authenticated policy response, validation and storage dispatch.
4+
# A category never authorises an arbitrary MIME type or filename extension.
5+
module CommentAttachmentPolicy
6+
MAX_BYTES = 30_000_000 # Exclusive, matching the existing comment API.
7+
MAX_SELECTION_COUNT = 5
8+
CATEGORIES = [
9+
{ id: 'pdf', name: 'PDF', extensions: %w[pdf], mime_types: %w[application/pdf], preview: 'pdf' },
10+
{ id: 'document', name: 'Document', extensions: %w[docx], mime_types: [FileHelper::DOCX_MIME_TYPE], preview: 'download' },
11+
{ id: 'spreadsheet', name: 'Spreadsheet', extensions: %w[csv xlsx], mime_types: %w[text/csv application/csv text/plain application/vnd.openxmlformats-officedocument.spreadsheetml.sheet], preview: 'download' },
12+
{ id: 'image', name: 'Image', extensions: %w[png bmp tiff tif jpeg jpg gif], mime_types: %w[image/png image/bmp image/x-ms-bmp image/tiff image/jpeg image/gif], preview: 'image' },
13+
{ id: 'audio', name: 'Audio', extensions: %w[wav ogg mp3 mp4 webm aac pcm aiff flac wma alac], mime_types: %w[audio/ video/webm application/ogg], preview: 'audio' }
14+
].freeze
15+
16+
def self.category(filename)
17+
extension = File.extname(filename.to_s).downcase.delete_prefix('.')
18+
CATEGORIES.find { |item| item[:extensions].include?(extension) }
19+
end
20+
21+
def self.public_policy
22+
{
23+
version: 1,
24+
max_bytes_exclusive: MAX_BYTES,
25+
max_selection_count: MAX_SELECTION_COUNT,
26+
categories: CATEGORIES.map { |item| item.except(:mime_types) }
27+
}
28+
end
29+
30+
def self.validate(file)
31+
path = file['tempfile'].path
32+
filename = file['filename'] || file[:filename]
33+
category = category(filename)
34+
# MediaRecorder sends a Blob with the browser's default extensionless name.
35+
category ||= CATEGORIES.find { |item| item[:id] == 'audio' } if filename == 'blob'
36+
return rejected('UPLOAD_EMPTY', 'Attachment is empty.', file) unless File.size?(path)
37+
return rejected('UPLOAD_TOO_LARGE', 'Attachment must be smaller than 30 MB.', file) if File.size(path) >= MAX_BYTES
38+
return rejected('UPLOAD_EXTENSION_NOT_ALLOWED', 'Unsupported attachment format. Choose a format listed beside Attach a file.', file) unless category
39+
40+
extension = File.extname(filename).downcase.delete_prefix('.')
41+
detected = MimeCheckHelpers.mime_type(path).split(';').first
42+
permitted_mimes = case extension
43+
when 'pcm' then %w[audio/L16 audio/x-pcm application/octet-stream]
44+
when 'csv' then %w[text/csv application/csv text/plain]
45+
when 'xlsx' then %w[application/vnd.openxmlformats-officedocument.spreadsheetml.sheet application/zip]
46+
else category[:mime_types]
47+
end
48+
return rejected('UPLOAD_MIME_INVALID', 'File contents do not match the selected format.', file) unless permitted_mimes.any? { |mime| detected == mime || (mime.end_with?('/') && detected.start_with?(mime)) }
49+
50+
result = case extension
51+
when 'docx' then FileHelper.validate_docx(path, max_file_size: MAX_BYTES - 1)
52+
when 'xlsx' then FileHelper.validate_docx(path, format: 'xlsx', max_file_size: MAX_BYTES - 1)
53+
when 'pdf' then FileHelper.validate_pdf(path)
54+
when 'csv' then validate_csv(path)
55+
else { valid: true }
56+
end
57+
return rejected(result[:encrypted] ? 'UPLOAD_ENCRYPTED' : 'UPLOAD_CORRUPT', 'The attachment is malformed, encrypted or contains unsupported active content.', file) if !result[:valid] || result[:encrypted]
58+
59+
Rails.logger.debug('Uploaded file is accepted')
60+
{ accepted: true, msg: 'success', category: category[:id] }
61+
end
62+
63+
def self.validate_csv(path)
64+
# Stream records so a near-limit CSV does not build a second in-memory table.
65+
CSV.foreach(path, encoding: 'bom|utf-8') { |row| return { valid: false } if row.any? { |cell| cell&.include?("\0") } }
66+
{ valid: true }
67+
rescue CSV::MalformedCSVError, EncodingError, ArgumentError
68+
{ valid: false }
69+
end
70+
71+
def self.rejected(code, message, file)
72+
reason = { 'UPLOAD_EXTENSION_NOT_ALLOWED' => 'File extension check failed', 'UPLOAD_MIME_INVALID' => 'File MIME check failed' }.fetch(code, 'Upload rejected')
73+
FileHelper.log_file_rejection(reason, 'comment_attachment', file, code: code)
74+
{ accepted: false, code: code, msg: message }
75+
end
76+
end

0 commit comments

Comments
 (0)