testmycode/tmc-server

View on GitHub
app/models/course.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

require 'gdocs_export'
require 'system_commands'
require 'date_and_time_utils'

class Course < ApplicationRecord
  include SystemCommands
  include Swagger::Blocks

  swagger_schema :Course do
    key :required, %i[name hide_after hidden cache_version spreadsheet_key hidden_if_registered_after
                      refreshed_at locked_exercise_points_visible description paste_visibility formal_name
                      certificate_downloadable certificate_unlock_spec organization_id disabled_status
                      title material_url course_template_id hide_submission_results external_scoreboard_url]

    property :name, type: :string, example: 'organizationid-coursename'
    property :hide_after, type: :string, example: '2016-10-10T13:22:19.554+03:00'
    property :hidden, type: :boolean, example: false
    property :cache_version, type: :integer, example: 1
    property :spreadsheet_key, type: :string
    property :hidden_if_registered_after, type: :string
    property :refreshed_at, type: :string, example: '2016-10-10T13:22:36.871+03:00'
    property :locked_exercise_points_visible, type: :boolean, example: true
    property :description, type: :string, example: ''
    property :paste_visibility, type: :integer
    property :formal_name, type: :string
    property :certificate_downloadable, type: :boolean, example: false
    property :certificate_unlock_spec, type: :string
    property :organization_id, type: :integer, example: 1
    property :disabled_status, type: :string, example: 'enabled'
    property :title, type: :string, example: 'testcourse'
    property :material_url, type: :string, example: ''
    property :course_template_id, type: :integer, example: 1
    property :hide_submission_results, type: :boolean, example: false
    property :external_scoreboard_url, type: :string
    property :organization_slug, type: :string, example: 'hy'
  end

  swagger_schema :CoreCourseDetails do
    key :required, %i[id name title description details_url unlock_url reviews_url comet_url spyware_urls unlockables exercises]

    property :id, type: :integer, example: 13
    property :name, type: :string, example: 'organizationid-coursename'
    property :title, type: :string, example: 'coursetitle'
    property :description, type: :string, example: 'description of the course'
    property :details_url, type: :string, example: 'http://tmc.mooc.fi/api/v8/core/courses/13'
    property :unlock_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/courses/13/unlock'
    property :reviews_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/courses/13/reviews'
    property :comet_url, type: :string, example: 'https://tmc.mooc.fi:8443/comet'
    property :spyware_urls, type: :array do
      items do
        key :type, :string
        key :example, 'http://mooc.spyware.testmycode.net/'
      end
    end
    property :unlockables, type: :array do
      items do
        key :type, :string
        key :example, ''
      end
    end
    property :exercises, type: :array do
      items do
        key :'$ref', :CoreExerciseDetails
      end
    end
  end

  def course_as_json
    {
      name: name,
      hide_after: hide_after,
      hidden: hidden,
      cache_version: cached_version,
      spreadsheet_key: spreadsheet_key,
      hidden_if_registered_after: hidden_if_registered_after,
      refreshed_at: refreshed_at,
      locked_exercise_points_visible: locked_exercise_points_visible,
      paste_visibility: paste_visibility,
      formal_name: formal_name,
      certificate_downloadable: certificate_downloadable,
      certificate_unlock_spec: certificate_unlock_spec,
      organization_id: organization_id,
      disabled_status: disabled_status,
      title: title,
      description: description,
      material_url: material_url,
      course_template_id: course_template_id,
      hide_submission_results: hide_submission_results,
      external_scoreboard_url: external_scoreboard_url,
      organization_slug: organization_slug,
    }
  end

  swagger_schema :CourseLinks do
    key :required, %i[id name title description details_url unlock_url reviews_url comet_url spyware_urls]

    property :id, type: :integer, example: 13
    property :name, type: :string, example: 'organizationid-coursename'
    property :title, type: :string, example: 'coursetitle'
    property :description, type: :string, example: 'description of the course'
    property :details_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/courses/13'
    property :unlock_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/courses/13/unlock'
    property :reviews_url, type: :string, example: 'https://tmc.mooc.fi/api/v8/core/courses/13/reviews'
    property :comet_url, type: :string, example: 'https://tmc.mooc.fi:8443/comet'
    property :spyware_urls, type: :array do
      items do
        key :type, :string
        key :example, 'http://mooc.spyware.testmycode.net/'
      end
    end
  end

  swagger_schema :CourseBasicInfo do
    key :required, %i[id name organization_id title]

    property :id, type: :integer, example: 13
    property :name, type: :string, example: 'organizationid-coursename'
    property :organization_id, type: :integer, example: 1
    property :title, type: :string, example: 'testcourse'
  end

  def links_as_json(view_context)
    {
      id: id,
      name: name,
      title: title,
      description: description,
      details_url: view_context.api_v8_core_course_url(self),
      unlock_url: view_context.api_v8_core_course_unlock_url(self),
      reviews_url: view_context.api_v8_core_course_reviews_url(self),
      comet_url: '',
      spyware_urls: SiteSetting.value('spyware_servers')
    }.as_json
  end

  self.include_root_in_json = false

  validates :name,
            presence: true,
            uniqueness: true,
            format: {
              without: / /,
              message: 'should not contain white spaces'
            }
  validates :title,
            presence: true,
            length: { within: 1..80 }
  validates :description, length: { maximum: 512 }
  validate :check_name_length

  # If made from template, make sure cached_version is not out of sync.
  before_save :set_cached_version
  before_validation :save_template
  validates :source_url, presence: true
  # validates :custom_points_url,
  #          format: {
  #            with: /(\Ahttps?:\/\/|\A\z|^$)/,
  #            message: 'should begin with http:// or https://'
  #          }
  validate :check_external_scoreboard_url
  validates :moocfi_id,
            uniqueness: true,
            format: {
              with: /\A[0-9a-f]{32}\z|\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/,
              message: "should be a hash ID. You can find it from mooc.fi from your course's details. Please leave this empty if your course is not in mooc.fi (note that mooc.fi and tmc.mooc.fi are separate services)"
            },
            if: -> (x) { x.moocfi_id.present? }

  has_many :exercises, dependent: :delete_all
  has_many :submissions, dependent: :delete_all
  has_many :users, -> { distinct }, through: :submissions
  has_many :available_points, through: :exercises
  has_many :awarded_points, dependent: :delete_all
  has_many :test_scanner_cache_entries, dependent: :delete_all
  has_many :feedback_questions, dependent: :delete_all
  has_many :feedback_answers # destroyed transitively when questions are destroyed
  has_many :unlocks, dependent: :delete_all
  has_many :uncomputed_unlocks, dependent: :delete_all
  has_many :course_notifications, dependent: :delete_all
  has_many :certificates
  has_many :assistantships, dependent: :destroy
  has_many :assistants, through: :assistantships, source: :user

  belongs_to :course_template
  belongs_to :organization

  scope :with_certificates_for, ->(user) { select { |c| c.visible_to?(user) && c.certificate_downloadable_for?(user) } }

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

  def destroy
    # Optimization: delete dependent objects quickly.
    # Rails' :dependent => :delete_all is very slow.
    # Even self.association.delete_all first does a SELECT.
    # This relies on the database to cascade deletes.
    ActiveRecord::Base.connection.execute("DELETE FROM courses WHERE id = #{id}")
    assistantships.each(&:destroy!) # apparently this is not performed automatically with optimized destroy

    # Delete cache.
    delete_cache # Would be an after_destroy callback normally
  end

  scope :ongoing, -> { where(['hide_after IS NULL OR hide_after > ?', Time.now]) }
  scope :expired, -> { where(['hide_after IS NOT NULL AND hide_after <= ?', Time.now]) }
  scope :assisted_courses, ->(user, organization) do
    joins(:assistantships)
      .where(assistantships: { user_id: user.id })
      .where(organization_id: organization.id)
  end
  scope :participated_courses, ->(user, organization) do
    joins(:awarded_points)
      .where(awarded_points: { user_id: user.id })
      .where(organization_id: organization.id)
      .group('courses.id')
  end

  def self.new_from_template(course_template)
    Course.new(name: course_template.name,
               title: course_template.title,
               description: course_template.description,
               material_url: course_template.material_url,
               cached_version: course_template.cached_version,
               course_template: course_template)
  end

  delegate :git_branch, to: :course_template_obj

  delegate :slug, to: :organization, prefix: true

  delegate :source_url, to: :course_template_obj

  delegate :source_backend, to: :course_template_obj

  delegate :git_branch=, to: :course_template_obj

  delegate :source_url=, to: :course_template_obj

  delegate :source_backend=, to: :course_template_obj

  def visible_to?(user)
    user.administrator? ||
      user.teacher?(organization) ||
      user.assistant?(self) || (
      initial_refresh_ready? &&
      !disabled? &&
      !hidden &&
      (hide_after.nil? || hide_after > Time.now) &&
      (
        hidden_if_registered_after.nil? ||
        hidden_if_registered_after > Time.now ||
        (!user.guest? && hidden_if_registered_after > user.created_at)
      )
    )
  end

  def hide_after=(x)
    super(DateAndTimeUtils.to_time(x, prefer_end_of_day: true))
  end

  def hidden_if_registered_after=(x)
    super(DateAndTimeUtils.to_time(x, prefer_end_of_day: false))
  end

  # This could eventually be made a hstore
  def options=(new_options)
    self.hide_after = new_options['hide_after'].presence

    self.hidden_if_registered_after = new_options['hidden_if_registered_after'].presence

    self.hidden = !!new_options['hidden']
    self.spreadsheet_key = new_options['spreadsheet_key']

    self.paste_visibility = new_options['paste_visibility']
    self.locked_exercise_points_visible = if !new_options['locked_exercise_points_visible'].nil?
      new_options['locked_exercise_points_visible']
    else
      true
    end

    self.formal_name = new_options['formal_name'].presence

    self.certificate_downloadable = !!new_options['certificate_downloadable']

    self.certificate_unlock_spec = new_options['certificate_unlock_spec'].presence
  end

  def gdocs_sheets(exercises = nil)
    exercises ||= self.exercises.select { |ex| !ex.hidden? && ex.published? }
    exercises.map(&:gdocs_sheet).reject(&:nil?).uniq
  end

  def refresh_gdocs_worksheet(sheetname)
    GDocsExport.refresh_course_worksheet_points self, sheetname
  end

  def self.cache_root
    "#{FileStore.root}/course"
  end

  delegate :increment_cached_version, to: :course_template_obj

  delegate :cache_path, to: :course_template_obj

  # Holds a clone of the course repository
  def clone_path
    "#{cache_path}/clone"
  end

  def git_revision
    Dir.chdir clone_path do
      output = `git rev-parse --verify HEAD`
      output.strip if $?.success?
    end
  rescue StandardError
    nil
  end

  def solution_path
    "#{cache_path}/solution"
  end

  def stub_path
    "#{cache_path}/stub"
  end

  def stub_zip_path
    "#{cache_path}/stub_zip"
  end

  def solution_zip_path
    "#{cache_path}/solution_zip"
  end

  def exercise_groups(force_reload = false)
    @groups = nil if force_reload
    @groups ||= begin
      result = exercises.all.map(&:exercise_group_name).uniq
                        .map { |gname| ExerciseGroup.new(self, gname) }

      new_parents = []
      begin
        all_parents = result.map(&:parent_name).reject(&:nil?)
        new_parents = all_parents.reject { |pn| result.any? { |eg| eg.name == pn } }.uniq
        result += new_parents.map { |pn| ExerciseGroup.new(self, pn) }
      end until new_parents.empty?

      result.sort
    end
  end

  def exercise_group_by_name(name, force_reload = false)
    exercise_groups(force_reload).find { |eg| eg.name == name }
  end

  # Returns exercises in group `name`, or whose full name is `name`.
  def exercises_by_name_or_group(name, force_reload = false)
    group = exercise_group_by_name(name, force_reload)
    exercises.to_a.select { |ex| ex.name == name || (group && ex.belongs_to_exercise_group?(group)) }
  end

  def unlockable_exercises_for(user)
    UncomputedUnlock.resolve(self, user)
    unlocked = unlocks.where(user_id: user.id).pluck(:exercise_name)
    exercises.to_a.select { |ex| !unlocked.include?(ex.name) && ex.unlockable_for?(user) }
  end

  def reload
    super
    @groups = nil
  end

  def refresh(current_user_id)
    CourseTemplateRefresh.create!(user_id: current_user_id, course_template_id: self.course_template_id)
  end

  def delete_cache
    FileUtils.rm_rf cache_path if custom?
  end

  def self.valid_source_backends
    ['git']
  end

  def self.default_source_backend
    'git'
  end

  def time_of_first_submission
    sub = submissions.order('created_at ASC').limit(1).first
    sub&.created_at
  end

  def time_of_last_submission
    sub = submissions.order('created_at DESC').limit(1).first
    sub&.created_at
  end

  def reviews_required
    submissions.where(
      requires_review: true,
      newer_submission_reviewed: false,
      reviewed: false,
      review_dismissed: false
    )
  end

  def reviews_requested
    submissions.where(
      requests_review: true,
      newer_submission_reviewed: false,
      reviewed: false,
      review_dismissed: false
    )
  end

  def submissions_to_review
    submissions.where('(requests_review OR requires_review) AND NOT reviewed AND NOT newer_submission_reviewed AND NOT review_dismissed')
  end

  def certificate_downloadable_for?(user)
    user.administrator? ||
    user.teacher?(organization) || (
      !user.guest? &&
       certificate_downloadable &&
       (certificate_unlock_spec.nil? ||
        UnlockSpec.new(self, ActiveSupport::JSON.decode(certificate_unlock_spec)).permits_unlock_for?(user)))
  end

  def toggle_submission_result_visiblity
    self.hide_submission_results = !hide_submission_results
    save!
  end

  # Returns a hash of exercise group => {
  #   :available_points => number of available points,
  #   :points_by_user => {user_id => number_of_points}
  # }
  def exercise_group_completion_by_user
    # TODO: clean up exercise group discovery

    groups = exercises
             .where(disabled_status: 0)
             .select(&:_fast_visible?)
             .map(&:name)
             .map { |name| name =~ /^(.+)-[^-]+$/ ? Regexp.last_match(1) : '' }
             .uniq

    result = {}
    for group in groups
      conn = ActiveRecord::Base.connection

      available_points = ExerciseGroup.new(self, group).available_point_names
      next if available_points.empty?

      sql = <<-EOS
        SELECT user_id, COUNT(*)
        FROM awarded_points
        WHERE course_id = #{conn.quote(id)} AND
              name IN (#{available_points.map { |ap| conn.quote(ap) }.join(',')})
        GROUP BY user_id
      EOS
      by_user = Hash[conn.select_rows(sql).map! { |uid, count| [uid.to_i, count.to_i] }]

      result[group] = {
        available_points: available_points.size,
        points_by_user: by_user
      }
    end
    result
  end

  # Returns a hash of exercise group => {
  #  { awarded: double, late: double }
  # }
  def exercise_group_completion_counts_for_user(user)
    # TODO: clean up exercise group discovery

    groups = exercises.enabled.map(&:name).map { |name| name =~ /^(.+)-[^-]+$/ ? Regexp.last_match(1) : '' }.uniq.sort

    conn = ActiveRecord::Base.connection
    groups.each_with_object({}) do |group, result|
      available_points = ExerciseGroup.new(self, group).available_point_names
      next if available_points.empty?

      sql = <<-EOS
        SELECT awarded_after_soft_deadline, COUNT(*)
        FROM awarded_points
        WHERE course_id = #{conn.quote(id)} AND
              name IN (#{available_points.map { |ap| conn.quote(ap) }.join(',')}) AND
              user_id = #{conn.quote(user.id)}
        GROUP BY awarded_after_soft_deadline
      EOS

      res = conn.execute(sql).values.to_h
      awarded = res[false].nil? ? 0 : res[false].to_i
      late = res[true].nil? ? 0 : res[true].to_i
      calculated_ratio = (awarded + late * self.soft_deadline_point_multiplier).to_f / available_points.length
      result[group] = {
        awarded: awarded,
        late: late,
        available_points: available_points.length,
        progress: calculated_ratio.floor(2)
      }
    end
  end

  def refreshed?
    !refreshed_at.nil?
  end

  def taught_by?(user)
    user.teacher?(organization)
  end

  def assistant?(user)
    assistants.exists?(user.id)
  end

  def contains_unlock_deadlines?
    exercise_groups.any?(&:contains_unlock_deadlines?)
  end

  def material_url=(material)
    return super('') if material.blank?
    return super("http://#{material}") unless /^https?:\/\//.match?(material)
    super(material)
  end

  def custom?
    course_template_obj.dummy?
  end

  def external_scoreboard_url=(url)
    return super("http://#{url}") unless url =~ /^(https?:\/\/|$)/
    super(url)
  end

  def has_external_scoreboard_url?
    external_scoreboard_url.present?
  end

  def parsed_external_scoreboard_url(organization, course, user)
    format(external_scoreboard_url, user: user.username, course: course.id.to_s, org: organization.slug)
  end

  private
    def set_cached_version
      self.cached_version = course_template_obj.cached_version
    end

    def save_template
      course_template_obj.save!
    rescue StandardError => e
      course_template_obj.errors.full_messages.each do |msg|
        errors.add(:base, msg + e.message)
      end
    end

    def course_template_obj
      self.course_template ||= CourseTemplate.new_dummy(self)
    end

    def check_name_length
      # If name starts with organization slug (org-course1), then check that
      # the actual name (course1) is within range (for backward compatibility).
      test_range = if !name.nil? && name.start_with?("#{organization.slug}-")
        name_range_with_slug
      else
        name_range
      end

      unless !name.nil? && test_range.include?(name.length)
        errors.add(:name, "must be between #{name_range} characters")
      end
    end

    def name_range
      1..40
    end

    def name_range_with_slug
      add_length = organization.slug.length + 1
      (name_range.first + add_length)..(name_range.last + add_length)
    end

    def check_external_scoreboard_url
      format(external_scoreboard_url, user: '', course: '', org: '') if external_scoreboard_url.present?
    rescue StandardError
      errors.add(:external_scoreboard_url, 'contains invalid keys')
    end
end