diff --git a/Dockerfile b/Dockerfile index d24dd00..cef9c84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM ruby:2.7.1-alpine ARG RAILS_ROOT=/task_manager -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 3b9bf4d..493ad50 100644 --- a/Gemfile +++ b/Gemfile @@ -77,3 +77,6 @@ gem 'sidekiq' gem 'sidekiq-failures' gem 'sidekiq-throttled' gem 'sidekiq-unique-jobs' +gem 'mini_magick' +gem 'virtus' +gem 'file_validators' diff --git a/Gemfile.lock b/Gemfile.lock index f35b45e..5b05d97 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,6 +77,10 @@ GEM addressable (2.8.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.9.1) @@ -101,6 +105,8 @@ GEM case_transform (0.2) activesupport childprocess (4.1.0) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.9) connection_pool (2.2.5) coveralls (0.7.1) @@ -110,6 +116,8 @@ GEM term-ansicolor thor crass (1.0.6) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -120,6 +128,9 @@ GEM factory_bot (~> 6.2.0) railties (>= 5.0.0) ffi (1.15.4) + file_validators (3.0.0) + activemodel (>= 3.2) + mime-types (>= 1.0) globalid (0.5.2) activesupport (>= 5.0) http-accept (1.7.0) @@ -127,6 +138,7 @@ GEM domain_name (~> 0.5) i18n (1.8.11) concurrent-ruby (~> 1.0) + ice_nine (0.11.2) jbuilder (2.11.2) activesupport (>= 5.0.0) js-routes (2.1.2) @@ -167,6 +179,7 @@ GEM mime-types (3.4.1) mime-types-data (~> 3.2015) mime-types-data (3.2021.1115) + mini_magick (4.11.0) mini_mime (1.1.2) mini_portile2 (2.6.1) minitest (5.14.4) @@ -316,6 +329,7 @@ GEM term-ansicolor (1.7.1) tins (~> 1.0) thor (1.1.0) + thread_safe (0.3.6) tilt (2.0.10) tins (1.29.1) sync @@ -326,6 +340,10 @@ GEM unf_ext (0.0.8) unicode-display_width (2.1.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.1.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -361,12 +379,14 @@ DEPENDENCIES capybara (>= 3.26) coveralls factory_bot_rails + file_validators jbuilder (~> 2.7) js-routes kaminari letter_opener letter_opener_web listen (~> 3.3) + mini_magick newrelic_rpm pg (~> 1.1) puma (~> 5.0) @@ -389,6 +409,7 @@ DEPENDENCIES state_machines state_machines-activerecord tzinfo-data + virtus web-console (>= 4.1.0) webdrivers webpacker (~> 5.0) diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index 8d1048e..7d3c68d 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -1,10 +1,11 @@ class Api::V1::TasksController < Api::V1::ApplicationController def index - tasks = Task.all. + tasks = Task. ransack(ransack_params). result. page(page). - per(per_page) + per(per_page). + with_attached_image respond_with(tasks, each_serializer: TaskSerializer, root: 'items', meta: build_meta(tasks)) end @@ -45,8 +46,34 @@ 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 attachment_params + params.require(:attachment).permit(:image, :crop_width, :crop_height, :crop_x, :crop_y) + end + def task_params params.require(:task).permit(:name, :description, :author_id, :assignee_id, :expired_at, :state_event) 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 ece0286..8274f7e 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -2,6 +2,8 @@ class Task < ApplicationRecord belongs_to :author, class_name: 'User' belongs_to :assignee, class_name: 'User', optional: true + has_one_attached :image + validates :name, presence: true validates :description, presence: true validates :author, presence: true diff --git a/app/serializers/task_serializer.rb b/app/serializers/task_serializer.rb index 93f52c5..dae040d 100644 --- a/app/serializers/task_serializer.rb +++ b/app/serializers/task_serializer.rb @@ -1,8 +1,12 @@ class TaskSerializer < ApplicationSerializer - attributes :id, :name, :description, :state, :expired_at, :transitions + attributes :id, :name, :description, :state, :expired_at, :transitions, :image_url belongs_to :author belongs_to :assignee + def image_url + object.image.attached? ? AttachmentsService.file_url(object.image) : nil + end + def transitions object.state_transitions.map do |transition| { diff --git a/app/services/attachments_service.rb b/app/services/attachments_service.rb new file mode 100644 index 0000000..115492c --- /dev/null +++ b/app/services/attachments_service.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 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/environments/development.rb b/config/environments/development.rb index b2033dd..9b0ad98 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,6 +1,7 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + routes.default_url_options[:host] = 'localhost:3000' # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded any time diff --git a/config/environments/test.rb b/config/environments/test.rb index aec0a53..410d2f4 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -6,8 +6,8 @@ # and recreated between test runs. Don't rely on the data there! Rails.application.configure do + routes.default_url_options[:host] = 'localhost:3000' # Settings specified here will take precedence over those in config/application.rb. - config.cache_classes = true # Do not eager load code on boot. This avoids loading your whole application diff --git a/config/routes.rb b/config/routes.rb index 299849a..80f0e48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,7 +10,12 @@ 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' + put 'remove_image' + end + end resources :users, only: [:index, :show] end end diff --git a/db/migrate/20220126155632_create_active_storage_tables.active_storage.rb b/db/migrate/20220126155632_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..8779826 --- /dev/null +++ b/db/migrate/20220126155632_create_active_storage_tables.active_storage.rb @@ -0,0 +1,36 @@ +# 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.string :service_name, null: false + 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 + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index %i[ blob_id variation_digest ], name: "index_active_storage_variant_records_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 8fbaa72..0cb3b5a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,39 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_01_16_111008) do +ActiveRecord::Schema.define(version: 2022_01_26_155632) 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.string "service_name", null: false + 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 "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "tasks", force: :cascade do |t| t.string "name" t.text "description" @@ -39,4 +67,6 @@ t.datetime "reset_sent_at" end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "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 19cf4f7..7432b43 100644 --- a/test/controllers/api/v1/tasks_controller_test.rb +++ b/test/controllers/api/v1/tasks_controller_test.rb @@ -58,4 +58,56 @@ 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') + 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 } + + 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 100644 index 0000000..88e92b1 Binary files /dev/null and b/test/fixtures/files/image.jpg differ