Files
codeocean/app/models/code_ocean/file.rb
Sebastian Serth 0e387ffda2 Add association for Comments and RequestForComments
For the submission and comments, I mainly decided to use a `has_one` association. Based on the database schema, multiple request for comments could be allowed (i.e., for each file or submission), but this won't happen practically (since we always create new submissions and files). Hence, the `has_one` association is representing our relationship better.
2023-09-25 18:17:52 +02:00

190 lines
6.2 KiB
Ruby

# frozen_string_literal: true
require File.expand_path('../../uploaders/file_uploader', __dir__)
module CodeOcean
class File < ApplicationRecord
include DefaultValues
DEFAULT_WEIGHT = 1.0
ROLES = %w[regular_file main_file reference_implementation executable_file teacher_defined_test user_defined_file
user_defined_test teacher_defined_linter].freeze
TEACHER_DEFINED_ROLES = ROLES - %w[user_defined_file]
OWNER_READ_PERMISSION = 0o400
OTHER_READ_PERMISSION = 0o004
ALLOWED_CONTEXT_TYPES = %w[Exercise Submission CommunitySolution CommunitySolutionContribution].freeze
after_initialize :set_default_values
before_validation :clear_weight, unless: :teacher_defined_assessment?
before_validation :hash_content, if: :content_present?
before_validation :set_ancestor_values, if: :incomplete_descendent?
attr_writer :size
# These attributes are mainly used when retrieving files from a runner
attr_accessor :download_path, :owner, :group, :privileged_execution
attr_reader :permissions
belongs_to :context, polymorphic: true
belongs_to :file, class_name: 'CodeOcean::File', optional: true # This is only required for submissions and is validated below
alias ancestor file
belongs_to :file_type
has_many :files, class_name: 'CodeOcean::File'
has_many :testruns
has_many :comments
has_one :request_for_comment
has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor'
alias descendants files
mount_uploader :native_file, FileUploader
scope :editable, -> { where(read_only: false) }
scope :visible, -> { where(hidden: false) }
ROLES.each do |role|
scope :"#{role}s", -> { where(role:) }
end
scope :teacher_defined_assessments, -> { where(role: %w[teacher_defined_test teacher_defined_linter]) }
default_scope { order(path: :asc, name: :asc) }
validates :feedback_message, if: :teacher_defined_assessment?, presence: true
validates :feedback_message, absence: true, unless: :teacher_defined_assessment?
validates :hashed_content, if: :content_present?, presence: true
validates :hidden, inclusion: [true, false]
validates :hidden_feedback, inclusion: [true, false]
validates :name, presence: true
validates :read_only, inclusion: [true, false]
validates :role, inclusion: {in: ROLES}
validates :weight, if: :teacher_defined_assessment?, numericality: true, presence: true
validates :weight, absence: true, unless: :teacher_defined_assessment?
validates :file, presence: true if :context.is_a?(Submission)
validates :context_type, inclusion: {in: ALLOWED_CONTEXT_TYPES}
validates_with FileNameValidator, fields: %i[name path file_type_id]
ROLES.each do |role|
define_method("#{role}?") { self.role == role }
end
def read
if native_file?
return nil unless native_file_location_valid?
native_file.read
else
content
end
end
def native_file_location_valid?
real_location = Pathname(native_file.current_path).realpath
upload_location = Pathname(::File.join(native_file.root, 'uploads')).realpath
real_location.fnmatch? ::File.join(upload_location.to_s, '**')
end
def ancestor_id
file_id || id
end
def clear_weight
self.weight = nil
end
private :clear_weight
def teacher_defined_assessment?
teacher_defined_test? || teacher_defined_linter?
end
def content_present?
content? || native_file?
end
private :content_present?
def filepath
if path.present?
::File.join(path, name_with_extension)
else
name_with_extension
end
end
def filepath_without_extension
if path.present?
::File.join(path, name)
else
name
end
end
def hash_content
self.hashed_content = Digest::MD5.new.hexdigest(read || '')
end
private :hash_content
def incomplete_descendent?
file_id.present? && file_type_id.blank?
end
private :incomplete_descendent?
def name_with_extension
name.to_s + (file_type&.file_extension || '')
end
def name_with_extension_and_size
"#{name_with_extension} (#{ActionController::Base.helpers.number_to_human_size(size)})"
end
def set_ancestor_values
%i[feedback_message file_type hidden name path read_only role weight].each do |attribute|
send(:"#{attribute}=", ancestor.send(attribute))
end
end
private :set_ancestor_values
def set_default_values
set_default_values_if_present(content: '', hidden: false, read_only: false)
set_default_values_if_present(weight: DEFAULT_WEIGHT) if teacher_defined_assessment?
end
private :set_default_values
def visible
!hidden
end
def size
@size ||= if native_file?
native_file.size
else
content.size
end
end
def permissions=(permission_string)
# We iterate through the permission string (e.g., `rwxrw-r--`) as received through Linux
# For each character in the string, we check for a corresponding permission (which is available if the character is not `-`)
# Then, we use a bit shift to move a `1` to the position of the given permission.
# First, it is moved within a group (e.g., `r` in `rwx` is moved twice to the left, `w` once, `x` not at all)
# Second, the bit is moved in accordance with the group (e.g., the `owner` is moved twice, the `group` once, the `other` group not at all)
# Finally, a sum is created, which technically could be an OR operation as well.
@permissions = permission_string.chars.map.with_index do |permission, index|
next 0 if permission == '-' # No permission
bit = 0b1 << ((2 - index) % 3) # Align bit in respective group
bit << ((2 - (index / 3)) * 3) # Align bit in bytes (for the group)
end.sum
end
def missing_read_permissions?
return false if permissions.blank?
# We use a bitwise AND with the permission bits and compare that to zero
if privileged_execution.present?
(permissions & OWNER_READ_PERMISSION).zero?
else
(permissions & OTHER_READ_PERMISSION).zero?
end
end
end
end