canvas-lms/app/models/grading_period.rb

305 lines
9.2 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2014 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class GradingPeriod < ActiveRecord::Base
include Canvas::SoftDeletable
belongs_to :grading_period_group, inverse_of: :grading_periods
has_many :scores, -> { active }
has_many :submissions, -> { active }
has_many :auditor_grade_change_records,
class_name: "Auditors::ActiveRecord::GradeChangeRecord",
inverse_of: :grading_period
validates :title, :start_date, :end_date, :close_date, :grading_period_group_id, presence: true
validates :weight, numericality: true, allow_nil: true
validate :start_date_is_before_end_date
validate :close_date_is_on_or_after_end_date
validate :not_overlapping, unless: :skip_not_overlapping_validator?
before_validation :adjust_close_date_for_course_period
before_validation :ensure_close_date
before_save :set_root_account_id
after_save :recompute_scores, if: :dates_or_weight_or_workflow_state_changed?
after_destroy :destroy_grading_period_set, if: :last_remaining_legacy_period?
after_destroy :destroy_scores
scope :current, -> do
now = Time.zone.now.change(sec: 0)
where(
"date_trunc('minute', grading_periods.end_date) >= ? AND date_trunc('minute', grading_periods.start_date) < ?",
now,
now
)
end
scope :closed, -> { where("grading_periods.close_date < ?", Time.zone.now) }
scope :open, -> { where("grading_periods.close_date IS NULL OR grading_periods.close_date >= ?", Time.zone.now) }
scope :grading_periods_by, ->(context_with_ids) do
joins(:grading_period_group).where(grading_period_groups: context_with_ids).readonly(false)
end
set_policy do
%i[read create update delete].each do |permission|
given do |user|
grading_period_group.present? &&
grading_period_group.grants_right?(user, permission)
end
can permission
end
end
def self.date_in_closed_grading_period?(course:, date:, periods: nil)
period = self.for_date_in_course(date: date, course: course, periods: periods)
period.present? && period.closed?
end
def self.for_date_in_course(date:, course:, periods: nil)
periods ||= self.for(course)
if date.nil?
return periods.sort_by(&:end_date).last
else
periods.detect { |p| p.in_date_range?(date) }
end
end
def self.for(context, inherit: true)
grading_periods = context.grading_periods.active
if context.is_a?(Course) && inherit && grading_periods.empty?
context.enrollment_term.grading_periods.active
else
grading_periods
end
end
def self.current_period_for(context)
self.for(context).find(&:current?)
end
def account_group?
grading_period_group.course_id.nil?
end
def course_group?
grading_period_group.course_id.present?
end
def assignments_for_student(course, assignments, student)
assignment_ids = GradebookGradingPeriodAssignments.new(course, student: student).to_h.fetch(id, [])
if assignment_ids.empty?
[]
else
assignments.select { |assignment| assignment_ids.include?(assignment.id.to_s) }
end
end
def assignments(course, assignments)
assignment_ids = GradebookGradingPeriodAssignments.new(course).to_h.fetch(id, [])
if assignment_ids.empty?
[]
else
assignments.select { |assignment| assignment_ids.include?(assignment.id.to_s) }
end
end
def current?
in_date_range?(Time.zone.now)
end
def in_date_range?(date)
comparison_date = date_for_comparison(date)
date_for_comparison(start_date) < comparison_date && comparison_date <= date_for_comparison(end_date)
end
def last?
# should never be nil, because self is part of the potential set
@last_period ||= grading_period_group
.grading_periods
.active
.order(end_date: :desc)
.first
@last_period == self
end
alias_method :is_last, :last?
def closed?
Time.zone.now > close_date
end
alias_method :is_closed, :closed?
def overlapping?
overlaps.active.exists?
end
def skip_not_overlapping_validator
@_skip_not_overlapping_validator = true
end
def self.json_for(context, user)
periods = self.for(context).sort_by(&:start_date)
self.periods_json(periods, user)
end
def self.periods_json(periods, user)
periods.map do |period|
period.as_json_with_user_permissions(user)
end
end
def as_json_with_user_permissions(user)
as_json(
only: [:id, :title, :start_date, :end_date, :close_date, :weight],
permissions: { user: user },
methods: [:is_last, :is_closed],
).fetch(:grading_period)
end
def disable_post_to_sis
raise(RangeError, "The grading period is not yet closed.") if Time.zone.now < close_date
# This method is called from a job, to know if it is already processed we
# cache that the job has processed.
# If the look_back in the job is changed, the amount of time we cache needs
# to also follow, so using the same setting.
look_back = Setting.get('disable_post_to_sis_on_grading_period', '60').to_i + 10
due_at_range = start_date..end_date
Rails.cache.fetch(['disable_post_to_sis_in_completed', self].cache_key, expires_in: look_back.minutes) do
possible_assignments_scope = Assignment.active.
where(root_account_id: root_account_id, post_to_sis: true)
scope = possible_assignments_scope.
where(due_at: due_at_range).
union(possible_assignments_scope.where("EXISTS (?)",
AssignmentOverride.active.
where("assignment_id = assignments.id").
where(set_type: "CourseSection", due_at_overridden: true, due_at: due_at_range)))
# until all post_to_sis in scope are false, repeat.
while scope.limit(1_000).update_all(post_to_sis: false, updated_at: Time.zone.now) == 1_000 do; end
# caching that it has completed, so if this gets called again, it can skip.
true
end
end
private
def set_root_account_id
self.root_account_id ||= grading_period_group&.root_account_id
end
def date_for_comparison(date)
comparison_date = date.is_a?(String) ? Time.zone.parse(date) : date
comparison_date&.change(sec: 0)
end
def destroy_scores
scores.find_ids_in_ranges do |min_id, max_id|
scores.where(id: min_id..max_id).update_all(workflow_state: :deleted)
end
end
def destroy_grading_period_set
grading_period_group.destroy
end
def last_remaining_legacy_period?
course_group? && grading_period_group.active? && siblings.active.empty?
end
def skip_not_overlapping_validator?
@_skip_not_overlapping_validator
end
scope :overlaps, ->(from, to) do
# sourced: http://c2.com/cgi/wiki?TestIfDateRangesOverlap
where(
"((date_trunc('minute', end_date) > ?) and (date_trunc('minute', start_date) < ?))",
from&.change(sec: 0),
to&.change(sec: 0)
)
end
def not_overlapping
if overlapping?
errors.add(:base, t('errors.overlap_message',
"Grading period cannot overlap with existing grading periods in group"))
end
end
def overlaps
siblings.overlaps(start_date, end_date)
end
def siblings
grading_periods = self.class.where(
grading_period_group_id: grading_period_group_id
)
if new_record?
grading_periods
else
grading_periods.where("id <> ?", id)
end
end
def start_date_is_before_end_date
if start_date && end_date && end_date < start_date
errors.add(:end_date, t('must be after start date'))
end
end
def adjust_close_date_for_course_period
self.close_date = end_date if grading_period_group.present? && course_group?
end
def ensure_close_date
self.close_date ||= end_date
end
def close_date_is_on_or_after_end_date
if close_date.present? && end_date.present? && close_date < end_date
errors.add(:close_date, t('must be on or after end date'))
end
end
def recompute_scores
dates_or_workflow_state_changed = time_boundaries_changed? || saved_change_to_workflow_state?
if course_group?
course = grading_period_group.course
course.recompute_student_scores(update_all_grading_period_scores: dates_or_workflow_state_changed)
DueDateCacher.recompute_course(course) if dates_or_workflow_state_changed
else
grading_period_group.recompute_scores_for_each_term(dates_or_workflow_state_changed)
end
end
def weight_actually_changed?
grading_period_group.weighted && saved_change_to_weight?
end
def time_boundaries_changed?
saved_change_to_start_date? || saved_change_to_end_date?
end
def dates_or_weight_or_workflow_state_changed?
time_boundaries_changed? || weight_actually_changed? || saved_change_to_workflow_state?
end
end