Year2013

How to test Paperclip uploads in Rails4

When testing Paperclip uploads in my application, I’m interested in treating Paperclip as a black box and trusting it for doing its magic. So what I really need to test is that valid files are processed and invalid ones are not. (see How to allow users to crop images in Rails 4 for model and controller code)

Tests for the model:

require 'test_helper'

class UploadTest < ActiveSupport::TestCase

  def assert_processed(filename, style)
    path = @upload.cargo.path(style)
    assert_match Regexp.new(Regexp.escape(filename) + '$'), path
    assert File.exist?(path), "#{style} not processed"
  end

  def assert_not_processed(filename, style)
    path = @upload.cargo.path(style)
    assert_match Regexp.new(Regexp.escape(filename) + '$'), path
    assert_not File.exist?(@upload.cargo.path(style)), "#{style} unduly processed"
  end

  setup do
    @upload = uploads(:one)
  end

  teardown do
    @upload.cargo.clear
  end

  test 'any image file is attached and thumbnailed' do
    assert_nil @upload.cargo.path(:original)
    assert_nil @upload.cargo.path(:thumb)
    assert_nil @upload.cargo.path(:pinky)

    attachment = File.new("#{Rails.root}/test/fixtures/files/portrait.jpg")
    result = @upload.update_attributes!(:cargo => attachment)

    assert_processed 'portrait.jpg', :original
    assert_processed 'portrait.jpg', :thumb
    assert_processed 'portrait.jpg', :pinky
  end

  test 'any non-image file is attached but not thumbnailed' do
    assert_nil @upload.cargo.path(:original)

    attachment = File.new("#{Rails.root}/test/fixtures/files/document.rtf")
    @upload.update_attributes!(:cargo => attachment)

    assert_processed     'document.rtf', :original
    assert_not_processed 'document.rtf', :thumb
    assert_not_processed 'document.rtf', :pinky
  end

  test 'any attached file is 0 to 5 megabytes' do
    assert_nil @upload.cargo.path(:original)

    # (system console)$ touch /test/fixtures/files/empty
    attachment = File.new("#{Rails.root}/test/fixtures/files/empty")
    @upload.update_attributes!(:cargo => attachment)
    assert_processed 'empty', :original

    # (rails console)> File.open('5-megabytes', 'w') {|f| f.write('$' * 5.megabytes) }
    attachment = File.new("#{Rails.root}/test/fixtures/files/5-megabytes")
    @upload.update_attributes!(:cargo => attachment)
    assert_processed '5-megabytes', :original

    # (rails console)> File.open('5-megabytes-plus-1', 'w') {|f| f.write('$' * (5.megabytes + 1)) }
    attachment = File.new("#{Rails.root}/test/fixtures/files/5-megabytes-plus-1")
    @upload.update_attributes(:cargo => attachment)  # we don't raise a validaton exception here, so no "!"
    assert_not_processed '5-megabytes-plus-1', :original
  end

end

Tests for the controller:

require 'test_helper'

class UploadsControllerTest < ActionController::TestCase
  setup do
    @upload = uploads(:one)
  end

  test "should get index" do
    get :index
    assert_response :success
    assert_not_nil assigns(:uploads)
  end

  test "should get new" do
    get :new
    assert_response :success
  end

  test "should create upload with an image" do
    assert_difference('Upload.count') do
      uploaded = fixture_file_upload('files/portrait.jpg', 'image/jpeg')
      post :create, upload: { name: @upload.name, notes: @upload.notes, owner_id: @upload.owner_id, cargo: uploaded }
    end

    assert_response :success
  end

  test "should create upload with a non-image" do
    assert_difference('Upload.count') do
      uploaded = fixture_file_upload('files/document.rtf', 'application/rtf')
      post :create, upload: { name: @upload.name, notes: @upload.notes, owner_id: @upload.owner_id, cargo: uploaded }
    end

    assert_redirected_to upload_path(assigns(:upload))
  end

  test "should show upload" do
    get :show, id: @upload
    assert_response :success
  end

  test "should get edit" do
    get :edit, id: @upload
    assert_response :success
  end

  test "should update upload" do
    uploaded = fixture_file_upload('files/portrait.jpg', 'image/jpeg')
    patch :update, id: @upload, upload: { name: 'test', notes: 'test', owner_id: @upload.owner_id, cargo: uploaded }
    assert_redirected_to upload_path(assigns(:upload))
  end

  test "should destroy upload" do
    assert_difference('Upload.count', -1) do
      delete :destroy, id: @upload
    end

    assert_redirected_to uploads_path
  end
end

 

How to allow users to crop images in Rails 4

For cropping images I use Jcrop, a plugin for jQuery. It works pretty well and there are already some tutorials for integrating it into a Rails app. So this post is almost a simple update to what’s already available in the internet. What follows is just my setup.

1: create an Upload model, i.e. a class for holding uploaded files, not only images. This is fairly straightforward.

class CreateUploads < ActiveRecord::Migration
  def change
    create_table :uploads do |t|
      t.string :name
      t.text :notes
      t.integer :owner_id

      t.timestamps
    end
  end
end

class Upload < ActiveRecord::Base

  public

  belongs_to :owner, class_name: User

  validates :owner, presence: true

  
  
  before_validation :clean_up

  def clean_up
    self.name  = self.name.strip   unless self.name.blank?
    self.notes = self.notes.strip  unless self.notes.blank?
  end

end

2: attach Paperclip to the Upload model.

gem 'paperclip', '~> 3.0'
=begin
$ bundle install
  Installing climate_control (0.0.3)
  Installing cocaine (0.5.1)
  Installing paperclip (3.5.0)

=end

class AddAttachmentCargoToUploads < ActiveRecord::Migration
  def self.up
    change_table :uploads do |t|
      t.attachment :cargo
    end
  end

  def self.down
    drop_attached_file :uploads, :cargo
  end
end

Note that in my setup, contrary to what’s generally advised, I’m trashing the really original image in favor of a (small and) predictable version of it. (width, height and weight)

class Upload < ActiveRecord::Base

  public

  has_attached_file :cargo,
                    styles:      lambda{ |attachment| attachment.instance.paperclip_styles },
                    default_url: lambda{ |attachment| attachment.instance.paperclip_default_url }

  validates_attachment :cargo, presence: true, size: { in:  0..5.megabytes }

  IMAGE_ORIGINAL_WIDTH  = 640  # 16 * 40
  IMAGE_ORIGINAL_HEIGHT = 480  # 16 * 30
  IMAGE_THUMB_SIDE = 144       # 16 * 9
  IMAGE_PINKY_SIDE = 48        # 16 * 3

  def paperclip_styles
    if image?
      {
          original: {
              geometry:        "#{IMAGE_ORIGINAL_WIDTH}x#{IMAGE_ORIGINAL_HEIGHT}>",
              format:          'jpg',
              convert_options: %w(-strip)
          },
          thumb: {
              geometry:        "#{IMAGE_THUMB_SIDE}x#{IMAGE_THUMB_SIDE}#",
              format:          'jpg',
              convert_options: %w(-strip -quality 75),
              processors:      [:manual_thumbnail]
          },
          pinky: {
              geometry:        "#{IMAGE_PINKY_SIDE}x#{IMAGE_PINKY_SIDE}#",
              format:          'jpg',
              convert_options: %w(-strip -quality 75),
              processors:      [:manual_thumbnail]
          },
      }
    else
      {}
    end
  end

  def paperclip_default_url
    if image?
      'picture_:style.png'
    else
      'document.png'
    end
  end

  before_cargo_post_process :process_if_image

  def image?
    cargo_content_type =~ %r{^image/}
  end

  private

  def process_if_image
    false unless image?
  end

end

3: add exif data support

gem 'exifr', '~> 1.1.3'
=begin
$ bundle install
  Installing exifr (1.1.3)

=end

class AddExifDataToUploads < ActiveRecord::Migration
  def change
    add_column :uploads, :exif_data, :text
  end
end

class Upload < ActiveRecord::Base

  public

  serialize :exif_data
  before_cargo_post_process :import_exif_data

  def image_jpeg?
    cargo_content_type =~ %r{/(jpeg|jpg|pjpeg)$}
  end

  def image_tiff?
    cargo_content_type =~ %r{/tiff$}
  end

  private

  def import_exif_data
    return unless image_jpeg? or image_tiff?  # skip unless it's an image with exif data
    return if self.exif_data                  # skip if it was previously imported
    original = cargo.queued_for_write[:original]
    exif_data = (image_jpeg? ? EXIFR::JPEG : EXIFR::TIFF).new(original.path)
    self.exif_data = if exif_data.exif?
                       {
                           width:         exif_data.width,                            # => 2272
                           height:        exif_data.height,                           # => 1704
                           model:         exif_data.model,                            # => "Canon PowerShot G3"
                           date_time:     exif_data.date_time,                        # => Fri Feb 09 16:48:54 +0100 2007
                           exposure_time: exif_data.exposure_time,                    # => 1/15
                           f_number:      exif_data.f_number,                         # => 29/5
                           latitude:      (exif_data.gps.latitude if exif_data.gps),  # => 52.7197888888889
                           longitude:     (exif_data.gps.longitude if exif_data.gps), # => 5.28397777777778
                       }
                     else
                       {} # this is needed to return ASAP on update even when no exif_data was found
                     end
  end

end

4: add cropping support

Jcrop must be installed in Rails.

  1. I added a /vendor/assets/jquery-jcrop/0.9.2 directory with all of its files in it.
  2. I copied js/jquery-Jcrop.js, css/jquery-Jcrop.css and Jcrop.gif into the /vendor/assets/jquery-jcrop directory.
  3. I added a = require jquery.Jcrop line into both the /app/assets/javascripts/application.js and /app/assets/stylesheets/application.css manifests.

module Paperclip

  class ManualThumbnail < Thumbnail

    def transformation_command
      result = super

      crop_arg = @attachment.instance.crop_arg
      return result unless crop_arg

      crop_index = result.index('-crop')
      result.slice!(crop_index, 2) if crop_index

      resize_index = result.index('-resize')
      result.insert(resize_index, '-crop', crop_arg)

      result
    end

  end

end

class AddCropArgToUploads < ActiveRecord::Migration
  def change
    add_column :uploads, :crop_arg, :string
  end
end

class Upload < ActiveRecord::Base

  public

  # the user sets crop_* attributes when editing an image
  attr_reader :crop_x, :crop_y, :crop_w, :crop_h
  def crop_x=(value); @crop_x = value.to_i end
  def crop_y=(value); @crop_y = value.to_i end
  def crop_w=(value); @crop_w = value.to_i end
  def crop_h=(value); @crop_h = value.to_i end

  def cropping?
    raise 'Expected an image.' unless image?
    crop_x && crop_y && crop_w && crop_h && crop_w > 0 && crop_h > 0
  end

  def geometry(style = :original)
    raise 'Expected an image.' unless image?
    @geometry ||= {}
    @geometry[style] ||= Paperclip::Geometry.from_file(cargo.path(style)) rescue Paperclip::Geometry.new(0, 0)
  end

  # this crops images when updating
  def crop
    raise 'Expected an image.' unless image?
    self.crop_arg = crop_arg_join(crop_x, crop_y, crop_w, crop_h) if cropping?
    self.cargo.reprocess!
  end
  
  def crop_arg_join(x, y, w, h)
    raise 'Expected an image.' unless image?
    "#{crop_w}x#{crop_h}+#{crop_x}+#{crop_y}"
  end
  
  def crop_arg_split(arg)
    raise 'Expected an image.' unless image?
    match = arg.match(/(\d+)x(\d+)\+(\d+)\+(\d+)/i)
    raise 'Expected a valid crop arg.' unless match
    w, h, x, y = match.captures
    [x.to_i, y.to_i, w.to_i, h.to_i]
  end

  def crop_select
    raise 'Expected an image.' unless image?
    default_size = ([geometry.width, geometry.height].min) / 5
    default_tl_x  = (geometry.width - default_size) / 2
    default_tl_y  = (geometry.height - default_size) / 2
    default = [default_tl_x, default_tl_y, default_size, default_size]
    result = if crop_arg
              crop_arg_split(crop_arg)
            else
              default
            end rescue default
    result
  end

end

5: modify the generated controller such that

  1. all necessary params are whitelisted
  2. upon image creation the user is given a chance to crop it
  3. upon image update it gets cropped

class UploadsController < ApplicationController

  #...

  # POST /uploads
  # POST /uploads.json
  def create
    @upload = Upload.new(upload_params)

    respond_to do |format|
      if @upload.save
        format.html { @upload.image? ? render(action: 'edit') : redirect_to(@upload) }
        format.json { render action: 'show', status: :created, location: @upload }
      else
        format.html { render action: 'new' }
        format.json { render json: @upload.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /uploads/1
  # PATCH/PUT /uploads/1.json
  def update
    respond_to do |format|
      if @upload.update(upload_params)

        # avoid infinite recursion, see # https://github.com/thoughtbot/paperclip/issues/866#issuecomment-8249786
        @upload.crop if @upload.cropping?

        format.html { redirect_to @upload, notice: 'Upload was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: 'edit' }
        format.json { render json: @upload.errors, status: :unprocessable_entity }
      end
    end
  end

  #...

  private
    #...

    # Never trust parameters from the scary internet, only allow the white list through.
    def upload_params
      params.require(:upload).permit(:name, :notes, :owner_id, :cargo, :crop_x, :crop_y, :crop_w, :crop_h)
    end
end

6: modify the form helper

<%= form_for(@upload) do |f| %>
    <% if @upload.errors.any? %>
        <div id="error_explanation">
          <h2><%= pluralize(@upload.errors.count, "error") %> prohibited this upload from being saved:</h2>

          <ul>
            <% @upload.errors.full_messages.each do |msg| %>
                <li><%= msg %></li>
            <% end %>
          </ul>
        </div>
    <% end %>

    <div class="field">
      <%= f.label :name %><br>
      <%= f.text_field :name %>
    </div>
    <div class="field">
      <%= f.label :notes %><br>
      <%= f.text_area :notes %>
    </div>
    <div class="field">
      <%= f.label :owner_id %><br>
      <%= f.number_field :owner_id %>
    </div>


    <% if @upload.new_record? %>

        <%= f.file_field :cargo %>

    <% elsif @upload.image? and File.exist?(@upload.cargo.path)
         x, y, w, h = @upload.crop_select
         select = [x, y, x + w, y + h].to_s
    %>

        <script type="text/javascript" charset="utf-8">
            $(function() {
                $('#cropbox').Jcrop({
                    onChange: update_crop,
                    onSelect: update_crop,
                    setSelect: <%= select.html_safe %>,
                    aspectRatio: 1
                });
            });

            function update_crop(coords) {
                var rx = 100/coords.w;
                var ry = 100/coords.h;
                $('#preview').css({
                    width: Math.round(rx * <%= @upload.geometry.width %>) + 'px',
                    height: Math.round(ry * <%= @upload.geometry.height %>) + 'px',
                    marginLeft: '-' + Math.round(rx * coords.x) + 'px',
                    marginTop: '-' + Math.round(ry * coords.y) + 'px'
                });
                $("#crop_x").val(coords.x);
                $("#crop_y").val(coords.y);
                $("#crop_w").val(coords.w);
                $("#crop_h").val(coords.h);
            }
        </script>

        <table>
          <tr>
            <td>
                <%= image_tag @upload.cargo.url, :id => "cropbox" %>
            </td>
            <td>
                <div style="width:100px; height:100px; overflow:hidden">
                  <%= image_tag @upload.cargo.url, :id => "preview" %>
                </div>
            </td>
          </tr>
        </table>

        <% for attribute in [:crop_x, :crop_y, :crop_w, :crop_h] %>
            <%= f.hidden_field attribute, :id => attribute %>
        <% end %>

    <% else %>

        <%= @upload.cargo_file_name %>

    <% end %>



    <div class="actions">
      <%= f.submit %>
    </div>
<% end %>

7: modify the show template

<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @upload.name %>
</p>

<p>
  <strong>Notes:</strong>
  <%= @upload.notes %>
</p>

<p>
  <strong>Owner:</strong>
  <%= @upload.owner_id %>
</p>

<p>
<% if @upload.image? and File.exist?(@upload.cargo.path) %>
    <%= image_tag @upload.cargo.url %>
    <%= image_tag @upload.cargo.url(:thumb) %>
    <%= image_tag @upload.cargo.url(:pinky) %>
<% else %>
    <%= @upload.cargo_file_name %>
<% end %>
</p>

<%= link_to 'Edit', edit_upload_path(@upload) %> |
<%= link_to 'Back', uploads_path %>

How to manage users in Rails 4 (using devise)

Devise is currently the most used gem for authentication in Rails, and version 3.0.0 is compatible with Rails 4. For this, and for being powerful and configurable and staying out of the way (at least initially), I’ve chosen devise for my current project.

To generate a User model I left it to devise itself by issuing

$ rails generate devise MODEL
$ rake db:migrate

Al went fine, but when I wanted to be able to also do some CRUD myself on User models, I issued

$ rails generate scaffold_controller User –skip

At this point I had to fix the form helper and a couple of views, for adding email, password and password_confirmation fields. Then the controller too needed some adjustments that were a little more difficult go get properly, but the model was already fine.

The problem with the controller was changing the update action so that I’d be able to edit the email and/or the password independently.

Here is my new update

  # PATCH/PUT /users/1
  # PATCH/PUT /users/1.json
  def update
    if user_params[:password].blank?
      user_params.delete(:password)
      user_params.delete(:password_confirmation)
    end

    # https://github.com/plataformatec/devise/wiki/How-To%3a-Allow-users-to-edit-their-account-without-providing-a-password
    successfully_updated = if needs_password?(@user, user_params)
                             @user.update(user_params)
                           else
                             @user.update_without_password(user_params)
                           end

    respond_to do |format|
      if successfully_updated
        format.html { redirect_to @user, notice: 'User was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: 'edit' }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

And here is the private defs for user_params and needs_password?

  # Never trust parameters from the scary internet, only allow the white list through.
  def user_params
    params.require(:user).permit(:email, :password, :password_confirmation)
  end

  # https://github.com/plataformatec/devise/wiki/How-To%3a-Allow-users-to-edit-their-account-without-providing-a-password
  def needs_password?(user, params)
    params[:password].present?
  end

So the difficult part here was that you have to use the normal update method when you also want to change the password and the special update_without_password method (provided by devise) when you don’t want to change the password.

Devise also provides an update_with_password method, but that’s misleading because it requires the current_password field, only useful when you want the user herself to be able to edit her data. It should be renamed to update_with_current_password…

 

How to validate dates in Rails 4

During the past few days I’ve been busy looking for existing gems doing date validation in Rails 4. I’ve tried a couple of the most famous, but they were compatible with Rails only up to 3.2, so I decided to write a solution myself.

It works fine for me. I’m not going to convert it to a gem because I don’t have time to learn how to right now. If you want to help, you’re welcome.

Docs

It’s all very straightforward.

  • The validator symbol is :date.
  • The validator really does nothing if you don’t provide any options…
  • Supported options are :after, :before, :on_or_after, and :on_or_before, plus :message.
  • If the message is not provided a fine default one is used instead.
  • Values of supported options can be date-castable objects, lambdas, or symbols.
  • Symbols must be method names of the validating object or its class.
  • Values are computed (if needed) and converted to date on each validation.

Code

Here is my DateValidator class, to be put into the app/validators folder.

class DateValidator < ActiveModel::EachValidator

  attr_accessor :computed_options

  def before(a, b);       a < b;  end
  def after(a, b);        a > b;  end
  def on_or_before(a, b); a <= b; end
  def on_or_after(a, b);  a >= b; end

  def checks
    %w(before after on_or_before on_or_after)
  end

  def message_limits
    needs_and =
        (computed_options[:on_or_after]  || computed_options[:after]) &&
        (computed_options[:on_or_before] || computed_options[:before])

    result = ['must be a date']
    result.push('on or after',  computed_options[:on_or_after])  if computed_options[:on_or_after]
    result.push('after',        computed_options[:after])        if computed_options[:after]
    result.push('and')                                           if needs_and
    result.push('on or before', computed_options[:on_or_before]) if computed_options[:on_or_before]
    result.push('before',       computed_options[:before])       if computed_options[:before]
    result.join(' ')
  end

  def compute_options(record)
    result = {}
    options.each do |key, val|
      next unless checks.include?(key.to_s)
      if val.respond_to?(:lambda?) and val.lambda?
        val = val.call
      elsif val.is_a? Symbol
        if record.respond_to?(val)
          val = record.send(val)
        elsif record.class.respond_to?(val)
          val = record.class.send(val)
        end
      end
      result[key] = val.to_date
    end
    self.computed_options = result
  end

  def validate_each(record, attribute, value)
    return unless value.present?

    return unless options
    compute_options(record) # do not cache this
                            # otherwise all the 'compute' thing is useless... #
    computed_options.each do |key, val|
      unless self.send(key, value, val)
        record.errors[attribute] << (computed_options[:message] || message_limits)
        return
      end
    end
  end
end

Here is an example of how to use it into a model.

class UserProfile < ActiveRecord::Base

  validates :name,       presence: true
  validates :birth_date, presence: true, date: {on_or_after: :birth_date_first, on_or_before: :birth_date_last}

  def self.birth_date_first
    118.years.ago
  end

  def self.birth_date_last
    18.years.ago
  end

end

Here is the form helper snippet (only what differs from scaffolding).

  <div class="field">
    <%= f.label :birth_date %><br>
    <%= f.date_field :birth_date, min: UserProfile.birth_date_first, max: UserProfile.birth_date_last %>
  </div>

Here is the controller snippet (only what differs from scaffolding).

  # GET /user_profiles/new
  def new
    @user_profile = UserProfile.new
    @user_profile.birth_date = 25.years.ago # this is the default value for new records
                                            # I tried to use the :value option in the date_field helper
                                            # but it overrides the value in the record also when editing... #
  end

 

English (and not just the Library of Congress)

I’m fed by those ridiculous requirements in job offers:

  • Javascript (and not just jQuery)
  • Ruby (and not just Rails)
  • PHP (and not just Zend Framework),

What do they want?

First of all, jQuery, Rails, and Zend Framework are libraries written in JavaScript, Ruby and PHP respectively. So, of course, knowledge of the latter implies that of the former, because you can’t really do anything with a library if you don’t know the language it’s written in.

Then, if you want me to program an application in any given language without using libraries available at my fingertips for that language, then you’re just a fool. Do you really want me to give up on years of worldwide engineering experience advising about code reuse? What is it open source to you?

 

© 2017 Notes Log

Theme by Anders NorenUp ↑