testmycode/tmc-server

View on GitHub
app/models/exercise.rb

Summary

Maintainability
D
1 day
Test Coverage
# frozen_string_literal: true

require 'shellwords'

require 'date_and_time_utils'

class Exercise < ApplicationRecord
  self.include_root_in_json = false
  include Swagger::Blocks

  swagger_schema :ExerciseWithPoints do
    key :required, %i[
      id name publish_time solution_visible_after
      deadline disabled available_points
    ]

    property :id, type: :integer, example: 1
    property :name, type: :string, example: 'Exercise name'
    property :publish_time, type: :string, format: 'date-time', example: '2016-10-24T14:06:36.730+03:00'
    property :solution_visible_after, type: :string, format: 'date-time', example: '2016-10-24T14:06:36.730+03:00'
    property :deadline, type: :string, format: 'date-time', example: '2016-10-24T14:06:36.730+03:00'
    property :soft_deadline, type: :string, format: 'date-time', example: '2016-10-24T14:06:36.730+03:00'
    property :disabled, type: :boolean, example: false
    property :available_points, type: :array do
      items do
        key :'$ref', :AvailablePoint
      end
    end
  end

  swagger_schema :CoreExercise do
    key :required, %i[
      course_name course_id code_review_requests_enabled run_tests_locally_action_enabled
      exercise_name exercise_id unlocked_at deadline submissions
    ]

    property :course_name, type: :string, example: 'course'
    property :course_id, type: :integer, example: 1
    property :code_review_requests_enabled, type: :boolean, example: true
    property :run_tests_locally_action_enabled, type: :boolean, example: true
    property :exercise_name, type: :string, example: 'exercise'
    property :exercise_id, type: :integer, example: 1
    property :unlocked_at, type: :string, format: 'date-time', example: '2016-12-05T12:00:00.000+03:00'
    property :deadline, type: :string, format: 'date-time', example: '2016-12-24T00:00:00.000+03:00'
    property :submissions, type: :array do
      items do
        key :'$ref', :CoreSubmission
      end
    end
  end

  swagger_schema :CoreExerciseQueryDetails do
    property :id, type: :integer, example: 1
    property :checksum, type: :string, example: 'f25e139769b2688e213938456959eeaf'
    property :course_name, type: :string, example: 'Introduction to Programming 2021'
    property :exercise_name, type: :string, example: 'Part01-01_smile'
    property :hide_submission_results, type: :boolean, example: false
  end

  swagger_schema :CoreExerciseDetails do
    key :required, %i[id name locked deadline_description deadline checksum return_url zip_url returnable requires_review attempted
                      completed reviewed all_review_point_given memory_limit runtime_params valgrind_strategy code_review_requests_enabled
                      run_tests_locally_action_enabled exercise_submissions_url]

    property :id, type: :integer, example: 1
    property :name, type: :string, example: 'Exercise name'
    property :locked, type: :boolean, example: false
    property :deadline_description, type: :string, example: '2016-02-29 23:59:00 +0200'
    property :deadline, type: :string, format: 'date-time', example: '2016-02-29T23:59:00.000+02:00'
    property :checksum, type: :string, example: 'f25e139769b2688e213938456959eeaf'
    property :return_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/exercises/1337/submissions'
    property :zip_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/exercises/4272/download'
    property :returnable, type: :boolean, example: true
    property :requires_review, type: :boolean, example: false
    property :attempted, type: :boolean, example: false
    property :completed, type: :boolean, example: false
    property :reviewed, type: :boolean, example: false
    property :all_review_points_given, type: :boolean, example: true
    property :memory_limit, type: :integer, example: 1024
    property :runtime_params, type: :array do
      items do
        key :type, :string
        key :example, '-Xss64M'
      end
    end
    property :valgrind_strategy, type: :string, example: 'fail'
    property :code_review_requests_enabled, type: :boolean, example: false
    property :run_tests_locally_action_enabled, type: :boolean, example: true
    property :exercise_submissions_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/exercises/1337/solution/download'
    # These are returned after submission
    property :latest_submission_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/exercises/1337'
    property :latest_submission_id, type: :integer, example: 13_337
    # This is returned if user == admin
    property :solution_zip_url, type: :string, example: 'http://tmc.mooc.fi/api/v8/core/submissions/1337/download'
  end

  attribute :docker_image, :string, default: 'eu.gcr.io/moocfi-public/tmc-sandbox-tmc-langs-rust'
  belongs_to :course

  has_many :available_points, dependent: :delete_all

  has_many :submissions,
           (lambda do |exercise|
             if exercise.respond_to?(:course_id)
               # Used when doing exercise.submissions
               where(course: exercise.course)
             else
               # Used when doing exercises.includes(:submissions)
               Submission.joins(:exercise)
             end
           end), foreign_key: :exercise_name, primary_key: :name

  has_many :feedback_answers, ->(exercise) { where(course: exercise.course) }, foreign_key: :exercise_name, primary_key: :name
  has_many :unlocks, ->(exercise) { where(course: exercise.course) }, foreign_key: :exercise_name, primary_key: :name

  validates :gdocs_sheet, format: { without: /\A(MASTER|PUBLIC)\z/ }

  scope :course_gdocs_sheet_exercises, lambda { |course, gdocs_sheet, hidden = false|
    res = where(course_id: course.id, gdocs_sheet: gdocs_sheet)
    res = res.where(hide_submission_results: false) unless hidden
    res
  }

  enum disabled_status: %i[enabled disabled]
  enum paste_visibility: %i[open secured no-tests-public everyone]

  def relative_path
    name.tr('-', '/')
  end

  def exercise_group
    course.exercise_group_by_name(exercise_group_name)
  end

  def exercise_group_name
    parts = name.split('-')
    parts.pop
    parts.join('-')
  end

  def part
    exercise.group_name.tr('^0-9', '').to_i
  rescue
    0
  end

  def belongs_to_exercise_group?(group)
    group.course.id == course_id && (exercise_group_name + '-').start_with?(group.name + '-')
  end

  def clone_path
    "#{course.clone_path}/#{relative_path}"
  end

  def solution_path
    "#{course.solution_path}/#{relative_path}"
  end

  def stub_path
    "#{course.stub_path}/#{relative_path}"
  end

  def stub_zip_file_path
    "#{course.stub_zip_path}/#{name}.zip"
  end

  def solution_zip_file_path
    "#{course.solution_zip_path}/#{name}.zip"
  end

  def solution
    Solution.new(self)
  end

  def set_submissions_by!(user, value)
    @submissions_by ||= {}
    @submissions_by[user.id] = value
  end

  def submissions_by(user)
    @submissions_by ||= {}
    @submissions_by[user.id] ||= submissions.where(user_id: user.id).to_a
  end

  def reload
    super
    @submissions_by = {}
  end

  # Whether a user may make submissions
  def submittable_by?(user)
    returnable? &&
      (user.administrator? || user.teacher?(course.organization) || user.assistant?(course) ||
        (!expired_for?(user) && !hidden? && published? && !disabled? && !user.guest? && unlocked_for?(user)))
  end

  # Whether a user may see all metadata about the exercise
  def visible_to?(user)
    user.administrator? || user.teacher?(course.organization) || user.assistant?(course) ||
      _fast_visible? && (unlocked_for?(user) || unlock_spec_obj.permits_unlock_for?(user))
  end

  def _fast_visible?
    !hidden? && !disabled? && published?
  end

  # Whether the user may see the scoreboard for the exercise
  def points_visible_to?(user)
    user.administrator? ||
      user.teacher?(course.organization) ||
      (
        !hidden? &&
        published? &&
        !disabled? &&
        (course.locked_exercise_points_visible? || unlock_spec_obj.permits_unlock_for?(user)) &&
        !hide_submission_results &&
        !course.hide_submission_results
      )
  end

  # Whether the user may download the exercise ZIP file
  def downloadable_by?(user)
    user.administrator? || user.teacher?(course.organization) || user.assistant?(course) ||
      visible_to?(user) && unlocked_for?(user)
  end

  # Whether the exercise has been published (it may still be hidden)
  def published?
    !publish_time || publish_time <= Time.now
  end

  delegate :deadline_for, to: :deadline_spec_obj

  def soft_deadline_for(user)
    soft_deadline_spec_obj.deadline_for(user)
  end

  # Whether the deadline has passed
  def expired_for?(user)
    Exercise.deadline_expired?(deadline_for(user))
  end

  def soft_deadline_expired_for?(user)
    Exercise.deadline_expired?(soft_deadline_for(user))
  end

  # Whether a user has made a submission for this exercise
  def attempted_by?(user)
    submissions_by(user).any?(&:processed)
  end

  # Whether a user has made a submission with all test cases passing
  def completed_by?(user)
    submissions_by(user).any? do |s|
      s.pretest_error.nil? && s.all_tests_passed?
    end
  end

  def requires_review?
    !available_review_points.empty?
  end

  # Whether a code review for this exercise exists for a submission made by 'user'.
  def reviewed_for?(user)
    submissions_by(user).any?(&:reviewed)
  end

  # Returns all reviewed submissions for this exercise for 'user'
  def reviewed_submissions_for(user)
    submissions_by(user).select(&:reviewed)
  end

  # Whether all of the required code review points have been given.
  # Returns true if the exercise doesn't require code review.
  def all_review_points_given_for?(user)
    arp = available_review_points
    return true if arp.empty? # optimization
    user.has_points?(course, arp)
  end

  def available_review_points
    # use 'select' instead of 'where' to use cached value of available_points
    available_points.to_a.select(&:requires_review).map(&:name)
  end

  def points_for(user)
    AwardedPoint.exercise_user_points(self, user).map(&:name)
  end

  def missing_review_points_for(user)
    available_review_points - points_for(user)
  end

  def unlock_spec=(spec)
    check_is_json_array_of_strings(spec)
    @unlock_spec_obj = UnlockSpec.from_str(course, spec)
    super(@unlock_spec_obj.empty? ? nil : spec)
  end

  def unlock_spec_obj
    @unlock_spec_obj ||= UnlockSpec.from_str(course, unlock_spec)
  end

  def unlock_conditions
    unlock_spec_obj.raw_spec
  end

  def deadline_spec=(spec)
    check_is_json_array_of_strings(spec)
    super(spec)
    @deadline_spec_obj = DeadlineSpec.new(self, ActiveSupport::JSON.decode(spec))
  end

  def soft_deadline_spec=(spec)
    check_is_json_array_of_strings(spec)
    super(spec)
    @soft_deadline_spec_obj = DeadlineSpec.new(self, ActiveSupport::JSON.decode(spec))
  end

  def deadline_spec_obj
    @deadline_spec_obj ||= new_deadline_spec_obj(deadline_spec)
  end

  def soft_deadline_spec_obj
    @soft_deadline_spec_obj ||= new_deadline_spec_obj(soft_deadline_spec)
  end

  def static_deadline
    deadline_spec_obj.static_deadline_spec
  end

  def unlock_deadline
    deadline_spec_obj.unlock_deadline_spec
  end

  def soft_static_deadline
    soft_deadline_spec_obj.static_deadline_spec
  end

  def soft_unlock_deadline
    soft_deadline_spec_obj.unlock_deadline_spec
  end

  def has_unlock_deadline?
    unlock_deadline.present? || soft_unlock_deadline.present?
  end

  def requires_unlock?
    !unlock_spec.nil?
  end

  def requires_explicit_unlock?
    deadline_spec_obj.depends_on_unlock_time?
  end

  def time_unlocked_for(user, resolve_unlocks = true)
    UncomputedUnlock.resolve(course, user) if resolve_unlocks
    unlocks.where(user_id: user).find_by('valid_after IS NULL OR valid_after < ?', Time.now)&.created_at
  end

  def unlocked_for?(user, resolve_unlocks = true)
    res = !requires_unlock? || time_unlocked_for(user, resolve_unlocks)
    !!res
  end

  def unlockable_for?(user)
    requires_explicit_unlock? && !unlocked_for?(user) && unlock_spec_obj.permits_unlock_for?(user)
  end

  def solution_visible_after=(new_value)
    super(DateAndTimeUtils.to_time(new_value, prefer_end_of_day: true))
  end

  # Ignore some options if already set; to keep changes done in UI.
  # metadata.yml support removed, should these be removed from database
  def options=(new_options)
    new_options = self.class.default_options.merge(new_options)

    self.deadline_spec = to_json_array(new_options['deadline']) if deadline_spec.nil?
    self.soft_deadline_spec = to_json_array(new_options['soft_deadline']) if soft_deadline_spec.nil?
    self.unlock_spec = to_json_array(new_options['unlocked_after']) if unlock_spec.nil?
    self.publish_time = new_options['publish_time']
    self.gdocs_sheet = new_gdocs_sheet(new_options['points_visible'], new_options['gdocs_sheet'])
    self.hidden = new_options['hidden']
    self.returnable_forced = new_options['returnable']
    self.solution_visible_after = new_options['solution_visible_after']
    self.runtime_params = parse_runtime_params(new_options['runtime_params'])
    self.valgrind_strategy = new_options['valgrind_strategy']
    self.code_review_requests_enabled = new_options['code_review_requests_enabled']
    self.run_tests_locally_action_enabled = new_options['run_tests_locally_action_enabled']
  end

  # Whether this exercise accepts submissions at all.
  # TMC may be used to distribute exercise templates without tests.
  def returnable?
    if !returnable_forced.nil?
      returnable_forced # may be true or false
    else
      has_tests? && course.initial_refresh_ready?
    end
  end

  # The memory limit in megabytes or nil if not set.
  # Limited by the global limit in site.yml (if any).
  # Not configurable per-exercise yet but possibly in the future.
  def memory_limit
    global_limit = SiteSetting.value('memory_limit')
    global_limit&.to_i
  end

  def runtime_params_array
    ActiveSupport::JSON.decode(runtime_params)
  end

  def code_review_requests_enabled?
    course.code_review_requests_enabled? && self[:code_review_requests_enabled]
  end

  def self.default_options
    {
      'deadline' => nil,
      'soft_deadline' => nil,
      'publish_time' => nil,
      'gdocs_sheet' => nil,
      'points_visible' => true,
      'hidden' => false,
      'returnable' => nil,
      'solution_visible_after' => nil,
      'valgrind_strategy' => 'fail',
      'runtime_params' => nil,
      'code_review_requests_enabled' => true,
      'run_tests_locally_action_enabled' => true
    }
  end

  def submissions_having_feedback
    submissions.where('EXISTS (SELECT 1 FROM feedback_answers WHERE feedback_answers.submission_id = submissions.id)')
  end

  def self.count_completed(users, exercises)
    return 0 if exercises.empty?

    s = Submission.arel_table

    user_ids = users.map(&:id)
    exercise_keys = exercises.map { |e| "(#{e.course_id}, #{Exercise.connection.quote(e.name)})" }

    query = s.project(Arel.sql('COUNT(DISTINCT (course_id, exercise_name, user_id))').as('count'))
             .where(s[:user_id].in(user_ids))
             .where(Arel.sql("(course_id, exercise_name) IN (#{exercise_keys.join(',')})"))
             .where(s[:pretest_error].eq(nil))
             .where(s[:all_tests_passed].eq(true))

    results = connection.execute(query.to_sql)
    begin
      results[0]['count'].to_i
    ensure
      results.clear
    end
  end

  def toggle_submission_result_visiblity
    self.hide_submission_results = !hide_submission_results
    save!
  end

  def self.deadline_expired?(deadline, time = Time.now)
    !deadline.nil? && deadline < time
  end

  private
    def new_deadline_spec_obj(spec)
      if spec
        DeadlineSpec.new(self, ActiveSupport::JSON.decode(spec))
      else
        DeadlineSpec.new(self, [])
      end
    end

    def new_gdocs_sheet(enabled, sheetname)
      return nil unless enabled
      return sheetname.to_s if sheetname.present?
      name_to_gdocs_sheet
    end

    def name_to_gdocs_sheet
      sheetname = name.split('-')[0..-2].join('-')
      sheetname.empty? ? 'root' : sheetname
    end

    def to_json_array(value)
      if !value.nil?
        value = [value] unless value.is_a?(Array)
        value.to_json
      else
        '[]'
      end
    end

    def check_is_json_array_of_strings(str)
      return if str.nil?
      array = ActiveSupport::JSON.decode(str)
      raise 'JSON array expected' unless array.is_a?(Array)
      raise 'JSON array of strings expected' if array.any? { |a| !a.is_a?(String) }
    end

    def parse_runtime_params(raw_params)
      if raw_params.nil?
        '[]'
      elsif raw_params.is_a?(String)
        to_json_array(Shellwords.shellwords(raw_params))
      elsif raw_params.is_a?(Array)
        to_json_array(raw_params)
      else
        raise "Invalid runtime_params: #{raw_params.inspect}"
      end
    end
end