app/models/course.rb
# frozen_string_literal: true File `course.rb` has 471 lines of code (exceeds 250 allowed). Consider refactoring.require 'gdocs_export'require 'system_commands'require 'date_and_time_utils' Class `Course` has 51 methods (exceeds 20 allowed). Consider refactoring.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 Method `visible_to?` has a Cognitive Complexity of 6 (exceeds 5 allowed). Consider refactoring. 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} # }Method `exercise_group_completion_by_user` has a Cognitive Complexity of 7 (exceeds 5 allowed). Consider refactoring. 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 } # }Method `exercise_group_completion_counts_for_user` has a Cognitive Complexity of 12 (exceeds 5 allowed). Consider refactoring. 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') endend