Coursemology/coursemology2

View on GitHub
app/models/course/lesson_plan/item.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true
class Course::LessonPlan::Item < ApplicationRecord
  include Course::LessonPlan::ItemTodoConcern
  include Course::SanitizeDescriptionConcern
  include Course::LessonPlan::Item::CikgoPushConcern

  has_many :personal_times,
           foreign_key: :lesson_plan_item_id, class_name: 'Course::PersonalTime',
           inverse_of: :lesson_plan_item, dependent: :destroy, autosave: true
  has_many :reference_times,
           foreign_key: :lesson_plan_item_id, class_name: 'Course::ReferenceTime', inverse_of: :lesson_plan_item,
           dependent: :destroy, autosave: true
  has_one :default_reference_time,
          -> { joins(:reference_timeline).where(course_reference_timelines: { default: true }) },
          foreign_key: :lesson_plan_item_id, class_name: 'Course::ReferenceTime', inverse_of: :lesson_plan_item,
          autosave: true
  validates :default_reference_time, presence: true
  validate :validate_only_one_default_reference_time

  actable optional: true, inverse_of: :lesson_plan_item
  has_many_attachments on: :description

  after_initialize :set_default_reference_time, if: :new_record?
  after_initialize :set_default_values, if: :new_record?

  validate :validate_presence_of_bonus_end_at
  validates :base_exp, :time_bonus_exp, numericality: { greater_than_or_equal_to: 0 }
  validates :actable_type, length: { maximum: 255 }, allow_nil: true
  validates :title, length: { maximum: 255 }, presence: true
  validates :published, inclusion: { in: [true, false] }
  validates :movable, inclusion: { in: [true, false] }
  validates :triggers_recomputation, inclusion: { in: [true, false] }
  validates :base_exp, numericality: { only_integer: true, greater_than_or_equal_to: -2_147_483_648,
                                       less_than: 2_147_483_648 }, presence: true
  validates :time_bonus_exp, numericality: { only_integer: true, greater_than_or_equal_to: -2_147_483_648,
                                             less_than: 2_147_483_648 }, presence: true
  validates :closing_reminder_token, numericality: true, allow_nil: true
  validates :creator, presence: true
  validates :updater, presence: true
  validates :course, presence: true
  validates :actable_id, uniqueness: { scope: [:actable_type], allow_nil: true,
                                       if: -> { actable_type? && actable_id_changed? } }
  validates :actable_type, uniqueness: { scope: [:actable_id], allow_nil: true,
                                         if: -> { actable_id? && actable_type_changed? } }

  # @!method self.ordered_by_date
  #   Orders the lesson plan items by the starting date.
  scope :ordered_by_date, (lambda do
    includes(reference_times: :reference_timeline).
      merge(Course::ReferenceTime.order(:start_at))
  end)

  scope :ordered_by_date_and_title, (lambda do
    includes(reference_times: :reference_timeline).
      merge(Course::ReferenceTime.order(:start_at)).
      order(:title)
  end)

  # @!method self.published
  #   Returns only the lesson plan items that are published.
  scope :published, (lambda do
    where(published: true)
  end)

  scope :with_personal_times_for, (lambda do |course_user|
    personal_times =
      if course_user.nil?
        nil
      else
        Course::PersonalTime.where(course_user_id: course_user.id, lesson_plan_item_id: all)
      end

    all.tap do |result|
      preloader = ActiveRecord::Associations::Preloader.new(records: result,
                                                            associations: :personal_times,
                                                            scope: personal_times)
      preloader.call
    end
  end)

  # Loads the reference times for `course_user`. If `course_user` is nil, then we load the default reference time for
  # `course`.
  scope :with_reference_times_for, (lambda do |course_user, course = nil|
    # Even if there's no course user, we can eager load if the course is known.
    return if course_user.nil? && course.nil?

    default_reference_timeline_id = course_user&.course&.default_reference_timeline&.id ||
                                    course.default_reference_timeline.id

    reference_timeline_id = course_user&.reference_timeline_id || default_reference_timeline_id

    eager_load(:reference_times).where(course_reference_times: {
      reference_timeline_id: [reference_timeline_id, default_reference_timeline_id]
    })
  end)

  # @!method self.with_actable_types
  #   Scopes the lesson plan items to those which belong to the given actable_types.
  #   Each actable type is further scoped to return the IDs of items for display.
  #   actable_data is provided to help the actable types figure out what should be displayed.
  #
  # @param actable_hash [Hash{String => Array<String> or nil}] Hash of actable_names to data.
  scope :with_actable_types, lambda { |actable_hash|
    where(
      actable_hash.map do |actable_type, actable_data|
        "course_lesson_plan_items.id IN (#{actable_type.constantize.
        ids_showable_in_lesson_plan(actable_data).to_sql})"
      end.join(' OR ')
    )
  }

  belongs_to :course, inverse_of: :lesson_plan_items
  has_many :todos, class_name: 'Course::LessonPlan::Todo', inverse_of: :item, dependent: :destroy

  delegate :start_at, :start_at=, :start_at_changed?, :bonus_end_at, :bonus_end_at=, :bonus_end_at_changed?,
           :end_at, :end_at=, :end_at_changed?,
           to: :default_reference_time
  before_validation :link_default_reference_time

  # Returns a frozen CourseReferenceTime or CoursePersonalTime.
  # The calling function is responsible for eager-loading both associations if calling time_for on a lot of items.
  def time_for(course_user)
    personal_time = personal_time_for(course_user)
    reference_time = reference_time_for(course_user)
    (personal_time || reference_time).clone.freeze
  end

  def personal_time_for(course_user)
    return nil if course_user.nil?

    # Do not make a separate call to DB if personal_times has already been preloaded
    if personal_times.loaded?
      personal_times.find { |x| x.course_user_id == course_user.id }
    else
      personal_times.find_by(course_personal_times: { course_user_id: course_user.id })
    end
  end

  def reference_time_for(course_user)
    default_reference_timeline_id = course.default_reference_timeline.id
    reference_timeline_id = course.reference_timeline_for(course_user)

    # This reversion anticipates if course_user is on a non-default timeline which does not override the
    # default time for this lesson plan item.
    reference_time_in(reference_timeline_id) || reference_time_in(default_reference_timeline_id)
  end

  # Gets the existing personal time for course_user, or instantiates and returns a new one
  def find_or_create_personal_time_for(course_user)
    personal_time = personal_time_for(course_user)
    return personal_time if personal_time.present?

    personal_time = personal_times.new(course_user: course_user)
    reference_time = reference_time_for(course_user)
    personal_time.start_at = reference_time.start_at
    personal_time.end_at = reference_time.end_at
    personal_time.bonus_end_at = reference_time.bonus_end_at
    personal_time
  end

  # Finds the lesson plan items which are starting within the next day for a given course user.
  # Rearrange the items into a hash keyed by the actable type as a string.
  # For example:
  # {
  #   ActableType_1_as_String => [ActableItems...],
  #   ActableType_2_as_String => [ActableItems...]
  # }
  #
  # @param course_user [CourseUser] The course user to check for published items starting within the next day.
  # @return [Hash]
  def self.upcoming_items_from_course_by_type_for_course_user(course_user)
    course = course_user.course
    opening_items = course.lesson_plan_items.published.
                    with_reference_times_for(course_user).
                    with_personal_times_for(course_user).
                    to_a
    opening_items_hash = Hash.new { |hash, actable_type| hash[actable_type] = [] }
    opening_items.
      select { |item| item.time_for(course_user).start_at.between?(Time.zone.now, 1.day.from_now) }.
      select { |item| item.actable.include_in_consolidated_email?(:opening_reminder) }.
      each { |item| opening_items_hash[item.actable_type].push(item.actable) }

    # Asssessment
    opening_items_hash['Course::Assessment'].delete_if do |assessment|
      email_enabled_assessment = course.email_enabled(:assessments, :opening_reminder, assessment.tab.category.id)
      exclude_assessment = (course_user.phantom? && !email_enabled_assessment.phantom) ||
                           (!course_user.phantom? && !email_enabled_assessment.regular) ||
                           course_user.
                           email_unsubscriptions.where(course_settings_email_id: email_enabled_assessment.id).exists?
      true if exclude_assessment
    end
    opening_items_hash.except!('Course::Assessment') if opening_items_hash['Course::Assessment'].empty?

    # Survey
    email_enabled_survey = course.email_enabled(:surveys, :opening_reminder)
    exclude_survey = (course_user.phantom? && !email_enabled_survey.phantom) ||
                     (!course_user.phantom? && !email_enabled_survey.regular) ||
                     course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled_survey.id).exists?
    opening_items_hash.except!('Course::Survey') if exclude_survey

    # Videos
    email_enabled_video = course.email_enabled(:videos, :opening_reminder)
    exclude_video = (course_user.phantom? && !email_enabled_video.phantom) ||
                    (!course_user.phantom? && !email_enabled_video.regular) ||
                    course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled_video.id).exists?
    opening_items_hash.except!('Course::Video') if exclude_video

    # Sort the items for each actable type by start_at time, followed by title.
    opening_items_hash.each_value do |items|
      items.sort_by! { |item| [item.time_for(course_user).start_at, item.title] }
    end
  end

  # Copy attributes for lesson plan item from the object being duplicated.
  # Shift the time related fields.
  #
  # @param other [Object] The source object to copy attributes from.
  # @param duplicator [Duplicator] The Duplicator object
  def copy_attributes(other, duplicator)
    self.course = duplicator.options[:destination_course]
    self.default_reference_time = duplicator.duplicate(other.default_reference_time)

    other_reference_times = other.reference_times - [other.default_reference_time]
    self.reference_times = duplicator.duplicate(other_reference_times).unshift(default_reference_time)

    self.title = other.title
    self.description = other.description
    self.published = duplicator.options[:unpublish_all] ? false : other.published
    self.base_exp = other.base_exp
    self.time_bonus_exp = other.time_bonus_exp
  end

  # Test if the lesson plan item has started for self directed learning.
  #
  # @return [Boolean]
  def self_directed_started?(course_user = nil)
    if course&.advance_start_at_duration
      time_for(course_user).start_at.blank? ||
        time_for(course_user).start_at - course.advance_start_at_duration < Time.zone.now
    else
      started?
    end
  end

  private

  # Sets default EXP values
  def set_default_values
    self.base_exp ||= 0
    self.time_bonus_exp ||= 0
  end

  def set_default_reference_time
    self.default_reference_time ||= Course::ReferenceTime.new(lesson_plan_item: self)
  end

  def link_default_reference_time
    self.default_reference_time.reference_timeline = course.default_reference_timeline
    self.default_reference_time.lesson_plan_item = self
  end

  def validate_only_one_default_reference_time
    num_defaults = reference_times.
                   includes(:reference_timeline).
                   where(course_reference_timelines: { default: true }).
                   count
    return if num_defaults <= 1 # Could be 0 if item is new

    errors.add(:reference_times, :must_have_at_most_one_default)
  end

  # User must set bonus_end_at if there's bonus exp
  def validate_presence_of_bonus_end_at
    return unless time_bonus_exp && time_bonus_exp > 0 && bonus_end_at.blank?

    errors.add(:bonus_end_at, :required)
  end

  def reference_time_in(reference_timeline_id)
    # Do not make a separate call to DB if reference_times has already been preloaded
    if reference_times.loaded?
      reference_times.find { |x| x.reference_timeline_id == reference_timeline_id }
    else
      reference_times.find_by(course_reference_times: { reference_timeline_id: reference_timeline_id })
    end
  end
end