Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/api/discussion_comment_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ class DiscussionCommentApi < Grape::API
if project.has_task_for_task_definition? task_definition
task = project.task_for_task_definition(task_definition)
discussion_comment = task.all_comments.find(params[:task_comment_id]).becomes(DiscussionComment)
discussion_comment.mark_discussion_started
# Opening a prompt is read-only while a submitted portfolio freezes the
# project. Keep the download available without changing discussion state.
discussion_comment.mark_discussion_started unless project.portfolio_locked?

prompt_path = discussion_comment.attachment_path(prompt_number)

Expand Down
6 changes: 6 additions & 0 deletions app/api/entities/project_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class ProjectEntity < Grape::Entity
expose :portfolio_files, unless: :summary_only
expose :compile_portfolio, unless: :summary_only
expose :portfolio_available
# Grape passes entity options to exposure procs, so Symbol#to_proc is not compatible here.
# rubocop:disable Style/SymbolProc
expose :portfolio_locked do |project|
project.portfolio_locked?
end
# rubocop:enable Style/SymbolProc
expose :portfolio_submission_date, if: :for_staff
expose :uses_draft_learning_summary, unless: :summary_only

Expand Down
3 changes: 3 additions & 0 deletions app/api/entities/unit_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ def can_read_unit_config?(my_role)
expose :allow_student_change_tutorial, unless: :summary_only
expose :allow_flexible_dates, unless: :summary_only
expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only
# Students need to see this too, so the portfolio review step can warn them
# that submitting will lock their tasks - it is not staff-only.
expose :lock_project_on_portfolio_submission, unless: :summary_only

expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only
expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only
Expand Down
16 changes: 15 additions & 1 deletion app/api/projects_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,21 @@ class ProjectsApi < Grape::API
project.save
end

Entities::ProjectEntity.represent(project, only: [:campus_id, :enrolled, :target_grade, :submitted_grade, :compile_portfolio, :portfolio_available, :uses_draft_learning_summary, :stats], for_student: for_student)
Entities::ProjectEntity.represent(
project,
only: [
:campus_id,
:enrolled,
:target_grade,
:submitted_grade,
:compile_portfolio,
:portfolio_available,
:portfolio_locked,
:uses_draft_learning_summary,
:stats
],
for_student: for_student
)
end # put

desc 'Enrol a student in a unit, creating them a project'
Expand Down
14 changes: 13 additions & 1 deletion app/api/submission/portfolio_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class PortfolioApi < Grape::API
error!({ error: "Not authorised to submit portfolio for project '#{params[:id]}'" }, 401)
end

if project.portfolio_locked?
error!({ error: 'Portfolio evidence is frozen because the portfolio has already been submitted.' }, 403)
end

file = params[:file0]
name = params[:name]
kind = params[:kind]
Expand Down Expand Up @@ -55,12 +59,20 @@ class PortfolioApi < Grape::API

# Remove file or portfolio?
if params[:idx].nil? && params[:name].nil? && params[:kind].nil?
# Deleting the whole portfolio is how a locked project gets unlocked again,
# so it is intentionally still allowed once a portfolio has been submitted.
# compile_portfolio is cleared too so a delete performed mid-compile still unlocks the project.
project.update!({
portfolio_submission_date: nil,
portfolio_production_date: nil
portfolio_production_date: nil,
compile_portfolio: false
})
project.remove_portfolio # returns details of file
elsif !(params[:idx].nil? || params[:name].nil? || params[:kind].nil?)
if project.portfolio_locked?
error!({ error: 'Portfolio evidence is frozen because the portfolio has already been submitted.' }, 403)
end

idx = params[:idx]
name = params[:name]
kind = params[:kind]
Expand Down
4 changes: 4 additions & 0 deletions app/api/tasks_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ class TasksApi < Grape::API
post '/projects/:id/task_def_id/:task_definition_id/check_in' do
project = Project.find(params[:id])

if project.portfolio_locked?
error!({ error: 'This project is locked because its portfolio has been submitted.' }, 403)
end

unless authorise?(current_user, project, :assess)
error!({ error: 'You do not have permission to assess this task.' }, 403)
end
Expand Down
4 changes: 4 additions & 0 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class UnitsApi < Grape::API
optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment'
optional :draft_task_definition_id, type: Integer, desc: 'Indicates the ID of the task definition used as the "draft learning summary task"'
optional :portfolio_auto_generation_date, type: Date, desc: 'Indicates a date where student portfolio will automatically compile'
optional :lock_project_on_portfolio_submission, type: Boolean, desc: 'Freeze task statuses, comments, and portfolio evidence while a portfolio is compiling or available'
optional :allow_flexible_dates, type: Boolean, desc: 'Can turn on/off flexible dates for tasks in this unit'
optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests'
optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials'
Expand Down Expand Up @@ -130,6 +131,7 @@ class UnitsApi < Grape::API
:enable_sync_enrolments,
:draft_task_definition_id,
:portfolio_auto_generation_date,
:lock_project_on_portfolio_submission,
:allow_flexible_dates,
:allow_student_extension_requests,
:extension_weeks_on_resubmit_request,
Expand Down Expand Up @@ -187,6 +189,7 @@ class UnitsApi < Grape::API
optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests', default: true
optional :extension_weeks_on_resubmit_request, type: Integer, desc: 'Determines the number of weeks extension on a resubmit request', default: 1
optional :portfolio_auto_generation_date, type: Date, desc: 'Indicates a date where student portfolio will automatically compile'
optional :lock_project_on_portfolio_submission, type: Boolean, desc: 'Freeze task statuses, comments, and portfolio evidence while a portfolio is compiling or available', default: false
optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true
optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox'
optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking'
Expand Down Expand Up @@ -229,6 +232,7 @@ class UnitsApi < Grape::API
:allow_student_extension_requests,
:extension_weeks_on_resubmit_request,
:portfolio_auto_generation_date,
:lock_project_on_portfolio_submission,
:allow_student_change_tutorial,
:feedback_warning_threshold_days,
:feedback_overflow_threshold_days,
Expand Down
17 changes: 17 additions & 0 deletions app/models/comments/task_comment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,32 @@
validates :recipient, presence: true
validates :comment, length: { minimum: 0, maximum: 4095, allow_blank: true }
validate :valid_reply_to?, on: :create
validate :prevent_changes_when_portfolio_locked

# After create, mark as read by user creating
after_create do
mark_as_read(self.user)
end

# Delete action - before dependent association
before_destroy :prevent_destroy_when_portfolio_locked
before_destroy :delete_associated_files

def prevent_changes_when_portfolio_locked
return unless task&.project&.portfolio_locked?

errors.add(:base, 'Comment cannot be changed while the project portfolio is submitted')
end

def prevent_destroy_when_portfolio_locked

Check warning on line 48 in app/models/comments/task_comment.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the callback method 'prevent_destroy_when_portfolio_locked' private.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaAORgyZ3ej0ChNo9ZWF&open=AaAORgyZ3ej0ChNo9ZWF&pullRequest=671
return unless task&.project&.portfolio_locked?
return if task.destroyed? || task.marked_for_destruction?
return if task.project.destroyed? || task.project.marked_for_destruction?

errors.add(:base, 'Comment cannot be deleted while the project portfolio is submitted')
throw :abort
end

def valid_reply_to?
if reply_to_id.present?
originalTaskComment = TaskComment.find(reply_to_id)
Expand Down
4 changes: 3 additions & 1 deletion app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@
# Locates other group members, and link to this submission.
# - contributors contains [ {project: ..., pct: ... } ]
#
def create_submission(submitter_task, notes, contributors)

Check failure on line 198 in app/models/group.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaASbxq88ziIV05gWro2&open=AaASbxq88ziIV05gWro2&pullRequest=671
total = 0
# check all members are in the same group
contributors.each do |contrib|
Expand Down Expand Up @@ -242,7 +242,9 @@
project = contrib[:project]
task = project.matching_task submitter_task

next if task.task_submission_closed?
# A group member whose own portfolio is locked keeps the exact task
# state they had when they submitted, so leave their task alone.
next if task.task_submission_closed? || task.project.portfolio_locked?

if contrib[:pct].to_i > 0
task.group_submission = gs
Expand Down
14 changes: 10 additions & 4 deletions app/models/group_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ class GroupSubmission < ApplicationRecord
begin
FileHelper.delete_group_submission(group_submission)

# also remove evidence from group members
# rubocop:disable Rails/SkipsModelValidations
tasks.where('portfolio_evidence IS NOT NULL').update_all(portfolio_evidence: nil)
# rubocop:enable Rails/SkipsModelValidations
# also remove evidence from group members - skip anyone whose portfolio
# is locked so their task state stays exactly as submitted
tasks.where('portfolio_evidence IS NOT NULL').find_each do |task|
next if task.project.portfolio_locked?

task.update(portfolio_evidence: nil)
end
rescue => e
logger.error "Failed to delete group submission #{group_submission.id}. Error: #{e.message}"
end
Expand All @@ -29,6 +32,7 @@ class GroupSubmission < ApplicationRecord
def propagate_transition(initial_task, trigger, by_user, quality)
tasks.each do |task|
next if [TaskStatus.complete.id, TaskStatus.feedback_exceeded.id, TaskStatus.fail.id].include? task.task_status_id
next if task.project.portfolio_locked?

if task != initial_task
task.extensions = initial_task.extensions unless initial_task.extensions < task.extensions
Expand All @@ -39,6 +43,8 @@ def propagate_transition(initial_task, trigger, by_user, quality)

def propagate_grade(initial_task, new_grade, ui)
tasks.each do |task|
next if task.project.portfolio_locked?

if task != initial_task
task.grade_task new_grade, ui, grading_group = true
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/overseer_assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def update_from_output(work_dir_path)
self.result_task_status = task.status
end

if task.ready_for_feedback? && new_status.present?
if task.ready_for_feedback? && new_status.present? && !task.project.portfolio_locked?
task.add_status_comment(task.task_definition.unit.main_convenor.user, new_status)
task.update task_status: new_status
end
Expand Down
4 changes: 4 additions & 0 deletions app/models/pdf_generation/project_compile_portfolio_module.rb
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ def create_portfolio
save!
true
rescue StandardError => e
# A partially written output is not an available portfolio. Removing it
# ensures a failed compilation releases the submission lock.
FileUtils.rm_f(portfolio_path)
self.portfolio_production_date = nil
self.compile_portfolio = false
save!

Expand Down
9 changes: 9 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ def active?
unit.active
end

# True once this project's portfolio has been submitted (compiling or available) in a unit
# that locks projects on portfolio submission. Tasks and comments are frozen while true, so
# the project reflects exactly what the student had when they created their portfolio.
def portfolio_locked?
unit.lock_project_on_portfolio_submission? && (compile_portfolio? || portfolio_available)
end

#
# Get a string representation of the Target Grade
#
Expand Down Expand Up @@ -537,6 +544,8 @@ def self.create_task_stats_from(total_task_counts, project_task_counts, target_g
end

def revert_overdue_tasks
return if portfolio_locked?

tasks.each do |task|
next if task.submission_date.blank?

Expand Down
29 changes: 28 additions & 1 deletion app/models/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
end

# Delete action - before dependent association
before_destroy :prevent_destroy_when_portfolio_locked
before_destroy :delete_associated_files

# Model associations
Expand Down Expand Up @@ -163,6 +164,7 @@
validate :prevent_complete_if_requires_discussion
validate :prevent_feedback_exceeed_if_assess_in_portfolio_enabled
validate :prevent_time_exceeed_if_assess_in_portfolio_enabled
validate :prevent_changes_when_portfolio_locked

include TaskTiiModule

Expand Down Expand Up @@ -191,6 +193,12 @@
end
end

def prevent_changes_when_portfolio_locked
return unless project&.portfolio_locked?

errors.add(:base, 'Task cannot be changed while the project portfolio is submitted')
end

def for_definition_with_quality?
task_definition.has_stars?
end
Expand Down Expand Up @@ -632,6 +640,11 @@

def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: false, quality: 1, recursive_fix: false,
check_feedback: false, system_transition: false)
if project.portfolio_locked?
errors.add(:base, 'Project is locked because its portfolio has been submitted')
return nil
end

#
# Ensure that assessor is allowed to update the task in the indicated way
#
Expand Down Expand Up @@ -1414,6 +1427,14 @@
result
end

def prevent_destroy_when_portfolio_locked

Check warning on line 1430 in app/models/task.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the callback method 'prevent_destroy_when_portfolio_locked' private.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaAORgxH3ej0ChNo9ZWE&open=AaAORgxH3ej0ChNo9ZWE&pullRequest=671
return unless project&.portfolio_locked?
return if project.destroyed? || project.marked_for_destruction?

errors.add(:base, 'Task cannot be deleted while the project portfolio is submitted')
throw :abort
end

class TaskAppController < ApplicationController
include LatexHelper

Expand Down Expand Up @@ -1671,7 +1692,13 @@
contribs = contributions.map { |data| { project: Project.find(data[:project_id]), pct: data[:pct].to_i, pts: data[:pts].to_i } }
end
group_submission = group.create_submission self, "#{user.name} has submitted work", contribs
group_submission.tasks.each { |t| t.create_submission_and_trigger_state_change(user, false, contributions, trigger, self) }
group_submission.tasks.each do |t|
# Group members whose own portfolio is locked keep the exact task state
# they had when they submitted - do not propagate this submission to them.
next if t.project.portfolio_locked?

t.create_submission_and_trigger_state_change(user, false, contributions, trigger, self)
end
reload
else
self.file_uploaded_at = Time.zone.now
Expand Down
4 changes: 4 additions & 0 deletions app/models/task_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ def update_overdue_tasks_aip
overdue_statuses = [TaskStatus.time_exceeded.id]

tasks.where(task_status_id: overdue_statuses).find_each do |task|
next if task.project.portfolio_locked?

task.add_status_comment(unit.main_convenor.user, TaskStatus.assess_in_portfolio)
task.update(task_status_id: TaskStatus.assess_in_portfolio.id)
end
Expand All @@ -305,6 +307,8 @@ def reset_overdue_tasks
.where(task_status: [TaskStatus.time_exceeded, TaskStatus.assess_in_portfolio])

late_submissions.each do |task|
next if task.project.portfolio_locked?

task.add_status_comment(unit.main_convenor.user, TaskStatus.ready_for_feedback)
task.update(task_status_id: TaskStatus.ready_for_feedback.id)
end
Expand Down
3 changes: 3 additions & 0 deletions app/models/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ def discuss_timeout_tasks
end

def notify_discuss_timeout_for(task, teaching_breaks: nil, now_time: Time.zone.now)
return 0 if task.project.portfolio_locked?
return 0 if task.moved_to_discuss_at.blank?

actor = task.project.tutor_for(task.task_definition) || main_convenor&.user
Expand Down Expand Up @@ -3984,6 +3985,8 @@ def update_overdue_tasks_aip
overdue_statuses = [TaskStatus.time_exceeded.id]

tasks.where(task_status_id: overdue_statuses).find_each do |task|
next if task.project.portfolio_locked?

task.add_status_comment(main_convenor.user, TaskStatus.assess_in_portfolio)
task.update(task_status_id: TaskStatus.assess_in_portfolio.id)
end
Expand Down
Loading
Loading