app/models/question.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# encoding: utf-8

class Question < ActiveRecord::Base
  attr_accessible :text, :answers, :ident, :video_link, :released
  validates :ident, :uniqueness => true, :presence => true
  validates :text, :presence => true

  attr_accessible :difficulty
  enumerate :difficulty
  validates_inclusion_of :difficulty, in: Difficulty

  attr_accessible :study_path
  enumerate :study_path
  validates_inclusion_of :study_path, in: StudyPath

  has_many :reviews, dependent: :destroy, order: 'updated_at DESC', inverse_of: :question
  has_many :answers, dependent: :destroy, inverse_of: :question
  has_many :hints, order: 'sort_hint ASC', dependent: :destroy, inverse_of: :question
  has_and_belongs_to_many :starred_by, :class_name => :User, :join_table => :starred
  before_destroy do |q|
    sql =  ["DELETE FROM starred WHERE question_id = ?", q.id]
    connection.execute(ActiveRecord::Base.send(:sanitize_sql_array, sql))
  end

  # simply remove, no deconstruction and the like
  has_many :stats, dependent: :delete_all, inverse_of: :question

  # i.e. this question has one parent, either Answer or Category
  belongs_to :parent, polymorphic: true, counter_cache: true

  include DotTools
  include StatTools
  include TraversalTools
  include CacheTools

  # returns all questions that have a parent category. If a categroy or
  # its id is given, only questions with that exact category are
  # returned.
  scope :with_parent_cat, (lambda do |cat = nil|
    cond = { parent_type: "Category" }
    cond[:parent_id] = cat.is_a?(Category) ? cat.id : cat if cat
    { conditions: cond }
  end)

  scope :siblings, (lambda do |quest|
    where(parent_type: quest.parent_type, parent_id: quest.parent_id).where(["id <> ?", quest.id])
  end)

  def siblings
    Question.siblings(self)
  end

  searchable do
    text :text,  stored: true
    text :ident, stored: true

    text :answers, stored: true do answers.map(&:text) end
    text :reviews, stored: true do reviews.map(&:comment) end
    text :hints,   stored: true do hints.map(&:text) end

    text :parent, stored: true do
      next "" unless parent
      p = parent
      r = p.text
      r << " " + p.title if p.respond_to?(:title)
      r << " " + p.ident if p.respond_to?(:ident)
    end
  end

  before_save do
    Rails.cache.write(:questions_last_update, Time.now)
    important_fields = [:parent_type, :parent_id, :text, :study_path, :difficulty]
    up = important_fields.any? { |f| send(f) }
    self.content_changed_at = Time.now if up || new_record?
  end

  def subquestions
    Question.where(parent_type: Answer, parent_id: answers)
  end

  def subcategories
    answers.map(&:categories).flatten.uniq
  end

  def complete?
    is_complete_helper[0]
  end

  def incomplete_reason
    is_complete_helper[1]
  end

  def matrix_validate?
    return false if answers.size != 1
    a = answers.first.text
    return false if a.scan(%(\\begin{pmatrix})).size != 1
    return false if a.scan(%(\\end{pmatrix})).size != 1
    true
  end

  def matrix_solution
    return nil unless matrix_validate?
    a = self.answers.first.text
    a = a.match(/\\begin\{pmatrix\}(.*)\\end\{pmatrix\}/m)[1]
    rows = a.split(/\s*\\\\\s*/)
    rows = rows.map { |r| r.strip.split(/\s*&\s*/).join(" ") }
    rows.join("  ")
  end

  def parent_html_ref
    "#{parent_type}_#{parent_id}"
  end

  def dot(active = false)
    id = ident.gsub('"', '')

    # strike text through using a strike through UTF8 character
    id = id.scan(/./).join('̶')+'̶' if !complete?

    txt = 'F: ' + id
    bg = active ? ', style=filled, fillcolor = "#AAC6D2"' : ''
    %(#{dot_id} [label="#{txt}"#{bg}, shape=box];\n)
  end

  def dot_hints
    return '' if hints.none?

    hintTexts = hints.map(&:dot_text).join("\\n")

    d = ""
    d << %(HINT#{dot_id} [label="#{hintTexts}", shape=none];)
    d << "#{dot_id} -> HINT#{dot_id};"
    d << "{ rank=same; #{dot_id} HINT#{dot_id} };\n"
  end

  def dot_region(may_omit = false)
    d = ''

    d << dot(true)
    d << dot_region_parent(may_omit)

    answers.each do |a|
      d << dot_link_to(self, a)
      d << dot_link_to(a, [a.questions, a.categories])
    end

    d << dot_hints
  end

  def has_bad_reviews?
    key = "quests_with_bad_reviews__#{Review.last_update}"
    q_with_bad_reviews = Rails.cache.fetch(key) do
      Review.where(okay: false).pluck(:question_id)
    end
    q_with_bad_reviews.include?(id)
  end

  private

  def dot_region_parent(may_omit)
    return "" unless parent

    d = parent.dot

    # link to ourselves
    d << "#{parent.dot_id} -> #{dot_id};\n"

    # link parent to our siblings, i.e. other questions
    d << dot_region_siblings(may_omit)

    # link to other children of the parents. Those are the same level
    # as the sibling questions above.
    d << dot_link_to(parent, parent.categories) if parent.respond_to?(:categories)

    d << dot_region_parent_of_parent
  end

  # renders dot that shows how our parent fits into the whole tree, i.e.
  # it renders our parent’s parents.
  def dot_region_parent_of_parent
    case parent
    when Category
      dot_link_from(parent.answers, parent)
    when Answer
      d = dot_link_from(parent.question, parent)
      d << dot_link_to(parent.question, siblings)
    end
  end

  # renders dot code for the siblings of this question. If may omit is
  # set to true, it may only include some of the siblings. Otherwise
  # all will be shown.
  def dot_region_siblings(may_omit)
    return "" unless parent.respond_to?(:questions)

    limit = may_omit ? 6 : -1
    qs = siblings.includes(:answers, :parent).limit(limit).to_a

    d = dot_link_to(parent, qs)
    remaining = parent.questions.size - limit - 1

    return d if remaining <= 0 or !may_omit

    # this is not always correct, as above may include the current
    # question. Thus, only left-1 questions would be left.
    d << %(#{dot_id}_hidden_siblings [label="+#{remaining} weitere Fragen", shape=none];)
    d << %(#{parent.dot_id} -> #{dot_id}_hidden_siblings;\n)
  end


  def is_complete_helper
    key = generate_cache_key("question_#{id}")
    Rails.cache.fetch(key) { is_complete_helper_real }
  end

  def is_complete_helper_real
    return false, "nicht freigegeben" if !released?
    return false, "keine Antworten" if answers.size == 0
    # note: matrix_validate? is false if there is more than one answers.
    # Thus it’s enough to ensure that the first answer is correct rather
    # than all of them. This avoids an additional database query.
    return false, "Matrix-Fragen müssen genau eine Antwort haben, welche richtig sein muss" if matrix_validate? && !answers.first.correct?
    return false, "Reviewer sagt „nicht okay“" if has_bad_reviews?
    return false, "Elter nicht freigegeben" if parent && !parent.released?
    return false, "Unerreichbar, da das Elter eine andere Zielgruppe als diese Frage hat." unless QuestionReachability.new(self).any_reachable?
    return true, ""
  end

  def self.duplicate_question
    require 'set'

    res = []
    sql = 'select min(q.id), q2.id from questions as q
          join questions as q2 on q.text = q2.text and q.text is not null and
          q2.text is not null and q.id < q2.id group by q2.id order by q.id'
    results = ActiveRecord::Base.connection.execute(sql)
    if results.present?
        results.each do |row|
          res << {'original' => row[0], 'duplicate' => row[1]}
        end
        return res
    else
        return nil
    end
  end

  def self.duplicate_question_and_answers
    require 'set'

    same = [], diff = []
    sql = 'select min(q.id), q2.id from questions as q
          join questions as q2 on q.text = q2.text and q.text is not null and
          q2.text is not null and q.id < q2.id group by q2.id order by q.id'
    results = ActiveRecord::Base.connection.execute(sql)
    if results.present?
        realid = 0, real_ans = []
        dupid = 0, dup_ans = []
        results.each do |row|
          if realid == 0 or realid != row[0]
            realid = row[0]
            real_ans.clear
            ActiveRecord::Base.connection.execute("select text, correct from answers where question_id=#{realid}").each do |a|
              real_ans << {'text' => a['text'], 'correct' => a['correct']}
            end
          end
          dupid = row[1]
          dup_ans.clear
          ActiveRecord::Base.connection.execute("select text, correct from answers where question_id=#{dupid}").each do |a|
            dup_ans << {'text' => a['text'], 'correct' => a['correct']}
          end
          if (real_ans.length > 0) && (real_ans.length == dup_ans.length) && (real_ans.to_set == dup_ans.to_set)
            same << {'original' => realid, 'duplicate' => dupid}
          else
            diff << {'orignal' => realid, 'maybe' => dupid}
          end
        end
        return same, diff
    else
        return nil
    end
  end
end