diff --git a/Dockerfile b/Dockerfile index a6ae167..0f038ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ 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 gcompat python2" +ARG PACKAGES="vim openssl-dev postgresql-dev build-base curl nodejs yarn less tzdata git postgresql-client bash screen gcompat python2 imagemagick" RUN apk update \ - && apk upgrade \ - && apk add --update --no-cache $PACKAGES + && apk upgrade \ + && apk add --update --no-cache $PACKAGES RUN gem install bundler:2.1.4 diff --git a/Gemfile b/Gemfile index 1bb5d3e..d43103d 100644 --- a/Gemfile +++ b/Gemfile @@ -27,8 +27,10 @@ gem 'bcrypt', '~> 3.1.7' gem 'bootsnap', '>= 1.4.2', require: false gem 'active_model_serializers' +gem 'file_validators' gem 'js-routes' gem 'kaminari' +gem 'mini_magick' gem 'ransack' gem 'responders' gem 'sidekiq', '~> 6.5.0' @@ -38,6 +40,7 @@ gem 'sidekiq-unique-jobs' gem 'simple_form' gem 'slim-rails' gem 'state_machines-activerecord' +gem 'virtus' gem 'webpacker-react' group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index c3a088f..c0bc3df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -64,6 +64,10 @@ GEM addressable (2.8.1) public_suffix (>= 2.0.2, < 6.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.18) bindex (0.8.1) bootsnap (1.15.0) @@ -85,9 +89,13 @@ GEM case_transform (0.2) activesupport childprocess (4.1.0) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.10) connection_pool (2.3.0) crass (1.0.6) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) docile (1.4.0) erubi (1.11.0) factory_bot (6.2.1) @@ -96,10 +104,14 @@ GEM factory_bot (~> 6.2.0) railties (>= 5.0.0) ffi (1.15.5) + file_validators (3.0.0) + activemodel (>= 3.2) + mime-types (>= 1.0) globalid (1.0.0) activesupport (>= 5.0) i18n (1.12.0) concurrent-ruby (~> 1.0) + ice_nine (0.11.2) jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -139,6 +151,10 @@ GEM marcel (1.0.2) matrix (0.4.2) method_source (1.0.0) + mime-types (3.4.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2022.0105) + mini_magick (4.12.0) mini_mime (1.1.2) mini_portile2 (2.8.0) minitest (5.16.3) @@ -284,6 +300,10 @@ GEM tzinfo (1.2.10) thread_safe (~> 0.1) unicode-display_width (2.3.0) + virtus (2.0.0) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) web-console (4.2.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -317,12 +337,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 pg (>= 0.18, < 2.0) puma (~> 4.1) rails (~> 6.0.3, >= 6.0.3.3) @@ -339,6 +361,7 @@ DEPENDENCIES simplecov slim-rails state_machines-activerecord + 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 fecd8e2..cd791c9 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -7,6 +7,7 @@ def show def index tasks = Task.all + .with_attached_image .ransack(ransack_params) .result .page(page) @@ -37,9 +38,40 @@ def destroy respond_with(task) end + def attach_image + task = Task.find(params[:id]) + attachment = params[:attachment] + attachment_params = { + image: attachment[:image], + crop_x: attachment[:crop_x], + crop_y: attachment[:crop_y], + crop_width: attachment[:crop_width], + crop_height: attachment[:crop_height] + } + 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(:id, :name, :description, :author_id, :assignee_id, :state_event, :expired_at) + params.require(:task).permit(:id, :name, :description, :author_id, :assignee_id, :state_event, :expired_at, + :attachment) 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 c11d709..00bd51c 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,6 +1,7 @@ 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 diff --git a/app/serializers/task_serializer.rb b/app/serializers/task_serializer.rb index cab79e7..4e80cb4 100644 --- a/app/serializers/task_serializer.rb +++ b/app/serializers/task_serializer.rb @@ -1,5 +1,5 @@ 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 @@ -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/attachment_service.rb b/app/services/attachment_service.rb new file mode 100644 index 0000000..115492c --- /dev/null +++ b/app/services/attachment_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/credentials.yml.enc b/config/credentials.yml.enc index 9dc89ec..1746f74 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -KKKUi8kqBT8qSJ39419AA3r2cQyQGUZLoJL83tOmIjGs5rfE+eNtghuEkO1s9sJ7wzW3mjdydHuRmcfE1Ic+1eidQZ4H/dwfb3EPJ6Ud8nO4Tn1EqAGrkCcZWEgTPRjIlVs+kmhq4VQcwkVHN0bfGXDoTKP5qCiaqdgjOPfnklaJ2jxeUfq1OoXQbwGgrVYB45b+r5RWOOJT+QwG2ddc1cJ7BzcFgipe9/bmVYdw84o4M5EQPr1FBc3eeqjPkMm0/f79R7iSg8F/ZlwUEBBGInrpGVJRffNJ+JDVDKrEXldf4+Lzhd2Hicvy/8cm6IGBEH/j96ksFw+Nv/hYSZ7bfTp046Pu2eU/Z4d0cz8VPGTC756NYecZaaesyHvLnbhBYWepDW8zASbdRMqcrv5zcoQ/Xhl07piPm1WK--eY0CL0IB0ObUsd/B--r2bkypAoIvjzNdkCloiTkQ== \ No newline at end of file +B8MkR7d7vVYwQr6sXYNpq5WLcGREgNVIWI2EjR7Sz9xV6Q04s+v/GQZiKfWgOQuFFOuInVQi94I2c/NV35s+9LIbQqt1gEaeZS8JaP6//RrKf2FJXzIs2pxuYEU/G2nkPTkQnHWaBz1ZJPJImq209bCa7wu57p6mrY+zk4wjn/C89eKiGgtFaw1QAYbWSJe6cNHCQEeAsNZR9whIi73eaPdDAIeLU4FFmGo8zsS087H462xPUm+UWCYMY62oWDEAhN9h2yoksj6DtVeQAZwbyclxK9Fo42wFR0V/b1PXaL++Farjr4Pwhhdf7pkXxLAWYmYCiWbQi81catnbYtXu4oLjml++JjPGajvov/k5ceqnq3muHSTk68IALIKhiwCRWs6mugfCU7m+sRaEMhPRwdLPT7ZAQZFzAeaO--3Xv+VSAo81kv6uFz--Gr4WW6P2xyb2D/WnxKmlyw== \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 31c23ab..da97e10 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,12 @@ namespace :api do namespace :v1, defaults: { format: 'json' } do - resources :tasks, only: %i[index show create update destroy] + resources :tasks, only: %i[index show create update destroy] do + member do + put :attach_image + delete :remove_image + end + end resources :users, only: %i[index show] end end diff --git a/db/migrate/20230112070536_create_active_storage_tables.active_storage.rb b/db/migrate/20230112070536_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..0b2ce25 --- /dev/null +++ b/db/migrate/20230112070536_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 2d85862..7d29041 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: 2023_01_05_072548) do +ActiveRecord::Schema.define(version: 2023_01_12_070536) 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" @@ -38,4 +59,5 @@ t.string "reset_password_token_digest" 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 d3a2e08..8331f4f 100644 --- a/test/controllers/api/v1/tasks_controller_test.rb +++ b/test/controllers/api/v1/tasks_controller_test.rb @@ -64,4 +64,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?) + + put :remove_image, params: { id: task.id, format: :json } + assert_response :success + 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..8c4c558 Binary files /dev/null and b/test/fixtures/files/image.jpg differ