diff --git a/Dockerfile b/Dockerfile index 8d2faae..30c5c7c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM ruby:2.7.1-alpine ARG RAILS_ROOT=/app -ARG PACKAGES="vim openssl-dev postgresql-dev build-base curl nodejs yarn less tzdata git postgresql-client bash screen" +ARG PACKAGES="vim openssl-dev postgresql-dev build-base curl nodejs yarn less tzdata git postgresql-client bash screen imagemagick" RUN apk update \ && apk upgrade \ diff --git a/Gemfile b/Gemfile index f5631d3..0b78c57 100644 --- a/Gemfile +++ b/Gemfile @@ -72,3 +72,6 @@ gem 'sidekiq' gem 'sidekiq-failures' gem 'sidekiq-throttled' gem 'sidekiq-unique-jobs', '~> 6.0.13' +gem 'mini_magick' +gem 'virtus' +gem 'file_validators' diff --git a/Gemfile.lock b/Gemfile.lock index f3d1874..f74d9cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,6 +73,10 @@ GEM addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) ast (2.4.2) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) bcrypt (3.1.16) bindex (0.8.1) bootsnap (1.4.8) @@ -93,9 +97,13 @@ GEM case_transform (0.2) activesupport childprocess (3.0.0) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.9) connection_pool (2.2.5) crass (1.0.6) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) docile (1.4.0) erubi (1.10.0) factory_bot (6.1.0) @@ -104,10 +112,14 @@ GEM factory_bot (~> 6.1.0) railties (>= 5.0.0) ffi (1.13.1) + file_validators (3.0.0) + activemodel (>= 3.2) + mime-types (>= 1.0) globalid (0.4.2) activesupport (>= 4.2.0) i18n (1.8.10) concurrent-ruby (~> 1.0) + ice_nine (0.11.2) jbuilder (2.10.1) activesupport (>= 5.0.0) js-routes (2.0.4) @@ -143,6 +155,10 @@ GEM mini_mime (>= 0.1.1) marcel (1.0.1) method_source (1.0.0) + mime-types (3.3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2021.0225) + mini_magick (4.11.0) mini_mime (1.1.0) mini_portile2 (2.5.3) minitest (5.14.4) @@ -282,6 +298,10 @@ GEM thread_safe (~> 0.1) unicode-display_width (2.0.0) uniform_notifier (1.14.2) + virtus (2.0.0) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) web-console (4.0.4) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -315,12 +335,14 @@ DEPENDENCIES byebug capybara (>= 2.15) factory_bot_rails + file_validators jbuilder (~> 2.7) js-routes kaminari letter_opener letter_opener_web listen (~> 3.2) + mini_magick newrelic_rpm pg (>= 0.18, < 2.0) puma (~> 4.1) @@ -341,6 +363,7 @@ DEPENDENCIES state_machines state_machines-activerecord tzinfo-data + virtus web-console (>= 3.3.0) webdrivers webpacker (~> 4.0) diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index 9239673..9705bbe 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -1,6 +1,6 @@ class Api::V1::TasksController < Api::V1::ApplicationController def index - tasks = Task.all. + tasks = Task.with_attached_image.all. ransack(ransack_params). result. page(page). @@ -47,9 +47,35 @@ def destroy respond_with(task) end + def attach_image + task = Task.find(params[:id]) + task_attach_image_form = TaskAttachImageForm.new(attachment_params) + + if task_attach_image_form.invalid? + respond_with(task_attach_image_form) + return + end + + image = task_attach_image_form.processed_image + task.image.attach(image) + + respond_with(task, serializer: TaskSerializer) + end + + def remove_image + task = Task.find(params[:id]) + task.image.purge + + respond_with(task, serializer: TaskSerializer) + end + private def task_params params.require(:task).permit(:name, :description, :author_id, :assignee_id, :state_event) end + + def attachment_params + params.require(:attachment).permit(:image, :crop_width, :crop_height, :crop_x, :crop_y) + end end diff --git a/app/forms/task_attach_image_form.rb b/app/forms/task_attach_image_form.rb new file mode 100644 index 0000000..9455231 --- /dev/null +++ b/app/forms/task_attach_image_form.rb @@ -0,0 +1,31 @@ +class TaskAttachImageForm + include ActiveModel::Validations + include Virtus.model + + attribute :image, ActionDispatch::Http::UploadedFile + attribute :crop_width, Integer + attribute :crop_height, Integer + attribute :crop_x, Integer + attribute :crop_y, Integer + + with_options numericality: { only_integer: true, greater_than_or_equal_to: 0 } do + validates :crop_width, if: -> { crop_width.present? } + validates :crop_height, if: -> { crop_height.present? } + validates :crop_x, if: -> { crop_x.present? } + validates :crop_y, if: -> { crop_y.present? } + end + + validates :image, presence: true, + file_size: { less_than_or_equal_to: 2.megabytes }, + file_content_type: { allow: ['image/jpeg', 'image/png'] } + + def processed_image + ImageProcessingService.crop!(image.path, crop_width, crop_height, crop_x, crop_y) if cropping? + + image + end + + def cropping? + [crop_width, crop_height, crop_x, crop_y].all?(&:present?) + end +end diff --git a/app/models/task.rb b/app/models/task.rb index 28da3d7..554dfae 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,4 +1,6 @@ class Task < ApplicationRecord + has_one_attached :image + state_machine initial: :new_task do event :archive do transition [:new_task, :released] => :archived diff --git a/app/serializers/task_serializer.rb b/app/serializers/task_serializer.rb index 93f52c5..301d7ec 100644 --- a/app/serializers/task_serializer.rb +++ b/app/serializers/task_serializer.rb @@ -12,4 +12,8 @@ def transitions } end end + + def image_url + object.image.attached? ? AttachmentsService.file_url(object.image) : nil + end end diff --git a/app/services/attachments_server.rb b/app/services/attachments_server.rb new file mode 100644 index 0000000..3757686 --- /dev/null +++ b/app/services/attachments_server.rb @@ -0,0 +1,7 @@ +module AttachmentsService + class << self + def file_url(file) + Rails.application.routes.url_helpers.rails_blob_url(file) + end + end + end \ No newline at end of file diff --git a/app/services/image_processing_service.rb b/app/services/image_processing_service.rb new file mode 100644 index 0000000..05d2d47 --- /dev/null +++ b/app/services/image_processing_service.rb @@ -0,0 +1,8 @@ +module ImageProcessingService + class << self + def crop!(path_to_image, crop_width, crop_height, crop_x, crop_y) + image = MiniMagick::Image.new(path_to_image) + image.crop("#{crop_width}x#{crop_height}+#{crop_x}+#{crop_y}") + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 952cacb..31b6a0c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,11 @@ namespace :api do namespace :v1, defaults: {format: :json} do - resources :tasks, only: [:index, :show, :create, :update, :destroy] + resources :tasks, only: [:index, :show, :create, :update, :destroy] do + member do + put :attach_image, :remove_image + end + end resources :users, only: [:index, :show] end end diff --git a/db/migrate/20210619102125_create_active_storage_tables.active_storage.rb b/db/migrate/20210619102125_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..0b2ce25 --- /dev/null +++ b/db/migrate/20210619102125_create_active_storage_tables.active_storage.rb @@ -0,0 +1,27 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[5.2] + def change + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 33a64c8..7186044 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,32 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_06_15_161358) do +ActiveRecord::Schema.define(version: 2021_06_19_102125) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + create_table "tasks", force: :cascade do |t| t.string "name" t.text "description" @@ -39,4 +60,5 @@ t.datetime "password_reset_sent_at" end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" end diff --git a/test/controllers/api/v1/tasks_controller_test.rb b/test/controllers/api/v1/tasks_controller_test.rb index 7111b3a..4c05a0a 100644 --- a/test/controllers/api/v1/tasks_controller_test.rb +++ b/test/controllers/api/v1/tasks_controller_test.rb @@ -62,4 +62,59 @@ class Api::V1::TasksControllerTest < ActionController::TestCase assert !Task.where(id: task.id).exists? end + + test 'should put attach_image' do + author = create(:user) + task = create(:task, author: author) + + image = file_fixture('image.jpg') + attachment_params = { + image: fixture_file_upload(image, 'image/jpeg'), + crop_x: 190, + crop_y: 100, + crop_width: 300, + crop_height: 300, + } + + put :attach_image, params: { id: task.id, attachment: attachment_params, format: :json } + assert_response :success + + task.reload + assert task.image.attached? + end + + test 'should put remove_image' do + author = create(:user) + task = create(:task, author: author) + + image = file_fixture('image.jpg') + # attachable_image = fixture_file_upload(image) + attachment_params = { + image: fixture_file_upload(image, 'image/jpeg'), + crop_x: 190, + crop_y: 100, + crop_width: 300, + crop_height: 300, + } + + put :attach_image, params: { id: task.id, attachment: attachment_params, format: :json } + + # task.image.attach(attachable_image) + + put :remove_image, params: { id: task.id, format: :json } + assert_response :success + + task.reload + refute task.image.attached? + end + + def after_teardown + super + + remove_uploaded_files + end + + def remove_uploaded_files + FileUtils.rm_rf(ActiveStorage::Blob.service.root) + end end diff --git a/test/fixtures/files/image.jpg b/test/fixtures/files/image.jpg new file mode 100755 index 0000000..d351765 Binary files /dev/null and b/test/fixtures/files/image.jpg differ