<commit_before>class Float
  def signif(signs)
    Float("%.#{signs}f" % self)
  end
end

class Project < ApplicationRecord
  include ApplicationHelper
  include LogHelper
  include DbHelpers

  belongs_to :unit
  belongs_to :user
  belongs_to :campus

  # has_one :user, through: :student
  has_many :tasks, dependent: :destroy # Destroying a project will also nuke all of its tasks

  has_many :group_memberships, dependent: :destroy
  has_many :groups, -> { where('group_memberships.active = :value', value: true) }, through: :group_memberships
  has_many :past_groups, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'group'
  has_many :task_engagements, through: :tasks
  has_many :comments, through: :tasks
  has_many :tutorial_enrolments, dependent: :destroy

  has_many :learning_outcome_task_links, through: :tasks

  # Callbacks - methods called are private
  before_destroy :can_destroy?

  validates :grade_rationale, length: { maximum: 4095, allow_blank: true }

  validate :tutorial_enrolment_same_campus, if: :campus_id_changed?

  before_update :check_withdraw_from_groups, if: :enrolled_changed?

  #
  # Permissions around project data
  #
  def self.permissions
    # What can students do with projects?
    student_role_permissions = [
      :get,
      :make_submission,
      :get_submission,
      :change
    ]
    # What can tutors do with projects?
    tutor_role_permissions = [
      :get,
      :trigger_week_end,
      :change_tutorial,
      :make_submission,
      :get_submission,
      :change,
      :assess,
      :change_campus
    ]
    # What can convenors do with projects?
    convenor_role_permissions = [
    ]
    # What can nil users do with projects?
    nil_role_permissions = [
    ]

    # Return permissions hash
    {
      student: student_role_permissions,
      tutor: tutor_role_permissions,
      nil: nil_role_permissions
    }
  end

  def role_for(user)
    user_role(user)
  end

  # Get all of the projects for the indicated user - with or without inactive units
  def self.for_user(user, include_inactive)
    # Limit to enrolled units... for this user
    result = where(enrolled: true).where('projects.user_id = :user_id', user_id: user.id)

    # Return the result if we include inactive units...
    return result if include_inactive

    # Otherwise link in units and only get active units
    result.joins(:unit).where('units.active = TRUE')
  end

  # Used to adjust the change tutorial permission in units that do not
  # allow students to change tutorials
  def specific_permission_hash(role, perm_hash, _other)
    result = perm_hash[role] unless perm_hash.nil?
    if result && role == :student && unit.allow_student_change_tutorial
      result << :change_tutorial
    end
    result
  end

  def enrol_in(tutorial)
    # Check if multiple enrolments changing to a single enrolment - due to no stream.
    # No need to delete if only 1, as that would be updated as well.
    if tutorial_enrolments.count > 1 && tutorial.tutorial_stream.nil?
      # So remove current enrolments
      tutorial_enrolments.delete_all()
    end

    tutorial_enrolment = matching_enrolment(tutorial)
    if tutorial_enrolment.nil?
      tutorial_enrolment = TutorialEnrolment.new
      tutorial_enrolment.tutorial = tutorial
      tutorial_enrolment.project = self

      # Add this enrolment to aid and check project validation
      tutorial_enrolments << tutorial_enrolment

      tutorial_enrolment.save!

      # add after save to ensure valid tutorial_enrolments
      self.tutorial_enrolments << tutorial_enrolment
    else # there is an existing enrolment...
      tutorial_enrolment.tutorial = tutorial
      tutorial_enrolment.update!(tutorial_id: tutorial.id)
    end
    tutorial_enrolment
  end

  def enrolled_in?(tutorial)
    tutorial_enrolments.select{|e| e.tutorial_id == tutorial.id}.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0
  end

  # Find enrolment in same tutorial stream
  def matching_enrolment(tutorial)
    tutorial_enrolments.
      joins(:tutorial).
      where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL OR :sid IS NULL', sid: tutorial.tutorial_stream_id).
      first
  end

  # Check tutorial membership if there is a campus change 
  def tutorial_enrolment_same_campus
    return unless enrolled && campus_id.present? && campus_id_changed?
    if tutorial_enrolments.joins(:tutorial).where('tutorials.campus_id <> :cid', cid: campus_id).count > 0
      errors.add(:campus, "does not match with tutorial enrolments.")
    end
  end

  def log_details
    "#{id} - #{student.name} (#{student.username}) #{unit.code}"
  end

  def task_outcome_alignments
    learning_outcome_task_links
  end

  #
  # All "discuss" and "demonstrate" become complete
  #
  def trigger_week_end(by_user)
    discuss_and_demonstrate_tasks.each { |task| task.trigger_transition(trigger: 'complete', by_user: by_user, bulk: true, quality: task.quality_pts) }
  end

  def student
    user
  end

  def tutors_and_tutorial
    current_tutor = nil
    first_tutor = true

    tutorial_enrolments.
      joins(tutorial: {unit_role: :user}).
      order('tutor').
      select("tutorials.abbreviation as tutorial_abbr, #{db_concat('users.first_name', "' '", 'users.last_name')} as tutor").
      map do |t|
        result = "#{t.tutor == current_tutor ? '' : "#{first_tutor ? '' : ') '}#{t.tutor} ("}#{t.tutorial_abbr}"
        current_tutor = t.tutor
        first_tutor = false
        result
      end.join(' ') + ( !first_tutor ? ')' : '')
  end

  def tutorial_enrolment_for_stream(tutorial_stream)
    tutorial_enrolments.
      joins(:tutorial).
      where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (tutorial_stream.present? ? tutorial_stream.id : nil)).
      first
  end

  def tutorial_for_stream(tutorial_stream)
    enrolment = tutorial_enrolment_for_stream(tutorial_stream)
    enrolment.tutorial unless enrolment.nil?
  end

  def tutorial_for(task_definition)
    tutorial_for_stream(task_definition.tutorial_stream) unless task_definition.nil?
  end

  def tutor_for(task_definition)
    tutorial = tutorial_for(task_definition)
    (tutorial.present? and tutorial.tutor.present?) ? tutorial.tutor : main_convenor_user
  end

  def main_convenor_user
    unit.main_convenor_user
  end

  def user_role(user)
    if user == student then :student
    elsif user.nil? then nil
    elsif unit.tutors.where(id: user.id).count != 0 then :tutor
    else nil
    end
  end

  def active?
    unit.active
  end

  #
  # Get a string representation of the Target Grade
  #
  def target_grade_desc
    case target_grade
    when 1
      'Credit'
    when 2
      'Distinction'
    when 3
      'High Distinction'
    else
      'Pass'
    end
  end

  def reference_date
    if application_reference_date > unit.end_date
      unit.end_date
    else
      application_reference_date
    end
  end

  def task_details_for_shallow_serializer(user)
    tasks
      .joins(:task_status)
      .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')")
      .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}")
      .select(
        'SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread', 'project_id', 'tasks.id as id',
        'task_definition_id', 'task_statuses.id as status_id',
        'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'tasks.grade as grade', 'quality_pts', 'include_in_portfolio', 'grade'
      )
      .group(
        'task_statuses.id', 'tasks.project_id', 'tasks.id', 'task_definition_id', 'status_id',
        'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'grade', 'quality_pts',
        'include_in_portfolio', 'grade'
      )
      .map do |r|
        t = Task.find(r.id)
        {
          id: r.id,
          status: TaskStatus.id_to_key(r.status_id),
          task_definition_id: r.task_definition_id,
          include_in_portfolio: r.include_in_portfolio,
          pct_similar: t.pct_similar,
          similar_to_count: t.similar_to_count,
          similar_to_dismissed_count: t.similar_to_dismissed_count,
          times_assessed: r.times_assessed,
          grade: r.grade,
          quality_pts: r.quality_pts,
          num_new_comments: r.number_unread,
          extensions: t.extensions,
          due_date: t.due_date
        }
      end
  end

  def assigned_tasks
    tasks.joins(:task_definition).where('task_definitions.target_grade <= :target', target: target_grade)
  end

  def portfolio_tasks
    # Get assigned tasks that are included in the portfolio
    tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation').where('tasks.include_in_portfolio = TRUE')

    # Remove the tasks that are not aligned... if there are ILOs
    unless unit.learning_outcomes.empty?
      tasks = tasks.select { |t| t.learning_outcome_task_links.count > 0 }
    end

    # Now select the tasks that and have a PDF... cant include the others...
    portfolio_tasks = tasks.select(&:has_pdf)
  end

  def target_grade=(value)
    self[:target_grade] = value
  end

  #
  # Get task_definitions and status for the current student for all tasks that are <= the target
  #
  def task_definitions_and_status(target)
    assigned_task_defs_for_grade(target).
      order("start_date ASC, abbreviation ASC").
      map { |td|
          if has_task_for_task_definition? td 
            task = task_for_task_definition(td)
            {task_definition: td, task: task, status: task.status } 
          else
            {task_definition: td, task: nil, status: :not_started } 
          end
        }.
      select { |r| [:not_started, :redo, :need_help, :working_on_it, :fix_and_resubmit, :demonstrate, :discuss].include? r[:status] }
  end

  #
  # Calculate a list of the top 5 task definitions the student should focus on,
  # in order of priority with reason.
  #
  def top_tasks
    result = []

    to_target = lambda { |ts| ts[:task].nil? ? ts[:task_definition].target_date : ts[:task].due_date }

    #
    # Get list of tasks that could be top tasks...
    #
    task_states = task_definitions_and_status(target_grade)

    #
    # Start with overdue...
    #
    overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today }

    grades = [ "Pass", "Credit", "Distinction", "High Distinction" ]

    for i in 0..3
      graded_tasks = overdue_tasks.select { |ts| ts[:task_definition].target_grade == i  }

      graded_tasks.each do |ts|<commit_msg>FIX: Ensure signif works on integers<commit_after>class Float
  def signif(signs)
    Float("%.#{signs}f" % self)
  end
end

class Integer
  def signif(signs)
    Float(self)
  end
end

class Project < ApplicationRecord
  include ApplicationHelper
  include LogHelper
  include DbHelpers

  belongs_to :unit
  belongs_to :user
  belongs_to :campus

  # has_one :user, through: :student
  has_many :tasks, dependent: :destroy # Destroying a project will also nuke all of its tasks

  has_many :group_memberships, dependent: :destroy
  has_many :groups, -> { where('group_memberships.active = :value', value: true) }, through: :group_memberships
  has_many :past_groups, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'group'
  has_many :task_engagements, through: :tasks
  has_many :comments, through: :tasks
  has_many :tutorial_enrolments, dependent: :destroy

  has_many :learning_outcome_task_links, through: :tasks

  # Callbacks - methods called are private
  before_destroy :can_destroy?

  validates :grade_rationale, length: { maximum: 4095, allow_blank: true }

  validate :tutorial_enrolment_same_campus, if: :campus_id_changed?

  before_update :check_withdraw_from_groups, if: :enrolled_changed?

  #
  # Permissions around project data
  #
  def self.permissions
    # What can students do with projects?
    student_role_permissions = [
      :get,
      :make_submission,
      :get_submission,
      :change
    ]
    # What can tutors do with projects?
    tutor_role_permissions = [
      :get,
      :trigger_week_end,
      :change_tutorial,
      :make_submission,
      :get_submission,
      :change,
      :assess,
      :change_campus
    ]
    # What can convenors do with projects?
    convenor_role_permissions = [
    ]
    # What can nil users do with projects?
    nil_role_permissions = [
    ]

    # Return permissions hash
    {
      student: student_role_permissions,
      tutor: tutor_role_permissions,
      nil: nil_role_permissions
    }
  end

  def role_for(user)
    user_role(user)
  end

  # Get all of the projects for the indicated user - with or without inactive units
  def self.for_user(user, include_inactive)
    # Limit to enrolled units... for this user
    result = where(enrolled: true).where('projects.user_id = :user_id', user_id: user.id)

    # Return the result if we include inactive units...
    return result if include_inactive

    # Otherwise link in units and only get active units
    result.joins(:unit).where('units.active = TRUE')
  end

  # Used to adjust the change tutorial permission in units that do not
  # allow students to change tutorials
  def specific_permission_hash(role, perm_hash, _other)
    result = perm_hash[role] unless perm_hash.nil?
    if result && role == :student && unit.allow_student_change_tutorial
      result << :change_tutorial
    end
    result
  end

  def enrol_in(tutorial)
    # Check if multiple enrolments changing to a single enrolment - due to no stream.
    # No need to delete if only 1, as that would be updated as well.
    if tutorial_enrolments.count > 1 && tutorial.tutorial_stream.nil?
      # So remove current enrolments
      tutorial_enrolments.delete_all()
    end

    tutorial_enrolment = matching_enrolment(tutorial)
    if tutorial_enrolment.nil?
      tutorial_enrolment = TutorialEnrolment.new
      tutorial_enrolment.tutorial = tutorial
      tutorial_enrolment.project = self

      # Add this enrolment to aid and check project validation
      tutorial_enrolments << tutorial_enrolment

      tutorial_enrolment.save!

      # add after save to ensure valid tutorial_enrolments
      self.tutorial_enrolments << tutorial_enrolment
    else # there is an existing enrolment...
      tutorial_enrolment.tutorial = tutorial
      tutorial_enrolment.update!(tutorial_id: tutorial.id)
    end
    tutorial_enrolment
  end

  def enrolled_in?(tutorial)
    tutorial_enrolments.select{|e| e.tutorial_id == tutorial.id}.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0
  end

  # Find enrolment in same tutorial stream
  def matching_enrolment(tutorial)
    tutorial_enrolments.
      joins(:tutorial).
      where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL OR :sid IS NULL', sid: tutorial.tutorial_stream_id).
      first
  end

  # Check tutorial membership if there is a campus change
  def tutorial_enrolment_same_campus
    return unless enrolled && campus_id.present? && campus_id_changed?
    if tutorial_enrolments.joins(:tutorial).where('tutorials.campus_id <> :cid', cid: campus_id).count > 0
      errors.add(:campus, "does not match with tutorial enrolments.")
    end
  end

  def log_details
    "#{id} - #{student.name} (#{student.username}) #{unit.code}"
  end

  def task_outcome_alignments
    learning_outcome_task_links
  end

  #
  # All "discuss" and "demonstrate" become complete
  #
  def trigger_week_end(by_user)
    discuss_and_demonstrate_tasks.each { |task| task.trigger_transition(trigger: 'complete', by_user: by_user, bulk: true, quality: task.quality_pts) }
  end

  def student
    user
  end

  def tutors_and_tutorial
    current_tutor = nil
    first_tutor = true

    tutorial_enrolments.
      joins(tutorial: {unit_role: :user}).
      order('tutor').
      select("tutorials.abbreviation as tutorial_abbr, #{db_concat('users.first_name', "' '", 'users.last_name')} as tutor").
      map do |t|
        result = "#{t.tutor == current_tutor ? '' : "#{first_tutor ? '' : ') '}#{t.tutor} ("}#{t.tutorial_abbr}"
        current_tutor = t.tutor
        first_tutor = false
        result
      end.join(' ') + ( !first_tutor ? ')' : '')
  end

  def tutorial_enrolment_for_stream(tutorial_stream)
    tutorial_enrolments.
      joins(:tutorial).
      where('tutorials.tutorial_stream_id = :sid OR tutorials.tutorial_stream_id IS NULL', sid: (tutorial_stream.present? ? tutorial_stream.id : nil)).
      first
  end

  def tutorial_for_stream(tutorial_stream)
    enrolment = tutorial_enrolment_for_stream(tutorial_stream)
    enrolment.tutorial unless enrolment.nil?
  end

  def tutorial_for(task_definition)
    tutorial_for_stream(task_definition.tutorial_stream) unless task_definition.nil?
  end

  def tutor_for(task_definition)
    tutorial = tutorial_for(task_definition)
    (tutorial.present? and tutorial.tutor.present?) ? tutorial.tutor : main_convenor_user
  end

  def main_convenor_user
    unit.main_convenor_user
  end

  def user_role(user)
    if user == student then :student
    elsif user.nil? then nil
    elsif unit.tutors.where(id: user.id).count != 0 then :tutor
    else nil
    end
  end

  def active?
    unit.active
  end

  #
  # Get a string representation of the Target Grade
  #
  def target_grade_desc
    case target_grade
    when 1
      'Credit'
    when 2
      'Distinction'
    when 3
      'High Distinction'
    else
      'Pass'
    end
  end

  def reference_date
    if application_reference_date > unit.end_date
      unit.end_date
    else
      application_reference_date
    end
  end

  def task_details_for_shallow_serializer(user)
    tasks
      .joins(:task_status)
      .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')")
      .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}")
      .select(
        'SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread', 'project_id', 'tasks.id as id',
        'task_definition_id', 'task_statuses.id as status_id',
        'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'tasks.grade as grade', 'quality_pts', 'include_in_portfolio', 'grade'
      )
      .group(
        'task_statuses.id', 'tasks.project_id', 'tasks.id', 'task_definition_id', 'status_id',
        'completion_date', 'times_assessed', 'submission_date', 'portfolio_evidence', 'grade', 'quality_pts',
        'include_in_portfolio', 'grade'
      )
      .map do |r|
        t = Task.find(r.id)
        {
          id: r.id,
          status: TaskStatus.id_to_key(r.status_id),
          task_definition_id: r.task_definition_id,
          include_in_portfolio: r.include_in_portfolio,
          pct_similar: t.pct_similar,
          similar_to_count: t.similar_to_count,
          similar_to_dismissed_count: t.similar_to_dismissed_count,
          times_assessed: r.times_assessed,
          grade: r.grade,
          quality_pts: r.quality_pts,
          num_new_comments: r.number_unread,
          extensions: t.extensions,
          due_date: t.due_date
        }
      end
  end

  def assigned_tasks
    tasks.joins(:task_definition).where('task_definitions.target_grade <= :target', target: target_grade)
  end

  def portfolio_tasks
    # Get assigned tasks that are included in the portfolio
    tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation').where('tasks.include_in_portfolio = TRUE')

    # Remove the tasks that are not aligned... if there are ILOs
    unless unit.learning_outcomes.empty?
      tasks = tasks.select { |t| t.learning_outcome_task_links.count > 0 }
    end

    # Now select the tasks that and have a PDF... cant include the others...
    portfolio_tasks = tasks.select(&:has_pdf)
  end

  def target_grade=(value)
    self[:target_grade] = value
  end

  #
  # Get task_definitions and status for the current student for all tasks that are <= the target
  #
  def task_definitions_and_status(target)
    assigned_task_defs_for_grade(target).
      order("start_date ASC, abbreviation ASC").
      map { |td|
          if has_task_for_task_definition? td
            task = task_for_task_definition(td)
            {task_definition: td, task: task, status: task.status }
          else
            {task_definition: td, task: nil, status: :not_started }
          end
        }.
      select { |r| [:not_started, :redo, :need_help, :working_on_it, :fix_and_resubmit, :demonstrate, :discuss].include? r[:status] }
  end

  #
  # Calculate a list of the top 5 task definitions the student should focus on,
  # in order of priority with reason.
  #
  def top_tasks
    result = []

    to_target = lambda { |ts| ts[:task].nil? ? ts[:task_definition].target_date : ts[:task].due_date }

    #
    # Get list of tasks that could be top tasks...
    #
    task_states = task_definitions_and_status(target_grade)

    #
    # Start with overdue...
    #
    overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today }

    grades = [ "Pass", "Credit", "Distinction", "High Distinction" ]

    for i in 0..3
      graded_tasks = overdue_tasks.select { |ts| ts[:task_definition].target_grade == i  }

      graded_tasks.each do |ts|
{'fraction_non_alphanumeric': 0.09159779614325068, 'fraction_numerical': 0.0017447199265381084, 'mean_word_length': 3.2758146839418925, 'pattern_counts': {'":': 0, '<': 27, '<?xml version=': 0, '>': 23, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 1, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '7110219', 'n_tokens_mistral': 7225, 'n_tokens_neox': 6897, 'n_words': 1741}