Coursemology/coursemology2

View on GitHub
app/controllers/concerns/course/assessment/question_bundle_assignment_concern.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true
module Course::Assessment::QuestionBundleAssignmentConcern
  extend ActiveSupport::Concern

  # All validations need to present a ValidationResult of this form, which will be consumed by the view.
  # This struct is loosely inspired by Rails' model validation, but heavily extended.
  # rubocop:disable Layout/CommentIndentation
  ValidationResult = Struct.new(
    :type,              # Hard or soft
    :pass,              # Whether this should be displayed as a tick or cross on the validation summary
    :score_penalty,     # For selecting the best randomized outcome
    :info,              # For displaying additional information. I18n string.
    :offending_cells,   # For highlighting the cell and displaying the error in a tooltip. I18n string.
                        # E.g. { (student, group): 'Lift: 1.4' }
    keyword_init: true
  )
  # rubocop:enable Layout/CommentIndentation

  # Computations on a large set of QBAs are expensive, and we need a lean in-memory representation of a set of QBAs.
  #
  # An AssignmentSet is a (thin) abstraction over a set of QBAs for an assessment which assumes consistency of the
  # underlying data. The constructing code is responsible for data translation / validation.
  #
  # Essentially a nested hash of Student -> Group -> Bundle. Group is nil if assigned bundle is extraneous. Everything
  # is identified by an integer ID.
  class AssignmentSet
    attr_accessor :assignments, :group_bundles

    def initialize(students, group_bundles)
      @assignments = students.to_h { |x| [x, nil => []] }
      @group_bundles = group_bundles
      @group_bundles_lookup = group_bundles.flat_map do |group, bundles|
        bundles.map { |bundle| [bundle, group] }
      end.to_h
    end

    def add_assignment(student, bundle)
      group = @group_bundles_lookup[bundle]
      @assignments[student] ||= { nil => [] }
      if @assignments[student][group].nil?
        @assignments[student][group] = bundle
      else
        @assignments[student][nil].append(bundle)
      end
    end
  end

  class AssignmentRandomizer
    attr_accessor :assignments, :students, :group_bundles, :name_lookup

    def initialize(assessment)
      @assessment = assessment
      @students = assessment.course.user_ids
      @group_bundles = assessment.question_group_ids.to_h { |x| [x, []] }
      assessment.question_bundles.each { |bundle| @group_bundles[bundle.group_id].append(bundle.id) }

      # Reverse lookup of user_id -> course_user.name or user.name
      # Retrieve for current course users, users with submissions, and users with bundle assignments
      @name_lookup = User.where(id: @assessment.question_bundle_assignments.select(:user_id)).
                     pluck(:id, :name).to_h.
                     merge(@assessment.course.course_users.pluck(:user_id, :name).to_h)
    end

    def load
      AssignmentSet.new(@students, @group_bundles).tap do |assignment_set|
        @assessment.question_bundle_assignments.where(submission: nil).each do |qba|
          assignment_set.add_assignment(qba.user_id, qba.bundle_id)
        end
      end
    end

    def save(assignment_set)
      # Deletion must be done atomically to prevent race conditions
      @assessment.question_bundle_assignments.where(submission: nil).delete_all
      new_question_bundle_assignments = []
      assignment_set.assignments.each do |student_id, assigned_group_bundles|
        assigned_group_bundles.each do |group_id, bundle_id|
          next if group_id.nil? || bundle_id.nil?

          new_question_bundle_assignments << Course::Assessment::QuestionBundleAssignment.new(
            user_id: student_id,
            assessment_id: @assessment.id,
            bundle_id: bundle_id
          )
        end
      end
      Course::Assessment::QuestionBundleAssignment.import! new_question_bundle_assignments
    end

    def randomize
      # Naive strategy: For each group, add a random bundle
      AssignmentSet.new(@students, @group_bundles).tap do |assignment_set|
        @students.each do |student|
          @group_bundles.each do |_, bundles|
            assignment_set.add_assignment(student, bundles.sample)
          end
        end
      end
    end

    def validate(assignment_set)
      [
        validate_no_overlapping_questions,
        validate_no_empty_groups,
        validate_one_bundle_assigned(assignment_set),
        validate_no_repeat_bundles(assignment_set)
      ].reduce(&:merge)
    end

    private

    def validate_no_overlapping_questions
      questions = Course::Assessment::Question.
                  where(id: @assessment.question_bundle_questions.group(:question_id).
                            having('count(*) > 1').
                            select(:question_id)).
                  pluck(:title).
                  to_sentence
      {
        no_overlapping_questions:
          ValidationResult.new(
            type: :hard,
            pass: questions.empty?,
            info: questions.empty? ? nil : t_scoped('.no_overlapping_questions.fail', questions: questions)
          )
      }
    end

    def validate_no_empty_groups
      groups = @assessment.question_groups.
               where.not(id: @assessment.question_bundles.select(:group_id)).
               pluck(:title).to_sentence
      {
        no_empty_groups:
          ValidationResult.new(
            type: :hard,
            pass: groups.empty?,
            info: groups.empty? ? nil : t_scoped('.no_empty_groups.fail', groups: groups)
          )
      }
    end

    def validate_one_bundle_assigned(assignment_set)
      student_ids = Set.new
      offending_cells = {}
      assignment_set.assignments.each do |student_id, assignment|
        assignment_set.group_bundles.each_key do |group_bundle|
          if assignment[group_bundle].nil?
            student_ids << student_id
            offending_cells[[student_id, group_bundle]] = t_scoped('.one_bundle_assigned.missing_bundle')
          end
        end
        if assignment[nil].present?
          student_ids << student_id
          offending_cells[[student_id, nil]] = t_scoped('.one_bundle_assigned.unbundled')
        end
      end
      students = student_ids.map { |student_id| @name_lookup[student_id] }.to_sentence
      {
        one_bundle_assigned:
          ValidationResult.new(
            type: :hard,
            pass: students.empty?,
            info: students.empty? ? nil : t_scoped('.one_bundle_assigned.fail', students: students),
            offending_cells: offending_cells
          )
      }
    end

    def validate_no_repeat_bundles(assignment_set)
      attempted_questions = {}
      @assessment.question_bundle_assignments.where.not(submission: nil).pluck(:user_id, :bundle_id).
        each do |user_id, bundle_id|
        attempted_questions[user_id] ||= Set.new
        attempted_questions[user_id] << bundle_id
      end
      student_ids = Set.new
      offending_cells = {}
      assignment_set.assignments.each do |student_id, assignment|
        assignment_set.group_bundles.each_key do |group_bundle|
          if assignment[group_bundle].present? && assignment[group_bundle].in?(attempted_questions[student_id] || [])
            student_ids << student_id
            offending_cells[[student_id, group_bundle]] = t_scoped('.no_repeat_bundles.repeat_bundle')
          end
        end
        if assignment[nil].present? && assignment[nil].any? { |b| b.in?(attempted_questions[student_id] || []) }
          student_ids << student_id
          offending_cells[[student_id, nil]] = t_scoped('.no_repeat_bundles.repeat_bundle')
        end
      end
      students = student_ids.map { |student_id| @name_lookup[student_id] }.to_sentence
      {
        no_repeat_bundles:
          ValidationResult.new(
            type: :hard,
            pass: students.empty?,
            info: students.empty? ? nil : t_scoped('.no_repeat_bundles.fail', students: students),
            offending_cells: offending_cells
          )
      }
    end

    # We can't use the default I18n lazy lookups because this is a concern, so we roll our own.
    def t_scoped(key, *args, **kwargs)
      I18n.t("course.assessment.question_bundle_assignments.validations#{key}", *args, **kwargs)
    end
  end
end