testmycode/tmc-server

View on GitHub
lib/course_refresh_database_updater.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

require 'benchmark'
require 'system_commands'

class CourseRefreshDatabaseUpdater
  def refresh_course(course, refreshed_course_data)
    Impl.new.refresh_course(course, refreshed_course_data)
  end

  class Report
    def initialize
      @errors = []
      @warnings = []
      @notices = []
      @timings = {}
    end

    attr_reader :errors
    attr_reader :warnings
    attr_reader :notices
    attr_reader :timings
  end

  class Failure < StandardError
    def initialize(report)
      super(report.errors.join("\n"))
      @report = report
    end
    attr_reader :report
  end

  private
    class Impl
      include SystemCommands

      def measure_and_log(method_name, *args)
        log(method_name, Benchmark.measure { send(method_name, *args) })
      end

      def log(method_name, result)
        Rails.logger.info "Refresh: #{method_name} - #{result}"
        @report.timings[method_name] = result
      end

      def merge_course_specific_suboptions(opts)
        if opts.key?('courses') && opts['courses'].key?(@course.name)
          opts = opts.merge(opts['courses'][@course.name])
        end
        opts.delete 'courses'
        opts
      end

      def refresh_course(course, data)
        @report = Report.new
        @rust_data = data['output-data']
        Course.transaction(requires_new: true) do
          @course = Course.find(course.id)

          measure_and_log :update_course_options
          measure_and_log :add_records_for_new_exercises
          measure_and_log :delete_records_for_removed_exercises
          measure_and_log :set_exercise_options
          measure_and_log :set_docker_image
          measure_and_log :update_available_points
          measure_and_log :update_exercise_checksums
          measure_and_log :invalidate_unlocks
          measure_and_log :kafka_publish_exercises

          @course.refreshed_at = Time.now
          @course.initial_refresh_ready = true unless @course.initial_refresh_ready
          @course.save!
          @course.exercises.each(&:save!)

          CourseRefreshDatabaseUpdater.simulate_failure! if ::Rails.env.test? && CourseRefreshDatabaseUpdater.respond_to?('simulate_failure!')
        rescue StandardError, ScriptError
          @report.errors << $!.message + "\n" + $!.backtrace.join("\n")
          raise ActiveRecord::Rollback
        end

        course.reload # reload the record given as parameter
        raise Failure, @report unless @report.errors.empty?
        @report
      end

      def update_course_options
        @course.options = merge_course_specific_suboptions(@rust_data['course-options'].clone)
      end

      def add_records_for_new_exercises
        rust_ex = @rust_data['exercises'].map { |ex| ex['name'] }
        course_ex = @course.exercises.map { |ex| ex.name }
        added_exercises = rust_ex - course_ex
        added_exercises.each do |exercise|
          @report.notices << "Added exercise #{exercise}"
          @course.exercises.new(name: exercise)
        end
      end

      def delete_records_for_removed_exercises
        removed_exercises = @course.exercises.reject { |e| @rust_data['exercises'].map { |ex| ex['name'] }.include?(e.name) }
        removed_exercises.each do |e|
          @report.notices << "Removed exercise #{e.name}"
          @course.exercises.destroy(e)
          e.destroy
        end
      end

      def set_exercise_options
        @course.exercises.each do |e|
          e.has_tests = true # we don't yet detect whether an exercise includes tests
          e.options = {} # exercise metadata.yml reading support removed, set default options
          e.disabled! if e.new_record? && e.course.refreshed? # disable new exercises
        end
      end

      def set_docker_image
        default_image = Exercise.new.docker_image
        @rust_data['exercises'].each do |exercise|
          ex = @course.exercises.find { |e| e.name == exercise['name'] }
          next unless ex
          if (exercise['tmcproject-yml'] || {}).include? 'sandbox_image'
            ex.docker_image = exercise['tmcproject-yml']['sandbox_image']
          else
            ex.docker_image = default_image
          end
        end
      end

      def set_memory_limit
        @rust_data['exercises'].each do |exercise|
          ex = @course.exercises.find { |e| e.name == exercise['name'] }
          next unless ex
          if (exercise['tmcproject-yml'] || {}).include? 'memory_gb'
            ex.memory_limit = exercise['tmcproject-yml']['memory_gb']
          else
            ex.memory_limit = 1
          end
        end
      end

      def set_cpu_limit
        @rust_data['exercises'].each do |exercise|
          ex = @course.exercises.find { |e| e.name == exercise['name'] }
          next unless ex
          if (exercise['tmcproject-yml'] || {}).include? 'cpus'
            ex.cpu_limit = exercise['tmcproject-yml']['cpus']
          else
            ex.cpu_limit = 1
          end
        end
      end

      def update_available_points
        @rust_data['exercises'].each do |exercise|
          added = []
          removed = []

          ex = @course.exercises.find { |e| e.name == exercise['name'] }
          unless ex
            Rails.logger.warn("Could not find exercise with name #{exercise['name']} in database for course #{@course.name}")
            next
          end

          exercise['points'].each do |point_name|
            next unless ex.available_points.none? { |point| point.name == point_name }
            added << point_name
            point = AvailablePoint.create(name: point_name, exercise: ex)
            ex.available_points << point
          end

          ex.available_points.to_a.clone.each do |point|
            if exercise['points'].none? { |name| name == point.name }
              removed << point.name
              point.destroy
              ex.available_points.destroy(point)
            else
              # TODO: Review_points and metadata.yml reading removed, point.requires_review functionality should be removed
              point.requires_review = false
              point.save!
            end
          end

          @report.notices << "Added points to exercise #{ex.name}: #{added.join(' ')}" unless added.empty?
          @report.notices << "Removed points from exercise #{ex.name}: #{removed.join(' ')}" unless removed.empty?
        end
      end

      def update_exercise_checksums
        @rust_data['exercises'].each do |exercise|
          ex = @course.exercises.find { |e| e.name == exercise['name'] }
          next unless ex
          if ex.checksum != exercise['checksum']
            @report.notices << "Exercise #{ex.name} updated" unless ex.checksum.empty?
            ex.checksum = exercise['checksum']
          end
        end
      end

      def invalidate_unlocks
        UncomputedUnlock.create_all_for_course(@course)
      end

      def kafka_publish_exercises
        KafkaBatchUpdatePoints.create!(course_id: @course.id, task_type: 'exercises') if @course.moocfi_id && !@course.moocfi_id.blank?
      end
    end
end