pupilfirst/pupilfirst

View on GitHub
app/services/course_exports/prepare_students_export_service.rb

Summary

Maintainability
A
2 hrs
Test Coverage
A
100%
module CourseExports
  class PrepareStudentsExportService
    include CourseExportable

    def execute
      tables = [
        { title: "Targets", rows: target_rows },
        { title: "Students", rows: student_rows },
        { title: "Submissions", rows: submission_rows },
      ]

      tables.push(PrepareUserStandingsExportService.new.execute(user_ids)) if @course_export.include_user_standings?

      finalize(tables)
    end

    private

    def target_rows
      values =
        targets.map do |target|
          milestone = milestone?(target)

          [
            target_id(target),
            target.level.number,
            target.title,
            target_type(target),
            milestone,
            students_with_submissions(target),
            submissions_pending_review(target)
          ] + average_grades_for_target(target)
        end

      (
        [
          [
            "ID",
            "Level",
            "Name",
            "Completion Method",
            "Milestone?",
            "Students with submissions",
            "Submissions pending review"
          ] + evaluation_criteria_names
        ] + values
      ).transpose
    end

    def evaluation_criteria_names
      @evaluation_criteria_names ||=
        EvaluationCriterion
          .where(id: evaluation_criteria_ids)
          .order(:name)
          .map { |ec| ec.display_name + " - Average" }
    end

    def evaluation_criteria_ids
      @evaluation_criteria_ids ||=
        targets
          .map do |target|
            assignment = target.assignments.not_archived.first
            if assignment
              assignment.evaluation_criteria.order(:name).pluck(:id)
            else
              []
            end
          end
          .flatten
          .uniq
    end

    def average_grades_for_target(target)
      empty_grades = Array.new(evaluation_criteria_ids.length)

      target
        .evaluation_criteria
        .pluck(:id)
        .each_with_object(empty_grades) do |evaluation_criterion_id, grades|
          average_grade =
            TimelineEventGrade
              .joins(timeline_event: :timeline_event_owners)
              .where(
                timeline_event_owners: {
                  latest: true,
                  student_id: students.pluck(:id)
                },
                timeline_events: {
                  target_id: target.id
                },
                evaluation_criterion_id: evaluation_criterion_id
              )
              .distinct
              .average(:grade)
              &.round(2)

          grades[
            evaluation_criteria_ids.index(evaluation_criterion_id)
          ] = average_grade
        end
    end

    def average_grades_for_student(student)
      evaluation_criteria_ids.map do |evaluation_criterion_id|
        TimelineEventGrade
          .joins(timeline_event: :timeline_event_owners)
          .where(
            timeline_event_owners: {
              latest: true,
              student_id: student.id
            },
            evaluation_criterion_id: evaluation_criterion_id
          )
          .distinct
          .average(:grade)
          &.round(2)
      end
    end

    def students_with_submissions(target)
      target
        .timeline_events
        .live
        .joins(:students)
        .where(students: { id: students.pluck(:id) })
        .distinct("students.id")
        .count("students.id")
    end

    def submissions_pending_review(target)
      target
        .timeline_events
        .live
        .pending_review
        .joins(:students)
        .where(students: { id: students.pluck(:id) })
        .distinct("timeline_events.id")
        .count
    end

    def report_path(student)
      @report_path_prefix ||=
        begin
          school = @course_export.user.school
          "https://#{school.domains.primary.fqdn}/students"
        end

      "#{@report_path_prefix}/#{student.id}/report"
    end

    def student_report_link(student)
      "oooc:=HYPERLINK(\"#{report_path(student)}\"; \"#{student.id}\")"
    end

    def latest_user_standing(user)
      user.user_standings.live.order(created_at: :desc).first
    end

    def school_default_standing(user)
      @school_default_standing ||= user.school.default_standing
    end

    def student_rows
      rows =
        students.map do |student|
          user = student.user

          [
            user.id,
            { formula: student_report_link(student) },
            user.email,
            user.name,
            user.title,
            user.affiliation,
            student.cohort.name,
            student.tags.order(:name).pluck(:name).join(", "),
            last_seen_at(user),
            student.completed_at&.iso8601 || "",
            latest_user_standing(user)&.standing&.name ||
              school_default_standing(user)&.name || "",
            latest_user_standing(user)&.reason ||
              school_default_standing(user)&.description || ""
          ] + average_grades_for_student(student)
        end

      [
        [
          "User ID",
          "Student ID",
          "Email Address",
          "Name",
          "Title",
          "Affiliation",
          "Cohort",
          "Tags",
          "Last Seen At",
          "Course Completed At",
          "Current Standing",
          "Current Standing Reason"
        ] + evaluation_criteria_names
      ] + rows
    end

    def submission_rows
      # Lay out the top row of target IDs.
      header =
        ["Student Email / Target ID"] +
          targets.map { |target| target_id(target) }

      target_ids = targets.pluck(:id)

      # Now populate status for each student.
      [header] +
        students.map do |student|
          grading = compute_grading_for_submissions(student, target_ids)
          [student.user.email] + grading
        end
    end

    def compute_grading_for_submissions(student, target_ids)
      TimelineEvent
        .live
        .includes(:timeline_event_grades)
        .joins(:students)
        .where(students: { id: student.id })
        .order(:created_at)
        .distinct
        .each_with_object([]) do |submission, grading|
          grade_index = target_ids.index(submission.target_id)

          # We can't record grades for submissions where the target has been archived.
          next if grade_index.nil?

          assign_styled_grade(grade_index, grading, submission)
        end
    end

    def students
      @students ||=
        begin
          scope =
            if @cohorts.present?
              Student.includes(:user).where(cohort: @cohorts)
            else
              course.students.includes(:user)
            end
          # Exclude inactive students, unless requested.
          scope =
            @course_export.include_inactive_students? ? scope : scope.active

          # Filter by tag, if applicable.
          tags.present? ? scope.tagged_with(tags, any: true) : scope
        end.order("users.email")
    end

    def last_seen_at(user)
      user.last_seen_at&.iso8601 || user.last_sign_in_at&.iso8601 || ""
    end

    def user_ids
      students.map(&:user_id)
    end
  end
end