Coursemology/coursemology2

View on GitHub
lib/extensions/attachable/active_record/base.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# frozen_string_literal: true
module Extensions::Attachable::ActiveRecord::Base
  module ClassMethods
    # Declaration for a model having many attachments. This declaration supports two possible ways
    # of association: direct association, or association via a column.
    #
    #   (i) Direct association denotes an object having multiple attachments (eg. Assessement has
    #   multiple files attached to the object itself)
    #
    #   (ii) Association via a column is meant to support the embedding of attachment images
    #   within columns containing HTML markup.
    #
    # For deletion of attachments, it is necessary for the model to implement the
    # +:destroy_attachment+ CanCanCan permission on the +attachable+ object.
    #
    # For (ii), the `has_many_attachments on: :column` declaration provides some additional
    # methods and logic:
    #
    #   1. +column_name_attachment_reference_ids+: Access ids of attachment_references within the
    #   column. This is done by using a HTML parser for the column to locate img tags with the
    #   attribute +data-attachment-reference-id+.
    #
    #   2. +column_name_attachment_references_change+: Similar to +ActiveModel::Dirty+, this
    #   returns an array of old and current attachment_reference_ids. This is to allow custom
    #   callback logic to be implemented by the model. The return value follows the following
    #   shape:
    #   [[Old ids on column], [Current ids on column]]
    #
    #   3. +column_name_for_email+: Returns a rich-text string to be used when sending emails.
    #   This is to allow models to send rich text emails with attachments embedded in them.
    #   This is because emails could be read on a different machine, which might not have
    #   access to the Coursemology server.
    #
    #   4. +update_attachment_references+ (before_save callback): This handles changes in
    #   attachment_references. This includes assocating new attachment_references with the
    #   current object, validating the current set of attachment_references (see
    #   +get_valid_attachment_reference+), and marking removed attachment_references for
    #   destruction (which will be deleted).
    #   This facilitates the WYSIWYG editor to insert images into the content by creating an
    #   attachment_reference first (with +nil+ attachable), then correctly associate the
    #   attachment_reference when the model is saved.
    #
    # @param [Hash] options
    # @option options [Symbol|Array<Symbol>] :on The column associated with attachments, note that
    #   the column type should be string or text.
    #   This can be a symbol or an array of symbols.
    #   An attribute named `column_name_attachment_references` will be defined, you can override it
    #   to customise the way to retrieve the attachment_references for the specific column.
    # @example Has many attachments on a column
    #   has_many_attachments on: :description #=> description contains HTML markup and images
    #   associated with the attachments. Updating description will result in attachments changing.
    #
    #   To change the provided logic, you can override `description_attachment_references_changes`.
    def has_many_attachments(options = {}) # rubocop:disable Naming/PredicateName
      include HasManyAttachments

      return unless options[:on]

      self.attachable_columns = Array(options[:on])
      before_save :update_attachment_references

      HasManyAttachments.define_attachment_references_readers(attachable_columns)
    end

    def has_one_attachment # rubocop:disable Naming/PredicateName
      include HasOneAttachment
    end
  end

  module HasManyAttachments
    extend ActiveSupport::Concern

    included do
      class_attribute :attachable_columns
      self.attachable_columns ||= []

      has_many :attachment_references, as: :attachable, class_name: '::AttachmentReference',
                                       inverse_of: :attachable, dependent: :destroy, autosave: true,
                                       after_add: :mark_attachments_as_changed
      # Attachment references can substitute attachments, so allow access using the `attachments`
      # identifier.
      alias_method :attachments, :attachment_references

      def attachments_changed?
        !!@attachments_changed
      end

      private

      def mark_attachments_as_changed(*)
        @attachments_changed = true
      end
    end

    ATTACHMENT_REFERENCE_SUFFIX = '_attachment_reference_ids'
    ATTACHMENT_CHANGED_SUFFIX = '_attachment_references_change'
    FOR_EMAIL_SUFFIX = '_to_email'

    def self.define_attachment_references_readers(attachable_columns)
      attachable_columns.each do |column|
        email_method_name = "#{column}#{FOR_EMAIL_SUFFIX}"
        unless method_defined?(email_method_name)

          # Define a method to get the content with attachment urls for the given content.
          # This is to be used for emails.
          define_method(email_method_name) do
            prepare_content_for_email(send(column))
          end
        end

        reader_method_name = "#{column}#{ATTACHMENT_REFERENCE_SUFFIX}"
        unless method_defined?(reader_method_name)

          # Define a reader to get attachment_reference_ids within the given content
          define_method(reader_method_name) do
            parse_attachment_reference_uuids_from_content(send(column))
          end
        end

        changed_method_name = "#{column}#{ATTACHMENT_CHANGED_SUFFIX}"
        next if method_defined?(changed_method_name)

        # Define a reader `#{column_name}_attachment_references_change` to allow clients
        # to implement logic when attachments have changed. This method returns previous
        # attachment_reference_ids and current_attachment_reference_ids by comparing
        # `column` and `column_was` (from ActiveRecord::Dirty).
        #
        # @return [Array<Array<String>>] Array with 2 elements:
        #   i) previous set of attachment_reference_ids
        #   ii) current set of attachment_reference_ids
        define_method(changed_method_name) do
          return [] unless send("#{column}_changed?")

          attachment_ids_was = parse_attachment_reference_uuids_from_content(send("#{column}_was"))
          attachment_ids = parse_and_validate_attachment_reference_uuids_from_content(send(column))

          [attachment_ids_was, attachment_ids]
        end
      end
    end

    def files=(files)
      files.each do |file|
        attachment_references.build(file: file)
      end
    end

    private # rubocop:disable Lint/UselessAccessModifier

    # Update attachment_references which are added or removed in this update. This also
    # associates all attachment_references that have no attachable yet.
    #
    # +attachment_reference_id_changes+ is called, which validates the current
    # attachment_references.
    def update_attachment_references
      changes = attachment_reference_id_changes
      added_ids, removed_ids = changes[1] - changes[0], changes[0] - changes[1]

      attachment_references.each do |attachment_reference|
        attachment_reference.mark_for_destruction if removed_ids.include?(attachment_reference.id)
      end
      added_ids.each do |attachment_reference_id|
        attachment_references << AttachmentReference.find(attachment_reference_id)
      end
    end

    # Find all changes in attachment_reference_ids in the columns specified.
    # The method also validates associated attachment_references in the current
    # object correctly.
    #
    # @return [Array<Array<String>>] Array with 2 elements:
    #   i) previous set of attachment_reference_ids
    #   ii) current set of attachment_reference_ids
    def attachment_reference_id_changes
      attachment_reference_id_changes = Array.new(2, [])
      self.class.attachable_columns.each do |column|
        old_ids, curr_ids = send("#{column}#{ATTACHMENT_CHANGED_SUFFIX}")
        attachment_reference_id_changes[0] += old_ids if old_ids
        attachment_reference_id_changes[1] += curr_ids if curr_ids
      end

      attachment_reference_id_changes
    end

    ATTACHMENT_URL_PREFIX = '/attachments/'

    # Parse all attachment_reference ids in the content, and validate
    # attachment_references. This runs +get_valid_attachment_reference+ through
    # all ids to ensure that
    #
    # @param [String] content The content which associated with the attachments.
    # @return [Array<Integer>] the ids of the attachment references in the content.
    def parse_and_validate_attachment_reference_uuids_from_content(content)
      ids = []
      doc = Nokogiri::HTML(content)
      doc.css('img').each do |image|
        id = parse_attachment_reference_uuid_from_url(image['src'])
        valid_id = get_valid_attachment_reference(id) if id

        next unless valid_id

        image['src'] = "#{ATTACHMENT_URL_PREFIX}#{valid_id}" unless valid_id == id
        ids << valid_id
      end

      ids
    end

    # Parse all attachment_reference uuids in the content.
    #
    # @param [String] content The content which associated with the attachments.
    # @return [Array<Integer>] the ids of the attachment references in the content.
    def parse_attachment_reference_uuids_from_content(content)
      ids = []
      doc = Nokogiri::HTML(content)
      doc.css('img').each do |image|
        id = parse_attachment_reference_uuid_from_url(image['src'])
        ids << id if id
      end

      ids
    end

    # Regex for filtering Attachment IDs from URLs.
    ATTACHMENT_ID_REGEX = /\/attachments\/([0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})$/

    # Parse attachment_reference uuid from the given url.
    #
    # @param [String] uuid The uuid.
    # @return [String|nil] the uuid of the attachment_references, or nil if invalid.
    def parse_attachment_reference_uuid_from_url(url)
      result = url&.match(ATTACHMENT_ID_REGEX)
      result ? result[1] : nil
    end

    # Gets the id of the attachment_reference associated with +self+. If an
    # attachment_reference referencing a different attachable is provided, this method
    # creates a new attachment_reference with the given attachment and name. If an
    # incorrect UUID was provided, +nil+ is returned.
    #
    # This handles two cases:
    #   i) Malformed attachment_reference id: returns +nil+.
    #   ii) During duplication, or Copy/Paste: new attachment_references are
    #       automatically created
    #
    # @param [String] id The given UUID of the attachment_reference
    # @return [String|nil] nil if provided ID is not found, otherwise the UUID of the validated
    #                      attachment_reference
    def get_valid_attachment_reference(id)
      object = AttachmentReference.find_by(id: id)
      return nil unless object
      return id if object.attachable == self || object.attachable.nil?

      AttachmentReference.create(attachment: object.attachment, name: object.name).id
    end

    # Given the rich-text content, transform the src of image nodes to direct URLs.
    #
    # If S3 is used as a storage with signed URLs, this would return a URL that has an
    # expiry (based on configuration settings).
    #
    # @param [String] content The content to be prepared
    # @return [String] The parsed content with the URL.
    def prepare_content_for_email(content)
      doc = Nokogiri::HTML.fragment(content)
      doc.css('img').each do |image|
        id = parse_attachment_reference_uuid_from_url(image['src'])
        attachment = AttachmentReference.find_by_id(id)&.attachment if id
        image['src'] = attachment.url if attachment&.url
      end
      doc.to_html
    end
  end

  module HasOneAttachment
    extend ActiveSupport::Concern

    included do
      after_commit :clear_attachment_change
      validates :attachment_references, length: { maximum: 1 }

      has_many :attachment_references, as: :attachable, class_name: '::AttachmentReference',
                                       inverse_of: :attachable, dependent: :destroy, autosave: true
    end

    def attachment_reference
      attachment_references.take
    end
    # Attachment references can substitute attachments, so allow access using the `attachment`
    # identifier.
    alias_method :attachment, :attachment_reference

    def attachment_reference=(attachment_reference)
      return self.attachment_reference if self.attachment_reference == attachment_reference

      mark_attachment_as_changed(self.attachment_reference)
      attachment_references.clear
      attachment_references << attachment_reference if attachment_reference
    end
    alias_method :attachment=, :attachment_reference=

    def build_attachment_reference(attributes = {})
      mark_attachment_as_changed(attachment_reference)
      attachment_references.clear
      attachment_references.build(attributes)
    end
    alias_method :build_attachment, :build_attachment_reference

    def file=(file)
      if file
        build_attachment_reference(file: file)
      else
        return nil if attachment_reference.nil?

        mark_attachment_as_changed(attachment_reference)
        attachment_references.clear
      end
    end

    # Tracks the the attachment_reference changes and support clear/revert the changes.
    # `attachment_reference` is a virtual attribute, and changes tracking to such attributes are not well supported in
    # rails 5: https://github.com/rails/rails/issues/25787
    def attachment_reference_changed?
      !!@attachment_changed
    end
    alias_method :attachment_changed?, :attachment_reference_changed?

    def mark_attachment_reference_as_changed(old)
      @attachment_changed = true
      @original_attachment = old
    end
    alias_method :mark_attachment_as_changed, :mark_attachment_reference_as_changed

    def clear_attachment_reference_change
      @attachment_changed = false
      @original_attachment = nil
    end
    alias_method :clear_attachment_change, :clear_attachment_reference_change

    # Restore the attachmenet_reference to its previous value.
    def restore_attachment_reference_change
      return unless attachment_reference_changed?

      self.attachment_reference = @original_attachment
      clear_attachment_change
    end
    alias_method :restore_attachment_change, :restore_attachment_reference_change
  end
end