9

I have a model that use both: Carrierwave for store photos, and PaperTrail for versioning.

I also configured Carrierwave for store diferent files when updates (That's because I want to version the photos) with config.remove_previously_stored_files_after_update = false

The problem is that PaperTrail try to store the whole Ruby Object from the photo (CarrierWave Uploader) instead of simply a string (that would be its url)

(version table, column object)

---
first_name: Foo
last_name: Bar
photo: !ruby/object:PhotoUploader
  model: !ruby/object:Bla
    attributes:
      id: 2
      first_name: Foo1
      segundo_nombre: 'Bar1'
      ........

How can I fix this to store a simple string in the photo version?

Jared Beck
  • 16,796
  • 9
  • 72
  • 97
eveevans
  • 4,392
  • 2
  • 31
  • 38

6 Answers6

10

You can override item_before_change on your versioned model so you don't call the uploader accesor directly and use write_attribute instead. Alternatively, since you might want to do that for several models, you can monkey-patch the method directly, like this:

module PaperTrail
  module Model
    module InstanceMethods
      private
        def item_before_change
          previous = self.dup
          # `dup` clears timestamps so we add them back.
          all_timestamp_attributes.each do |column|
            previous[column] = send(column) if respond_to?(column) && !send(column).nil?
          end
          previous.tap do |prev|
            prev.id = id
            changed_attributes.each do |attr, before|
              if defined?(CarrierWave::Uploader::Base) && before.is_a?(CarrierWave::Uploader::Base)
                prev.send(:write_attribute, attr, before.url && File.basename(before.url))
              else
                prev[attr] = before
              end
            end
          end
        end
    end
  end
end

Not sure if it's the best solution, but it seems to work.

rabusmar
  • 4,084
  • 1
  • 25
  • 29
  • I tried putting this code in /config/initializers/papertrail.rb, but it's still adding the full upload object. This is with Rails 4.1. – Sjors Provoost May 07 '14 at 15:01
  • 3
    As an alternative, I found it's easiest to just mount on a different attribute, which avoids the issue all together with paper trail, no monkey patching needed – beardedd Dec 08 '14 at 01:45
  • This doesn't work in versions of PaperTrail released since January 2015 or so when `item_before_change` was renamed. Since then, it's been renamed and changed in various ways. – Henrik N Aug 16 '19 at 14:32
6

Adding @beardedd's comment as an answer because I think this is a better way to handle the problem.

Name your database columns something like picture_filename and then in your model mount the uploader using:

class User < ActiveRecord::Base has_paper_trail mount_uploader :picture, PictureUploader, mount_on: :picture_filename end

You still use the user.picture.url attribute to access your model but PaperTrail will store revisions under picture_filename.

Gerry Shaw
  • 9,178
  • 5
  • 41
  • 45
  • Is this really a good way? Will there only change the file name when replacing an uploaded file, or will there also be a lot of meta information like image width, height, etc.? This would be lost when only tracking filename, and when restoring an old version the infos would be incorrect. – Joshua Muheim Apr 10 '15 at 14:48
  • I made a quick test: I compared the dumps of two full carrierwave objects (one with an uploaded file `avatar.jpg` and one with `nayeli.jpg`). It seems that really only the timestamps and file names are different, except one line (#237) where the file's name of the previously uploaded file is stored (`url.jpg`, which was the file before `avatar.jpg`). As far as I can see, your solution should basically work, while line 237 would be wrong when restoring revisions as it isn't tracked correctly. See results here: https://github.com/jmuheim/base/commit/c5f93b261efa02ff70265ef7397dfd77aecb644e – Joshua Muheim Apr 11 '15 at 07:49
2

Here is a bit updated version of monkeypatch from @rabusmar, I use it for rails 4.2.0 and paper_trail 4.0.0.beta2, in /config/initializers/paper_trail.rb.

The second method override is required if you use optional object_changes column for versions. It works in a bit strange way for carrierwave + fog if you override filename in uploader, old value will be from cloud and new one from local filename, but in my case it's ok.

Also I have not checked if it works correctly when you restore old version.

module PaperTrail
  module Model
    module InstanceMethods
      private

      # override to keep only basename for carrierwave attributes in object hash
      def item_before_change
        previous = self.dup
        # `dup` clears timestamps so we add them back.
        all_timestamp_attributes.each do |column|
          if self.class.column_names.include?(column.to_s) and not send("#{column}_was").nil?
            previous[column] = send("#{column}_was")
          end
        end
        enums = previous.respond_to?(:defined_enums) ? previous.defined_enums : {}
        previous.tap do |prev|
          prev.id = id # `dup` clears the `id` so we add that back
          changed_attributes.select { |k,v| self.class.column_names.include?(k) }.each do |attr, before|
            if defined?(CarrierWave::Uploader::Base) && before.is_a?(CarrierWave::Uploader::Base)
              prev.send(:write_attribute, attr, before.url && File.basename(before.url))
            else
              before = enums[attr][before] if enums[attr]
              prev[attr] = before
            end
          end
        end
      end

      # override to keep only basename for carrierwave attributes in object_changes hash
      def changes_for_paper_trail
        _changes = changes.delete_if { |k,v| !notably_changed.include?(k) }
        if PaperTrail.serialized_attributes?
          self.class.serialize_attribute_changes(_changes)
        end
        if defined?(CarrierWave::Uploader::Base)
          Hash[
              _changes.to_hash.map do |k, values|
                [k, values.map { |value| value.is_a?(CarrierWave::Uploader::Base) ? value.url && File.basename(value.url) : value }]
              end
          ]
        else
          _changes.to_hash
        end
      end

    end
  end
end
biomancer
  • 1,284
  • 1
  • 9
  • 20
  • This doesn't prevent a previous file to be overwritten with a new file of the same name. – Joshua Muheim Apr 11 '15 at 08:19
  • Restoring an old version DOESN'T work on `model.reload`! If you do a `user.previous_version.save`, `user` will still point to the latest file, and `user.reload` also won't fix this. You have to do a manual `user = User.find(user.id)`, then it will point to the previous file. Is there a fix for this? Typically between requests this isn't a problem, but it's still a bug. – Joshua Muheim Apr 12 '15 at 07:34
  • This doesn't work in versions of PaperTrail released since January 2015 or so when `item_before_change` and `changes_for_paper_trail` were renamed. – Henrik N Aug 16 '19 at 15:35
1

This is what actually functions for me, put this on config/initializers/paper_trail/.rb

module PaperTrail
  module Reifier
    class << self
      def reify_attributes(model, version, attrs)
        enums = model.class.respond_to?(:defined_enums) ? model.class.defined_enums : {}
        AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs)
        attrs.each do |k, v|

          is_enum_without_type_caster = ::ActiveRecord::VERSION::MAJOR < 5 && enums.key?(k)

          if model.send("#{k}").is_a?(CarrierWave::Uploader::Base)
            if v.present?
               model.send("remote_#{k}_url=", v["#{k}"][:url])
               model.send("#{k}").recreate_versions!
            else
               model.send("remove_#{k}!")
            end
          else
              if model.has_attribute?(k) && !is_enum_without_type_caster
                model[k.to_sym] = v
              elsif model.respond_to?("#{k}=")
                model.send("#{k}=", v)
              elsif version.logger
                version.logger.warn(
                  "Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
                )
              end
            end
        end
      end
    end
  end
end

This overrides the reify method to work on S3 + heroku

For uploaders to keep old files from updated or deleted records do this in the uploader

configure do |config|
   config.remove_previously_stored_files_after_update = false
end
def remove!
   true
end

Then make up some routine to clear old files from time to time, good luck

ErvalhouS
  • 4,178
  • 1
  • 22
  • 38
0

I want to add to the previous answers the following:

It can happen that you upload different files with the same name, and this may overwrite your previous file, so you won't be able to restore the old one.

You may use a timestamp in file names or create random and unique filenames for all versioned files.

Update

This doesn't seem to work in all edge cases for me, when assigning more than a single file to the same object within a single request request.

I'm using this right now:

def filename
  [@cache_id, original_filename].join('-') if original_filename.present?
end

This seems to work, as the @cache_id is generated for each and every upload again (which isn't the case as it seems for the ideas provided in the links above).

Joshua Muheim
  • 12,617
  • 9
  • 76
  • 152
0

@Sjors Provoost

We also need to override pt_recordable_object method in PaperTrail::Model::InstanceMethods module

  def pt_recordable_object
    attr = attributes_before_change
    object_attrs = object_attrs_for_paper_trail(attr)

    hash = Hash[
        object_attrs.to_hash.map do |k, value|
          [k, value.is_a?(CarrierWave::Uploader::Base) ? value.url && File.basename(value.url) : value ]
        end
    ]

    if self.class.paper_trail_version_class.object_col_is_json?
      hash
    else
      PaperTrail.serializer.dump(hash)
    end
  end