testmycode/tmc-server

View on GitHub
app/controllers/submissions_controller.rb

Summary

Maintainability
F
3 days
Test Coverage
# frozen_string_literal: true

require 'submission_processor'

# Receives submissions and presents the full submission list and submission view.
# Also handles rerun requests.
class SubmissionsController < ApplicationController
  around_action :course_transaction
  before_action :get_course_and_exercise

  # Manually checked for #show and index
  skip_authorization_check only: %i[show index difference_with_solution]

  def index
    respond_to do |format|
      format.json do
        if params[:row_format] == 'datatables'
          index_json_datatables
        else
          index_json
        end
      end
      format.html do # uses AJAX
        respond_forbidden if !current_user.administrator? && @course.hide_submissions?
        @organization = @course.organization
        add_course_breadcrumb
        add_breadcrumb 'All submissions'
      end
    end
  end

  def show
    @course ||= @submission.course
    @exercise ||= @submission.exercise
    @organization = @course.organization

    @model_solution_token_used = ModelSolutionTokenUsed.where(course: @course, exercise_name: @exercise.name, user: @submission.user)

    add_course_breadcrumb
    add_exercise_breadcrumb
    add_submission_breadcrumb

    respond_to do |format|
      format.html do
        respond_forbidden if !current_user.administrator? && @course.hide_submissions?
        @files = SourceFileList.for_submission(@submission)
      end
      format.zip do
        respond_forbidden if !current_user.administrator? && @course.hide_submissions?
        send_data(@submission.return_file, filename: "#{@submission.user.login}-#{@exercise.name}-#{@submission.id}.zip")
      end
      format.json do
        output = {
          api_version: ApiVersion::API_VERSION,
          all_tests_passed: @submission.all_tests_passed?,
          user_id: @submission.user_id,
          login: @submission.user.login,
          course: @course.name,
          exercise_name: @submission.exercise.name,
          status: @submission.status(current_user),
          points: @submission.points_list,
          validations: @submission.validations,
          valgrind: @submission.valgrind,
          solution_url: @exercise.solution.visible_to?(current_user) ? view_context.exercise_solution_url(@exercise) : nil,
          submitted_at: @submission.created_at,
          processing_time: @submission.processing_time,
          reviewed: @submission.reviewed?,
          requests_review:  @submission.requests_review?,
          paste_url: @submission.paste_available ? paste_url(@submission.paste_key) : nil,
          message_for_paste: @submission.paste_available ? @submission.message_for_paste : nil,
          missing_review_points: @exercise.missing_review_points_for(@submission.user)
        }

        output = output.merge(
          case @submission.status(current_user)
          when :processing then {
            submissions_before_this: @submission.unprocessed_submissions_before_this,
            total_unprocessed: Submission.unprocessed_count
          }
          when :ok then {
            test_cases: @submission.test_case_records,
            feedback_questions: @course.feedback_questions.order(:position).map(&:record_for_api),
            feedback_answer_url: submission_feedback_answers_url(@submission, format: :json)
          }
          when :fail then {
            test_cases: @submission.test_case_records
          }
          when :hidden then {
            all_tests_passed:  nil,
            test_cases: [{ name: 'TestResultsAreHidden test', successful: true, message: nil, exception: nil, detailed_message: nil }],
            points: [],
            validations: nil,
            valgrind: nil
          }
          when :error then {
            error: @submission.pretest_error
          }
          end
        )
        output[:status] = :ok if output[:status] == :hidden
        if !!params[:include_files]
          output[:files] = SourceFileList.for_submission(@submission).map { |f| { path: f.path, contents: f.contents } }
        end

        render json: output
      end
    end
  end

  def create
    if !params[:submission] || !params[:submission][:file]
      return respond_not_found('No ZIP file selected or failed to receive it')
    end

    unless @exercise.submittable_by?(current_user)
      return respond_forbidden('Submissions for this exercise are no longer accepted.')
    end

    file_contents = File.read(params[:submission][:file].tempfile.path)

    errormsg = nil

    unless file_contents.start_with?('PK')
      errormsg = "The uploaded file doesn't look like a ZIP file."
    end

    submission_params = {
      error_msg_locale: params[:error_msg_locale]
    }

    low_priority = Submission.where(created_at: (Time.now - 10.minutes)..Time.now, user: current_user).count > 3

    unless errormsg
      @submission = Submission.new(
        user: current_user,
        course: @course,
        exercise: @exercise,
        return_file: file_contents,
        params_json: submission_params.to_json,
        requests_review: !!params[:request_review],
        paste_available: !!params[:paste],
        message_for_paste: params[:paste] ? (params[:message_for_paste] || '') : '',
        message_for_reviewer: params[:request_review] ? (params[:message_for_reviewer] || '') : '',
        client_time: params[:client_time] ? Time.at(params[:client_time].to_i) : nil,
        client_nanotime: params[:client_nanotime],
        client_ip: request.env['HTTP_X_FORWARDED_FOR'] || request.remote_ip,
        processing_priority: low_priority ? -100 : 0
      )

      authorize! :create, @submission

      errormsg = 'Failed to save submission.' unless @submission.save
    end

    unless errormsg
      # SubmissionProcessor.new.process_submission(@submission)
    end

    respond_to do |format|
      format.html do
        if !errormsg
          redirect_to(submission_path(@submission),
                      notice: 'Submission received.')
        else
          redirect_to(exercise_path(@exercise),
                      alert: errormsg)
        end
      end
      format.json do
        if !errormsg
          render json: { submission_url: submission_url(@submission, format: 'json', api_version: ApiVersion::API_VERSION),
                         paste_url: @submission.paste_key ? paste_url(@submission.paste_key) : '' }
        else
          render json: { error: errormsg }
        end
      end
    end
  end

  def update
    submission = Submission.find(params[:id]) || respond_not_found
    authorize! :update, submission
    if params[:rerun]
      authorize! :rerun, submission
      schedule_for_rerun(submission, -1)
      redirect_to submission_path(submission), notice: 'Rerun scheduled'
    elsif params[:dismiss_review]
      submission.review_dismissed = true
      submission.save!
      redirect_to new_submission_review_path(submission), notice: 'Code review dismissed'
    else
      respond_not_found
    end
  end

  def update_by_exercise
    for submission in @exercise.submissions
      schedule_for_rerun(submission, -2)
    end
    redirect_to exercise_path(@exercise), notice: 'Reruns scheduled'
  end

  def difference_with_solution
    @course ||= @submission.course
    authorize! :teach, @course
    @exercise ||= @submission.exercise
    @organization = @course.organization
    add_course_breadcrumb
    add_exercise_breadcrumb
    add_submission_breadcrumb
    add_breadcrumb 'Difference with model solution'

    submission_files = SourceFileList.for_submission(@submission)
    solution_files = SourceFileList.for_solution(@exercise.solution)
    files_in_list = Set.new
    @files = []
    submission_files.each do |file|
      # TODO: In some exercises files may be named differently. Some kind of
      # similarity metric would be nice here
      model = solution_files.find { |solution_file| file.path == solution_file.path }
      @files << {
        path: file.path,
        submission_contents: file.contents,
        model_contents: (model.nil? ? '' : model.contents)
      }
      files_in_list << file.path
    end
    solution_files.each do |file|
      next if files_in_list.include?(file.path)
      @files << {
        path: file.path,
        submission_contents: '',
        model_contents: file.contents
      }
    end
  end

  private
    def course_transaction(&block)
      Course.transaction(requires_new: true, &block)
    end

    # Ugly manual access control :/
    def get_course_and_exercise
      submission_id = params[:id] || params[:submission_id]
      if submission_id
        @submission = Submission.find(submission_id)
        authorize! :read, @submission
        @course = @submission.course
        @exercise = @submission.exercise
      elsif params[:exercise_id]
        @exercise = Exercise.find(params[:exercise_id])
        @course = Course.lock('FOR SHARE').find(@exercise.course_id)
        authorize! :read, @course
        authorize! :read, @exercise
      elsif params[:paste_key]
        @submission = Submission.find_by!(paste_key: params[:paste_key])
        @exercise = @submission.exercise
        @course = @exercise.course
        @is_paste = true
        check_access!
      elsif params[:course_id]
        @course = Course.lock('FOR SHARE').find(params[:course_id])
        @organization = @course.organization
        authorize! :read, @course
      else
        respond_forbidden
      end
    end

    def schedule_for_rerun(submission, priority)
      submission.set_to_be_reprocessed!(priority)
    end

    def index_json
      return respond_forbidden unless current_user.administrator?

      submissions = @course.submissions
      if params[:user_id]
        submissions = submissions.where(user_id: params[:user_id])
      end

      render json: {
        api_version: ApiVersion::API_VERSION,
        json_url_schema: submission_url(id: ':id', format: 'json'),
        zip_url_schema: submission_url(id: ':id', format: 'zip'),
        submissions: submissions.map(&:id)
      }
    end

    def index_json_datatables
      submissions = @course.submissions

      unless current_user.administrator? || can?(:teach, @course)
        submissions = submissions.where(user_id: current_user.id)
      end

      if params[:max_id]
        submissions = submissions.where('id <= ?', params[:max_id])
      end
      submissions = submissions.includes(:user).order('id DESC')
      remaining = submissions.count
      submissions_limited = submissions.limit(1000)
      Submission.eager_load_exercises(submissions_limited)

      render json: {
        remaining: remaining,
        max_id: params[:max_id].to_i,
        last_id: submissions_limited.empty? ? nil : submissions_limited.last.id.to_i,
        rows: view_context.submissions_for_datatables(submissions_limited)
      }
    end

    def check_access!
      if current_user.guest?
        raise CanCan::AccessDenied
      end

      paste_visible = @submission.paste_visible_for?(current_user)
      return if paste_visible
      paste_visibility = @exercise.paste_visibility
      paste_visibility ||= @course.paste_visibility
      paste_visibility ||= 'open'
      case paste_visibility
      when 'protected', 'secured'
        respond_forbidden unless can?(:teach, @course) || @submission.user_id.to_s == current_user.id.to_s || paste_visible
      when 'no-tests-public'
        respond_forbidden unless can?(:teach, @course) || @submission.created_at > 2.hours.ago || @submission.user_id.to_s == current_user.id.to_s
      when 'everyone'
        nil
      else
        return if can?(:teach, @course) || @submission.user_id.to_s == current_user.id.to_s
        if @submission.created_at > 2.hours.ago
          respond_forbidden("You cannot see this paste because all tests passed and you haven't completed this exercise.") unless paste_visible
          return
        else
          unless paste_visible
            if @submission.exercise && !@submission.exercise.completed_by?(current_user)
              respond_forbidden("You cannot see this paste because you haven't completed this exercise.")
              return
            else
              respond_forbidden('You cannot see this paste because it was created over 2 hours ago.')
            end
            return
          end
        end

        respond_forbidden('You cannot see this paste because all tests passed.') unless paste_visible
      end
    end
end