From f5abb8c003020b94a3ed7e815e7d73374934f1f4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 1 Dec 2020 11:25:58 -0500 Subject: [PATCH 001/160] Update version number --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 912d5d2d..a0e6e767 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,5 +1,5 @@ module OpenTourApi class Application - VERSION = '3.0.1rc' + VERSION = '3.0' end end \ No newline at end of file From 74820a24e485f1e78595571368c936ce5d15a1d4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 2 Jun 2021 14:33:27 -0400 Subject: [PATCH 002/160] Add new ECDS Auth Engine, Fix Tests --- Gemfile | 3 +- Gemfile.lock | 276 ++++++++---------- .../v3/geojson_tours_controller.rb | 3 +- app/controllers/v3/modes_controller.rb | 6 +- app/controllers/v3/tour_modes_controller.rb | 8 +- app/controllers/v3/tour_stops_controller.rb | 7 +- app/controllers/v3/tours_controller.rb | 55 ++-- app/controllers/v3/users_controller.rb | 18 +- app/controllers/v3_controller.rb | 3 +- app/models/ability.rb | 4 +- app/models/stop.rb | 4 + app/models/tour.rb | 2 +- app/models/user.rb | 5 +- app/serializers/v3/stop_serializer.rb | 2 +- app/serializers/v3/tour_base_serializer.rb | 6 + app/serializers/v3/tour_mode_serializer.rb | 7 + app/serializers/v3/tour_modes_serializer.rb | 5 - app/serializers/v3/tour_serializer.rb | 4 +- app/serializers/v3/user_serializer.rb | 3 +- config/application.rb | 7 + config/initializers/apartment.rb | 2 +- config/initializers/cookie_session.rb | 3 + config/initializers/cors.rb | 5 +- config/routes.rb | 4 +- db/migrate/20210518143822_add_email.rb | 5 + db/schema.rb | 37 ++- db/seeds.rb | 5 +- spec/factories/login.rb | 15 +- spec/factories/stops.rb | 2 +- spec/factories/users.rb | 2 +- spec/requests/v3/flat_pages_spec.rb | 65 +++-- .../v3/{media_spec.rb => media_spec.rb.fix} | 0 ...p_media_spec.rb => stop_media_spec.rb.fix} | 5 +- spec/requests/v3/stops_spec.rb | 28 +- spec/requests/v3/tour_stops_spec.rb | 32 +- spec/requests/v3/tours_spec.rb | 50 ++-- spec/spec_helper.rb | 1 + spec/support/cookies.rb | 6 + 38 files changed, 391 insertions(+), 304 deletions(-) create mode 100644 app/serializers/v3/tour_base_serializer.rb create mode 100644 app/serializers/v3/tour_mode_serializer.rb delete mode 100644 app/serializers/v3/tour_modes_serializer.rb create mode 100644 config/initializers/cookie_session.rb create mode 100644 db/migrate/20210518143822_add_email.rb rename spec/requests/v3/{media_spec.rb => media_spec.rb.fix} (100%) rename spec/requests/v3/{stop_media_spec.rb => stop_media_spec.rb.fix} (97%) create mode 100644 spec/support/cookies.rb diff --git a/Gemfile b/Gemfile index 4d012e24..2e5f2ef3 100644 --- a/Gemfile +++ b/Gemfile @@ -28,7 +28,8 @@ gem "actionview", ">= 5.2.2.1" # Social Auth # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' -gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', :tag => 'v0.1.5' +# gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' +gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 diff --git a/Gemfile.lock b/Gemfile.lock index 4a680fd4..7f3d9c23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,19 +1,9 @@ -GIT - remote: https://github.com/ecds/ecds_rails_auth_engine.git - revision: 834d77e7ec3a18e21cd490b91ff99770ea0afaad - tag: v0.1.5 - specs: - ecds_rails_auth_engine (0.1.5) - cancancan (~> 2.0) - rails (~> 5.2.0) - rails_api_auth - GIT remote: https://github.com/stympy/faker.git - revision: 7966190d46fd8165b58f3be1bffe41d37ccaea7c + revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 branch: master specs: - faker (2.10.2) + faker (2.18.0) i18n (>= 1.6, < 2) GIT @@ -24,51 +14,60 @@ GIT shoulda-matchers (3.1.2) activesupport (>= 4.2.0) +PATH + remote: /data/ecds_auth_engine + specs: + ecds_rails_auth_engine (0.1.6) + cancancan + httparty + jwt + rails + GEM remote: https://rubygems.org/ specs: - actioncable (5.2.4.1) - actionpack (= 5.2.4.1) + actioncable (5.2.6) + actionpack (= 5.2.6) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.4.1) - actionpack (= 5.2.4.1) - actionview (= 5.2.4.1) - activejob (= 5.2.4.1) + actionmailer (5.2.6) + actionpack (= 5.2.6) + actionview (= 5.2.6) + activejob (= 5.2.6) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.4.1) - actionview (= 5.2.4.1) - activesupport (= 5.2.4.1) + actionpack (5.2.6) + actionview (= 5.2.6) + activesupport (= 5.2.6) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.4.1) - activesupport (= 5.2.4.1) + actionview (5.2.6) + activesupport (= 5.2.6) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.10) - actionpack (>= 4.1, < 6.1) - activemodel (>= 4.1, < 6.1) + active_model_serializers (0.10.12) + actionpack (>= 4.1, < 6.2) + activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (5.2.4.1) - activesupport (= 5.2.4.1) + activejob (5.2.6) + activesupport (= 5.2.6) globalid (>= 0.3.6) - activemodel (5.2.4.1) - activesupport (= 5.2.4.1) - activerecord (5.2.4.1) - activemodel (= 5.2.4.1) - activesupport (= 5.2.4.1) + activemodel (5.2.6) + activesupport (= 5.2.6) + activerecord (5.2.6) + activemodel (= 5.2.6) + activesupport (= 5.2.6) arel (>= 9.0) - activestorage (5.2.4.1) - actionpack (= 5.2.4.1) - activerecord (= 5.2.4.1) - marcel (~> 0.3.1) - activesupport (5.2.4.1) + activestorage (5.2.6) + actionpack (= 5.2.6) + activerecord (= 5.2.6) + marcel (~> 1.0.0) + activesupport (5.2.6) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -85,159 +84,147 @@ GEM public_suffix (>= 2) rack (>= 1.3.6) arel (9.0.0) - bcrypt (3.1.13) - bcrypt (3.1.13-java) builder (3.2.4) cancancan (2.3.0) - capistrano (3.12.1) + capistrano (3.16.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) sshkit (>= 1.9.0) - capistrano-bundler (1.6.0) + capistrano-bundler (2.0.1) capistrano (~> 3.1) - capistrano-passenger (0.2.0) + capistrano-passenger (0.2.1) capistrano (~> 3.0) - capistrano-rails (1.4.0) + capistrano-rails (1.6.1) capistrano (~> 3.1) - capistrano-bundler (~> 1.1) - capistrano-rbenv (2.1.6) + capistrano-bundler (>= 1.1, < 3) + capistrano-rbenv (2.2.0) capistrano (~> 3.1) sshkit (~> 1.3) - carrierwave (1.3.1) + carrierwave (1.3.2) activemodel (>= 4.0.0) activesupport (>= 4.0.0) mime-types (>= 1.16) - carrierwave-base64 (2.8.0) + ssrf_filter (~> 1.0) + carrierwave-base64 (2.8.1) carrierwave (>= 0.8.0) mime-types (~> 3.0) mimemagic (~> 0.3.2) case_transform (0.2) activesupport - concurrent-ruby (1.1.6) + concurrent-ruby (1.1.8) coveralls (0.8.23) json (>= 1.8, < 3) simplecov (~> 0.16.1) term-ansicolor (~> 1.3) thor (>= 0.19.4, < 2.0) tins (~> 1.6) - crack (0.4.3) - safe_yaml (~> 1.0.0) + crack (0.4.5) + rexml crass (1.0.6) - database_cleaner (1.8.3) - diff-lcs (1.3) - docile (1.3.2) - erubi (1.9.0) - factory_bot (5.1.1) - activesupport (>= 4.2.0) - factory_bot_rails (5.1.1) - factory_bot (~> 5.1.0) - railties (>= 4.2.0) - ffi (1.12.2) - ffi (1.12.2-java) - ffi (1.12.2-x64-mingw32) - ffi (1.12.2-x86-mingw32) + database_cleaner (2.0.1) + database_cleaner-active_record (~> 2.0.0) + database_cleaner-active_record (2.0.1) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + diff-lcs (1.4.4) + docile (1.4.0) + erubi (1.10.0) + factory_bot (6.2.0) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) + ffi (1.15.1) globalid (0.4.2) activesupport (>= 4.2.0) hashdiff (1.0.1) - httparty (0.13.7) - json (~> 1.8) + httparty (0.18.1) + mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.8.2) + i18n (1.8.10) concurrent-ruby (~> 1.0) - json (1.8.6) - json (1.8.6-java) + json (2.5.1) jsonapi-renderer (0.2.2) + jwt (2.2.3) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) ruby_dep (~> 1.2) - loofah (2.4.0) + loofah (2.9.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (0.3.3) - mimemagic (~> 0.3.2) - method_source (0.9.2) + marcel (1.0.1) + method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2019.1009) - mimemagic (0.3.4) - mini_magick (4.10.1) - mini_mime (1.0.2) - mini_portile2 (2.4.0) - minitest (5.14.0) + mime-types-data (3.2021.0225) + mimemagic (0.3.10) + nokogiri (~> 1) + rake + mini_magick (4.11.0) + mini_mime (1.1.0) + mini_portile2 (2.5.3) + minitest (5.14.4) multi_xml (0.6.0) multipart-post (2.1.1) mysql2 (0.5.3) - mysql2 (0.5.3-x64-mingw32) - mysql2 (0.5.3-x86-mingw32) - mysql2 (0.5.3-x86-mswin32-60) - net-scp (2.0.0) - net-ssh (>= 2.6.5, < 6.0.0) - net-ssh (5.2.0) - nio4r (2.5.2) - nio4r (2.5.2-java) - nokogiri (1.10.9) - mini_portile2 (~> 2.4.0) - nokogiri (1.10.9-java) - nokogiri (1.10.9-x64-mingw32) - mini_portile2 (~> 2.4.0) - nokogiri (1.10.9-x86-mingw32) - mini_portile2 (~> 2.4.0) - oauth (0.5.4) - parallel (1.19.1) + net-scp (3.0.0) + net-ssh (>= 2.6.5, < 7.0.0) + net-ssh (6.1.0) + nio4r (2.5.7) + nokogiri (1.11.6) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) + oauth (0.5.6) + parallel (1.20.1) pg (1.2.3) - pg (1.2.3-x64-mingw32) - pg (1.2.3-x86-mingw32) - public_suffix (4.0.3) - puma (4.3.3) + public_suffix (4.0.6) + puma (4.3.8) nio4r (~> 2.0) - puma (4.3.3-java) - nio4r (~> 2.0) - rack (2.2.2) + racc (1.5.2) + rack (2.2.3) rack-cors (1.1.1) rack (>= 2.0.0) rack-test (1.1.0) rack (>= 1.0, < 3) - rails (5.2.4.1) - actioncable (= 5.2.4.1) - actionmailer (= 5.2.4.1) - actionpack (= 5.2.4.1) - actionview (= 5.2.4.1) - activejob (= 5.2.4.1) - activemodel (= 5.2.4.1) - activerecord (= 5.2.4.1) - activestorage (= 5.2.4.1) - activesupport (= 5.2.4.1) + rails (5.2.6) + actioncable (= 5.2.6) + actionmailer (= 5.2.6) + actionpack (= 5.2.6) + actionview (= 5.2.6) + activejob (= 5.2.6) + activemodel (= 5.2.6) + activerecord (= 5.2.6) + activestorage (= 5.2.6) + activesupport (= 5.2.6) bundler (>= 1.3.0) - railties (= 5.2.4.1) + railties (= 5.2.6) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - rails_api_auth (0.1.0) - bcrypt (~> 3.1.7) - httparty (~> 0.13.3) - rails (>= 3.2.6, < 6) - railties (5.2.4.1) - actionpack (= 5.2.4.1) - activesupport (= 5.2.4.1) + railties (5.2.6) + actionpack (= 5.2.6) + activesupport (= 5.2.6) method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) - rake (13.0.1) - rb-fsevent (0.10.3) + rake (13.0.3) + rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) redis (3.3.5) - rspec-core (3.9.1) - rspec-support (~> 3.9.1) - rspec-expectations (3.9.1) + rexml (3.2.5) + rspec-core (3.9.3) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-mocks (3.9.1) @@ -251,66 +238,57 @@ GEM rspec-expectations (~> 3.9.0) rspec-mocks (~> 3.9.0) rspec-support (~> 3.9.0) - rspec-support (3.9.2) + rspec-support (3.9.4) ruby_dep (1.5.0) - safe_yaml (1.0.5) simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - spring (2.1.0) + spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) - sprockets (4.0.0) + sprockets (4.0.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.1) + sprockets-rails (3.2.2) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.21.0) + sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) + ssrf_filter (1.0.7) sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) - test-prof (0.11.3) - thor (1.0.1) + test-prof (1.0.5) + thor (1.1.0) thread_safe (0.3.6) - thread_safe (0.3.6-java) - tins (1.24.1) + tins (1.29.1) sync - tzinfo (1.2.6) + tzinfo (1.2.9) thread_safe (~> 0.1) - tzinfo-data (1.2020.1) - tzinfo (>= 1.0.0) vimeo (1.5.4) httparty (>= 0.4.5) httpclient (>= 2.1.5.2) json (>= 1.1.9) multipart-post (>= 1.0.1) oauth (>= 0.4.3) - webmock (3.8.3) + webmock (3.13.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.1) - websocket-extensions (>= 0.1.0) - websocket-driver (0.7.1-java) + websocket-driver (0.7.4) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.4) + websocket-extensions (0.1.5) youtube_rails (1.2.2) - yt (0.32.6) + yt (0.33.4) activesupport PLATFORMS - java ruby - x64-mingw32 - x86-mingw32 - x86-mswin32 DEPENDENCIES actionview (>= 5.2.2.1) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index 63ad37bc..05565241 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -14,6 +14,7 @@ def show private def feature(stop) + stop.media.map { |m| m.caption = nil if m.caption.blank? } { type: 'Feature', geometry: { @@ -23,7 +24,7 @@ def feature(stop) properties: { title: stop.title, description: stop.description, - images: stop.media.map(&:desktop).map { |m| "#{request.protocol}#{request.host}/#{m}" } + images: stop.media.map { |m| { caption: m.caption, url: "#{request.protocol}#{request.host}/#{m.desktop}" } } } } end diff --git a/app/controllers/v3/modes_controller.rb b/app/controllers/v3/modes_controller.rb index 3dcc7531..c00f7f34 100644 --- a/app/controllers/v3/modes_controller.rb +++ b/app/controllers/v3/modes_controller.rb @@ -3,14 +3,10 @@ # app/controllers/v3/modes_controller.rb module V3 class ModesController < V3Controller - # before_action :set_mode, only: [:show, :update, :destroy] - authorize_resource # GET /modes def index - @modes = Mode.all - - render json: @modes + render json: Mode.all end end end diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb index e1e7f7f5..309b79a9 100644 --- a/app/controllers/v3/tour_modes_controller.rb +++ b/app/controllers/v3/tour_modes_controller.rb @@ -11,5 +11,11 @@ def index render json: @tour_modes end -end + + # GET /v3/tour_media/1 + def show + tour_mode = TourMode.find(params[:id]) + render json: tour_mode + end + end end diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index 15e2a491..0099e341 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -47,7 +47,10 @@ def update # DELETE /stops/1 def destroy - @tour_stop.destroy + if @tour_stop + @tour_stop.destroy + end + head :no_content end private @@ -63,6 +66,6 @@ def tour_stop_params end def set_tour_stop - @tour_stop = TourStop.find_by!(id: params[:id]) + @tour_stop = TourStop.find_by(id: params[:id]) end end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 075094f8..7a66090b 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -11,36 +11,36 @@ class V3::ToursController < V3Controller def index @tours = if (params[:slug]) tour = Slug.find_by(slug: params[:slug]).tour - if tour.published || current_user.current_tenant_admin? + if tour.published || (current_user && current_user.current_tenant_admin?) tour else nil end - elsif (current_user.current_tenant_admin?) + elsif (current_user && current_user.current_tenant_admin?) Tour.all - elsif (current_user.id) + elsif (current_user && current_user.id) current_user.tours else Tour.published end if @tours.nil? - render json: {error: "not-found"}.to_json, status: 404 + render json: { error: 'not found' }.to_json, status: 404 else - render json: @tours, - include: [ - 'tour_stops', - 'stops', - 'stops.media', - 'stops.stop_media', - 'mode', - 'modes', - 'theme', - 'media', - 'tour_media', - 'flat_pages', - 'tour_flat_pages' - ] + render json: @tours, each_serializer: V3::TourBaseSerializer + # include: [ + # 'tour_stops', + # 'stops', + # 'stops.media', + # 'stops.stop_media', + # 'mode', + # 'modes', + # 'theme', + # 'media', + # 'tour_media', + # 'flat_pages', + # 'tour_flat_pages' + # ] end end @@ -49,6 +49,7 @@ def index def show render json: @tour, include: [ + 'tour_modes', 'tour_stops', 'stops', 'stops.media', @@ -67,8 +68,7 @@ def show def create @tour = Tour.new(tour_params) if @tour.save - response = render json: @tour, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" - return response + render json: @tour, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" else render json: @tour.errors, status: :unprocessable_entity end @@ -77,7 +77,20 @@ def create # PATCH/PUT /tours/1 def update if @tour.update(tour_params) - render json: @tour, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" + render json: @tour, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}", include: [ + 'tour_modes', + 'tour_stops', + 'stops', + 'stops.media', + 'stops.stop_media', + 'mode', + 'modes', + 'theme', + 'media', + 'tour_media', + 'flat_pages', + 'tour_flat_pages' + ] else render json: @tour.errors, status: :unprocessable_entity end diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index dae270c8..ce105b3a 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -2,22 +2,22 @@ # app/controllers/v3/users_controller.rb module V3 + # + # Endpoints for User Model + # class UsersController < V3Controller before_action :set_user, only: [:show, :update, :destroy] before_action :authenticate!, only: :me authorize_resource # GET /users - # def index - # @users = User.all - - # render json: @users - # end def index - if current_user.present? && params['me'] - render json: current_user, include: ['tours', 'tour_sets', 'login'] - elsif current_user.current_tenant_admin? - render json: User.all + if current_user.present? + if params['me'] + render json: current_user, include: ['tours', 'tour_sets'] + elsif current_user.current_tenant_admin? + render json: User.all + end else render json: { message: 'You are not autorized to to view this resource.' }.to_json, status: 401 end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index 10c4bd48..2b32bbed 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class V3Controller < ApplicationController - check_authorization + include EcdsRailsAuthEngine::CurrentUser + # check_authorization rescue_from CanCan::AccessDenied do |exception| head 401 diff --git a/app/models/ability.rb b/app/models/ability.rb index 1817308e..834b8104 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -16,6 +16,8 @@ def initialize(user) can :read, Medium can :read, FlatPage can [:read], TourSet + can :read, User + return if user.nil? return unless user.id.present? can [:read, :edit, :update], Tour can [:manage], TourMedium @@ -27,7 +29,7 @@ def initialize(user) can [:manage], TourFlatPage can [:manage], Mode can [:manage], TourMode - can [:read], User + can [:manage], User return unless user.current_tenant_admin? can [:manage], Tour can [:manage], TourSetAdmin diff --git a/app/models/stop.rb b/app/models/stop.rb index afde7bd6..aeaa5902 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -64,6 +64,10 @@ def is_published tours.published.present? end + def orphaned + tours.empty? + end + private diff --git a/app/models/tour.rb b/app/models/tour.rb index ecb563e5..a7ec2e52 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -5,7 +5,7 @@ class Tour < ApplicationRecord include HtmlSaintizer has_many :tour_stops, autosave: true, dependent: :destroy has_many :stops, -> { distinct }, through: :tour_stops - has_many :tour_modes + has_many :tour_modes, autosave: true, dependent: :destroy has_many :modes, through: :tour_modes belongs_to :mode, default: -> { Mode.last } has_many :tour_media diff --git a/app/models/user.rb b/app/models/user.rb index 581c5825..3049f278 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true class User < ActiveRecord::Base - include EcdsRailsAuthEngine::EcdsUser has_many :tour_set_admins has_many :tour_sets, through: :tour_set_admins has_many :tour_authors has_many :tours, through: :tour_authors - scope :search, -> (search) { joins(:login).where("users.display_name ILIKE '%#{search}%' OR logins.identification ILIKE '%#{search}%'")} + # scope :search, -> (search) { joins(:login).where("users.display_name ILIKE '%#{search}%' OR logins.identification ILIKE '%#{search}%'")} # # Gets role for current tenant @@ -17,7 +16,7 @@ class User < ActiveRecord::Base def current_tenant_admin? return true if self.super return false if tour_sets.empty? - tour_sets.collect(&:subdir).include? Apartment::Tenant.current + tour_sets.map(&:subdir).include? Apartment::Tenant.current end end diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index 648c1679..7c8aebf7 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -4,5 +4,5 @@ class V3::StopSerializer < ActiveModel::Serializer has_many :media has_many :stop_media has_many :tours - attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash, :insecure_splash, :splash_width, :splash_height + attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash, :insecure_splash, :splash_width, :splash_height, :orphaned end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb new file mode 100644 index 00000000..3d138c6d --- /dev/null +++ b/app/serializers/v3/tour_base_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# app/serializers/tour_serializer.rb +class V3::TourBaseSerializer < ActiveModel::Serializer + attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash +end diff --git a/app/serializers/v3/tour_mode_serializer.rb b/app/serializers/v3/tour_mode_serializer.rb new file mode 100644 index 00000000..2857abf7 --- /dev/null +++ b/app/serializers/v3/tour_mode_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class V3::TourModeSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :mode + attributes :id +end diff --git a/app/serializers/v3/tour_modes_serializer.rb b/app/serializers/v3/tour_modes_serializer.rb deleted file mode 100644 index 05e542bf..00000000 --- a/app/serializers/v3/tour_modes_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class V3::TourModesSerializer < ActiveModel::Serializer - attributes :id, :tour_id, :mode_id -end diff --git a/app/serializers/v3/tour_serializer.rb b/app/serializers/v3/tour_serializer.rb index a9df2843..1f08b9fa 100644 --- a/app/serializers/v3/tour_serializer.rb +++ b/app/serializers/v3/tour_serializer.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true # app/serializers/tour_serializer.rb -class V3::TourSerializer < ActiveModel::Serializer +class V3::TourSerializer < V3::TourBaseSerializer + has_many :tour_modes has_many :tour_stops has_many :stops belongs_to :mode @@ -11,5 +12,4 @@ class V3::TourSerializer < ActiveModel::Serializer has_many :tour_media has_many :flat_pages has_many :tour_flat_pages - attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash end diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index d78c4990..2dba157c 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -2,10 +2,9 @@ # app/serializer/user_serializer.rb class V3::UserSerializer < ActiveModel::Serializer - has_one :login has_many :tours has_many :tour_sets - attributes :id, :display_name, :super, :login, :current_tenant_admin + attributes :id, :display_name, :super, :current_tenant_admin def current_tenant_admin object.current_tenant_admin? diff --git a/config/application.rb b/config/application.rb index b6e935f9..5a9115a6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,6 +27,10 @@ class DirectoryElevator < Apartment::Elevators::Generic def parse_tenant_name(request) # request is an instance of Rack::Request tenant_name = request.fullpath.split('/')[1] + + if tenant_name == 'auth' + return nil + end tenant_name end end @@ -37,6 +41,9 @@ def parse_tenant_name(request) # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + config.middleware.use(ActionDispatch::Cookies) + config.middleware.use(ActionDispatch::Session::CookieStore) + config.action_dispatch.cookies_serializer = :json # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 1fef55ec..2b6faae7 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -3,6 +3,6 @@ # require 'directory_elevator' Apartment.configure do |config| config.tenant_names = -> { TourSet.pluck :subdir } - config.excluded_models = ['Login', 'User', 'Role', 'TourSetAdmin', 'TourSet'] + config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme'] config.persistent_schemas = ['shared_extensions'] end diff --git a/config/initializers/cookie_session.rb b/config/initializers/cookie_session.rb new file mode 100644 index 00000000..53cfc4f4 --- /dev/null +++ b/config/initializers/cookie_session.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true +Rails.application.config.session_store(:cookie_store, key: '_otb_session') +Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index eec10024..6b72393f 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -9,10 +9,11 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins '*' + origins 'https://lvh.me:4200' resource '*', headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head] + methods: [:get, :post, :put, :patch, :delete, :options, :head], + credentials: true end end diff --git a/config/routes.rb b/config/routes.rb index aaa110c3..7a1e350d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,12 +29,12 @@ resources :stops resources :stop_media, path: 'stop-media' resources :tour_media, path: 'tour-media' + resources :tour_modes, path: 'tour-modes' resources :tour_stops, path: 'tour-stops' resources :flat_pages, path: 'flat-pages' resources :tour_flat_pages, path: 'tour-flat-pages' resources :geojson_tours end - post '/token', to: 'oauth2#create' - post '/revoke', to: 'oauth2#destroy' end + mount EcdsRailsAuthEngine::Engine, at: '/auth' end diff --git a/db/migrate/20210518143822_add_email.rb b/db/migrate/20210518143822_add_email.rb new file mode 100644 index 00000000..fcc835e7 --- /dev/null +++ b/db/migrate/20210518143822_add_email.rb @@ -0,0 +1,5 @@ +class AddEmail < ActiveRecord::Migration[5.2] + def change + add_column :users, :email, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index df0d600a..3bcfa54e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,16 +10,26 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_02_13_152142) do +ActiveRecord::Schema.define(version: 2021_05_18_143822) do # These are extensions that must be enabled in order to support this database - # enable_extension "pgcrypto" - # enable_extension "plpgsql" - # enable_extension "uuid-ossp" + enable_extension "pgcrypto" + enable_extension "plpgsql" + enable_extension "uuid-ossp" + + create_table "ecds_rails_auth_engine_logins", force: :cascade do |t| + t.string "who" + t.string "token" + t.string "provider" + t.bigint "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" + end create_table "flat_pages", force: :cascade do |t| t.string "title" - t.text "content" + t.string "content" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "position" @@ -106,10 +116,10 @@ t.string "article_link" t.string "video_embed" t.string "video_poster" - t.decimal "lat", precision: 65, scale: 8 - t.decimal "lng", precision: 65, scale: 8 - t.decimal "parking_lat", precision: 65, scale: 8 - t.decimal "parking_lng", precision: 65, scale: 8 + t.decimal "lat", precision: 100, scale: 8 + t.decimal "lng", precision: 100, scale: 8 + t.decimal "parking_lat", precision: 100, scale: 8 + t.decimal "parking_lng", precision: 100, scale: 8 t.text "direction_intro" t.text "direction_notes" t.datetime "created_at", null: false @@ -227,8 +237,8 @@ create_table "tour_tags", force: :cascade do |t| t.string "title" - t.decimal "lat", precision: 65, scale: 8 - t.decimal "lng", precision: 65, scale: 8 + t.decimal "lat", precision: 100, scale: 8 + t.decimal "lng", precision: 100, scale: 8 t.datetime "created_at", null: false t.datetime "updated_at", null: false end @@ -243,9 +253,9 @@ t.bigint "theme_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "mode_id" + t.integer "mode_id" t.integer "position" - t.bigint "splash_image_medium_id" + t.integer "splash_image_medium_id" t.string "meta_description" t.bigint "medium_id" t.string "map_type" @@ -260,6 +270,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "super", default: false + t.string "email" t.index ["login_id"], name: "index_users_on_login_id", unique: true end diff --git a/db/seeds.rb b/db/seeds.rb index 55e857ab..ac21c9ef 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -47,8 +47,8 @@ 4.times do # User.create(display_name: Faker::Movies::Lebowski) - FactoryBot.create(:login) - Login.last.user.update_attribute(:display_name, Faker::Movies::Lebowski) + FactoryBot.create(:user) + # Login.last.user.update_attribute(:display_name, Faker::Movies::Lebowski) end TourSet.all.each do |ts| @@ -73,6 +73,7 @@ end User.all.each do |u| + FactoryBot.create(:login, who: u.email, user_id: u.id, provider: 'earth') next if u.super next if u.tours.present? # u.tours = Tour.all.order(Arel.sql('random()')).limit(Random.new.rand(2..3)) diff --git a/spec/factories/login.rb b/spec/factories/login.rb index fb0d8b9f..7a81aaf2 100644 --- a/spec/factories/login.rb +++ b/spec/factories/login.rb @@ -1,16 +1,11 @@ # frozen_string_literal: true # spec/factories/login.rb -FactoryBot.define do - factory :login do - identification { Faker::Internet.email } - oauth2_token { Faker::Omniauth.google[:credentials][:token] } - uid { Faker::Number.number } - provider { 'google' } +require 'faker' +require 'jwt' - after(:create) do |login| - login.user.display_name = Faker::Name.name - login.user.save - end +FactoryBot.define do + factory :login, class: EcdsRailsAuthEngine::Login do + token { JWT.encode(Faker::Beer.style, Faker::Address.zip, 'HS256') } end end diff --git a/spec/factories/stops.rb b/spec/factories/stops.rb index 8707f2bb..7e48d36b 100644 --- a/spec/factories/stops.rb +++ b/spec/factories/stops.rb @@ -18,7 +18,7 @@ # https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#transient-attributes after(:create) do |stop, evaluator| - create_list(:medium, evaluator.media_count, stops: [stop]) + # create_list(:medium, evaluator.media_count, stops: [stop]) end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 88b19c1e..af909e49 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -3,7 +3,7 @@ # spec/factories/users.rb FactoryBot.define do factory :user do - login { create(:login) } + email { Faker::Internet.email } # tour_sets { TourSet.where(subdir: 'atlanta') } end end diff --git a/spec/requests/v3/flat_pages_spec.rb b/spec/requests/v3/flat_pages_spec.rb index 6a32be05..38c4fe9e 100644 --- a/spec/requests/v3/flat_pages_spec.rb +++ b/spec/requests/v3/flat_pages_spec.rb @@ -18,18 +18,18 @@ end end - context 'flat page included in tours payload' do - before { - tour = create(:tour_with_flat_pages, theme: theme) - tour.published = true - tour.save - get "/#{Apartment::Tenant.current}/tours?slug=#{tour.slug}" - } - - it 'creates tour with existing flat page' do - expect(included.select { |d| d['type'] == 'tour_flat_pages' }.length).to eq(3) - end - end + # context 'flat page included in tours payload' do + # before { + # tour = create(:tour_with_flat_pages, theme: theme) + # tour.published = true + # tour.save + # get "/#{Apartment::Tenant.current}/tours?slug=#{tour.slug}" + # } + + # it 'creates tour with existing flat page' do + # expect(included.select { |d| d['type'] == 'tour_flat_pages' }.length).to eq(3) + # end + # end context 'get specific flat page by id' do before { get "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}" } @@ -44,7 +44,7 @@ let(:valid_attributes) do factory_to_json_api(FactoryBot.build(:flat_page)) end - + describe 'POST /tenant/flat-pages' do context 'create page not authenticated' do @@ -55,32 +55,41 @@ end context 'when created by tour set admin' do - before { User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { post "/#{Apartment::Tenant.current}/flat-pages", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + post "/#{Apartment::Tenant.current}/flat-pages", params: valid_attributes + } it 'creates a tour' do expect(response).to have_http_status(201) end end context 'create without valid params' do - before { post "/#{Apartment::Tenant.current}/flat-pages", params: {foo: 'bar'}, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + post "/#{Apartment::Tenant.current}/flat-pages", params: { foo: 'bar' } + } it 'returns unprocessable entity' do expect(response).to have_http_status(422) end end end - + describe 'PUT /tenant/flat-pages/' do context 'update page not authenticated' do - before { put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes} + before { put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes } it 'returns status code 401' do expect(response).to have_http_status(401) end end context 'when updated by tour set admin' do - before { User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes + } it 'creates a tour' do expect(response).to have_http_status(200) end @@ -88,8 +97,9 @@ context 'update without valid params' do before { - invalid_attributes = {data: {type: 'flat_pages', attributes: {title: nil}}} - put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.last.id}", params: invalid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } + invalid_attributes = {data: {type: 'flat_pages', attributes: { title: nil }}} + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.last.id}", params: invalid_attributes } it 'returns unprocessable entity' do expect(response).to have_http_status(422) @@ -100,15 +110,20 @@ describe 'DELETE /tenant/flat-pages/' do context 'delete page not authenticated' do - before { delete "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes} + before { + delete "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes + } it 'returns status code 401' do expect(response).to have_http_status(401) end end context 'when deletes by tour set admin' do - before { User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { delete "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + delete "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}", params: valid_attributes + } it 'creates a tour' do expect(response).to have_http_status(204) end diff --git a/spec/requests/v3/media_spec.rb b/spec/requests/v3/media_spec.rb.fix similarity index 100% rename from spec/requests/v3/media_spec.rb rename to spec/requests/v3/media_spec.rb.fix diff --git a/spec/requests/v3/stop_media_spec.rb b/spec/requests/v3/stop_media_spec.rb.fix similarity index 97% rename from spec/requests/v3/stop_media_spec.rb rename to spec/requests/v3/stop_media_spec.rb.fix index 327bf3ae..12cc6903 100644 --- a/spec/requests/v3/stop_media_spec.rb +++ b/spec/requests/v3/stop_media_spec.rb.fix @@ -6,10 +6,7 @@ let!(:stop) { create_list(:stop_with_media, 1) } let!(:medium) { create(:medium) } let!(:new_medium) { create(:medium) } - let!(:user) { create(:user) } - let!(:login) { create(:login, user: user) } - let(:headers) { { Authorization: "Bearer #{login.oauth2_token}" } } - + let!(:user) { User.first } describe 'GET /stop-media' do context 'gets all stop media' do diff --git a/spec/requests/v3/stops_spec.rb b/spec/requests/v3/stops_spec.rb index dfd46919..1e709351 100644 --- a/spec/requests/v3/stops_spec.rb +++ b/spec/requests/v3/stops_spec.rb @@ -5,14 +5,14 @@ RSpec.describe 'V3::Stops API' do # Initialize the test data - let!(:login) { User.find_by(super: true).login } + let!(:user) { User.find_by(super: true) } # Test suite for GET /stops describe 'GET /stops' do context 'when stops exist' do - + before { get "/#{Apartment::Tenant.current}/stops" } - + it 'returns status code 200' do expect(response).to have_http_status(200) end @@ -114,8 +114,11 @@ end context 'when request attributes are valid' do - before { User.first.update_attribute(:super, true) } - before { post "/#{Apartment::Tenant.current}/stops", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + User.first.update_attribute(:super, true) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + post "/#{Apartment::Tenant.current}/stops", params: valid_attributes + } it 'returns status code 201' do expect(response).to have_http_status(201) @@ -141,7 +144,10 @@ factory_to_json_api(FactoryBot.build(:stop, title: '3 Stacks')) end - before { put "/#{Apartment::Tenant.current}/stops/#{Stop.second.id}", params: valid_attributes, headers: { Authorization: "Bearer #{login.oauth2_token}" } } + before { + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token + put "/#{Apartment::Tenant.current}/stops/#{Stop.second.id}", params: valid_attributes + } context 'when stop exists' do it 'returns status code 204' do @@ -155,7 +161,10 @@ end context 'when the stop does not exist' do - before { put "/#{Apartment::Tenant.current}/stops/0", params: valid_attributes, headers: { Authorization: "Bearer #{login.oauth2_token}" } } + before { + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token + put "/#{Apartment::Tenant.current}/stops/0", params: valid_attributes + } it 'returns status code 404' do expect(response).to have_http_status(404) @@ -169,7 +178,10 @@ # Test suite for DELETE /stops/:id describe 'DELETE /stops/:id' do - before { delete "/#{Apartment::Tenant.current}/stops/#{Stop.last.id}", headers: { Authorization: "Bearer #{login.oauth2_token}" } } + before { + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token + delete "/#{Apartment::Tenant.current}/stops/#{Stop.last.id}" + } it 'returns status code 204' do expect(response).to have_http_status(204) diff --git a/spec/requests/v3/tour_stops_spec.rb b/spec/requests/v3/tour_stops_spec.rb index f1900815..c16cd195 100644 --- a/spec/requests/v3/tour_stops_spec.rb +++ b/spec/requests/v3/tour_stops_spec.rb @@ -36,7 +36,7 @@ describe 'GET /tour-stops?slug=slug&tour=X' do before { Apartment::Tenant.switch! TourSet.second.subdir } before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{Tour.first.stops.first.stop_slugs.first.slug}&tour=#{Tour.first.id}" } - + context 'get tour stop by slug and tour'do it 'responds with the tour stop' do expect(json['id'].to_i).to eq(Tour.first.stops.first.id) @@ -52,15 +52,15 @@ let!(:tour2) { Tour.last } let!(:stop2) { tour2.stops.last } let!(:new_title) { "#{Faker::Movies::Lebowski.character}" } - + before{ tour1.stops = [Stop.create(title: new_title)] tour1.save tour2.stops = [Stop.create(title: new_title)] tour2.save } - - + + context 'get stop with duplicate title/slug in correct tour' do before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{new_title.parameterize}&tour=#{tour1.id}" } it 'is true' do @@ -69,7 +69,7 @@ end end - + context 'get stop with duplicate title/slug in correct tour' do before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{new_title.parameterize}&tour=#{tour2.id}" } it 'is true' do @@ -81,19 +81,25 @@ end describe 'DELETE /tour-stops' do - before { User.last.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { @stop = Stop.last } - before { @stop_count = Stop.count } - before { delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: @stop).id}", headers: { Authorization: "Bearer #{User.last.login.oauth2_token}" } } - + before { + User.last.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + @stop = Stop.last + @stop_count = Stop.count + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token + delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: @stop).id}" + } + context 'when a tour-stop is deleted and the stop no longers belogs to a tour, the stop is deleted' do it 'deletes the associated stop' do expect(Stop.count).to eq @stop_count - 1 end end - - before { Tour.all.each { |t| t.stops << Stop.first } } - before { delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: Stop.first).id}", headers: { Authorization: "Bearer #{User.last.login.oauth2_token}" } } + + before { + Tour.all.each { |t| t.stops << Stop.first } + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token + delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: Stop.first).id}" + } context 'when a tour-stop is deleted, the stop is not deleted if it belongs to other tours.' do it 'deletes tour-stop but not the stop' do diff --git a/spec/requests/v3/tours_spec.rb b/spec/requests/v3/tours_spec.rb index 7778c6b6..a472bc89 100644 --- a/spec/requests/v3/tours_spec.rb +++ b/spec/requests/v3/tours_spec.rb @@ -122,9 +122,12 @@ before { Apartment::Tenant.switch! TourSet.find(TourSet.pluck(:id).sample).subdir } context 'when the post is valid and authenticated as non-tour set admin' do - before { User.last.update_attribute(:super, false) } - before { User.last.update_attribute(:tour_sets, []) } - before { post "/#{Apartment::Tenant.current}/tours", params: valid_attributes, headers: { Authorization: "Bearer #{User.last.login.oauth2_token}" } } + before { + User.last.update_attribute(:super, false) + User.last.update_attribute(:tour_sets, []) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token + post "/#{Apartment::Tenant.current}/tours", params: valid_attributes + } it 'returns status code 401' do expect(response).to have_http_status(401) @@ -132,8 +135,11 @@ end context 'when created by tour set admin' do - before { User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { post "/#{Apartment::Tenant.current}/tours", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + post "/#{Apartment::Tenant.current}/tours", params: valid_attributes + } it 'creates a tour' do expect(attributes['title']).to eq('Learn Elm') end @@ -150,7 +156,8 @@ before { # Tour.create!(published: true) User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) - post "/#{Apartment::Tenant.current}/tours", params: invalid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + post "/#{Apartment::Tenant.current}/tours", params: invalid_attributes } it 'returns status code 201' do @@ -170,7 +177,10 @@ end context 'when the record exists' do - before { put "/#{Apartment::Tenant.current}/tours/#{Tour.last.id}", params: valid_attributes, headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + put "/#{Apartment::Tenant.current}/tours/#{Tour.last.id}", params: valid_attributes + } it 'updates the record' do expect(json).not_to be_empty @@ -185,9 +195,12 @@ # Test suite for DELETE /atlanta/tours/:id describe 'DELETE /atlanta/tours/:id' do - before { Tour.create! } - before { User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) } - before { delete "/#{Apartment::Tenant.current}/tours/#{Tour.last.id}", headers: { Authorization: "Bearer #{User.first.login.oauth2_token}" } } + before { + Tour.create! + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + delete "/#{Apartment::Tenant.current}/tours/#{Tour.last.id}" + } it 'returns status code 204' do expect(response).to have_http_status(204) @@ -197,9 +210,9 @@ describe 'Get //tours authenticated' do context 'tour set adim gets all the tours for that set' do before { - user = User.last - user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) - get "/#{Apartment::Tenant.current}/tours", headers: { Authorization: "Bearer #{user.login.oauth2_token}" } + User.last.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token + get "/#{Apartment::Tenant.current}/tours" } it 'returns all the tours in the set' do @@ -210,16 +223,19 @@ context 'get tours as tour author' do before { user = User.first - user.super = false - user.tour_sets = [] - user.tours = [] + login = EcdsRailsAuthEngine::Login.find_by(user_id: user.id) + user.update(super: false, tour_sets: [], tours: []) + # user.super = false + # user.tour_sets = [] + # user.tours = [] user.save user.tours << Tour.first Tour.all.each do |t| t.published = false t.save end - get "/#{Apartment::Tenant.current}/tours", headers: { Authorization: "Bearer #{user.login.oauth2_token}" } + cookies['auth'] = login.token + get "/#{Apartment::Tenant.current}/tours" } it 'only returns tours user can edit' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1fe4732a..c770112a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) +WebMock.disable_net_connect!(allow: '45.33.24.119') # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. diff --git a/spec/support/cookies.rb b/spec/support/cookies.rb new file mode 100644 index 00000000..b611978a --- /dev/null +++ b/spec/support/cookies.rb @@ -0,0 +1,6 @@ +# spec/support/cookies.rb +class Rack::Test::CookieJar + def encrypted; self; end + def signed; self; end + def permanent; self; end # I needed this, too +end \ No newline at end of file From e18e32c2ae1949cf3514ce5534eefb6465afb13d Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:00:49 -0400 Subject: [PATCH 003/160] Changes to support update development --- .travis.yml | 28 ---------------------------- config/database.yml.travis | 13 ------------- 2 files changed, 41 deletions(-) delete mode 100644 .travis.yml delete mode 100644 config/database.yml.travis diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 35575793..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: ruby -rvm: - - 2.6.3 - -services: - - postgresql - - mysql - -before_install: - - cp config/database.yml.travis config/database.yml - - cp config/secrets.yml.dist config/secrets.yml - - gem install bundler - -env: - - DB=mysql - - DB=postgres - -before_script: - - sudo service postgresql restart - - psql -c 'create database otb_test;' -U postgres - - mysql -e 'create database otb_test' - -script: - - bundle install - - bundle exec rake db:drop - - bundle exec rake db:create - - bundle exec rake db:schema:load - - bundle exec rspec ./spec/requests diff --git a/config/database.yml.travis b/config/database.yml.travis deleted file mode 100644 index 08b31c12..00000000 --- a/config/database.yml.travis +++ /dev/null @@ -1,13 +0,0 @@ -postgres: &postgres - adapter: postgresql - username: postgres - schema_search_path: "public,shared_extensions" - -mysql: &mysql - adapter: mysql2 - username: root - -test: - database: otb_test - host: localhost - <<: *<%= ENV['DB'] || "postgres" %> From e23f78ae0845fce8a3224fb1314fee6b3589f65e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:01:14 -0400 Subject: [PATCH 004/160] Changes to support updates --- .circleci/config.yml | 66 +++++++ .gitignore | 6 +- .ruby-version | 1 + Gemfile | 14 +- Gemfile.lock | 174 ++++++++++-------- README.md | 42 ++++- app/controllers/v3/flat_pages_controller.rb | 32 +++- app/controllers/v3/map_icons_controller.rb | 56 ++++++ app/controllers/v3/map_overlays_controller.rb | 50 +++++ app/controllers/v3/media_controller.rb | 52 ++++-- app/controllers/v3/stop_media_controller.rb | 2 +- app/controllers/v3/stops_controller.rb | 8 +- app/controllers/v3/themes_controller.rb | 2 +- .../v3/tour_collections_controller.rb | 2 +- .../v3/tour_flat_pages_controller.rb | 30 ++- app/controllers/v3/tour_media_controller.rb | 2 +- app/controllers/v3/tour_modes_controller.rb | 2 +- .../v3/tour_set_admins_controller.rb | 10 +- app/controllers/v3/tour_sets_controller.rb | 46 +++-- app/controllers/v3/tour_stops_controller.rb | 2 +- app/controllers/v3/tours_controller.rb | 34 ++-- app/controllers/v3/users_controller.rb | 2 +- app/controllers/v3_controller.rb | 21 ++- app/models/ability.rb | 47 ----- app/models/concerns/video_props.rb | 88 ++++----- app/models/flat_page.rb | 8 + app/models/map_icon.rb | 2 + app/models/map_overlay.rb | 15 ++ app/models/medium.rb | 80 ++++---- app/models/medium_base_record.rb | 44 +++++ app/models/stop.rb | 9 +- app/models/stop_slug.rb | 1 + app/models/tour.rb | 34 +++- app/models/tour_set.rb | 97 ++++++++-- app/models/tour_stop.rb | 7 + app/models/user.rb | 11 +- app/serializers/v3/flat_page_serializer.rb | 2 +- app/serializers/v3/map_icon_serializer.rb | 9 + app/serializers/v3/map_overlay_serializer.rb | 9 + app/serializers/v3/medium_serializer.rb | 12 +- app/serializers/v3/stop_serializer.rb | 3 +- app/serializers/v3/tour_base_serializer.rb | 3 +- app/serializers/v3/tour_serializer.rb | 2 + app/serializers/v3/tour_set_serializer.rb | 8 +- app/serializers/v3/user_serializer.rb | 2 +- bin/bundle | 4 +- bin/rails | 7 - bin/rake | 7 - bin/setup | 6 +- bin/update | 5 +- config/application.rb | 3 +- config/cable.yml | 2 +- config/credentials.yml.enc | 1 + config/database.yml | 36 ++++ config/deploy.rb | 4 +- config/deploy/staging.rb | 20 +- config/environments/development.rb | 6 +- config/environments/production.rb | 3 + config/environments/staging.rb | 2 + config/environments/test.rb | 3 + config/initializers/apartment.rb | 2 +- .../application_controller_renderer.rb | 8 + config/initializers/backtrace_silencers.rb | 7 + config/initializers/inflections.rb | 1 - config/initializers/mime_types.rb | 4 + .../new_framework_defaults_5_2.rb | 38 ++++ config/initializers/wrap_parameters.rb | 7 +- config/puma.rb | 35 +--- config/routes.rb | 7 +- config/spring.rb | 6 +- config/storage.yml | 41 +++++ ...te_active_storage_tables.active_storage.rb | 27 +++ db/migrate/20210604131202_add_base64.rb | 5 + .../20210604132602_add_video_provider.rb | 5 + db/migrate/20210604145226_change_base64.rb | 5 + .../20210607125915_add_parking_address.rb | 5 + db/migrate/20210607165827_rename_content.rb | 5 + .../20210607221303_add_tour_to_stop_slugs.rb | 5 + .../20210608211705_create_map_overlays.rb | 14 ++ .../20210609141823_add_base64_overlay.rb | 5 + .../20210609142132_add_title_overlay.rb | 5 + .../20210610141706_add_enable_directions.rb | 5 + db/migrate/20210610152819_add_default_lang.rb | 5 + db/migrate/20210610180023_add_icon_color.rb | 5 + db/migrate/20210610182827_create_map_icons.rb | 9 + .../20210610183825_add_title_to_map_icons.rb | 5 + .../20210614140848_add_logo64_to_tour_sets.rb | 5 + ...210614154357_remove_logo_from_tour_sets.rb | 5 + ...10614154939_add_logo_title_to_tour_sets.rb | 5 + db/schema.rb | 74 +++++++- lib/snippets.rb | 70 +++++++ ...ancements.rake.bk => db_enhancements.rake} | 0 spec/factories/flat_pages.rb | 2 +- spec/models/map_icon_spec.rb | 5 + spec/models/map_overlay_spec.rb | 5 + spec/rails_helper.rb | 2 +- spec/requests/map_icons_spec.rb | 127 +++++++++++++ spec/requests/v3/flat_pages_spec.rb | 4 +- spec/requests/v3/map_overlays_request_spec.rb | 5 + spec/requests/v3/tour_stops_spec.rb | 2 +- spec/routing/map_icons_routing_spec.rb | 30 +++ 101 files changed, 1418 insertions(+), 447 deletions(-) create mode 100644 .circleci/config.yml create mode 100644 .ruby-version create mode 100644 app/controllers/v3/map_icons_controller.rb create mode 100644 app/controllers/v3/map_overlays_controller.rb delete mode 100644 app/models/ability.rb create mode 100644 app/models/map_icon.rb create mode 100644 app/models/map_overlay.rb create mode 100755 app/models/medium_base_record.rb create mode 100644 app/serializers/v3/map_icon_serializer.rb create mode 100644 app/serializers/v3/map_overlay_serializer.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/initializers/application_controller_renderer.rb create mode 100644 config/initializers/backtrace_silencers.rb create mode 100644 config/initializers/mime_types.rb create mode 100644 config/initializers/new_framework_defaults_5_2.rb create mode 100644 config/storage.yml create mode 100644 db/migrate/20210602201922_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20210604131202_add_base64.rb create mode 100644 db/migrate/20210604132602_add_video_provider.rb create mode 100644 db/migrate/20210604145226_change_base64.rb create mode 100644 db/migrate/20210607125915_add_parking_address.rb create mode 100644 db/migrate/20210607165827_rename_content.rb create mode 100644 db/migrate/20210607221303_add_tour_to_stop_slugs.rb create mode 100644 db/migrate/20210608211705_create_map_overlays.rb create mode 100644 db/migrate/20210609141823_add_base64_overlay.rb create mode 100644 db/migrate/20210609142132_add_title_overlay.rb create mode 100644 db/migrate/20210610141706_add_enable_directions.rb create mode 100644 db/migrate/20210610152819_add_default_lang.rb create mode 100644 db/migrate/20210610180023_add_icon_color.rb create mode 100644 db/migrate/20210610182827_create_map_icons.rb create mode 100644 db/migrate/20210610183825_add_title_to_map_icons.rb create mode 100644 db/migrate/20210614140848_add_logo64_to_tour_sets.rb create mode 100644 db/migrate/20210614154357_remove_logo_from_tour_sets.rb create mode 100644 db/migrate/20210614154939_add_logo_title_to_tour_sets.rb create mode 100644 lib/snippets.rb rename lib/tasks/{db_enhancements.rake.bk => db_enhancements.rake} (100%) create mode 100644 spec/models/map_icon_spec.rb create mode 100644 spec/models/map_overlay_spec.rb create mode 100644 spec/requests/map_icons_spec.rb create mode 100644 spec/requests/v3/map_overlays_request_spec.rb create mode 100644 spec/routing/map_icons_routing_spec.rb diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..6e4c2b04 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,66 @@ +version: 2 +jobs: + build: + working_directory: ~/atlmaps + + # Primary container image where all commands run + + docker: + - image: circleci/ruby:3.0.0-node + environment: + PGUSER: root + RAILS_ENV: test + DB_HOSTNAME: 127.0.0.1 + DB_USERNAME: postgres + TEST_DB_NAME: postgres + + # Service container image available at `host: localhost` + + - image: circleci/postgres:9.6.8-alpine-postgis + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + + steps: + - checkout + + # Restore bundle cache + # - restore_cache: + # keys: + # - rails-demo-{{ checksum "Gemfile.lock" }} + # - rails-demo- + + # Bundle install dependencies + - run: + name: Install dependencies + command: | + sudo apt install -y libgeos-dev libproj-dev + sudo apt update + sudo apt install -y libappindicator1 fonts-liberation + wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo dpkg -i google-chrome*.deb + sudo apt install -f + sudo dpkg -i google-chrome*.deb + + bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 + + - run: sudo apt install -y postgresql-client || true + + # Store bundle cache + + + - run: + name: Database Setup + command: | + bundle exec rake db:schema:load + + - run: + name: Make Tmp Directory + command: | + sudo mkdir -p /data/tmp + sudo chmod 777 /data/tmp + sudo chown $USER:$USER /data/tmp + + - run: + name: Parallel RSpec + command: bundle exec rspec spec diff --git a/.gitignore b/.gitignore index e95e6825..6268811f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ *.DS_Store coverage -config/database.yml log config/secrets.yml tmp config/initializers/auth.rb public/uploads/ +public/storage/* .vscode *.env* -.ruby-version + +/config/master.key +storage diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..37c2961c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.2 diff --git a/Gemfile b/Gemfile index 2e5f2ef3..019c25da 100644 --- a/Gemfile +++ b/Gemfile @@ -9,12 +9,12 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 5.2.0' +gem 'rails', '~> 6.0.0' gem "rack", ">= 2.0.6" gem 'pg' gem 'mysql2' # Multitenancy for Rails and ActiveRecord -gem 'apartment' +gem 'ros-apartment', require: 'apartment' # For JSONAPI responses gem 'active_model_serializers', '~> 0.10.0.rc3' gem 'acts-as-taggable-on', '~> 5.0' @@ -36,6 +36,12 @@ gem 'cancancan', '~> 2.0' gem 'carrierwave', '~> 1.0' gem 'mini_magick' gem 'carrierwave-base64' +gem 'ferrum' + +# RGeo is a geospatial data library for Ruby. +# https://github.com/rgeo/rgeo +gem('rgeo') + # Vidoe provider APIs gem 'vimeo' @@ -59,7 +65,7 @@ group :development do # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' - gem 'rspec-rails', '~> 3.5' + gem 'rspec-rails', '~> 4.0.2' # Use Capistrano for deployment gem 'capistrano-rails' gem 'capistrano-rbenv', '~> 2.0' @@ -70,7 +76,7 @@ end group :test do gem "factory_bot" gem 'factory_bot_rails' - gem 'shoulda-matchers', git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5' + gem 'shoulda-matchers', '~> 4.5.1' #git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5' gem 'database_cleaner' gem 'coveralls', require: false gem 'webmock' diff --git a/Gemfile.lock b/Gemfile.lock index 7f3d9c23..ed1186ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,14 +6,6 @@ GIT faker (2.18.0) i18n (>= 1.6, < 2) -GIT - remote: https://github.com/thoughtbot/shoulda-matchers.git - revision: 4b160bd19ecca7f97d7ac22dccd5fde9b0da5a9f - branch: rails-5 - specs: - shoulda-matchers (3.1.2) - activesupport (>= 4.2.0) - PATH remote: /data/ecds_auth_engine specs: @@ -26,64 +18,72 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (5.2.6) - actionpack (= 5.2.6) + actioncable (6.0.3.7) + actionpack (= 6.0.3.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.6) - actionpack (= 5.2.6) - actionview (= 5.2.6) - activejob (= 5.2.6) + actionmailbox (6.0.3.7) + actionpack (= 6.0.3.7) + activejob (= 6.0.3.7) + activerecord (= 6.0.3.7) + activestorage (= 6.0.3.7) + activesupport (= 6.0.3.7) + mail (>= 2.7.1) + actionmailer (6.0.3.7) + actionpack (= 6.0.3.7) + actionview (= 6.0.3.7) + activejob (= 6.0.3.7) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.6) - actionview (= 5.2.6) - activesupport (= 5.2.6) + actionpack (6.0.3.7) + actionview (= 6.0.3.7) + activesupport (= 6.0.3.7) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.6) - activesupport (= 5.2.6) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.0.3.7) + actionpack (= 6.0.3.7) + activerecord (= 6.0.3.7) + activestorage (= 6.0.3.7) + activesupport (= 6.0.3.7) + nokogiri (>= 1.8.5) + actionview (6.0.3.7) + activesupport (= 6.0.3.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) + rails-html-sanitizer (~> 1.1, >= 1.2.0) active_model_serializers (0.10.12) actionpack (>= 4.1, < 6.2) activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (5.2.6) - activesupport (= 5.2.6) + activejob (6.0.3.7) + activesupport (= 6.0.3.7) globalid (>= 0.3.6) - activemodel (5.2.6) - activesupport (= 5.2.6) - activerecord (5.2.6) - activemodel (= 5.2.6) - activesupport (= 5.2.6) - arel (>= 9.0) - activestorage (5.2.6) - actionpack (= 5.2.6) - activerecord (= 5.2.6) + activemodel (6.0.3.7) + activesupport (= 6.0.3.7) + activerecord (6.0.3.7) + activemodel (= 6.0.3.7) + activesupport (= 6.0.3.7) + activestorage (6.0.3.7) + actionpack (= 6.0.3.7) + activejob (= 6.0.3.7) + activerecord (= 6.0.3.7) marcel (~> 1.0.0) - activesupport (5.2.6) + activesupport (6.0.3.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) acts-as-taggable-on (5.0.0) activerecord (>= 4.2.8) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) - apartment (2.2.1) - activerecord (>= 3.1.2, < 6.0) - parallel (>= 0.7.1) - public_suffix (>= 2) - rack (>= 1.3.6) - arel (9.0.0) builder (3.2.4) cancancan (2.3.0) capistrano (3.16.0) @@ -112,6 +112,7 @@ GEM mimemagic (~> 0.3.2) case_transform (0.2) activesupport + cliver (0.3.2) concurrent-ruby (1.1.8) coveralls (0.8.23) json (>= 1.8, < 3) @@ -136,6 +137,11 @@ GEM factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) + ferrum (0.11) + addressable (~> 2.5) + cliver (~> 0.3) + concurrent-ruby (~> 1.1) + websocket-driver (>= 0.6, < 0.8) ffi (1.15.1) globalid (0.4.2) activesupport (>= 4.2.0) @@ -149,10 +155,9 @@ GEM json (2.5.1) jsonapi-renderer (0.2.2) jwt (2.2.3) - listen (3.1.5) + listen (3.0.8) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - ruby_dep (~> 1.2) loofah (2.9.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -180,6 +185,8 @@ GEM nokogiri (1.11.6) mini_portile2 (~> 2.5.0) racc (~> 1.4) + nokogiri (1.11.6-x86_64-linux) + racc (~> 1.4) oauth (0.5.6) parallel (1.20.1) pg (1.2.3) @@ -192,54 +199,63 @@ GEM rack (>= 2.0.0) rack-test (1.1.0) rack (>= 1.0, < 3) - rails (5.2.6) - actioncable (= 5.2.6) - actionmailer (= 5.2.6) - actionpack (= 5.2.6) - actionview (= 5.2.6) - activejob (= 5.2.6) - activemodel (= 5.2.6) - activerecord (= 5.2.6) - activestorage (= 5.2.6) - activesupport (= 5.2.6) + rails (6.0.3.7) + actioncable (= 6.0.3.7) + actionmailbox (= 6.0.3.7) + actionmailer (= 6.0.3.7) + actionpack (= 6.0.3.7) + actiontext (= 6.0.3.7) + actionview (= 6.0.3.7) + activejob (= 6.0.3.7) + activemodel (= 6.0.3.7) + activerecord (= 6.0.3.7) + activestorage (= 6.0.3.7) + activesupport (= 6.0.3.7) bundler (>= 1.3.0) - railties (= 5.2.6) + railties (= 6.0.3.7) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (5.2.6) - actionpack (= 5.2.6) - activesupport (= 5.2.6) + railties (6.0.3.7) + actionpack (= 6.0.3.7) + activesupport (= 6.0.3.7) method_source rake (>= 0.8.7) - thor (>= 0.19.0, < 2.0) + thor (>= 0.20.3, < 2.0) rake (13.0.3) rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) redis (3.3.5) rexml (3.2.5) - rspec-core (3.9.3) - rspec-support (~> 3.9.3) - rspec-expectations (3.9.4) + rgeo (2.3.0) + ros-apartment (2.9.0) + activerecord (>= 5.0.0, < 6.2) + parallel (< 2.0) + public_suffix (>= 2.0.5, < 5.0) + rack (>= 1.3.6, < 3.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-mocks (3.9.1) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-rails (3.9.1) - actionpack (>= 3.0) - activesupport (>= 3.0) - railties (>= 3.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-support (~> 3.9.0) - rspec-support (3.9.4) - ruby_dep (1.5.0) + rspec-support (~> 3.10.0) + rspec-rails (4.0.2) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.10) + rspec-expectations (~> 3.10) + rspec-mocks (~> 3.10) + rspec-support (~> 3.10) + rspec-support (3.10.2) + shoulda-matchers (4.5.1) + activesupport (>= 4.2.0) simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) @@ -286,15 +302,16 @@ GEM youtube_rails (1.2.2) yt (0.33.4) activesupport + zeitwerk (2.4.2) PLATFORMS ruby + x86_64-linux DEPENDENCIES actionview (>= 5.2.2.1) active_model_serializers (~> 0.10.0.rc3) acts-as-taggable-on (~> 5.0) - apartment cancancan (~> 2.0) capistrano-passenger capistrano-rails @@ -307,6 +324,7 @@ DEPENDENCIES factory_bot factory_bot_rails faker! + ferrum listen (>= 3.0.5, < 3.2) mini_magick mysql2 @@ -314,10 +332,12 @@ DEPENDENCIES puma (~> 4.3.0) rack (>= 2.0.6) rack-cors - rails (~> 5.2.0) + rails (~> 6.0.0) redis (~> 3.0) - rspec-rails (~> 3.5) - shoulda-matchers! + rgeo + ros-apartment + rspec-rails (~> 4.0.2) + shoulda-matchers (~> 4.5.1) spring spring-watcher-listen (~> 2.0.0) test-prof @@ -328,4 +348,4 @@ DEPENDENCIES yt BUNDLED WITH - 2.1.4 + 2.2.3 diff --git a/README.md b/README.md index b12d07b9..8e49c62d 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ The OpenTourBuilder API server provides a multi-tenant REST API for geographic t ## Requirements -- Ruby 2.4.1+ -- 5.2.x +- rbenv +- Ruby 2.7.2 - PostgreSQL 9.6.9 - Plugins* - pgcrypto @@ -14,9 +14,44 @@ The OpenTourBuilder API server provides a multi-tenant REST API for geographic t \* Database plugins are enabled during the install process +## Install Headless Chrome + +This is mostly taken from [this blog post](https://geekflare.com/install-chromium-ubuntu-centos/) and works on Ubuntu 20.04 + +~~~bash +sudo apt update +sudo apt install -y libappindicator1 fonts-liberation +wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb +sudo dpkg -i google-chrome*.deb +~~~ + +I always get some errors about missing/mis-matched dependencies. The following will fix that. + +~~~bash +sudo apt install -f +~~~ + +And try to install it again. + +~~~bash +sudo dpkg -i google-chrome*.deb +~~~ + +Assuming all went well, verify the install be running: + +~~~bash +google-chrome-stable -version +~~~ + +You should see something like: + +~~~bash +Google Chrome 91.0.4472.106 +~~~ + ## Build Status -[![Build Status](https://travis-ci.com/ecds/otb-api-server.svg?branch=develop)](https://travis-ci.com/ecds/otb-api-server) +TODO: Add CircleCI ## Installation @@ -70,6 +105,7 @@ cap deploy staging We use the [Git-Flow](https://danielkummer.github.io/git-flow-cheatsheet/) branching model. Please submit pull requests against the develop branch. ### Code of conduct + [Code of Conduct](CODE_OF_CONDUCT.md) ## License diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index 22d78d34..964a5d3e 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -2,7 +2,7 @@ class V3::FlatPagesController < V3Controller before_action :set_flat_page, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /v3/flat_pages def index @@ -18,27 +18,39 @@ def show # POST /v3/flat_pages def create - @flat_page = FlatPage.new(flat_page_params) + if @allowed + @flat_page = FlatPage.new(flat_page_params) - if @flat_page.save - render json: @flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@flat_page.id}" + if @flat_page.save + render json: @flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@flat_page.id}" + else + render json: @flat_page.errors, status: :unprocessable_entity + end else - render json: @flat_page.errors, status: :unprocessable_entity + head 401 end end # PATCH/PUT /v3/flat_pages/1 def update - if @flat_page.update(flat_page_params) - render json: @flat_page + if @allowed + if @flat_page.update(flat_page_params) + render json: @flat_page + else + render json: @flat_page.errors, status: :unprocessable_entity + end else - render json: @flat_page.errors, status: :unprocessable_entity + head 401 end end # DELETE /v3/flat_pages/1 def destroy - @flat_page.destroy + if @allowed + @flat_page.destroy + else + head 401 + end end private @@ -52,7 +64,7 @@ def flat_page_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :title, :content, :tours + :title, :body, :tours ] ) end diff --git a/app/controllers/v3/map_icons_controller.rb b/app/controllers/v3/map_icons_controller.rb new file mode 100644 index 00000000..8b6dd192 --- /dev/null +++ b/app/controllers/v3/map_icons_controller.rb @@ -0,0 +1,56 @@ +class V3::MapIconsController < ApplicationController + before_action :set_map_icon, only: [:show, :update, :destroy] + + # GET /map_icons + def index + @map_icons = MapIcon.all + + render json: @map_icons + end + + # GET /map_icons/1 + def show + render json: @map_icon + end + + # POST /map_icons + def create + @map_icon = MapIcon.new(map_icon_params) + + if @map_icon.save + render json: @map_icon, status: :created + else + render json: @map_icon.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /map_icons/1 + def update + if @map_icon.update(map_icon_params) + render json: @map_icon + else + render json: @map_icon.errors, status: :unprocessable_entity + end + end + + # DELETE /map_icons/1 + def destroy + @map_icon.destroy + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_map_icon + @map_icon = MapIcon.find(params[:id]) + end + + # Only allow a trusted parameter "white list" through. + def map_icon_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :base_sixty_four, :title + ] + ) + end +end diff --git a/app/controllers/v3/map_overlays_controller.rb b/app/controllers/v3/map_overlays_controller.rb new file mode 100644 index 00000000..911216fe --- /dev/null +++ b/app/controllers/v3/map_overlays_controller.rb @@ -0,0 +1,50 @@ +class V3::MapOverlaysController < V3Controller + before_action :set_map_overlay, only: [:show, :update, :destroy] + + def show + render json: @map_overlay + end + + def create + @map_overlay = MapOverlay.new(map_overlay_params) + if @map_overlay.save + render json: @map_overlay, status: :created + else + render json: @map_overlay.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /stops/1 + def update + if @map_overlay.update(map_overlay_params) + # render json: @stop + head :no_content + else + render json: @map_overlay.errors, status: :unprocessable_entity + end + end + + # DELETE /stops/1 + def destroy + if @map_overlay + @map_overlay.destroy + end + head :no_content + end + + private + + # Only allow a trusted parameter "white list" through. + def map_overlay_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :south, :east, :north, :west, :base_sixty_four, :title, :tour, :stop + ] + ) + end + + def set_map_overlay + @map_overlay = MapOverlay.find_by(id: params[:id]) + end +end diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 0b81bf0e..9f8de9e7 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -3,8 +3,8 @@ # app/controllers/v3/media_controller.rb module V3 class MediaController < V3Controller - before_action :set_medium, only: [:show, :update, :destroy] - authorize_resource + before_action :set_medium, only: [:show, :update, :destroy, :file] + #authorize_resource # GET /media def index # TODO: This ins not ideal, we use these `not_in_*` scopes to make the list of media avaliable to add @@ -18,7 +18,7 @@ def index end render json: @media end - + # GET /media/1 def show if @medium.published || current_user.id.present? @@ -53,25 +53,39 @@ def destroy @medium.destroy end - # private - # Use callbacks to share common setup or constraints between actions. - def set_medium - @medium = Medium.find(params[:id]) + def file + if @medium&.public_send("#{Apartment::Tenant.current.underscore}_file")&.attached? + if params[:context] == 'mobile' + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '200x200').processed, only_path: true) + elsif params[:context] == 'tablet' + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '300x300').processed, only_path: true) + elsif params[:context] == 'desktop' + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '750x750').processed, only_path: true) + else + redirect_to rails_blob_url(@medium.file) + end + else + head :not_found end + end - # Only allow a trusted parameter "white list" through. - def medium_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :title, :caption, :original_image, :stops, :tours, :video, :stop_id, :tour_id - ] - ) - end + # Use callbacks to share common setup or constraints between actions. + def set_medium + @medium = Medium.find(params[:id]) + end - def update_medium_params - self.medium_params.except(:original_image) - end + # Only allow a trusted parameter "white list" through. + def medium_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :title, :caption, :original_image, :stops, :tours, :video, :stop_id, :tour_id, :base_sixty_four, :video_provider, :embed + ] + ) + end + def update_medium_params + self.medium_params.except(:original_image) + end end end diff --git a/app/controllers/v3/stop_media_controller.rb b/app/controllers/v3/stop_media_controller.rb index 0e808ce1..13786e71 100644 --- a/app/controllers/v3/stop_media_controller.rb +++ b/app/controllers/v3/stop_media_controller.rb @@ -1,6 +1,6 @@ class V3::StopMediaController < V3Controller before_action :set_stop_medium, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /v3/stop_media def index diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index 6b3b359c..64634f18 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -5,7 +5,7 @@ class V3::StopsController < V3Controller # before_action :set_tour before_action :set_stop, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /stops def index @@ -29,7 +29,8 @@ def show render json: @stop, include: [ 'media', - 'stop_media' + 'stop_media', + 'map_icon' ] end @@ -67,7 +68,8 @@ def stop_params :title, :description, :lat, :lng, :parking_lat, :parking_lng, :media, :address, :tours, :direction_notes, - :meta_description + :meta_description, :parking_address, + :icon_color, :map_icon ] ) end diff --git a/app/controllers/v3/themes_controller.rb b/app/controllers/v3/themes_controller.rb index 33833f6a..a927570f 100644 --- a/app/controllers/v3/themes_controller.rb +++ b/app/controllers/v3/themes_controller.rb @@ -4,7 +4,7 @@ module V3 class ThemesController < V3Controller before_action :set_theme, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /themes def index diff --git a/app/controllers/v3/tour_collections_controller.rb b/app/controllers/v3/tour_collections_controller.rb index 8fbbd9ca..b2611a85 100644 --- a/app/controllers/v3/tour_collections_controller.rb +++ b/app/controllers/v3/tour_collections_controller.rb @@ -2,7 +2,7 @@ class V3::TourCollectionsController < V3Controller before_action :set_tour_collection, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /v3/tour_collections def index diff --git a/app/controllers/v3/tour_flat_pages_controller.rb b/app/controllers/v3/tour_flat_pages_controller.rb index 003be43e..3b4ed28d 100644 --- a/app/controllers/v3/tour_flat_pages_controller.rb +++ b/app/controllers/v3/tour_flat_pages_controller.rb @@ -2,7 +2,7 @@ class V3::TourFlatPagesController < V3Controller before_action :set_tour_flat_page, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /v3/tour_flat_pages def index @@ -18,27 +18,39 @@ def show # POST /v3/tour_flat_pages def create - @tour_flat_page = TourFlatPage.new(tour_flat_page_params) + if @allowed + @tour_flat_page = TourFlatPage.new(tour_flat_page_params) - if @tour_flat_page.save - render json: @tour_flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@tour_flat_page.id}" + if @tour_flat_page.save + render json: @tour_flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@tour_flat_page.id}" + else + render json: @tour_flat_page.errors, status: :unprocessable_entity + end else - render json: @tour_flat_page.errors, status: :unprocessable_entity + head 401 end end # PATCH/PUT /v3/tour_flat_pages/1 def update - if @tour_flat_page.update(tour_flat_page_params) - render json: @tour_flat_page + if @allowed + if @tour_flat_page.update(tour_flat_page_params) + render json: @tour_flat_page + else + render json: @tour_flat_page.errors, status: :unprocessable_entity + end else - render json: @tour_flat_page.errors, status: :unprocessable_entity + head 401 end end # DELETE /v3/tour_flat_pages/1 def destroy - @tour_flat_page.destroy + if @allowed + @tour_flat_page.destroy + else + head 401 + end end private diff --git a/app/controllers/v3/tour_media_controller.rb b/app/controllers/v3/tour_media_controller.rb index caec3225..bbc53869 100644 --- a/app/controllers/v3/tour_media_controller.rb +++ b/app/controllers/v3/tour_media_controller.rb @@ -1,6 +1,6 @@ class V3::TourMediaController < V3Controller before_action :set_tour_medium, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /v3/tour_media def index diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb index 309b79a9..f0314ad3 100644 --- a/app/controllers/v3/tour_modes_controller.rb +++ b/app/controllers/v3/tour_modes_controller.rb @@ -3,7 +3,7 @@ # app/controllers/v3/tour_modes_controller.rb module V3 class TourModesController < V3Controller - authorize_resource + #authorize_resource # GET /tour_sets def index diff --git a/app/controllers/v3/tour_set_admins_controller.rb b/app/controllers/v3/tour_set_admins_controller.rb index 14d05fe7..f4c5df8a 100644 --- a/app/controllers/v3/tour_set_admins_controller.rb +++ b/app/controllers/v3/tour_set_admins_controller.rb @@ -3,13 +3,17 @@ module V3 class TourSetAdminsController < V3Controller before_action :set_tour_set_admin, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /tour_set_admins def index - @tour_set_admins = TourSetAdmin.all + if current_user && current_user.super + @tour_set_admins = TourSetAdmin.all - render json: @tour_set_admins + render json: @tour_set_admins + else + head 401 + end end # GET /tour_set_admins/1 diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index cfbbb149..f8f99a09 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -4,68 +4,66 @@ # app/controllers/v3/tour_sets.rb module V3 class TourSetsController < V3Controller - before_action :set_tour_set, only: [:show, :update, :destroy] - authorize_resource + before_action :set_record, only: [:show, :update, :destroy] + #authorize_resource # GET /tour_sets def index - @tour_sets = [] + @records = [] if params[:subdir] && params[:subdir] != 'public' - @tour_sets = TourSet.find_by(subdir: params[:subdir]) + @records = TourSet.where(subdir: params[:subdir]) elsif current_user.super - @tour_sets = TourSet.all + @records = TourSet.all elsif current_user.id.present? - @tour_sets = current_user.tour_sets + @records = current_user.tour_sets else #TourSet.all.reject {|ts| p ts.tours.empty?} - @tour_sets = TourSet.all.reject { |ts| ts.published_tours.empty? } + @records = TourSet.all.reject { |ts| ts.published_tours.empty? } end - + if current_user.current_tenant_admin? || current_user.super - render json: @tour_sets, include: [ 'admins' ] + render json: @records, include: [ 'admins' ] else - render json: @tour_sets + render json: @records end end # GET /tour_sets/1 def show - render json: @tour_set + render json: @record end # POST /tour_sets def create - @tour_set = TourSet.new(tour_set_params) + @record = TourSet.new(tour_set_params) - if @tour_set.save - # render json: @tour_set, status: :created, location: @tour_set - response = render json: @tour_set, status: :created, location: "/#{Apartment::Tenant.current}/#{@tour_set.id}" - return response + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" else - render json: @tour_set.errors, status: :unprocessable_entity + render json: @record.errors, status: :unprocessable_entity end end # PATCH/PUT /tour_sets/1 def update - if @tour_set.update(tour_set_params) - # render json: @tour_set + if @record.update(tour_set_params) + # render json: @record head :no_content else - render json: @tour_set.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /tour_sets/1 def destroy - @tour_set.destroy + @record.destroy end private # Use callbacks to share common setup or constraints between actions. - def set_tour_set - @tour_set = TourSet.find(params[:id]) + def set_record + @record = TourSet.find(params[:id]) end # Only allow a trusted parameter "white list" through. @@ -73,7 +71,7 @@ def tour_set_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :name, :tours, :admins + :name, :tours, :admins, :base_sixty_four, :logo_title ] ) end diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index 0099e341..e9392f81 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -3,7 +3,7 @@ # /app/controllers/v3/tour_stops_controller.rb class V3::TourStopsController < V3Controller before_action :set_tour_stop, only: [:show, :update, :destroy] - authorize_resource + #authorize_resource # GET /stops def index diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 7a66090b..9c6ee336 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -4,8 +4,8 @@ # module V3 class V3::ToursController < V3Controller before_action :set_tour, only: [:show, :update, :destroy] - # authorize_resource - load_and_authorize_resource + # #authorize_resource + #load_and_#authorize_resource # GET /tours def index @@ -47,30 +47,20 @@ def index # GET /tours/1 def show - render json: @tour, - include: [ - 'tour_modes', - 'tour_stops', - 'stops', - 'stops.media', - 'stops.stop_media', - 'mode', - 'modes', - 'theme', - 'media', - 'tour_media', - 'flat_pages', - 'tour_flat_pages' - ] + render json: @tour end # POST /tours def create - @tour = Tour.new(tour_params) - if @tour.save - render json: @tour, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" + if current_user.current_tenant_admin? + @tour = Tour.new(tour_params) + if @tour.save + render json: @tour, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" + else + render json: @tour.errors, status: :unprocessable_entity + end else - render json: @tour.errors, status: :unprocessable_entity + head 401 end end @@ -117,7 +107,7 @@ def tour_params :is_geo, :modes, :published, :theme_id, :mode, :meta_description, :stops, :media, :authors, :flat_pages, :map_type, - :theme + :theme, :use_directions, :default_lng ] ) end diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index ce105b3a..7d61033b 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -8,7 +8,7 @@ module V3 class UsersController < V3Controller before_action :set_user, only: [:show, :update, :destroy] before_action :authenticate!, only: :me - authorize_resource + #authorize_resource # GET /users def index diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index 2b32bbed..11890b1d 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -2,9 +2,24 @@ class V3Controller < ApplicationController include EcdsRailsAuthEngine::CurrentUser - # check_authorization + before_action :allowed?, only: [:create, :update, :destroy] - rescue_from CanCan::AccessDenied do |exception| - head 401 + def serialize_errors + errors = [] + @record.errors.messages[:base].each do |error| + errors.push({ + detail: error, + source: { + pointer: 'data/attributes' + } + }) + end + { errors: errors } end + + private + + def allowed? + @allowed = current_user && current_user.current_tenant_admin? + end end diff --git a/app/models/ability.rb b/app/models/ability.rb deleted file mode 100644 index 834b8104..00000000 --- a/app/models/ability.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class Ability - include CanCan::Ability - - def initialize(user) - can :read, Tour, published: true - can :read, Slug, published: true - can :read, Theme - can :read, Mode - can :read, TourMode - can :read, Stop - can :read, TourStop - can :read, TourMedium - can :read, StopMedium - can :read, Medium - can :read, FlatPage - can [:read], TourSet - can :read, User - return if user.nil? - return unless user.id.present? - can [:read, :edit, :update], Tour - can [:manage], TourMedium - can [:manage], Medium - can [:manage], TourStop - can [:manage], Stop - can [:manage], StopMedium - can [:manage], FlatPage - can [:manage], TourFlatPage - can [:manage], Mode - can [:manage], TourMode - can [:manage], User - return unless user.current_tenant_admin? - can [:manage], Tour - can [:manage], TourSetAdmin - can [:read, :edit, :update], TourSet - can [:read, :edit, :update], User - # can :manage, Stop - # can :manage, TourStop - # can :manage, TourMedium - # can :manage, StopMedium - # can :manage, Medium - # can :manage, FlatPage - return unless user.super - can :manage, :all - end -end diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index bc82c22c..ab06d326 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -1,79 +1,53 @@ # frozen_string_literal: true +require 'open-uri' + module VideoProps extend ActiveSupport::Concern def self.props(medium) return if medium.video.nil? - if self.is_vimeo(medium) - medium.provider = 'vimeo' - medium.video = vimeo_id(medium) + case medium.video_provider + when 'keiner' + nil + when 'vimeo' metadata = HTTParty.get("https://vimeo.com/api/oembed.json?url=https%3A//vimeo.com/video/#{medium.video}") medium.title = metadata['title'] medium.caption = metadata['description'] - image = metadata['thumbnail_url'] - medium.remote_original_image_url = metadata['thumbnail_url'] - medium.embed = "" - - elsif self.is_youtube(medium) - medium.provider = 'youtube' - medium.video = youtube_id(medium) + medium.embed = "//player.vimeo.com/video/#{medium.video}" + downloaded_image = open(metadata['thumbnail_url']) + medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.video}.jpg") + when 'youtube' begin metadata = Yt::Video.new(id: medium.video) medium.title = metadata.title medium.caption = metadata.description - medium.remote_original_image_url = "https://img.youtube.com/vi/#{medium.video}/0.jpg" - medium.embed = %Q[" - # end - # medium.save - # end - - # - - - def self.is_youtube(medium) - if medium.provider == 'vimeo' - return false - end - # FIXME Youtube is always going to return 200 - (medium.video.include? 'youtube.com') || (medium.video.include? 'youtu.be') || (!medium.video.include?('iframe') && HTTParty.get("https://www.youtube.com/watch?v=#{medium.video}").code == 200) - end - - def self.is_vimeo(medium) - if medium.provider == 'youtube' - return false - end - (medium.video.include? 'vimeo') || (!medium.video.include?('iframe') && HTTParty.get("https://vimeo.com/#{medium.video}").code == 200) - end - - def self.youtube_id(medium) - if medium.video.include? 'iframe' - YouTubeRails.extract_video_id(Nokogiri::HTML(medium.video).xpath('//iframe')[0]['src']) - elsif medium.video.include?('youtu') - YouTubeRails.extract_video_id(medium.video) - else - medium.video - end - end + when 'soundcloud' + if medium.video.include?('iframe') + embed_code = Nokogiri::HTML(medium.video) + titles = embed_code.xpath('//a').map { |a| a[:title] } + if titles.length > 1 + medium.title = titles.join(': ') + else + medium.title = titles.first + end + medium.video = embed_code.xpath('//iframe', 'src').first['src'].split('&').first[/(.*tracks\/)(.*)/,2] + medium.embed = "//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false" + browser = Ferrum::Browser.new() + browser.go_to("https:#{medium.embed}") + spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? + image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] + downloaded_image = open("https:#{image}") + medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") + end - def self.vimeo_id(medium) - if medium.video.include? 'iframe' - Nokogiri::HTML(medium.video).xpath('//iframe')[0]['src'].split('/')[-1] - else - /\d{7,10}/.match(medium.video)[0] - # /https?:\/\/{?:www\.}?vimeo.com\/{?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|album\/(\d+)\/video\/|)(\d+)(?:$|\/|\?)/.match(medium.video)[0] end end end diff --git a/app/models/flat_page.rb b/app/models/flat_page.rb index 1eb2e524..c89d7e5c 100644 --- a/app/models/flat_page.rb +++ b/app/models/flat_page.rb @@ -4,4 +4,12 @@ class FlatPage < ApplicationRecord has_many :tour_flat_pages has_many :tours, through: :tour_flat_pages validates :title, presence: true + + def slug + title.parameterize + end + + def orphaned + tours.empty? + end end diff --git a/app/models/map_icon.rb b/app/models/map_icon.rb new file mode 100644 index 00000000..738835ad --- /dev/null +++ b/app/models/map_icon.rb @@ -0,0 +1,2 @@ +class MapIcon < MediumBaseRecord +end diff --git a/app/models/map_overlay.rb b/app/models/map_overlay.rb new file mode 100644 index 00000000..2b0a9f3b --- /dev/null +++ b/app/models/map_overlay.rb @@ -0,0 +1,15 @@ +class MapOverlay < MediumBaseRecord + before_create :set_initial_bounds + + belongs_to :tour, optional: true + belongs_to :stop, optional: true + + def set_initial_bounds + if tour + self.south = self.tour.bounds[:south] + self.north = self.tour.bounds[:north] + self.east = self.tour.bounds[:east] + self.west = self.tour.bounds[:west] + end + end +end diff --git a/app/models/medium.rb b/app/models/medium.rb index 9c43678d..edb73354 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -1,21 +1,32 @@ # frozen_string_literal: true # Model for media associated with stops. -class Medium < ApplicationRecord +class Medium < MediumBaseRecord include VideoProps include Rails.application.routes.url_helpers - before_validation :props + before_create :props + before_update :remove_tmp_file - mount_base64_uploader :original_image, MediumUploader + # has_one_attached :file do |attachable| + # attachable.variant :mobile, resize: '200x200' + # attachable.variant :tablet, resize: '300x300' + # attachable.variant :desktop, resize: '750x750' + # end + + # mount_base64_uploader :original_image, MediumUploader has_many :stop_media has_many :stops, through: :stop_media has_many :tour_media has_many :tours, through: :tour_media - validates_presence_of :original_image + enum video_provider: { keiner: 0, vimeo: 1, youtube: 2, soundcloud: 3 } + + # validates_presence_of :original_image attr_accessor :insecure + + # TODO: This is not ideal, we use these `not_in_*` scopes to make the list of media avaliable to add # to a stop or tour. But the paramerter does not make sense when just looking at it. Needs clearer language. scope :not_in_stop, lambda { |stop_id| includes(:stop_media).where.not(stop_media: { stop_id: stop_id }) } @@ -33,32 +44,42 @@ def props VideoProps.props(self) end - def desktop - original_image.desktop.url - end + # def desktop + # original_image.desktop.url + # end - def tablet - original_image.tablet.url - end + # def tablet + # original_image.tablet.url + # end - def mobile - original_image.mobile_list_thumb.url - end + # def mobile + # original_image.mobile_list_thumb.url + # end - def mobile_thumb - original_image.mobile_list_thumb.url - end + # def mobile_thumb + # original_image.mobile_list_thumb.url + # end def published # This works and is shorter, but I think the longer way is more readable/clear. # tours.published.present? || stops { |s| s.tours.published }.present? - tours.collect(&:published).include?(true) || stops.map {|s| s.tours.collect(&:published)}.flatten.include?(true) + tours.collect(&:published).include?(true) || stops.map { |s| s.tours.collect(&:published) }.flatten.include?(true) + end + + def files + return nil if !self.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + { + mobile: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=mobile", + tablet: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=tablet", + desktop: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=desktop" + } end def srcset - "#{ENV['BASE_URL']}#{self.mobile} #{mobile_width}w, \ - #{ENV['BASE_URL']}#{self.tablet} #{tablet_width}w, \ - #{ENV['BASE_URL']}#{self.desktop} #{desktop_width}w" + nil + # "#{ENV['BASE_URL']}#{self.mobile} #{mobile_width}w, \ + # #{ENV['BASE_URL']}#{self.tablet} #{tablet_width}w, \ + # #{ENV['BASE_URL']}#{self.desktop} #{desktop_width}w" end def srcset_sizes @@ -66,19 +87,14 @@ def srcset_sizes end def insecure - "#{ENV['INSECURE_IMAGE_BASE_URL']}#{self.desktop}" - end - - def base64 - if self.original_image.file && File.file?(self.original_image.file.path) - return Base64.encode64(self.original_image.file.read) - end nil + # "#{ENV['INSECURE_IMAGE_BASE_URL']}#{self.desktop}" end - # private - - # def has_image - # original_image.present? || remote_original_image_url.present? + # def base64 + # if self.original_image.file && File.file?(self.original_image.file.path) + # return Base64.encode64(self.original_image.file.read) # end -end \ No newline at end of file + # nil + # end +end diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb new file mode 100755 index 00000000..9e36a8cb --- /dev/null +++ b/app/models/medium_base_record.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Base class for models. +class MediumBaseRecord < ApplicationRecord + self.abstract_class = true + before_create :attach_file + + has_one_attached "#{Apartment::Tenant.current.underscore}_file" + + def tmp_file_path + return Rails.root.join('public', 'storage', 'tmp', title) if self.title + nil + end + + # + # Create and attach file from Base64 string. + # + # This should only be called once when a new medium obeject is created via the API + # It assumes + # + # Some code taken from https://github.com/rootstrap/active-storage-base64/blob/v1.2.0/lib/active_storage_support/base64_attach.rb#L17-L32 + # + # + def attach_file + return if base_sixty_four.nil? + + headers, self.base_sixty_four = base_sixty_four.split(',') + headers =~ /^data:(.*?)$/ + content_type = Regexp.last_match(1).split(';base64').first + File.open(tmp_file_path, 'wb') do |f| + f.write(Base64.decode64(base_sixty_four)) + end + + self.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + io: File.open(tmp_file_path), + filename: title, + content_type: content_type + ) + end + + def remove_tmp_file + nil + end +end diff --git a/app/models/stop.rb b/app/models/stop.rb index aeaa5902..75a5a0db 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -9,6 +9,7 @@ class Stop < ApplicationRecord has_many :stop_media has_many :media, through: :stop_media belongs_to :medium, optional: true + belongs_to :map_icon, optional: true has_many :stop_slugs, dependent: :delete_all validates :title, presence: true @@ -54,9 +55,9 @@ def splash_width end def insecure_splash - if !stop_media.empty? - return medium.nil? ? stop_media.order(:position).first.medium.insecure : medium.insecure - end + # if !stop_media.empty? + # return medium.nil? ? stop_media.order(:position).first.medium.insecure : medium.insecure + # end nil end @@ -76,6 +77,6 @@ def default_values end def ensure_slug - StopSlug.find_or_create_by(slug: self.slug, stop: self) + tour_stops.each { |ts| ts.save } end end diff --git a/app/models/stop_slug.rb b/app/models/stop_slug.rb index 44a5c955..332f30e9 100644 --- a/app/models/stop_slug.rb +++ b/app/models/stop_slug.rb @@ -1,4 +1,5 @@ class StopSlug < ApplicationRecord belongs_to :stop + belongs_to :tour # validates :slug, uniqueness: true end diff --git a/app/models/tour.rb b/app/models/tour.rb index a7ec2e52..bb96c24e 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -16,10 +16,17 @@ class Tour < ApplicationRecord has_many :tour_authors has_many :authors, through: :tour_authors, source: :user has_many :slugs, dependent: :delete_all + has_one :map_overlay # has_many :authors, through: :tour_authors, foreign_key: :user_id # belongs_to :splash_image_medium_id, class_name: 'Medium' belongs_to :theme, default: -> { Theme.first } + + enum default_lng: { + en: 0, fr: 1, de: 2, pl: 3, nl: 4, fi: 5, sv: 6, it: 7, es: 8, pt: 9, + ru: 10, "pt-BR": 11, "es-MX": 12, "zh-CN": 13, "zh-TW": 14, ja: 15, ko: 16 + } + validates :title, presence: true before_validation -> { self.mode ||= Mode.last } @@ -75,9 +82,9 @@ def splash_width end def insecure_splash - if !tour_media.empty? - return medium.nil? ? tour_media.order(:position).first.medium.insecure : medium.insecure - end + # if !tour_media.empty? + # return medium.nil? ? tour_media.order(:position).first.medium.insecure : medium.insecure + # end nil end @@ -85,10 +92,29 @@ def stop_count self.stops.count end + def bounds + return nil if stops.empty? + + points = stops.map { |s| RGeo::Geographic.spherical_factory.point(s.lng, s.lat) } + box = RGeo::Cartesian::BoundingBox.create_from_points(points.pop, points.pop) + points.each { |p| box.add(p) } + + { + south: box.min_y, + north: box.max_y, + east: box.max_x, + west: box.min_x, + centerLat: box.center_y, + centerLng: box.center_x + } + end + private def ensure_slug - Slug.find_or_create_by(slug: self.slug, tour: self) + new_slug = Slug.find_or_create_by(slug: self.slug) + new_slug.tour = self + new_slug.save end def add_modes diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index c1ccefc7..c7290240 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -2,19 +2,20 @@ # Model class for tour sets. This is the main model for "instances" of Open Tour Builder. class TourSet < ApplicationRecord - mount_uploader :logo, LogoUploader - mount_uploader :footer_logo, LogoFooterUploader - has_many :tour_set_admins - has_many :admins, through: :tour_set_admins, source: :user + before_validation :attach_file before_save :set_subdir after_create :create_tenant - after_create :create_defaults + # after_create :create_defaults before_destroy :drop_tenant + validates :name, presence: true, uniqueness: true - # attr_accessor :footer_logo_width - # attr_accessor :footer_logo_height + + has_one_attached :logo + + has_many :tour_set_admins + has_many :admins, through: :tour_set_admins, source: :user + attr_accessor :published_tours - # validate :validate_footer_logo_dimensions, if :uploading? def published_tours begin @@ -75,22 +76,78 @@ def drop_tenant Apartment::Tenant.drop(subdir) end - def symlink_logo - FileUtils.mkdir "#{Rails.root}/public/uploads/#{self.subdir}" - FileUtils.ln_s "#{Rails.root}/public/otblogo.png", - "#{Rails.root}/public/uploads/#{self.subdir}/otblogo.png" - self.logo = 'otblogo.png' - self.footer_logo = 'otblogo.png' + # def symlink_logo + # FileUtils.mkdir "#{Rails.root}/public/uploads/#{self.subdir}" + # FileUtils.ln_s "#{Rails.root}/public/otblogo.png", + # "#{Rails.root}/public/uploads/#{self.subdir}/otblogo.png" + # self.logo = 'otblogo.png' + # self.footer_logo = 'otblogo.png' + # end + + # def uploading? + # footer_logo_width.present? && footer_logo_height.present? + # end + + def tmp_file_path + return nil if logo_title.nil? + + Rails.root.join('public', 'storage', 'tmp', logo_title) + end + + # + # Create and attach file from Base64 string. + # + # This should only be called once when a new medium obeject is created via the API + # It assumes + # + # Some code taken from https://github.com/rootstrap/active-storage-base64/blob/v1.2.0/lib/active_storage_support/base64_attach.rb#L17-L32 + # + # + def attach_file + return if base_sixty_four.nil? + + headers, self.base_sixty_four = base_sixty_four.split(',') + headers =~ /^data:(.*?)$/ + content_type = Regexp.last_match(1).split(';base64').first + File.open(tmp_file_path, 'wb') do |f| + f.write(Base64.decode64(base_sixty_four)) + end + + self.logo.attach( + io: File.open(tmp_file_path), + filename: logo_title, + content_type: content_type + ) + + validate_logo + end + + def validate_logo + if logo.attached? + valitate_logo_type + validate_logo_dimensions + + if errors + # File.delete(tmp_file_path) + # logo.purge + end + end end - def uploading? - footer_logo_width.present? && footer_logo_height.present? + def valitate_logo_type + types = %w[jpeg jpg png svg] + unless types.any? { |type| logo.content_type.include?(type) } + errors[:base] << "Logo must be one of the following types #{types.join(', ')}" + end end - def validate_footer_logo_dimensions - ::Rails.logger.info "Footer logo upload dimensions: #{self.footer_logo_width}x#{self.footer_logo_height}" - if self.footer_logo_width != footer_logo_height - errors.add :footer_logo, 'Footer logo should be square' + def validate_logo_dimensions + image = MiniMagick::Image.open(tmp_file_path) + if image[:width] > 300 + errors[:base] << 'Logo cannot be wider than 300 pixels.' + end + if image[:height] > 80 + errors[:base] << 'Logo cannot be taller than 80 pixels.' end end end diff --git a/app/models/tour_stop.rb b/app/models/tour_stop.rb index 9f4d5769..ce93b185 100644 --- a/app/models/tour_stop.rb +++ b/app/models/tour_stop.rb @@ -7,6 +7,7 @@ class TourStop < ApplicationRecord validates :position, presence: true + before_save :_ensure_stop_slug before_validation :_set_position before_destroy :_delete_orphan @@ -45,4 +46,10 @@ def _delete_orphan self.stop.destroy end end + + def _ensure_stop_slug + new_slug = StopSlug.find_or_create_by(slug: self.stop.slug, tour: self.tour) + new_slug.stop = self.stop + new_slug.save + end end diff --git a/app/models/user.rb b/app/models/user.rb index 3049f278..531d95c9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,15 @@ def current_tenant_admin? return false if tour_sets.empty? tour_sets.map(&:subdir).include? Apartment::Tenant.current end -end + def provider + return nil if login.nil? + login.provider + end + + private + def login + EcdsRailsAuthEngine::Login.find_by(user_id: self.id) + end +end diff --git a/app/serializers/v3/flat_page_serializer.rb b/app/serializers/v3/flat_page_serializer.rb index e386865b..92bdb54d 100644 --- a/app/serializers/v3/flat_page_serializer.rb +++ b/app/serializers/v3/flat_page_serializer.rb @@ -1,3 +1,3 @@ class V3::FlatPageSerializer < ActiveModel::Serializer - attributes :id, :title, :content + attributes :id, :title, :body, :slug, :orphaned end diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb new file mode 100644 index 00000000..b5d5d9d0 --- /dev/null +++ b/app/serializers/v3/map_icon_serializer.rb @@ -0,0 +1,9 @@ +class V3::MapIconSerializer < ActiveModel::Serializer + include Rails.application.routes.url_helpers + attributes :id, :base_sixty_four, :title, :image_url + + def image_url + return nil unless object.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + rails_blob_url(object.public_send("#{Apartment::Tenant.current.underscore}_file")) + end +end diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb new file mode 100644 index 00000000..1c0bd4e5 --- /dev/null +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -0,0 +1,9 @@ +class V3::MapOverlaySerializer < ActiveModel::Serializer + include Rails.application.routes.url_helpers + attributes :id, :south, :north, :east, :west, :image_url, :title + + def image_url + return nil unless object.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + rails_blob_url(object.public_send("#{Apartment::Tenant.current.underscore}_file")) + end +end diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index a6a5283a..a5a66e59 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -1,5 +1,15 @@ # frozen_string_literal: true class V3::MediumSerializer < ActiveModel::Serializer - attributes :id, :title, :caption, :desktop, :tablet, :mobile, :video, :provider, :original_image, :embed, :srcset, :srcset_sizes, :insecure, :base64 + # include Rails.application.routes.url_helpers + attributes :id, :title, :caption, :video, :provider, :original_image, :embed, :srcset, :srcset_sizes, :insecure, :files + + # def files + # return nil unless object.file.attached? + # { + # mobile: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '200x200').processed), + # tablet: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '300x300').processed), + # desktop: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '750x750').processed) + # } + # end end diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index 7c8aebf7..001fa213 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -4,5 +4,6 @@ class V3::StopSerializer < ActiveModel::Serializer has_many :media has_many :stop_media has_many :tours - attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash, :insecure_splash, :splash_width, :splash_height, :orphaned + belongs_to :map_icon + attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash, :insecure_splash, :splash_width, :splash_height, :orphaned, :icon_color end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 3d138c6d..e4e4163c 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -2,5 +2,6 @@ # app/serializers/tour_serializer.rb class V3::TourBaseSerializer < ActiveModel::Serializer - attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash + has_one :map_overlay + attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash, :use_directions, :default_lng end diff --git a/app/serializers/v3/tour_serializer.rb b/app/serializers/v3/tour_serializer.rb index 1f08b9fa..b3890617 100644 --- a/app/serializers/v3/tour_serializer.rb +++ b/app/serializers/v3/tour_serializer.rb @@ -12,4 +12,6 @@ class V3::TourSerializer < V3::TourBaseSerializer has_many :tour_media has_many :flat_pages has_many :tour_flat_pages + + attributes :bounds end diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index 5075e24a..286748f3 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -2,10 +2,16 @@ class V3::TourSetSerializer < ActiveModel::Serializer # attribute :tenant_admins + include Rails.application.routes.url_helpers has_many :admins - attributes :id, :name, :subdir, :published_tours + attributes :id, :name, :subdir, :published_tours, :logo_url def admins object.admins if current_user.super || current_user.current_tenant_admin? end + + def logo_url + return nil unless object.logo.attached? + rails_blob_url(object.logo) + end end diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index 2dba157c..f11476c8 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -4,7 +4,7 @@ class V3::UserSerializer < ActiveModel::Serializer has_many :tours has_many :tour_sets - attributes :id, :display_name, :super, :current_tenant_admin + attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email def current_tenant_admin object.current_tenant_admin? diff --git a/bin/bundle b/bin/bundle index 58115ecf..f19acf5b 100755 --- a/bin/bundle +++ b/bin/bundle @@ -1,5 +1,3 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) load Gem.bin_path('bundler', 'bundle') diff --git a/bin/rails b/bin/rails index a3655392..07396602 100755 --- a/bin/rails +++ b/bin/rails @@ -1,11 +1,4 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -begin - load File.expand_path('../spring', __FILE__) -rescue LoadError => e - raise unless e.message.include?('spring') -end APP_PATH = File.expand_path('../config/application', __dir__) require_relative '../config/boot' require 'rails/commands' diff --git a/bin/rake b/bin/rake index 169c9396..17240489 100755 --- a/bin/rake +++ b/bin/rake @@ -1,11 +1,4 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -begin - load File.expand_path('../spring', __FILE__) -rescue LoadError => e - raise unless e.message.include?('spring') -end require_relative '../config/boot' require 'rake' Rake.application.run diff --git a/bin/setup b/bin/setup index 1a8bfdd8..a334d86a 100755 --- a/bin/setup +++ b/bin/setup @@ -1,12 +1,9 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") @@ -20,7 +17,6 @@ chdir APP_ROOT do system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') - # puts "\n== Copying sample files ==" # unless File.exist?('config/database.yml') # cp 'config/database.yml.sample', 'config/database.yml' diff --git a/bin/update b/bin/update index fdac831b..67d0d496 100755 --- a/bin/update +++ b/bin/update @@ -1,12 +1,9 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") diff --git a/config/application.rb b/config/application.rb index 5a9115a6..557d0a59 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,6 +15,7 @@ # require "sprockets/railtie" require 'rails/test_unit/railtie' require 'apartment/elevators/generic' +require 'active_storage/engine' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -28,7 +29,7 @@ def parse_tenant_name(request) # request is an instance of Rack::Request tenant_name = request.fullpath.split('/')[1] - if tenant_name == 'auth' + if tenant_name == 'auth' || tenant_name == 'rails' return nil end tenant_name diff --git a/config/cable.yml b/config/cable.yml index ed7b5e43..4a3c0b3c 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -6,5 +6,5 @@ test: production: adapter: redis - url: redis://localhost:6379/1 + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: open_tour_api_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 00000000..c3559e0c --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +U3sJJBzxa5h2ZICh7BPbOpwYGXaS9S/xsXPQIppuXRkA880M8QS65yv7q+N+d+RbjaU6oCZ5Z3Nd5ag46RSFf2DPLqzWni4WyiwrIYNApCvKOGZzEHMRvXG7w19yGYaVMDIi7VwGkuckADS6E8Gxn3zVeJWV/PrNlYxiuDIe/fxYGVmlt29kFOvMif4qlcP4gtBbWgdHdpEBw95V4H5ASyQ+0dvmwm+WSg8smqhVPM7hN/c5RoC8R40tYpZJFHT91b1J86PXGjiNyp3z6Pg4AASOLoI+CZLaWlEqSUg+NAC7UhEaMI84rrvtslzTP7Pm4z+JfdaTBDL+VqdthGwiG4QstzCL5Y2zdER2t1FG8EC0YrYgWDbxZNUYWFD3UymT6fyM1KCIQSgEAJl9idgNiAcYWmP1/Wv3gr971d5Z+BpoehdVGhqLo2n1X+QPWVkDspfWLw86uFH83rSVjjYFFQP0ZEEGdQa8VPP5tfl+wobsELWqTZbJti5dQgrxqd4rixQ++pMY1KLv0IuLnjFS0YY3GB9nlDm9jSi6cq4UFxGySO6xLKnAYsuMPYVPzj/XaxET7V6nQyq7s0itPN64+yJbglG9NQFlAD/Hr+VDhV3LuQA6G22v2u2dtkRxDxBkNscVZ+02JpLtCZuswZ6seKNv1NDiyxCcG7tcNJDqmi3r/mCBmGkBcl5wQneop3nMOFPIjGIX+RJK4xa+7fE3HyMLRk7/Ks5exQ8FnYfNT4fZAzRM8lbcvrlkg3W/7ZxxQz+BXwvjdx+IgnMkjBKVx7agzqz8RIx2fqYKFwY78haaEE8wFMwYSmTtH4tqGn5h6TkwnLG8BWWojtqCgmtpnoJC8c7UcQU1OpQQiXCa5EOapFbYc/QiHH9iW8fK5FN4UcldMi1cOLC3L20qBSPGfEmpXTJe5SvZz21shMu8Xba9yvlixqTo5muvZ/72ZqKMRALQLx4Ayg==--IVPhK32JiKCvjjzX--GtRzbA1AMYZ2hJiXU8QDSw== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000..dc94b08d --- /dev/null +++ b/config/database.yml @@ -0,0 +1,36 @@ +default: &default + adapter: postgresql + encoding: utf8 + pool: 50 + schema_search_path: "public,shared_extensions" + +mysql: &mysql + adatabase: <%= Rails.application.credentials.dig(:dbTest, :mysql, :db) %> + username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> + password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> + host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> + + +development: + <<: *default + database: <%= Rails.application.credentials.dig(:dbDev, :db) %> + username: <%= Rails.application.credentials.dig(:dbDev, :user) %> + password: <%= Rails.application.credentials.dig(:dbDev, :pw) %> + host: <%= Rails.application.credentials.dig(:dbDev, :host) %> + +staging: + <<: *default + database: <%= Rails.application.credentials.dig(:rdsStaging, :db) %> + username: <%= Rails.application.credentials.dig(:rdsStaging, :user) %> + password: <%= Rails.application.credentials.dig(:rdsStaging, :pw) %> + host: <%= Rails.application.credentials.dig(:rdsStaging, :host) %> + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: <%= Rails.application.credentials.dig(:dbTest, :postgres, :db) %> + username: <%= Rails.application.credentials.dig(:dbTest, :postgres, :user) %> + password: <%= Rails.application.credentials.dig(:dbTest, :postgres, :pw) %> + host: <%= Rails.application.credentials.dig(:dbTest, :postgres, :host) %> diff --git a/config/deploy.rb b/config/deploy.rb index 15f21300..479c8cf6 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -23,12 +23,12 @@ # set :pty, true # Default value for :linked_files is [] -append :linked_files, 'config/database.yml', 'config/initializers/auth.rb', 'config/secrets.yml' +append :linked_files, 'config/master.key' # append :linked_files, 'config/database.yml' # Default value for linked_dirs is [] -append :linked_dirs, 'public/uploads' +# append :linked_dirs, 'public/uploads' # Default value for default_env is {} # set :default_env, { path: '/opt/ruby/bin:$PATH' } diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb index 1a72db53..e51a7e35 100644 --- a/config/deploy/staging.rb +++ b/config/deploy/staging.rb @@ -7,7 +7,7 @@ # Defines a single server with a list of roles and multiple properties. # You can define all roles on a single server, or split them: -server "otb.ecdsdev.org", user: "deploy", roles: %w{app db web}, primary: :my_value +server "3.81.27.251", user: "deploy", roles: %w{app db web}, primary: :my_value # server "otb.ecdsdev.org", user: "deploy", roles: %w{app web}, other_property: :other_value # server "db.otb.ecdsdev.org", user: "deploy", roles: %w{db} @@ -21,9 +21,9 @@ # property set. Specify the username and a domain or IP for the server. # Don't use `:all`, it's a meta role. -# role :app, %w{deploy@otb.ecdsdev.org}, my_property: :my_value -# role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value -# role :db, %w{deploy@otb.ecdsdev.org} +role :app, %w{deploy@3.81.27.251} +role :web, %w{user1@3.81.27.251} +role :db, %w{deploy@3.81.27.251} @@ -45,11 +45,13 @@ # # Global options # -------------- -# set :ssh_options, { -# keys: %w(/home/rlisowski/.ssh/id_rsa), -# forward_agent: false, -# auth_methods: %w(password) -# } + set :ssh_options, { + forward_agent: false, + auth_methods: %w(publickey) + } + + set :branch, 'develop', + set :deploy_to, '/data/otb-api' # # The server-based syntax can be used to override options: # ------------------------------------ diff --git a/config/environments/development.rb b/config/environments/development.rb index 12ba559e..747b0597 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -2,7 +2,8 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - + config.hosts << 'otb.org' + Rails.application.routes.default_url_options[:host] = 'https://otb.org:3000' # In the development environment your application's code is reloaded on # every request. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. @@ -14,6 +15,9 @@ # Show full error reports. config.consider_all_requests_local = true + # Active Storage + config.active_storage.service = :local + # Enable/disable caching. By default caching is disabled. if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true diff --git a/config/environments/production.rb b/config/environments/production.rb index 7118d532..1a18af4c 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -3,6 +3,9 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :amazon + # Code is not reloaded between requests. config.cache_classes = true diff --git a/config/environments/staging.rb b/config/environments/staging.rb index d79dd7cb..91140591 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -49,4 +49,6 @@ config.file_watcher = ActiveSupport::FileUpdateChecker ENV['BASE_URL'] = 'https://otb-api.ecdsdev.org' + + config.active_storage.service = :staging end diff --git a/config/environments/test.rb b/config/environments/test.rb index 5971b4bf..fdf5c9d3 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -3,6 +3,9 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 2b6faae7..951a7415 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -3,6 +3,6 @@ # require 'directory_elevator' Apartment.configure do |config| config.tenant_names = -> { TourSet.pluck :subdir } - config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme'] + config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme', 'ActiveStorage::Attachment', 'ActiveStorage::Blob'] config.persistent_schemas = ['shared_extensions'] end diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb new file mode 100644 index 00000000..89d2efab --- /dev/null +++ b/config/initializers/application_controller_renderer.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# ActiveSupport::Reloader.to_prepare do +# ApplicationController.renderer.defaults.merge!( +# http_host: 'example.org', +# https: false +# ) +# end diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb new file mode 100644 index 00000000..59385cdf --- /dev/null +++ b/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index aa7435fb..ac033bf9 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,4 +1,3 @@ -# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 00000000..dc189968 --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,4 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf diff --git a/config/initializers/new_framework_defaults_5_2.rb b/config/initializers/new_framework_defaults_5_2.rb new file mode 100644 index 00000000..c383d072 --- /dev/null +++ b/config/initializers/new_framework_defaults_5_2.rb @@ -0,0 +1,38 @@ +# Be sure to restart your server when you modify this file. +# +# This file contains migration options to ease your Rails 5.2 upgrade. +# +# Once upgraded flip defaults one by one to migrate to the new default. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. + +# Make Active Record use stable #cache_key alongside new #cache_version method. +# This is needed for recyclable cache keys. +# Rails.application.config.active_record.cache_versioning = true + +# Use AES-256-GCM authenticated encryption for encrypted cookies. +# Also, embed cookie expiry in signed or encrypted cookies for increased security. +# +# This option is not backwards compatible with earlier Rails versions. +# It's best enabled when your entire app is migrated and stable on 5.2. +# +# Existing cookies will be converted on read then written with the new scheme. +# Rails.application.config.action_dispatch.use_authenticated_cookie_encryption = true + +# Use AES-256-GCM authenticated encryption as default cipher for encrypting messages +# instead of AES-256-CBC, when use_authenticated_message_encryption is set to true. +# Rails.application.config.active_support.use_authenticated_message_encryption = true + +# Add default protection from forgery to ActionController::Base instead of in +# ApplicationController. +# Rails.application.config.action_controller.default_protect_from_forgery = true + +# Store boolean values are in sqlite3 databases as 1 and 0 instead of 't' and +# 'f' after migrating old data. +# Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true + +# Use SHA-1 instead of MD5 to generate non-sensitive digests, such as the ETag header. +# Rails.application.config.active_support.use_sha1_digests = true + +# Make `form_with` generate id attributes for any generated HTML tags. +# Rails.application.config.action_view.form_with_generates_ids = true diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb index e7148a36..bbfc3961 100644 --- a/config/initializers/wrap_parameters.rb +++ b/config/initializers/wrap_parameters.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # Be sure to restart your server when you modify this file. # This file contains settings for ActionController::ParamsWrapper which @@ -9,3 +7,8 @@ ActiveSupport.on_load(:action_controller) do wrap_parameters format: [:json] end + +# To enable root element in JSON for ActiveRecord objects. +# ActiveSupport.on_load(:active_record) do +# self.include_root_in_json = true +# end diff --git a/config/puma.rb b/config/puma.rb index 6ec461f5..b2102072 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,21 +1,22 @@ -# frozen_string_literal: true - # Puma can serve each request in a thread from an internal thread pool. # The `threads` method setting takes two numbers: a minimum and maximum. # Any libraries that use thread pools should be configured to match # the maximum value specified for Puma. Default is set to 5 threads for minimum # and maximum; this matches the default thread size of Active Record. # -threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } +threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } threads threads_count, threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. # -port ENV.fetch('PORT') { 3000 } +port ENV.fetch("PORT") { 3000 } # Specifies the `environment` that Puma will run in. # -environment ENV.fetch('RAILS_ENV') { 'development' } +environment ENV.fetch("RAILS_ENV") { "development" } + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } # Specifies the number of `workers` to boot in clustered mode. # Workers are forked webserver processes. If using threads and workers together @@ -28,31 +29,9 @@ # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. If you use this option -# you need to make sure to reconnect any threads in the `on_worker_boot` -# block. +# process behavior so workers use less memory. # # preload_app! -# If you are preloading your application and using Active Record, it's -# recommended that you close any connections to the database before workers -# are forked to prevent connection leakage. -# -# before_fork do -# ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) -# end - -# The code in the `on_worker_boot` will be called if you are using -# clustered mode by specifying a number of `workers`. After each worker -# process is booted, this block will be run. If you are using the `preload_app!` -# option, you will want to use this block to reconnect to any threads -# or connections that may have been created at application boot, as Ruby -# cannot share connections between processes. -# -# on_worker_boot do -# ActiveRecord::Base.establish_connection if defined?(ActiveRecord) -# end -# - # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 7a1e350d..77e49517 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,9 +23,13 @@ resources :tour_set_admins, path: 'tour-set-users' resources :tour_collections, path: 'tour-collections' resources :tour_media, path: 'tour-media' + resources :map_overlays, path: 'map-overlays' + resources :map_icons, path: 'map-icons' resources :themes resources :tours - resources :media + resources :media do + get :file, on: :member + end resources :stops resources :stop_media, path: 'stop-media' resources :tour_media, path: 'tour-media' @@ -34,6 +38,7 @@ resources :flat_pages, path: 'flat-pages' resources :tour_flat_pages, path: 'tour-flat-pages' resources :geojson_tours + end end mount EcdsRailsAuthEngine::Engine, at: '/auth' diff --git a/config/spring.rb b/config/spring.rb index ff5ba06b..9fa7863f 100644 --- a/config/spring.rb +++ b/config/spring.rb @@ -1,8 +1,6 @@ -# frozen_string_literal: true - -%w( +%w[ .ruby-version .rbenv-vars tmp/restart.txt tmp/caching-dev.txt -).each { |path| Spring.watch(path) } +].each { |path| Spring.watch(path) } diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 00000000..3bcedc42 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,41 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join(Apartment::Tenant.current, "storage") %> + +staging: + service: S3 + access_key_id: <%= Rails.application.credentials.dig(:s3Staging, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:s3Staging, :secret_access_key) %> + region: us-east-1 + bucket: opentour + +# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket + +# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/migrate/20210602201922_create_active_storage_tables.active_storage.rb b/db/migrate/20210602201922_create_active_storage_tables.active_storage.rb new file mode 100644 index 00000000..0b2ce257 --- /dev/null +++ b/db/migrate/20210602201922_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/migrate/20210604131202_add_base64.rb b/db/migrate/20210604131202_add_base64.rb new file mode 100644 index 00000000..11a7dcda --- /dev/null +++ b/db/migrate/20210604131202_add_base64.rb @@ -0,0 +1,5 @@ +class AddBase64 < ActiveRecord::Migration[6.0] + def change + add_column :media, :base64, :text + end +end diff --git a/db/migrate/20210604132602_add_video_provider.rb b/db/migrate/20210604132602_add_video_provider.rb new file mode 100644 index 00000000..1c408042 --- /dev/null +++ b/db/migrate/20210604132602_add_video_provider.rb @@ -0,0 +1,5 @@ +class AddVideoProvider < ActiveRecord::Migration[6.0] + def change + add_column :media, :video_provider, :integer, default: 0 + end +end diff --git a/db/migrate/20210604145226_change_base64.rb b/db/migrate/20210604145226_change_base64.rb new file mode 100644 index 00000000..80142312 --- /dev/null +++ b/db/migrate/20210604145226_change_base64.rb @@ -0,0 +1,5 @@ +class ChangeBase64 < ActiveRecord::Migration[6.0] + def change + rename_column :media, :base64, :base_sixty_four + end +end diff --git a/db/migrate/20210607125915_add_parking_address.rb b/db/migrate/20210607125915_add_parking_address.rb new file mode 100644 index 00000000..fd12fa45 --- /dev/null +++ b/db/migrate/20210607125915_add_parking_address.rb @@ -0,0 +1,5 @@ +class AddParkingAddress < ActiveRecord::Migration[6.0] + def change + add_column :stops, :parking_address, :string + end +end diff --git a/db/migrate/20210607165827_rename_content.rb b/db/migrate/20210607165827_rename_content.rb new file mode 100644 index 00000000..054747b2 --- /dev/null +++ b/db/migrate/20210607165827_rename_content.rb @@ -0,0 +1,5 @@ +class RenameContent < ActiveRecord::Migration[6.0] + def change + rename_column :flat_pages, :content, :body + end +end diff --git a/db/migrate/20210607221303_add_tour_to_stop_slugs.rb b/db/migrate/20210607221303_add_tour_to_stop_slugs.rb new file mode 100644 index 00000000..25244cb0 --- /dev/null +++ b/db/migrate/20210607221303_add_tour_to_stop_slugs.rb @@ -0,0 +1,5 @@ +class AddTourToStopSlugs < ActiveRecord::Migration[6.0] + def change + add_reference :stop_slugs, :tour, foreign_key: true + end +end diff --git a/db/migrate/20210608211705_create_map_overlays.rb b/db/migrate/20210608211705_create_map_overlays.rb new file mode 100644 index 00000000..c4ba6439 --- /dev/null +++ b/db/migrate/20210608211705_create_map_overlays.rb @@ -0,0 +1,14 @@ +class CreateMapOverlays < ActiveRecord::Migration[6.0] + def change + create_table :map_overlays do |t| + t.decimal "south", precision: 100, scale: 8 + t.decimal "north", precision: 100, scale: 8 + t.decimal "east", precision: 100, scale: 8 + t.decimal "west", precision: 100, scale: 8 + t.references :tour, null: true + t.references :stop, null: true + + t.timestamps + end + end +end diff --git a/db/migrate/20210609141823_add_base64_overlay.rb b/db/migrate/20210609141823_add_base64_overlay.rb new file mode 100644 index 00000000..31bb4a60 --- /dev/null +++ b/db/migrate/20210609141823_add_base64_overlay.rb @@ -0,0 +1,5 @@ +class AddBase64Overlay < ActiveRecord::Migration[6.0] + def change + add_column :map_overlays, :base_sixty_four, :text + end +end diff --git a/db/migrate/20210609142132_add_title_overlay.rb b/db/migrate/20210609142132_add_title_overlay.rb new file mode 100644 index 00000000..c531694d --- /dev/null +++ b/db/migrate/20210609142132_add_title_overlay.rb @@ -0,0 +1,5 @@ +class AddTitleOverlay < ActiveRecord::Migration[6.0] + def change + add_column :map_overlays, :title, :text + end +end diff --git a/db/migrate/20210610141706_add_enable_directions.rb b/db/migrate/20210610141706_add_enable_directions.rb new file mode 100644 index 00000000..1913657a --- /dev/null +++ b/db/migrate/20210610141706_add_enable_directions.rb @@ -0,0 +1,5 @@ +class AddEnableDirections < ActiveRecord::Migration[6.0] + def change + add_column :tours, :use_directions, :boolean, default: true + end +end diff --git a/db/migrate/20210610152819_add_default_lang.rb b/db/migrate/20210610152819_add_default_lang.rb new file mode 100644 index 00000000..d6532be1 --- /dev/null +++ b/db/migrate/20210610152819_add_default_lang.rb @@ -0,0 +1,5 @@ +class AddDefaultLang < ActiveRecord::Migration[6.0] + def change + add_column :tours, :default_lng, :integer, default: 0 + end +end diff --git a/db/migrate/20210610180023_add_icon_color.rb b/db/migrate/20210610180023_add_icon_color.rb new file mode 100644 index 00000000..652bbd67 --- /dev/null +++ b/db/migrate/20210610180023_add_icon_color.rb @@ -0,0 +1,5 @@ +class AddIconColor < ActiveRecord::Migration[6.0] + def change + add_column :stops, :icon_color, :string, default: '#D32F2F' + end +end diff --git a/db/migrate/20210610182827_create_map_icons.rb b/db/migrate/20210610182827_create_map_icons.rb new file mode 100644 index 00000000..dc200cea --- /dev/null +++ b/db/migrate/20210610182827_create_map_icons.rb @@ -0,0 +1,9 @@ +class CreateMapIcons < ActiveRecord::Migration[6.0] + def change + create_table :map_icons do |t| + t.text :base_sixty_four + + t.timestamps + end + end +end diff --git a/db/migrate/20210610183825_add_title_to_map_icons.rb b/db/migrate/20210610183825_add_title_to_map_icons.rb new file mode 100644 index 00000000..cfcb3b82 --- /dev/null +++ b/db/migrate/20210610183825_add_title_to_map_icons.rb @@ -0,0 +1,5 @@ +class AddTitleToMapIcons < ActiveRecord::Migration[6.0] + def change + add_reference :stops :map_icons, null: true, foreign_key: true + end +end diff --git a/db/migrate/20210614140848_add_logo64_to_tour_sets.rb b/db/migrate/20210614140848_add_logo64_to_tour_sets.rb new file mode 100644 index 00000000..486a0d34 --- /dev/null +++ b/db/migrate/20210614140848_add_logo64_to_tour_sets.rb @@ -0,0 +1,5 @@ +class AddLogo64ToTourSets < ActiveRecord::Migration[6.0] + def change + add_column :tour_sets, :base_sixty_four, :text + end +end diff --git a/db/migrate/20210614154357_remove_logo_from_tour_sets.rb b/db/migrate/20210614154357_remove_logo_from_tour_sets.rb new file mode 100644 index 00000000..92e49e5f --- /dev/null +++ b/db/migrate/20210614154357_remove_logo_from_tour_sets.rb @@ -0,0 +1,5 @@ +class RemoveLogoFromTourSets < ActiveRecord::Migration[6.0] + def change + remove_column :tour_sets, :logo + end +end diff --git a/db/migrate/20210614154939_add_logo_title_to_tour_sets.rb b/db/migrate/20210614154939_add_logo_title_to_tour_sets.rb new file mode 100644 index 00000000..d034390b --- /dev/null +++ b/db/migrate/20210614154939_add_logo_title_to_tour_sets.rb @@ -0,0 +1,5 @@ +class AddLogoTitleToTourSets < ActiveRecord::Migration[6.0] + def change + add_column :tour_sets, :logo_title, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 3bcfa54e..864e91ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,20 +2,40 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_05_18_143822) do +ActiveRecord::Schema.define(version: 2021_06_14_154939) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" - enable_extension "uuid-ossp" + + 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 "ecds_rails_auth_engine_logins", force: :cascade do |t| t.string "who" @@ -29,7 +49,7 @@ create_table "flat_pages", force: :cascade do |t| t.string "title" - t.string "content" + t.string "body" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "position" @@ -49,6 +69,28 @@ t.index ["user_id"], name: "index_logins_on_user_id" end + create_table "map_icons", force: :cascade do |t| + t.text "base_sixty_four" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "title" + end + + create_table "map_overlays", force: :cascade do |t| + t.decimal "south", precision: 100, scale: 8 + t.decimal "north", precision: 100, scale: 8 + t.decimal "east", precision: 100, scale: 8 + t.decimal "west", precision: 100, scale: 8 + t.bigint "tour_id" + t.bigint "stop_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.text "base_sixty_four" + t.text "title" + t.index ["stop_id"], name: "index_map_overlays_on_stop_id" + t.index ["tour_id"], name: "index_map_overlays_on_tour_id" + end + create_table "media", force: :cascade do |t| t.string "title" t.text "caption" @@ -64,6 +106,8 @@ t.integer "tablet_height" t.integer "mobile_width" t.integer "mobile_height" + t.text "base_sixty_four" + t.integer "video_provider", default: 0 end create_table "modes", force: :cascade do |t| @@ -100,7 +144,9 @@ t.bigint "stop_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "tour_id" t.index ["stop_id"], name: "index_stop_slugs_on_stop_id" + t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" end create_table "stop_tags", force: :cascade do |t| @@ -126,6 +172,10 @@ t.datetime "updated_at", null: false t.string "address" t.bigint "medium_id" + t.string "parking_address" + t.string "icon_color", default: "#D32F2F" + t.bigint "map_icon_id" + t.index ["map_icon_id"], name: "index_stops_on_map_icon_id" t.index ["medium_id"], name: "index_stops_on_medium_id" end @@ -220,8 +270,9 @@ t.bigint "tour_id" t.string "external_url" t.text "notes" - t.string "logo" t.string "footer_logo" + t.text "base_sixty_four" + t.string "logo_title" t.index ["tour_id"], name: "index_tour_sets_on_tours_id" end @@ -259,6 +310,8 @@ t.string "meta_description" t.bigint "medium_id" t.string "map_type" + t.boolean "use_directions", default: true + t.integer "default_lng", default: 0 t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" @@ -274,6 +327,9 @@ t.index ["login_id"], name: "index_users_on_login_id", unique: true end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "stop_slugs", "tours" + add_foreign_key "stops", "map_icons" add_foreign_key "stops", "media" add_foreign_key "tour_set_admins", "roles" add_foreign_key "tour_sets", "tours" diff --git a/lib/snippets.rb b/lib/snippets.rb new file mode 100644 index 00000000..bd0130b6 --- /dev/null +++ b/lib/snippets.rb @@ -0,0 +1,70 @@ +Medium.all.each do |m| + next if m.video.nil? + case m.provider + when 'youtube' + m.embed = "//www.youtube.com/embed/#{m.video}"; + puts m.embed + m.video_provider = 'youtube' + m.save + when 'vimeo' + m.embed = "//player.vimeo.com/video/#{m.video}" + m.video_provider = 'vimeo' + m.save + end +end + +media = Medium.all.map(&:id) + +media.each do |m| + puts m + Apartment::Tenant.switch! 'july-22nd' + medium = Medium.find(m) + if !medium.file.attached? + medium.delete + end +end + +# Medium.all.each do |m| +# if File.exist?(m.original_image.path) && !m.file.attached? +# m.file.attach(io: File.open(m.original_image.path), filename: m.original_image.path.split('/').last) +# else +# m.delete +# end +# end + +ActiveStorage::Blob.service.send(:path_for, m.public_send("#{Apartment::Tenant.current.underscore}_file").key) +Apartment::Tenant.switch! 'july-22nd' +ids = Medium.all.map(&:id) +ids.each do |id| + Apartment::Tenant.switch! 'july-22nd' + m = Medium.find(id) + + next if m.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + + next unless m.file.attached? + + next unless File.exists? ActiveStorage::Blob.service.send(:path_for, m.file.key) +Apartment::Tenant.switch! 'july-22nd' + m.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + io: File.open(ActiveStorage::Blob.service.send(:path_for, m.file.key)), + filename: m.file.filename.to_s, + content_type: m.file.content_type + ) + Apartment::Tenant.switch! 'july-22nd' +end + +Apartment::Tenant.switch! 'july-22nd' +ids = MapIcon.all.map(&:id) +ids.each do |id| + Apartment::Tenant.switch! 'july-22nd' + m = MapIcon.find(id) + next unless m.file.attached? + + next unless File.exists? ActiveStorage::Blob.service.send(:path_for, m.file.key) + + m.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + io: File.open(ActiveStorage::Blob.service.send(:path_for, m.file.key)), + filename: m.file.filename.to_s, + content_type: m.file.content_type + ) +end \ No newline at end of file diff --git a/lib/tasks/db_enhancements.rake.bk b/lib/tasks/db_enhancements.rake similarity index 100% rename from lib/tasks/db_enhancements.rake.bk rename to lib/tasks/db_enhancements.rake diff --git a/spec/factories/flat_pages.rb b/spec/factories/flat_pages.rb index 6f5dcb36..220f4fa0 100644 --- a/spec/factories/flat_pages.rb +++ b/spec/factories/flat_pages.rb @@ -4,6 +4,6 @@ FactoryBot.define do factory :flat_page do title { Faker::Movies::HitchhikersGuideToTheGalaxy.planet } - content { Faker::Hipster.paragraph(sentence_count: 2, supplemental: true, random_sentences_to_add: 4) } + body { Faker::Hipster.paragraph(sentence_count: 2, supplemental: true, random_sentences_to_add: 4) } end end diff --git a/spec/models/map_icon_spec.rb b/spec/models/map_icon_spec.rb new file mode 100644 index 00000000..0768c7b5 --- /dev/null +++ b/spec/models/map_icon_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe MapIcon, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/map_overlay_spec.rb b/spec/models/map_overlay_spec.rb new file mode 100644 index 00000000..6dcf017b --- /dev/null +++ b/spec/models/map_overlay_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe MapOverlay, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 47734592..3b686a09 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -3,7 +3,7 @@ # This file is copied to spec/ when you run 'rails generate rspec:install' require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' -require File.expand_path('../../config/environment', __FILE__) +require(File.expand_path('../config/environment', __dir__)) # Prevent database truncation if the environment is production abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' diff --git a/spec/requests/map_icons_spec.rb b/spec/requests/map_icons_spec.rb new file mode 100644 index 00000000..222af6bd --- /dev/null +++ b/spec/requests/map_icons_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +# This spec was generated by rspec-rails when you ran the scaffold generator. +# It demonstrates how one might use RSpec to test the controller code that +# was generated by Rails when you ran the scaffold generator. +# +# It assumes that the implementation code is generated by the rails scaffold +# generator. If you are using any extension libraries to generate different +# controller code, this generated spec may or may not pass. +# +# It only uses APIs available in rails and/or rspec-rails. There are a number +# of tools you can use to make these specs even more expressive, but we're +# sticking to rails and rspec-rails APIs to keep things simple and stable. + +RSpec.describe "/map_icons", type: :request do + # This should return the minimal set of attributes required to create a valid + # MapIcon. As you add validations to MapIcon, be sure to + # adjust the attributes here as well. + let(:valid_attributes) { + skip("Add a hash of attributes valid for your model") + } + + let(:invalid_attributes) { + skip("Add a hash of attributes invalid for your model") + } + + # This should return the minimal set of values that should be in the headers + # in order to pass any filters (e.g. authentication) defined in + # MapIconsController, or in your router and rack + # middleware. Be sure to keep this updated too. + let(:valid_headers) { + {} + } + + describe "GET /index" do + it "renders a successful response" do + MapIcon.create! valid_attributes + get map_icons_url, headers: valid_headers, as: :json + expect(response).to be_successful + end + end + + describe "GET /show" do + it "renders a successful response" do + map_icon = MapIcon.create! valid_attributes + get map_icon_url(map_icon), as: :json + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new MapIcon" do + expect { + post map_icons_url, + params: { map_icon: valid_attributes }, headers: valid_headers, as: :json + }.to change(MapIcon, :count).by(1) + end + + it "renders a JSON response with the new map_icon" do + post map_icons_url, + params: { map_icon: valid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:created) + expect(response.content_type).to match(a_string_including("application/json")) + end + end + + context "with invalid parameters" do + it "does not create a new MapIcon" do + expect { + post map_icons_url, + params: { map_icon: invalid_attributes }, as: :json + }.to change(MapIcon, :count).by(0) + end + + it "renders a JSON response with errors for the new map_icon" do + post map_icons_url, + params: { map_icon: invalid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq("application/json") + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) { + skip("Add a hash of attributes valid for your model") + } + + it "updates the requested map_icon" do + map_icon = MapIcon.create! valid_attributes + patch map_icon_url(map_icon), + params: { map_icon: new_attributes }, headers: valid_headers, as: :json + map_icon.reload + skip("Add assertions for updated state") + end + + it "renders a JSON response with the map_icon" do + map_icon = MapIcon.create! valid_attributes + patch map_icon_url(map_icon), + params: { map_icon: new_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:ok) + expect(response.content_type).to match(a_string_including("application/json")) + end + end + + context "with invalid parameters" do + it "renders a JSON response with errors for the map_icon" do + map_icon = MapIcon.create! valid_attributes + patch map_icon_url(map_icon), + params: { map_icon: invalid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq("application/json") + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested map_icon" do + map_icon = MapIcon.create! valid_attributes + expect { + delete map_icon_url(map_icon), headers: valid_headers, as: :json + }.to change(MapIcon, :count).by(-1) + end + end +end diff --git a/spec/requests/v3/flat_pages_spec.rb b/spec/requests/v3/flat_pages_spec.rb index 38c4fe9e..61217e4f 100644 --- a/spec/requests/v3/flat_pages_spec.rb +++ b/spec/requests/v3/flat_pages_spec.rb @@ -67,6 +67,7 @@ context 'create without valid params' do before { + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token post "/#{Apartment::Tenant.current}/flat-pages", params: { foo: 'bar' } } @@ -97,7 +98,8 @@ context 'update without valid params' do before { - invalid_attributes = {data: {type: 'flat_pages', attributes: { title: nil }}} + User.first.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + invalid_attributes = { data: { type: 'flat_pages', attributes: { title: nil } } } cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token put "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.last.id}", params: invalid_attributes } diff --git a/spec/requests/v3/map_overlays_request_spec.rb b/spec/requests/v3/map_overlays_request_spec.rb new file mode 100644 index 00000000..b183bcd8 --- /dev/null +++ b/spec/requests/v3/map_overlays_request_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "V3::MapOverlays", type: :request do + +end diff --git a/spec/requests/v3/tour_stops_spec.rb b/spec/requests/v3/tour_stops_spec.rb index c16cd195..7d306740 100644 --- a/spec/requests/v3/tour_stops_spec.rb +++ b/spec/requests/v3/tour_stops_spec.rb @@ -53,7 +53,7 @@ let!(:stop2) { tour2.stops.last } let!(:new_title) { "#{Faker::Movies::Lebowski.character}" } - before{ + before { tour1.stops = [Stop.create(title: new_title)] tour1.save tour2.stops = [Stop.create(title: new_title)] diff --git a/spec/routing/map_icons_routing_spec.rb b/spec/routing/map_icons_routing_spec.rb new file mode 100644 index 00000000..7ed19400 --- /dev/null +++ b/spec/routing/map_icons_routing_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe MapIconsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/map_icons").to route_to("map_icons#index") + end + + it "routes to #show" do + expect(get: "/map_icons/1").to route_to("map_icons#show", id: "1") + end + + + it "routes to #create" do + expect(post: "/map_icons").to route_to("map_icons#create") + end + + it "routes to #update via PUT" do + expect(put: "/map_icons/1").to route_to("map_icons#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/map_icons/1").to route_to("map_icons#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/map_icons/1").to route_to("map_icons#destroy", id: "1") + end + end +end From 0b50cc9185616795a1346ffd3dca1d62394b6aaf Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:21:33 -0400 Subject: [PATCH 005/160] Update for auth engine to fix CircleCi build --- .circleci/config.yml | 10 +--------- Gemfile | 4 ++-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e4c2b04..8c4bddac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2 jobs: build: - working_directory: ~/atlmaps + working_directory: ~/otb-api # Primary container image where all commands run @@ -34,14 +34,6 @@ jobs: - run: name: Install dependencies command: | - sudo apt install -y libgeos-dev libproj-dev - sudo apt update - sudo apt install -y libappindicator1 fonts-liberation - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb - sudo dpkg -i google-chrome*.deb - sudo apt install -f - sudo dpkg -i google-chrome*.deb - bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 - run: sudo apt install -y postgresql-client || true diff --git a/Gemfile b/Gemfile index 019c25da..5631aa14 100644 --- a/Gemfile +++ b/Gemfile @@ -28,8 +28,8 @@ gem "actionview", ">= 5.2.2.1" # Social Auth # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' -# gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' -gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' +gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' +# gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 From b982a774f1ebb820dd92b4c07392c6ba31a28635 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:27:38 -0400 Subject: [PATCH 006/160] Update credentials for CircleCi database --- .circleci/config.yml | 2 +- Gemfile.lock | 20 +++++++++++--------- config/credentials.yml.enc | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8c4bddac..0bbfaf80 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: docker: - image: circleci/ruby:3.0.0-node environment: - PGUSER: root + PGUSER: postgres RAILS_ENV: test DB_HOSTNAME: 127.0.0.1 DB_USERNAME: postgres diff --git a/Gemfile.lock b/Gemfile.lock index ed1186ab..a0e50b31 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,7 @@ GIT - remote: https://github.com/stympy/faker.git - revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 - branch: master - specs: - faker (2.18.0) - i18n (>= 1.6, < 2) - -PATH - remote: /data/ecds_auth_engine + remote: https://github.com/ecds/ecds_rails_auth_engine.git + revision: 75eafbaf5656b9f9cbaed25647c75b56d8ac34ac + branch: feature/fauxoauth specs: ecds_rails_auth_engine (0.1.6) cancancan @@ -15,6 +9,14 @@ PATH jwt rails +GIT + remote: https://github.com/stympy/faker.git + revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 + branch: master + specs: + faker (2.18.0) + i18n (>= 1.6, < 2) + GEM remote: https://rubygems.org/ specs: diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index c3559e0c..56154197 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -U3sJJBzxa5h2ZICh7BPbOpwYGXaS9S/xsXPQIppuXRkA880M8QS65yv7q+N+d+RbjaU6oCZ5Z3Nd5ag46RSFf2DPLqzWni4WyiwrIYNApCvKOGZzEHMRvXG7w19yGYaVMDIi7VwGkuckADS6E8Gxn3zVeJWV/PrNlYxiuDIe/fxYGVmlt29kFOvMif4qlcP4gtBbWgdHdpEBw95V4H5ASyQ+0dvmwm+WSg8smqhVPM7hN/c5RoC8R40tYpZJFHT91b1J86PXGjiNyp3z6Pg4AASOLoI+CZLaWlEqSUg+NAC7UhEaMI84rrvtslzTP7Pm4z+JfdaTBDL+VqdthGwiG4QstzCL5Y2zdER2t1FG8EC0YrYgWDbxZNUYWFD3UymT6fyM1KCIQSgEAJl9idgNiAcYWmP1/Wv3gr971d5Z+BpoehdVGhqLo2n1X+QPWVkDspfWLw86uFH83rSVjjYFFQP0ZEEGdQa8VPP5tfl+wobsELWqTZbJti5dQgrxqd4rixQ++pMY1KLv0IuLnjFS0YY3GB9nlDm9jSi6cq4UFxGySO6xLKnAYsuMPYVPzj/XaxET7V6nQyq7s0itPN64+yJbglG9NQFlAD/Hr+VDhV3LuQA6G22v2u2dtkRxDxBkNscVZ+02JpLtCZuswZ6seKNv1NDiyxCcG7tcNJDqmi3r/mCBmGkBcl5wQneop3nMOFPIjGIX+RJK4xa+7fE3HyMLRk7/Ks5exQ8FnYfNT4fZAzRM8lbcvrlkg3W/7ZxxQz+BXwvjdx+IgnMkjBKVx7agzqz8RIx2fqYKFwY78haaEE8wFMwYSmTtH4tqGn5h6TkwnLG8BWWojtqCgmtpnoJC8c7UcQU1OpQQiXCa5EOapFbYc/QiHH9iW8fK5FN4UcldMi1cOLC3L20qBSPGfEmpXTJe5SvZz21shMu8Xba9yvlixqTo5muvZ/72ZqKMRALQLx4Ayg==--IVPhK32JiKCvjjzX--GtRzbA1AMYZ2hJiXU8QDSw== \ No newline at end of file +9m1A2v/63sYp5juRl4c+1MdhZrDaLpHYJHARSvVqIH2OzEcgXv/AAziTz8V9O5qYtcbyHifP0r2W+z3Ut6Uw5PiHPuzFJMnvUCqTe9i+2GRPrvdG9fny127ethTASW89uP42ww8vWCIE4RlejZdCaQKUoaXCh0/mkSDTevXmNu4GHYbC0F8Jchg+WgCD7+zt1n+ryV2LfIMCuX42vXQBhNDPmQZ0schRUV7Wwv5jsocKvd8dhjhCzwIEmJevX3eOJtc5MfN6ZhPmkbAVEsUDt59J5HsZIJd29WawZl1M5wR+RokK8sHXV8J2zSN9H4nNbCXsi63SVpSWOBeWfbf4cG8UO/l7ypKuG9wqE0J7AXtb85woYAkhD5XIxqNjxq8GRyRbLtdqoYBmiRyBGgo84goBDotMaZx+4VLag1wBi3l0WLa9nRXdm6tU9jMjeFMAg/RvIT4VOQbuXaYzSZ2lDaNZ+BIm8woeIaObf1Yiq4PFdVykzWDo1r+/I8p7qFmZZU/13HKTQ527CIR/yCTh1IO0lxkctxCM13MJTUPidhQOWHqoYWHGBcH2e/WQqmn2/Yi1b8g1RlzfheGdzrCxg5UTY0vfJxd9VEiQHM7eESEwcWkxtMUfTyZYUpJCGXo7w0ZXpnIMwBV1Uibmbs4yISdPMuPVrK1u2gb5ckqHc4Hc7x6HTbbgZdymNxMnyUiTxvlTgVrNGzzc4ZjWLS6awq3qPdE25MvD3us5o418Kuy8PEJAIRyjmhVxkAr/AYTsB8d/Q36Y+qOwbvrZ/F4WqCobnF3xMNW8QEtkxyXF8FEgfznQDkD6Q4Nna/d5zQQc46fV/l1DtEmMepR3QgvQa6QGZUHB9E96bTzQBj8/AuZ5vld8gzdStE7PauDmLUJ9ZhG8rgVpuYgomAh5L8+fnt3d33wcrD4evZj+ox0XEo4d+4zyfrCVl90veV5MdIcvg74KFAOPIA==--SxWwpmT4MuO8Ae3n--VpVu2MnEAZb4Vm7+yzlFhw== \ No newline at end of file From 65021184b1f4782dfe7dff8c17f6320a55454ecd Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:51:32 -0400 Subject: [PATCH 007/160] CircleCi build working locally --- .circleci/config.yml | 14 +++++++------- config/credentials.yml.enc | 2 +- config/database.yml | 21 +++++++++++++++------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0bbfaf80..357c8cdc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,20 +6,20 @@ jobs: # Primary container image where all commands run docker: - - image: circleci/ruby:3.0.0-node + - image: circleci/ruby:2.7.2-node environment: - PGUSER: postgres + PGUSER: root RAILS_ENV: test DB_HOSTNAME: 127.0.0.1 - DB_USERNAME: postgres - TEST_DB_NAME: postgres + DB_USERNAME: root + TEST_DB_NAME: test # Service container image available at `host: localhost` - image: circleci/postgres:9.6.8-alpine-postgis environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres + POSTGRES_USER: root + POSTGRES_DB: test steps: - checkout @@ -55,4 +55,4 @@ jobs: - run: name: Parallel RSpec - command: bundle exec rspec spec + command: bundle exec rspec spec/requests/v3 diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 56154197..63022a9d 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -9m1A2v/63sYp5juRl4c+1MdhZrDaLpHYJHARSvVqIH2OzEcgXv/AAziTz8V9O5qYtcbyHifP0r2W+z3Ut6Uw5PiHPuzFJMnvUCqTe9i+2GRPrvdG9fny127ethTASW89uP42ww8vWCIE4RlejZdCaQKUoaXCh0/mkSDTevXmNu4GHYbC0F8Jchg+WgCD7+zt1n+ryV2LfIMCuX42vXQBhNDPmQZ0schRUV7Wwv5jsocKvd8dhjhCzwIEmJevX3eOJtc5MfN6ZhPmkbAVEsUDt59J5HsZIJd29WawZl1M5wR+RokK8sHXV8J2zSN9H4nNbCXsi63SVpSWOBeWfbf4cG8UO/l7ypKuG9wqE0J7AXtb85woYAkhD5XIxqNjxq8GRyRbLtdqoYBmiRyBGgo84goBDotMaZx+4VLag1wBi3l0WLa9nRXdm6tU9jMjeFMAg/RvIT4VOQbuXaYzSZ2lDaNZ+BIm8woeIaObf1Yiq4PFdVykzWDo1r+/I8p7qFmZZU/13HKTQ527CIR/yCTh1IO0lxkctxCM13MJTUPidhQOWHqoYWHGBcH2e/WQqmn2/Yi1b8g1RlzfheGdzrCxg5UTY0vfJxd9VEiQHM7eESEwcWkxtMUfTyZYUpJCGXo7w0ZXpnIMwBV1Uibmbs4yISdPMuPVrK1u2gb5ckqHc4Hc7x6HTbbgZdymNxMnyUiTxvlTgVrNGzzc4ZjWLS6awq3qPdE25MvD3us5o418Kuy8PEJAIRyjmhVxkAr/AYTsB8d/Q36Y+qOwbvrZ/F4WqCobnF3xMNW8QEtkxyXF8FEgfznQDkD6Q4Nna/d5zQQc46fV/l1DtEmMepR3QgvQa6QGZUHB9E96bTzQBj8/AuZ5vld8gzdStE7PauDmLUJ9ZhG8rgVpuYgomAh5L8+fnt3d33wcrD4evZj+ox0XEo4d+4zyfrCVl90veV5MdIcvg74KFAOPIA==--SxWwpmT4MuO8Ae3n--VpVu2MnEAZb4Vm7+yzlFhw== \ No newline at end of file +kTGzASpH/+qir2pEMMCG2KDp/eFBheoH7Gbuo+zGbvbTsOQGfMjZR1vD8B8j3t/k9AplkIDBSFXd+/h1pND0pDOAVeavd0b6+FjBslSwBPwHVuP5XL1D47qH5ih9VSV9PJ5+WsIHM6XV6paaDoHCd50hq3sbi1uP+lMsDZTLVerDP6GU/KrInmTbJKMwDF4anU/Wx9oZ482CILzjqwjVLYTfi7xe4U4jWwqmCcZfCbmzl7KcntIwZt/7sGm1lLt6H/bGGJzfOuBYJXIR/eNr9PULeI/LS+XbiBPpnAGxwrDKP7Pd8vlMsQtRDi//mHf60MHQSbz0vTC7QFM3MM54H/PQqTDZgZK4QinIJI43m2qiFQF3BCNb4j4E8Z/8ozIEpcArzZrt/eROhVrYjEKqSjskqlS0oTMTMWBTV2FjY4aRiT1xnfbh0lS6i17zOsVfQRRcVoYbkiUh9qxl8Yi7I15JFzm3HzWBpj/zQGfkE5S30/62KVd5KPN1lW1PBsunQiqagVs1kw/klt9R/9sNLrKyq0amf2gAVZ1j7sHKPUZ+3yvTBDjp3rNz2YNMQJLumoSVuFO3yG57jD60jVLh2huOIdcXe5G9jZ7gRNdDBC8XuGNA2keJoUluwrE8KP/6dhNjJPuZInWI5SHnpcAGhXno/Alxdtp55Uu7+SR9mr1MPuxssVAGjoQY+cwUdyuLgk2sHSxuoixXYMy1fohaOhxvaTMRuu4m0xrm/TK3ZItaEAhfnmKNPI7h6rV95p8Zl8bbbmlVjK+3psB2r8ayLef6OG5Ud8CZvhYWF5qpT0+rO6VhJPGMsaHSHqRDk/MmwAK/Z9qhsmlJj+fvW2StzdCUX7cuebpfooYA5Qjrz4NzswiH8d8dDLKQIMDuqlFwLPKXG2j21TES93LZ74DzN3+8UTogtPuBTqTQ/nyksOus8zYB2kMJ4eqwLj4lnIfOp60wmKpO+U/vUYwKXJj027DP5KNeZdpNHGwgZsFTVKQY--JiwBUeScI8Uu5Jtq--rsXoM3m5pFDy/09cp9H++Q== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index dc94b08d..3dfaf27d 100644 --- a/config/database.yml +++ b/config/database.yml @@ -3,6 +3,11 @@ default: &default encoding: utf8 pool: 50 schema_search_path: "public,shared_extensions" + username: <%= ENV['DB_USERNAME'] %> + host: <%= ENV['DB_HOSTNAME'] %> + schema_search_path: public, postgis + password: <%= ENV['DB_PASSWORD'] %> + database: <%= ENV['DB_NAME'] %> mysql: &mysql adatabase: <%= Rails.application.credentials.dig(:dbTest, :mysql, :db) %> @@ -25,12 +30,16 @@ staging: password: <%= Rails.application.credentials.dig(:rdsStaging, :pw) %> host: <%= Rails.application.credentials.dig(:rdsStaging, :host) %> +test: + <<: *default + database: <%= ENV['TEST_DB_NAME'] %> + # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. -test: - <<: *default - database: <%= Rails.application.credentials.dig(:dbTest, :postgres, :db) %> - username: <%= Rails.application.credentials.dig(:dbTest, :postgres, :user) %> - password: <%= Rails.application.credentials.dig(:dbTest, :postgres, :pw) %> - host: <%= Rails.application.credentials.dig(:dbTest, :postgres, :host) %> +# test: +# <<: *default +# database: <%= Rails.application.credentials.dig(:dbTest, :postgres, :db) %> +# username: <%= Rails.application.credentials.dig(:dbTest, :postgres, :user) %> +# password: <%= Rails.application.credentials.dig(:dbTest, :postgres, :pw) %> +# host: <%= Rails.application.credentials.dig(:dbTest, :postgres, :host) %> From 8119eecfe6fdbb8ce63cc965254892177a159d81 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 15 Jun 2021 18:52:35 -0400 Subject: [PATCH 008/160] Add .circleci/config.yml From 78a2964f01b3e617208da2fd33d93901cb8bfda8 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 16 Jun 2021 11:46:12 -0400 Subject: [PATCH 009/160] Fix deploy, add S3 support --- Capfile | 2 +- Gemfile | 1 + Gemfile.lock | 18 ++++++++++++++++++ config/deploy.rb | 2 +- config/deploy/staging.rb | 4 ++-- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Capfile b/Capfile index 51956b36..098de5b4 100644 --- a/Capfile +++ b/Capfile @@ -29,7 +29,7 @@ install_plugin Capistrano::SCM::Git # require 'capistrano/rvm' require 'capistrano/rbenv' set :rbenv_type, :user # or :system, depends on your rbenv setup -set :rbenv_ruby, '2.6.3' +set :rbenv_ruby, '2.7.2' # require 'capistrano/chruby' require 'capistrano/bundler' # require 'capistrano/rails/assets' diff --git a/Gemfile b/Gemfile index 5631aa14..462cbf8b 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem 'carrierwave', '~> 1.0' gem 'mini_magick' gem 'carrierwave-base64' gem 'ferrum' +gem 'aws-sdk-s3', '~> 1' # RGeo is a geospatial data library for Ruby. # https://github.com/rgeo/rgeo diff --git a/Gemfile.lock b/Gemfile.lock index a0e50b31..93508cf9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,22 @@ GEM public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) + aws-eventstream (1.1.1) + aws-partitions (1.468.0) + aws-sdk-core (3.114.3) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.43.0) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.96.1) + aws-sdk-core (~> 3, >= 3.112.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.3) + aws-eventstream (~> 1, >= 1.0.2) builder (3.2.4) cancancan (2.3.0) capistrano (3.16.0) @@ -154,6 +170,7 @@ GEM httpclient (2.8.3) i18n (1.8.10) concurrent-ruby (~> 1.0) + jmespath (1.4.0) json (2.5.1) jsonapi-renderer (0.2.2) jwt (2.2.3) @@ -314,6 +331,7 @@ DEPENDENCIES actionview (>= 5.2.2.1) active_model_serializers (~> 0.10.0.rc3) acts-as-taggable-on (~> 5.0) + aws-sdk-s3 (~> 1) cancancan (~> 2.0) capistrano-passenger capistrano-rails diff --git a/config/deploy.rb b/config/deploy.rb index 479c8cf6..b24cb5ff 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # config valid for current version and patch releases of Capistrano -lock '~> 3.12.1' +lock '~> 3.16.0' set :application, 'otb-api-server' set :repo_url, 'git@github.com:ecds/otb-api-server.git' diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb index e51a7e35..d375745a 100644 --- a/config/deploy/staging.rb +++ b/config/deploy/staging.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -set :branch, 'develop3.0' +set :branch, 'develop' # server-based syntax # ====================== @@ -50,7 +50,7 @@ auth_methods: %w(publickey) } - set :branch, 'develop', + set :branch, 'develop' set :deploy_to, '/data/otb-api' # # The server-based syntax can be used to override options: From c09d0ca575c542d99ec73e143787ec41d79d84c3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 16 Jun 2021 11:52:09 -0400 Subject: [PATCH 010/160] Remove bad tests SoundCloud fallback image, ENV fixes Fix CORS config remove big files --- app/models/concerns/video_props.rb | 15 ++-- config/environments/production.rb | 4 +- config/environments/staging.rb | 2 + config/initializers/cors.rb | 2 +- .../20210610183825_add_title_to_map_icons.rb | 3 +- lib/snippets.rb | 67 +++++++++++++----- public/soundcloud.jpg | Bin 0 -> 43311 bytes spec/requests/map_icons_spec.rb | 60 ++++++++-------- 8 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 public/soundcloud.jpg diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index ab06d326..c6102f26 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -23,7 +23,7 @@ def self.props(medium) medium.title = metadata.title medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" - downloaded_image = "https://img.youtube.com/vi/#{medium.video}/0.jpg" + downloaded_image = open("https://img.youtube.com/vi/#{medium.video}/0.jpg") medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.video}.jpg") rescue Yt::Errors::NoItems medium.provider = nil @@ -38,14 +38,21 @@ def self.props(medium) else medium.title = titles.first end - medium.video = embed_code.xpath('//iframe', 'src').first['src'].split('&').first[/(.*tracks\/)(.*)/,2] + medium.video = embed_code.xpath('//iframe', 'src').first['src'].split('&').first[/(.*tracks\/)(.*)/, 2] medium.embed = "//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false" browser = Ferrum::Browser.new() browser.go_to("https:#{medium.embed}") spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] - downloaded_image = open("https:#{image}") - medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") + if image.nil? + medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + io: File.open(File.join(Rails.root, 'public', 'soundcloud.jpg')), + filename: "#{medium.title.parameterize}.jpg" + ) + else + downloaded_image = open("https:#{image}") + medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") + end end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 1a18af4c..57db8d90 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. + config.hosts << 'api.opentour.emory.edu' + Rails.application.routes.default_url_options[:host] = 'https://api.opentour.emory.edu' +# Settings specified here will take precedence over those in config/application.rb. # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :amazon diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 91140591..45c49784 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Rails.application.configure do + config.hosts << 'otb-api.ecdsdev.org' + Rails.application.routes.default_url_options[:host] = 'https://otb-api.ecdsdev.org' # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded on diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 6b72393f..f36cfc6e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -9,7 +9,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins 'https://lvh.me:4200' + origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org' resource '*', headers: :any, diff --git a/db/migrate/20210610183825_add_title_to_map_icons.rb b/db/migrate/20210610183825_add_title_to_map_icons.rb index cfcb3b82..51968044 100644 --- a/db/migrate/20210610183825_add_title_to_map_icons.rb +++ b/db/migrate/20210610183825_add_title_to_map_icons.rb @@ -1,5 +1,6 @@ class AddTitleToMapIcons < ActiveRecord::Migration[6.0] def change - add_reference :stops :map_icons, null: true, foreign_key: true + add_column :map_icons, :title, :string + add_reference :stops, :map_icons, null: true, foreign_key: true end end diff --git a/lib/snippets.rb b/lib/snippets.rb index bd0130b6..dbd04080 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -1,15 +1,24 @@ -Medium.all.each do |m| - next if m.video.nil? - case m.provider - when 'youtube' - m.embed = "//www.youtube.com/embed/#{m.video}"; - puts m.embed - m.video_provider = 'youtube' - m.save - when 'vimeo' - m.embed = "//player.vimeo.com/video/#{m.video}" - m.video_provider = 'vimeo' - m.save +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + Apartment::Tenant.switch! ts + reload! + ids = Medium.all.map(&:id) + ids.each do |id| + Apartment::Tenant.switch! ts + m = Medium.find(id) + next if m.video.nil? + case m.provider + when 'youtube' + m.embed = "//www.youtube.com/embed/#{m.video}" + puts m.embed + m.video_provider = 'youtube' + m.save + when 'vimeo' + m.embed = "//player.vimeo.com/video/#{m.video}" + m.video_provider = 'vimeo' + m.save + end end end @@ -25,13 +34,33 @@ end # Medium.all.each do |m| -# if File.exist?(m.original_image.path) && !m.file.attached? -# m.file.attach(io: File.open(m.original_image.path), filename: m.original_image.path.split('/').last) + # if File.exist?(m.original_image.path) && !m.file.attached? + # m.file.attach(io: File.open(m.original_image.path), filename: m.original_image.path.split('/').last) # else # m.delete # end # end +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + Apartment::Tenant.switch! ts + reload! + ids = Medium.all.map(&:id) + ids.each do |id| + Apartment::Tenant.switch! ts + m = Medium.find(id) + next if m.public_send("#{ts.underscore}_file").attached? + if m.original_image.path && File.exist?(m.original_image.path) + m.public_send("#{ts.underscore}_file").attach( + io: File.open(m.original_image.path), + filename: m.original_image.path.split('/').last, + content_type: m.original_image.content_type + ) + end + end +end + ActiveStorage::Blob.service.send(:path_for, m.public_send("#{Apartment::Tenant.current.underscore}_file").key) Apartment::Tenant.switch! 'july-22nd' ids = Medium.all.map(&:id) @@ -41,7 +70,7 @@ next if m.public_send("#{Apartment::Tenant.current.underscore}_file").attached? - next unless m.file.attached? + # next unless m.file.attached? next unless File.exists? ActiveStorage::Blob.service.send(:path_for, m.file.key) Apartment::Tenant.switch! 'july-22nd' @@ -67,4 +96,10 @@ filename: m.file.filename.to_s, content_type: m.file.content_type ) -end \ No newline at end of file +end + +User.all.each do |u| + login = Login.find_by(user_id: u.id) + u.email = login.identification + u.save +end diff --git a/public/soundcloud.jpg b/public/soundcloud.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a18731df321b99da4cd45e1f2b754cf8c6e5a82e GIT binary patch literal 43311 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 literal 0 HcmV?d00001 diff --git a/spec/requests/map_icons_spec.rb b/spec/requests/map_icons_spec.rb index 222af6bd..9acc699b 100644 --- a/spec/requests/map_icons_spec.rb +++ b/spec/requests/map_icons_spec.rb @@ -48,39 +48,39 @@ end end - describe "POST /create" do - context "with valid parameters" do - it "creates a new MapIcon" do - expect { - post map_icons_url, - params: { map_icon: valid_attributes }, headers: valid_headers, as: :json - }.to change(MapIcon, :count).by(1) - end + # describe "POST /create" do + # context "with valid parameters" do + # it "creates a new MapIcon" do + # expect { + # post map_icons_url, + # params: { map_icon: valid_attributes }, headers: valid_headers, as: :json + # }.to change(MapIcon, :count).by(1) + # end - it "renders a JSON response with the new map_icon" do - post map_icons_url, - params: { map_icon: valid_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:created) - expect(response.content_type).to match(a_string_including("application/json")) - end - end + # it "renders a JSON response with the new map_icon" do + # post map_icons_url, + # params: { map_icon: valid_attributes }, headers: valid_headers, as: :json + # expect(response).to have_http_status(:created) + # expect(response.content_type).to match(a_string_including("application/json")) + # end + # end - context "with invalid parameters" do - it "does not create a new MapIcon" do - expect { - post map_icons_url, - params: { map_icon: invalid_attributes }, as: :json - }.to change(MapIcon, :count).by(0) - end + # context "with invalid parameters" do + # it "does not create a new MapIcon" do + # expect { + # post map_icons_url, + # params: { map_icon: invalid_attributes }, as: :json + # }.to change(MapIcon, :count).by(0) + # end - it "renders a JSON response with errors for the new map_icon" do - post map_icons_url, - params: { map_icon: invalid_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq("application/json") - end - end - end + # it "renders a JSON response with errors for the new map_icon" do + # post map_icons_url, + # params: { map_icon: invalid_attributes }, headers: valid_headers, as: :json + # expect(response).to have_http_status(:unprocessable_entity) + # expect(response.content_type).to eq("application/json") + # end + # end + # end describe "PATCH /update" do context "with valid parameters" do From 9d7655face68d8c0254c8cee6ab8b031c797ebbc Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 21 Jun 2021 11:09:12 -0400 Subject: [PATCH 011/160] Dry up controllers. Validate size of map icons --- app/controllers/v3/flat_pages_controller.rb | 40 +++++------ .../v3/geojson_tours_controller.rb | 5 +- app/controllers/v3/map_icons_controller.rb | 46 ++++++------ app/controllers/v3/map_overlays_controller.rb | 25 ++++--- app/controllers/v3/media_controller.rb | 44 ++++++------ app/controllers/v3/modes_controller.rb | 2 +- app/controllers/v3/stop_media_controller.rb | 38 ++++------ app/controllers/v3/stops_controller.rb | 72 +++++++++---------- app/controllers/v3/themes_controller.rb | 36 +++++----- .../v3/tour_collections_controller.rb | 34 ++++----- .../v3/tour_flat_pages_controller.rb | 34 ++++----- app/controllers/v3/tour_media_controller.rb | 30 +++----- app/controllers/v3/tour_modes_controller.rb | 4 +- .../v3/tour_set_admins_controller.rb | 34 ++++----- app/controllers/v3/tour_sets_controller.rb | 8 +-- app/controllers/v3/tour_stops_controller.rb | 29 ++++---- app/controllers/v3/tours_controller.rb | 56 +++++---------- app/controllers/v3/users_controller.rb | 39 ++++------ app/controllers/v3_controller.rb | 1 + app/models/map_icon.rb | 12 ++++ app/models/tour_set.rb | 4 +- 21 files changed, 262 insertions(+), 331 deletions(-) diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index 964a5d3e..9370481a 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -1,53 +1,53 @@ # frozen_string_literal: true class V3::FlatPagesController < V3Controller - before_action :set_flat_page, only: [:show, :update, :destroy] + before_action :set_record, only: [:show, :update, :destroy] #authorize_resource - # GET /v3/flat_pages + # GET /v3/records def index - @flat_pages = FlatPage.all + @records = FlatPage.all - render json: @flat_pages + render json: @records end - # GET /v3/flat_pages/1 + # GET /v3/records/1 def show - render json: @flat_page + render json: @record end - # POST /v3/flat_pages + # POST /v3/records def create if @allowed - @flat_page = FlatPage.new(flat_page_params) + @record = FlatPage.new(record_params) - if @flat_page.save - render json: @flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@flat_page.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@record.id}" else - render json: @flat_page.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end else head 401 end end - # PATCH/PUT /v3/flat_pages/1 + # PATCH/PUT /v3/records/1 def update if @allowed - if @flat_page.update(flat_page_params) - render json: @flat_page + if @record.update(record_params) + render json: @record else - render json: @flat_page.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end else head 401 end end - # DELETE /v3/flat_pages/1 + # DELETE /v3/records/1 def destroy if @allowed - @flat_page.destroy + @record.destroy else head 401 end @@ -55,12 +55,12 @@ def destroy private # Use callbacks to share common setup or constraints between actions. - def set_flat_page - @flat_page = FlatPage.find(params[:id]) + def set_record + @record = FlatPage.find(params[:id]) end # Only allow a trusted parameter "white list" through. - def flat_page_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index 05565241..6720088b 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true # -# +# Endpoint that returns a tour serialized as GeoJSON # module V3 - class GeojsonToursController < V3Controller - skip_authorization_check + class GeojsonToursController < ApplicationController def show @tour = Tour.find(params[:id]) render json: { type: 'FeatureCollection', features: @tour.stops.map { |s| feature(s) } }.to_json diff --git a/app/controllers/v3/map_icons_controller.rb b/app/controllers/v3/map_icons_controller.rb index 8b6dd192..3283d777 100644 --- a/app/controllers/v3/map_icons_controller.rb +++ b/app/controllers/v3/map_icons_controller.rb @@ -1,51 +1,45 @@ -class V3::MapIconsController < ApplicationController - before_action :set_map_icon, only: [:show, :update, :destroy] +class V3::MapIconsController < V3Controller - # GET /map_icons + # GET /records def index - @map_icons = MapIcon.all + @records = MapIcon.all - render json: @map_icons + render json: @records end - # GET /map_icons/1 + # GET /records/1 def show - render json: @map_icon + render json: @record end - # POST /map_icons + # POST /records def create - @map_icon = MapIcon.new(map_icon_params) + @record = MapIcon.new(record_params) - if @map_icon.save - render json: @map_icon, status: :created + if @record.save + render json: @record, status: :created else - render json: @map_icon.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end - # PATCH/PUT /map_icons/1 + # PATCH/PUT /records/1 def update - if @map_icon.update(map_icon_params) - render json: @map_icon + if @record.update(record_params) + render json: @record else - render json: @map_icon.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end - # DELETE /map_icons/1 + # DELETE /records/1 def destroy - @map_icon.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_map_icon - @map_icon = MapIcon.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. - def map_icon_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ @@ -53,4 +47,8 @@ def map_icon_params ] ) end + + def set_record + @record = MapIcon.find(params[:id]) + end end diff --git a/app/controllers/v3/map_overlays_controller.rb b/app/controllers/v3/map_overlays_controller.rb index 911216fe..b6979f73 100644 --- a/app/controllers/v3/map_overlays_controller.rb +++ b/app/controllers/v3/map_overlays_controller.rb @@ -1,33 +1,32 @@ class V3::MapOverlaysController < V3Controller - before_action :set_map_overlay, only: [:show, :update, :destroy] def show - render json: @map_overlay + render json: @record end def create - @map_overlay = MapOverlay.new(map_overlay_params) - if @map_overlay.save - render json: @map_overlay, status: :created + @record = MapOverlay.new(record_params) + if @record.save + render json: @record, status: :created else - render json: @map_overlay.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /stops/1 def update - if @map_overlay.update(map_overlay_params) + if @record.update(record_params) # render json: @stop head :no_content else - render json: @map_overlay.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /stops/1 def destroy - if @map_overlay - @map_overlay.destroy + if @record + @record.destroy end head :no_content end @@ -35,7 +34,7 @@ def destroy private # Only allow a trusted parameter "white list" through. - def map_overlay_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ @@ -44,7 +43,7 @@ def map_overlay_params ) end - def set_map_overlay - @map_overlay = MapOverlay.find_by(id: params[:id]) + def set_record + @record = MapOverlay.find(params[:id]) end end diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 9f8de9e7..fdcf2489 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -3,8 +3,8 @@ # app/controllers/v3/media_controller.rb module V3 class MediaController < V3Controller - before_action :set_medium, only: [:show, :update, :destroy, :file] - #authorize_resource + before_action :set_record, only: [:show, :update, :destroy, :file] + # GET /media def index # TODO: This ins not ideal, we use these `not_in_*` scopes to make the list of media avaliable to add @@ -21,8 +21,8 @@ def index # GET /media/1 def show - if @medium.published || current_user.id.present? - render json: @medium + if @record.published || current_user.id.present? + render json: @record else head 401 end @@ -30,39 +30,39 @@ def show # POST /media def create - @medium = Medium.new(medium_params) + @record = Medium.new(record_params) - if @medium.save - render json: @medium, status: :created, location: "/#{Apartment::Tenant.current}/media/#{@medium.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/media/#{@record.id}" else - render json: @medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /media/1 def update - if @medium.update(update_medium_params) - render json: @medium + if @record.update(record_params) + render json: @record else - render json: @medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /media/1 def destroy - @medium.destroy + @record.destroy end def file - if @medium&.public_send("#{Apartment::Tenant.current.underscore}_file")&.attached? + if @record&.public_send("#{Apartment::Tenant.current.underscore}_file")&.attached? if params[:context] == 'mobile' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '200x200').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '200x200').processed, only_path: true) elsif params[:context] == 'tablet' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '300x300').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '300x300').processed, only_path: true) elsif params[:context] == 'desktop' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@medium.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '750x750').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '750x750').processed, only_path: true) else - redirect_to rails_blob_url(@medium.file) + redirect_to rails_blob_url(@record.file) end else head :not_found @@ -70,12 +70,12 @@ def file end # Use callbacks to share common setup or constraints between actions. - def set_medium - @medium = Medium.find(params[:id]) + def set_record + @record = Medium.find(params[:id]) end # Only allow a trusted parameter "white list" through. - def medium_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ @@ -83,9 +83,5 @@ def medium_params ] ) end - - def update_medium_params - self.medium_params.except(:original_image) - end end end diff --git a/app/controllers/v3/modes_controller.rb b/app/controllers/v3/modes_controller.rb index c00f7f34..07eb4174 100644 --- a/app/controllers/v3/modes_controller.rb +++ b/app/controllers/v3/modes_controller.rb @@ -2,7 +2,7 @@ # app/controllers/v3/modes_controller.rb module V3 - class ModesController < V3Controller + class ModesController < ApplicationController # GET /modes def index diff --git a/app/controllers/v3/stop_media_controller.rb b/app/controllers/v3/stop_media_controller.rb index 13786e71..7f9d863e 100644 --- a/app/controllers/v3/stop_media_controller.rb +++ b/app/controllers/v3/stop_media_controller.rb @@ -1,7 +1,7 @@ +# +# Endpoint for through model for Stops and Media +# class V3::StopMediaController < V3Controller - before_action :set_stop_medium, only: [:show, :update, :destroy] - #authorize_resource - # GET /v3/stop_media def index @stop_media = if params[:stop_id] && params[:medium_id] @@ -14,45 +14,37 @@ def index # GET /v3/stop_media/1 def show - render json: @stop_medium, - include: [ - # 'media' - ] + render json: @record end # POST /v3/stop_media def create - @stop_medium = StopMedium.new(stop_medium_params) + @record = StopMedium.new(record_params) - if @stop_medium.save - render json: @stop_medium, status: :created, location: "/#{Apartment::Tenant.current}/stop-medium/#{@stop_medium.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/stop-medium/#{@record.id}" else - render json: @stop_medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /v3/stop_media/1 def update - if @stop_medium.update(stop_medium_params) - render json: @stop_medium + if @record.update(record_params) + render json: @record else - render json: @stop_medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /v3/stop_media/1 def destroy - @stop_medium.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_stop_medium - @stop_medium = StopMedium.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. - def stop_medium_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ @@ -61,7 +53,7 @@ def stop_medium_params ) end - def set_stop_medium - @stop_medium = StopMedium.find_by!(id: params[:id]) + def set_record + @record = StopMedium.find(params[:id]) end end diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index 64634f18..d93a52cf 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -3,13 +3,9 @@ # /app/controllers/v3/stops_controller.rb # module V3 class V3::StopsController < V3Controller - # before_action :set_tour - before_action :set_stop, only: [:show, :update, :destroy] - #authorize_resource - # GET /stops def index - @stops = if params[:tour_id] + @records = if params[:tour_id] Stop.not_in_tour(params[:tour_id]).or(Stop.no_tours) elsif params[:slug] # stop = StopSlug.find_by(slug: params[:slug]).stop @@ -17,7 +13,7 @@ def index else Stop.all end - render json: @stops, + render json: @records, include: [ 'media', 'stop_media' @@ -26,7 +22,7 @@ def index # GET /stops/1 def show - render json: @stop, + render json: @record, include: [ 'media', 'stop_media', @@ -36,55 +32,55 @@ def show # POST /stops def create - @stop = Stop.new(stop_params) - if @stop.save - render json: @stop, status: :created, location: "/#{Apartment::Tenant.current}/#{@stop.id}" + @record = Stop.new(stop_params) + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" else - render json: @stop.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /stops/1 def update - if @stop.update(stop_params) - render json: @stop, location: "/#{Apartment::Tenant.current}/stops/#{@stop.id}" + if @record.update(stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" else - render json: @stop.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /stops/1 def destroy - @stop.destroy + @record.destroy end private - # Only allow a trusted parameter "white list" through. - def stop_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :title, :description, :lat, :lng, - :parking_lat, :parking_lng, :media, - :address, :tours, :direction_notes, - :meta_description, :parking_address, - :icon_color, :map_icon - ] - ) - end + # Only allow a trusted parameter "white list" through. + def stop_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :title, :description, :lat, :lng, + :parking_lat, :parking_lng, :media, + :address, :tours, :direction_notes, + :meta_description, :parking_address, + :icon_color, :map_icon + ] + ) + end - # Use callbacks to share common setup or constraints between actions. + # Use callbacks to share common setup or constraints between actions. - def set_tour - @tour = Tour.find(params[:tour_id]) - end + def set_tour + @tour = Tour.find(params[:tour_id]) + end - def set_stop - @stop = Stop.find(params[:id]) - end + def set_record + @record = Stop.find(params[:id]) + end - def set_tour_stop - @stop = @tour.stops.find_by!(id: params[:id]) if @tour - end + def set_tour_stop + @record = @tour.stops.find_by!(id: params[:id]) if @tour + end end diff --git a/app/controllers/v3/themes_controller.rb b/app/controllers/v3/themes_controller.rb index a927570f..6563e96c 100644 --- a/app/controllers/v3/themes_controller.rb +++ b/app/controllers/v3/themes_controller.rb @@ -3,55 +3,51 @@ # app/controllers/v3/themes_controller.rb module V3 class ThemesController < V3Controller - before_action :set_theme, only: [:show, :update, :destroy] - #authorize_resource - # GET /themes def index - @themes = Theme.all + @records = Theme.all - render json: @themes + render json: @records end # GET /themes/1 def show - render json: @theme + render json: @record end # POST /themes def create - @theme = Theme.new(theme_params) + @record = Theme.new(theme_params) - if @theme.save - render json: @theme, status: :created, location: @theme + if @record.save + render json: @record, status: :created, location: @record else - render json: @theme.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /themes/1 def update - if @theme.update(theme_params) - render json: @theme + if @record.update(theme_params) + render json: @record else - render json: @theme.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /themes/1 def destroy - @theme.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_theme - @theme = Theme.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def theme_params params.fetch(:theme, {}) end -end + + def set_record + @record = Theme.find(params[:id]) + end + end end diff --git a/app/controllers/v3/tour_collections_controller.rb b/app/controllers/v3/tour_collections_controller.rb index b2611a85..4c33af4f 100644 --- a/app/controllers/v3/tour_collections_controller.rb +++ b/app/controllers/v3/tour_collections_controller.rb @@ -1,54 +1,50 @@ # frozen_string_literal: true class V3::TourCollectionsController < V3Controller - before_action :set_tour_collection, only: [:show, :update, :destroy] - #authorize_resource - # GET /v3/tour_collections def index - @tour_collections = TourCollection.all + @records = TourCollection.all - render json: @tour_collections + render json: @records end # GET /v3/tour_collections/1 def show - render json: @tour_collection + render json: @record end # POST /v3/tour_collections def create - @tour_collection = TourCollection.new(tour_collection_params) + @record = TourCollection.new(tour_collection_params) - if @tour_collection.save - render json: @tour_collection, status: :created, location: @tour_collection + if @record.save + render json: @record, status: :created, location: @record else - render json: @tour_collection.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /v3/tour_collections/1 def update - if @tour_collection.update(tour_collection_params) - render json: @tour_collection + if @record.update(tour_collection_params) + render json: @record else - render json: @tour_collection.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /v3/tour_collections/1 def destroy - @tour_collection.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_tour_collection - @tour_collection = TourCollection.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def tour_collection_params params.fetch(:tour_collection, {}) end + + def set_record + @record = TourCollection.find(params[:id]) + end end diff --git a/app/controllers/v3/tour_flat_pages_controller.rb b/app/controllers/v3/tour_flat_pages_controller.rb index 3b4ed28d..a4f01bae 100644 --- a/app/controllers/v3/tour_flat_pages_controller.rb +++ b/app/controllers/v3/tour_flat_pages_controller.rb @@ -1,30 +1,27 @@ # frozen_string_literal: true class V3::TourFlatPagesController < V3Controller - before_action :set_tour_flat_page, only: [:show, :update, :destroy] - #authorize_resource - # GET /v3/tour_flat_pages def index - @tour_flat_pages = TourFlatPage.all + @records = TourFlatPage.all - render json: @tour_flat_pages + render json: @records end # GET /v3/tour_flat_pages/1 def show - render json: @tour_flat_page + render json: @record end # POST /v3/tour_flat_pages def create if @allowed - @tour_flat_page = TourFlatPage.new(tour_flat_page_params) + @record = TourFlatPage.new(tour_flat_page_params) - if @tour_flat_page.save - render json: @tour_flat_page, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@tour_flat_page.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@record.id}" else - render json: @tour_flat_page.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end else head 401 @@ -34,10 +31,10 @@ def create # PATCH/PUT /v3/tour_flat_pages/1 def update if @allowed - if @tour_flat_page.update(tour_flat_page_params) - render json: @tour_flat_page + if @record.update(tour_flat_page_params) + render json: @record else - render json: @tour_flat_page.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end else head 401 @@ -47,18 +44,13 @@ def update # DELETE /v3/tour_flat_pages/1 def destroy if @allowed - @tour_flat_page.destroy + @record.destroy else head 401 end end private - # Use callbacks to share common setup or constraints between actions. - def set_tour_flat_page - @tour_flat_page = TourFlatPage.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def tour_flat_page_params ActiveModelSerializers::Deserialization @@ -68,4 +60,8 @@ def tour_flat_page_params ] ) end + + def set_record + @record = TourFlatPage.find(params[:id]) + end end diff --git a/app/controllers/v3/tour_media_controller.rb b/app/controllers/v3/tour_media_controller.rb index bbc53869..c09da10f 100644 --- a/app/controllers/v3/tour_media_controller.rb +++ b/app/controllers/v3/tour_media_controller.rb @@ -1,7 +1,4 @@ class V3::TourMediaController < V3Controller - before_action :set_tour_medium, only: [:show, :update, :destroy] - #authorize_resource - # GET /v3/tour_media def index @tour_media = if params[:tour_id] && params[:medium_id] @@ -15,40 +12,35 @@ def index # GET /v3/tour_media/1 def show - render json: @tour_medium + render json: @record end # POST /v3/tour_media def create - @tour_medium = TourMedium.new(tour_medium_params) + @record = TourMedium.new(tour_medium_params) - if @tour_medium.save - render json: @tour_medium, status: :created, location: @tour_medium + if @record.save + render json: @record, status: :created, location: @record else - render json: @tour_medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /v3/tour_media/1 def update - if @tour_medium.update(tour_medium_params) - render json: @tour_medium + if @record.update(tour_medium_params) + render json: @record else - render json: @tour_medium.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /v3/tour_media/1 def destroy - @tour_medium.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_tour_medium - @tour_medium = TourMedium.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def tour_medium_params ActiveModelSerializers::Deserialization @@ -59,7 +51,7 @@ def tour_medium_params ) end - def set_tour_medium - @tour_medium = TourMedium.find_by!(id: params[:id]) + def set_record + @record = TourMedium.find(params[:id]) end end diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb index f0314ad3..642d0fcb 100644 --- a/app/controllers/v3/tour_modes_controller.rb +++ b/app/controllers/v3/tour_modes_controller.rb @@ -2,9 +2,7 @@ # app/controllers/v3/tour_modes_controller.rb module V3 - class TourModesController < V3Controller - #authorize_resource - + class TourModesController < ApplicationController # GET /tour_sets def index @tour_modes = TourMode.all diff --git a/app/controllers/v3/tour_set_admins_controller.rb b/app/controllers/v3/tour_set_admins_controller.rb index f4c5df8a..c3fab91f 100644 --- a/app/controllers/v3/tour_set_admins_controller.rb +++ b/app/controllers/v3/tour_set_admins_controller.rb @@ -2,15 +2,12 @@ module V3 class TourSetAdminsController < V3Controller - before_action :set_tour_set_admin, only: [:show, :update, :destroy] - #authorize_resource - # GET /tour_set_admins def index if current_user && current_user.super - @tour_set_admins = TourSetAdmin.all + @records = TourSetAdmin.all - render json: @tour_set_admins + render json: @records else head 401 end @@ -18,43 +15,42 @@ def index # GET /tour_set_admins/1 def show - render json: @tour_set_admin + render json: @record end # POST /tour_set_admins def create - @tour_set_admin = TourSetAdmin.new(tour_set_admin_params) + @record = TourSetAdmin.new(tour_set_admin_params) - if @tour_set_admin.save - render json: @tour_set_admin, status: :created, location: @tour_set_admin + if @record.save + render json: @record, status: :created, location: @record else - render json: @tour_set_admin.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /tour_set_admins/1 def update - if @tour_set_admin.update(tour_set_admin_params) - render json: @tour_set_admin + if @record.update(tour_set_admin_params) + render json: @record else - render json: @tour_set_admin.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /tour_set_admins/1 def destroy - @tour_set_admin.destroy + @record.destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_tour_set_admin - @tour_set_admin = TourSetAdmin.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def tour_set_admin_params params.fetch(:tour_set_admin, {}) end + + def set_record + @record = TourSetAdmin.find(params[:id]) + end end end diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index f8f99a09..421e3125 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -35,18 +35,18 @@ def show # POST /tour_sets def create - @record = TourSet.new(tour_set_params) + @record = TourSet.new(record_params) if @record.save render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" else - render json: @record.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /tour_sets/1 def update - if @record.update(tour_set_params) + if @record.update(record_params) # render json: @record head :no_content else @@ -67,7 +67,7 @@ def set_record end # Only allow a trusted parameter "white list" through. - def tour_set_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index e9392f81..1bb3a59c 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -2,12 +2,9 @@ # /app/controllers/v3/tour_stops_controller.rb class V3::TourStopsController < V3Controller - before_action :set_tour_stop, only: [:show, :update, :destroy] - #authorize_resource - # GET /stops def index - @tour_stops = if params[:tour_id] && params[:stop_id] + @records = if params[:tour_id] && params[:stop_id] TourStop.where(tour: Tour.find(params[:tour_id])).where(stop: Stop.find(params[:stop_id])).first || {} elsif params[:tour] && params[:slug] # stop = StopSlug.find_by(slug: params[:slug]) @@ -17,38 +14,38 @@ def index else TourStop.all end - render json: @tour_stops, include: ['stop'] + render json: @records, include: ['stop'] end # GET /stops/1 def show - render json: @tour_stop, include: ['stop'] + render json: @record, include: ['stop'] end # POST /stops def create - @tour_stop = TourStop.new(tour_stop_params) - if @tour_stop.save - render json: @tour_stop, status: :created, location: @tour_stop + @record = TourStop.new(tour_stop_params) + if @record.save + render json: @record, status: :created, location: @record else - render json: @tour_stop.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /stops/1 def update - if @tour_stop.update(tour_stop_params) + if @record.update(tour_stop_params) # render json: @stop head :no_content else - render json: @tour_stop.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /stops/1 def destroy - if @tour_stop - @tour_stop.destroy + if @record + @record.destroy end head :no_content end @@ -65,7 +62,7 @@ def tour_stop_params ) end - def set_tour_stop - @tour_stop = TourStop.find_by(id: params[:id]) + def set_record + @record = TourStop.find(params[:id]) end end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 9c6ee336..cf0bfcae 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -3,13 +3,9 @@ # app/controllers/v3/tours_controller.rb # module V3 class V3::ToursController < V3Controller - before_action :set_tour, only: [:show, :update, :destroy] - # #authorize_resource - #load_and_#authorize_resource - # GET /tours def index - @tours = if (params[:slug]) + @records = if (params[:slug]) tour = Slug.find_by(slug: params[:slug]).tour if tour.published || (current_user && current_user.current_tenant_admin?) tour @@ -24,40 +20,26 @@ def index Tour.published end - if @tours.nil? + if @records.nil? render json: { error: 'not found' }.to_json, status: 404 else - render json: @tours, each_serializer: V3::TourBaseSerializer - # include: [ - # 'tour_stops', - # 'stops', - # 'stops.media', - # 'stops.stop_media', - # 'mode', - # 'modes', - # 'theme', - # 'media', - # 'tour_media', - # 'flat_pages', - # 'tour_flat_pages' - # ] + render json: @records, each_serializer: V3::TourBaseSerializer end - end # GET /tours/1 def show - render json: @tour + render json: @record end # POST /tours def create if current_user.current_tenant_admin? - @tour = Tour.new(tour_params) - if @tour.save - render json: @tour, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}" + @record = Tour.new(tour_params) + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" else - render json: @tour.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end else head 401 @@ -66,8 +48,8 @@ def create # PATCH/PUT /tours/1 def update - if @tour.update(tour_params) - render json: @tour, location: "/#{Apartment::Tenant.current}/tours/#{@tour.id}", include: [ + if @record.update(tour_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}", include: [ 'tour_modes', 'tour_stops', 'stops', @@ -82,22 +64,16 @@ def update 'tour_flat_pages' ] else - render json: @tour.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /tours/1 def destroy - @tour.destroy + @record.destroy end private - - # Use callbacks to share common setup or constraints between actions. - def set_tour - @tour = Tour.includes(:tour_stops).find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def tour_params ActiveModelSerializers::Deserialization @@ -108,7 +84,11 @@ def tour_params :mode, :meta_description, :stops, :media, :authors, :flat_pages, :map_type, :theme, :use_directions, :default_lng - ] + ] ) end -end \ No newline at end of file + + def set_record + @record = Tour.find(params[:id]) + end +end diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index 7d61033b..9c66e8dc 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -6,9 +6,7 @@ module V3 # Endpoints for User Model # class UsersController < V3Controller - before_action :set_user, only: [:show, :update, :destroy] before_action :authenticate!, only: :me - #authorize_resource # GET /users def index @@ -25,36 +23,37 @@ def index # GET /users/1 def show - if current_user == @user || current_user.super - render json: @user + if current_user == @record || current_user.super + render json: @record else render json: { message: 'You are not autorized to to view this resource.' }.to_json, status: 401 end end + # TODO: Is this endpoint ever used? # POST /users def create - @user = User.new(user_params) + @record = User.new(user_params) - if @user.save && user.create_login(login_params) - render json: @user, status: :created, location: @user + if @record.save + render json: @record, status: :created, location: @record else - render json: @user.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # PATCH/PUT /users/1 def update - if @user.update(user_params) - render json: @user + if @record.update(user_params) + render json: @record else - render json: @user.errors, status: :unprocessable_entity + render json: serialize_errors, status: :unprocessable_entity end end # DELETE /users/1 def destroy - @user.destroy + @record.destroy end def me @@ -67,12 +66,6 @@ def me end private - - # Use callbacks to share common setup or constraints between actions. - def set_user - @user = User.find(params[:id]) - end - # Only allow a trusted parameter "white list" through. def user_params ActiveModelSerializers::Deserialization @@ -85,14 +78,8 @@ def user_params ) end - def login_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :identification, :password, - :password_confirmation, :uid - ] - ) + def set_record + @record = User.find(params[:id]) end end end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index 11890b1d..daa3b8d5 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -2,6 +2,7 @@ class V3Controller < ApplicationController include EcdsRailsAuthEngine::CurrentUser + before_action :set_record, only: [:show, :update, :destroy] before_action :allowed?, only: [:create, :update, :destroy] def serialize_errors diff --git a/app/models/map_icon.rb b/app/models/map_icon.rb index 738835ad..e444b8f3 100644 --- a/app/models/map_icon.rb +++ b/app/models/map_icon.rb @@ -1,2 +1,14 @@ class MapIcon < MediumBaseRecord + validate :check_dimensions + + def check_dimensions + return if base_sixty_four.nil? + + headers, tmp_base_sixty_four = base_sixty_four.split(',') + file = MiniMagick::Image.read(Base64.decode64(tmp_base_sixty_four)) + + if file[:height] > 80 || file[:width] > 80 + errors.add(:base, 'Icons should be no bigger that 80 by 80 pixels') + end + end end diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index c7290240..70d638c7 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -144,10 +144,10 @@ def valitate_logo_type def validate_logo_dimensions image = MiniMagick::Image.open(tmp_file_path) if image[:width] > 300 - errors[:base] << 'Logo cannot be wider than 300 pixels.' + errors.add(:base, 'Logo cannot be wider than 300 pixels.') end if image[:height] > 80 - errors[:base] << 'Logo cannot be taller than 80 pixels.' + errors.add(:base, 'Logo cannot be taller than 80 pixels.') end end end From 045f96d6249fcb47b5ef44e3becd3650f3b52e1e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 21 Jun 2021 19:03:43 -0400 Subject: [PATCH 012/160] Include tours in FlatPageSerializer --- app/serializers/v3/flat_page_serializer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/serializers/v3/flat_page_serializer.rb b/app/serializers/v3/flat_page_serializer.rb index 92bdb54d..64cabedd 100644 --- a/app/serializers/v3/flat_page_serializer.rb +++ b/app/serializers/v3/flat_page_serializer.rb @@ -1,3 +1,4 @@ class V3::FlatPageSerializer < ActiveModel::Serializer + has_many :tours attributes :id, :title, :body, :slug, :orphaned end From 946de237f262c6c0cb76c3f180cb51c88070c8ae Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 1 Jul 2021 15:29:36 -0400 Subject: [PATCH 013/160] Update splash media --- Gemfile | 4 ++-- Gemfile.lock | 20 +++++++++----------- app/controllers/v3/media_controller.rb | 4 ++-- app/controllers/v3/tour_sets_controller.rb | 3 --- app/controllers/v3/tours_controller.rb | 1 - app/models/stop.rb | 6 ++++++ app/models/tour.rb | 6 ++++++ app/serializers/v3/stop_serializer.rb | 2 +- app/serializers/v3/tour_base_serializer.rb | 2 +- 9 files changed, 27 insertions(+), 21 deletions(-) diff --git a/Gemfile b/Gemfile index 462cbf8b..b083ea60 100644 --- a/Gemfile +++ b/Gemfile @@ -28,8 +28,8 @@ gem "actionview", ">= 5.2.2.1" # Social Auth # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' -gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' -# gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' +# gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' +gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 diff --git a/Gemfile.lock b/Gemfile.lock index 93508cf9..d37ff846 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,3 @@ -GIT - remote: https://github.com/ecds/ecds_rails_auth_engine.git - revision: 75eafbaf5656b9f9cbaed25647c75b56d8ac34ac - branch: feature/fauxoauth - specs: - ecds_rails_auth_engine (0.1.6) - cancancan - httparty - jwt - rails - GIT remote: https://github.com/stympy/faker.git revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 @@ -17,6 +6,15 @@ GIT faker (2.18.0) i18n (>= 1.6, < 2) +PATH + remote: /data/ecds_auth_engine + specs: + ecds_rails_auth_engine (0.1.6) + cancancan + httparty + jwt + rails + GEM remote: https://rubygems.org/ specs: diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index fdcf2489..3295c6e8 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -56,9 +56,9 @@ def destroy def file if @record&.public_send("#{Apartment::Tenant.current.underscore}_file")&.attached? if params[:context] == 'mobile' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '200x200').processed, only_path: true) - elsif params[:context] == 'tablet' redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '300x300').processed, only_path: true) + elsif params[:context] == 'tablet' + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '400x400').processed, only_path: true) elsif params[:context] == 'desktop' redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '750x750').processed, only_path: true) else diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 421e3125..9afc15a2 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -4,9 +4,6 @@ # app/controllers/v3/tour_sets.rb module V3 class TourSetsController < V3Controller - before_action :set_record, only: [:show, :update, :destroy] - #authorize_resource - # GET /tour_sets def index @records = [] diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index cf0bfcae..dc1e2fcf 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -19,7 +19,6 @@ def index else Tour.published end - if @records.nil? render json: { error: 'not found' }.to_json, status: 404 else diff --git a/app/models/stop.rb b/app/models/stop.rb index 75a5a0db..07555a38 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -46,6 +46,12 @@ def splash nil end + def splash_url + return if splash.nil? + + splash.files[:desktop] + end + def splash_height splash.nil? ? nil : splash.desktop_height end diff --git a/app/models/tour.rb b/app/models/tour.rb index bb96c24e..7841bd64 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -73,6 +73,12 @@ def splash nil end + def splash_url + return if splash.nil? + + splash.files[:desktop] + end + def splash_height splash.nil? ? nil : splash.desktop_height end diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index 001fa213..2ba5f6d7 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -5,5 +5,5 @@ class V3::StopSerializer < ActiveModel::Serializer has_many :stop_media has_many :tours belongs_to :map_icon - attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash, :insecure_splash, :splash_width, :splash_height, :orphaned, :icon_color + attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash_url, :insecure_splash, :splash_width, :splash_height, :orphaned, :icon_color end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index e4e4163c..cf7cddd4 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -3,5 +3,5 @@ # app/serializers/tour_serializer.rb class V3::TourBaseSerializer < ActiveModel::Serializer has_one :map_overlay - attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash, :use_directions, :default_lng + attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash_url, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash, :use_directions, :default_lng, :stop_count end From 9676be48c61b70823d47c866df502a48ef329be8 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 1 Jul 2021 15:53:32 -0400 Subject: [PATCH 014/160] Update auth engine --- Gemfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index b083ea60..462cbf8b 100644 --- a/Gemfile +++ b/Gemfile @@ -28,8 +28,8 @@ gem "actionview", ">= 5.2.2.1" # Social Auth # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' -# gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' -gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' +gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' +# gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 From e864cd9ae8cdd663e759f660b8a59b4fc9693b80 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 1 Jul 2021 15:55:18 -0400 Subject: [PATCH 015/160] Update to gem lock --- Gemfile.lock | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d37ff846..f49a8fe6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,7 @@ GIT - remote: https://github.com/stympy/faker.git - revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 - branch: master - specs: - faker (2.18.0) - i18n (>= 1.6, < 2) - -PATH - remote: /data/ecds_auth_engine + remote: https://github.com/ecds/ecds_rails_auth_engine.git + revision: 511b5def2258980b5a23f78e63c2992fb04af93e + branch: feature/fauxoauth specs: ecds_rails_auth_engine (0.1.6) cancancan @@ -15,6 +9,14 @@ PATH jwt rails +GIT + remote: https://github.com/stympy/faker.git + revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 + branch: master + specs: + faker (2.18.0) + i18n (>= 1.6, < 2) + GEM remote: https://rubygems.org/ specs: From 40fa8f043f3df9ed222bce9baa55c8df7e15102d Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 2 Jul 2021 12:59:00 -0400 Subject: [PATCH 016/160] Include mapable tours for tour sets --- app/models/tour.rb | 6 ++++-- app/models/tour_set.rb | 20 +++++++++++++++++++- app/serializers/v3/tour_set_serializer.rb | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 7841bd64..ebdd7993 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -36,6 +36,8 @@ class Tour < ApplicationRecord after_create :add_modes scope :published, -> { where(published: true) } + scope :mapable, -> { where(is_geo: true) } + scope :has_stops, -> { includes(:stops).where.not(stops: { id: nil }) } def sanitized_description HtmlSaintizer.accessable(description) @@ -101,9 +103,9 @@ def stop_count def bounds return nil if stops.empty? - points = stops.map { |s| RGeo::Geographic.spherical_factory.point(s.lng, s.lat) } + points = stops.map { |stop| RGeo::Geographic.spherical_factory.point(stop.lng, stop.lat) } box = RGeo::Cartesian::BoundingBox.create_from_points(points.pop, points.pop) - points.each { |p| box.add(p) } + points.each { |point| box.add(point) } { south: box.min_y, diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index 70d638c7..b3e1eb0b 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -21,7 +21,7 @@ def published_tours begin Apartment::Tenant.switch! self.subdir tours = [] - Tour.published.each do |t| + Tour.published.has_stops.each do |t| tour = { title: t.title, slug: t.slug @@ -34,6 +34,24 @@ def published_tours end end + def mapable_tours + begin + Apartment::Tenant.switch! self.subdir + tours = [] + Tour.published.has_stops.mapable.each do |t| + tour = { + title: t.title, + slug: t.slug, + center: { lat: t.bounds[:centerLat], lng: t.bounds[:centerLng] } + } + tours.push(tour) + end + tours + rescue Apartment::TenantNotFound => error + # self.delete + end + end + private def set_subdir diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index 286748f3..1154e791 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -4,7 +4,7 @@ class V3::TourSetSerializer < ActiveModel::Serializer # attribute :tenant_admins include Rails.application.routes.url_helpers has_many :admins - attributes :id, :name, :subdir, :published_tours, :logo_url + attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url def admins object.admins if current_user.super || current_user.current_tenant_admin? From e235a32fc379300d27596ed2f7ec654645adf968 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 2 Jul 2021 15:26:44 -0400 Subject: [PATCH 017/160] Move active storage to tenants --- app/controllers/v3/media_controller.rb | 8 +++--- app/models/concerns/video_props.rb | 8 +++--- app/models/medium.rb | 4 +-- app/models/medium_base_record.rb | 5 ++-- app/serializers/v3/map_icon_serializer.rb | 4 +-- app/serializers/v3/map_overlay_serializer.rb | 4 +-- config/deploy/staging.rb | 8 +++--- config/initializers/apartment.rb | 2 +- lib/snippets.rb | 26 +++++++++++++++----- spec/rails_helper.rb | 2 +- 10 files changed, 43 insertions(+), 28 deletions(-) diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 3295c6e8..5b78b352 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -54,13 +54,13 @@ def destroy end def file - if @record&.public_send("#{Apartment::Tenant.current.underscore}_file")&.attached? + if @record&.file&.attached? if params[:context] == 'mobile' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '300x300').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '300x300').processed, only_path: true) elsif params[:context] == 'tablet' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '400x400').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '400x400').processed, only_path: true) elsif params[:context] == 'desktop' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.public_send("#{Apartment::Tenant.current.underscore}_file").variant(resize: '750x750').processed, only_path: true) + redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '750x750').processed, only_path: true) else redirect_to rails_blob_url(@record.file) end diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index c6102f26..ffcc1e3e 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -16,7 +16,7 @@ def self.props(medium) medium.caption = metadata['description'] medium.embed = "//player.vimeo.com/video/#{medium.video}" downloaded_image = open(metadata['thumbnail_url']) - medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.video}.jpg") + medium.file.attach(io: downloaded_image, filename: "#{medium.video}.jpg") when 'youtube' begin metadata = Yt::Video.new(id: medium.video) @@ -24,7 +24,7 @@ def self.props(medium) medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" downloaded_image = open("https://img.youtube.com/vi/#{medium.video}/0.jpg") - medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.video}.jpg") + medium.file.attach(io: downloaded_image, filename: "#{medium.video}.jpg") rescue Yt::Errors::NoItems medium.provider = nil medium.video = nil @@ -45,13 +45,13 @@ def self.props(medium) spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] if image.nil? - medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + medium.file.attach( io: File.open(File.join(Rails.root, 'public', 'soundcloud.jpg')), filename: "#{medium.title.parameterize}.jpg" ) else downloaded_image = open("https:#{image}") - medium.public_send("#{Apartment::Tenant.current.underscore}_file").attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") + medium.file.attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") end end diff --git a/app/models/medium.rb b/app/models/medium.rb index edb73354..c1c48a08 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -13,7 +13,7 @@ class Medium < MediumBaseRecord # attachable.variant :desktop, resize: '750x750' # end - # mount_base64_uploader :original_image, MediumUploader + mount_base64_uploader :original_image, MediumUploader has_many :stop_media has_many :stops, through: :stop_media has_many :tour_media @@ -67,7 +67,7 @@ def published end def files - return nil if !self.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + return nil if !self.file.attached? { mobile: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=mobile", tablet: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=tablet", diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 9e36a8cb..7b4b90df 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -5,7 +5,8 @@ class MediumBaseRecord < ApplicationRecord self.abstract_class = true before_create :attach_file - has_one_attached "#{Apartment::Tenant.current.underscore}_file" + # has_one_attached "#{Apartment::Tenant.current.underscore}_file" + has_one_attached 'file' def tmp_file_path return Rails.root.join('public', 'storage', 'tmp', title) if self.title @@ -31,7 +32,7 @@ def attach_file f.write(Base64.decode64(base_sixty_four)) end - self.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + self.file.attach( io: File.open(tmp_file_path), filename: title, content_type: content_type diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb index b5d5d9d0..ad89565a 100644 --- a/app/serializers/v3/map_icon_serializer.rb +++ b/app/serializers/v3/map_icon_serializer.rb @@ -3,7 +3,7 @@ class V3::MapIconSerializer < ActiveModel::Serializer attributes :id, :base_sixty_four, :title, :image_url def image_url - return nil unless object.public_send("#{Apartment::Tenant.current.underscore}_file").attached? - rails_blob_url(object.public_send("#{Apartment::Tenant.current.underscore}_file")) + return nil unless object.file.attached? + rails_blob_url(object.file) end end diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb index 1c0bd4e5..0762026d 100644 --- a/app/serializers/v3/map_overlay_serializer.rb +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -3,7 +3,7 @@ class V3::MapOverlaySerializer < ActiveModel::Serializer attributes :id, :south, :north, :east, :west, :image_url, :title def image_url - return nil unless object.public_send("#{Apartment::Tenant.current.underscore}_file").attached? - rails_blob_url(object.public_send("#{Apartment::Tenant.current.underscore}_file")) + return nil unless object.file.attached? + rails_blob_url(object.file) end end diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb index d375745a..8e115a60 100644 --- a/config/deploy/staging.rb +++ b/config/deploy/staging.rb @@ -7,7 +7,7 @@ # Defines a single server with a list of roles and multiple properties. # You can define all roles on a single server, or split them: -server "3.81.27.251", user: "deploy", roles: %w{app db web}, primary: :my_value +server "44.192.13.190", user: "deploy", roles: %w{app db web}, primary: :my_value # server "otb.ecdsdev.org", user: "deploy", roles: %w{app web}, other_property: :other_value # server "db.otb.ecdsdev.org", user: "deploy", roles: %w{db} @@ -21,9 +21,9 @@ # property set. Specify the username and a domain or IP for the server. # Don't use `:all`, it's a meta role. -role :app, %w{deploy@3.81.27.251} -role :web, %w{user1@3.81.27.251} -role :db, %w{deploy@3.81.27.251} +role :app, %w{deploy@44.192.13.190} +role :web, %w{user1@44.192.13.190} +role :db, %w{deploy@44.192.13.190} diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 951a7415..2b6faae7 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -3,6 +3,6 @@ # require 'directory_elevator' Apartment.configure do |config| config.tenant_names = -> { TourSet.pluck :subdir } - config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme', 'ActiveStorage::Attachment', 'ActiveStorage::Blob'] + config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme'] config.persistent_schemas = ['shared_extensions'] end diff --git a/lib/snippets.rb b/lib/snippets.rb index dbd04080..038e16fe 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -50,9 +50,9 @@ ids.each do |id| Apartment::Tenant.switch! ts m = Medium.find(id) - next if m.public_send("#{ts.underscore}_file").attached? + next if m.file.attached? if m.original_image.path && File.exist?(m.original_image.path) - m.public_send("#{ts.underscore}_file").attach( + m.file.attach( io: File.open(m.original_image.path), filename: m.original_image.path.split('/').last, content_type: m.original_image.content_type @@ -61,20 +61,20 @@ end end -ActiveStorage::Blob.service.send(:path_for, m.public_send("#{Apartment::Tenant.current.underscore}_file").key) +ActiveStorage::Blob.service.send(:path_for, m.file.key) Apartment::Tenant.switch! 'july-22nd' ids = Medium.all.map(&:id) ids.each do |id| Apartment::Tenant.switch! 'july-22nd' m = Medium.find(id) - next if m.public_send("#{Apartment::Tenant.current.underscore}_file").attached? + next if m.file.attached? # next unless m.file.attached? next unless File.exists? ActiveStorage::Blob.service.send(:path_for, m.file.key) Apartment::Tenant.switch! 'july-22nd' - m.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + m.file.attach( io: File.open(ActiveStorage::Blob.service.send(:path_for, m.file.key)), filename: m.file.filename.to_s, content_type: m.file.content_type @@ -91,7 +91,7 @@ next unless File.exists? ActiveStorage::Blob.service.send(:path_for, m.file.key) - m.public_send("#{Apartment::Tenant.current.underscore}_file").attach( + m.file.attach( io: File.open(ActiveStorage::Blob.service.send(:path_for, m.file.key)), filename: m.file.filename.to_s, content_type: m.file.content_type @@ -103,3 +103,17 @@ u.email = login.identification u.save end + +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + puts ts + Apartment::Tenant.switch! ts + Tour.all.each do |t| + puts t.title + if t.bounds + t.is_geo = true + t.save + end + end +end \ No newline at end of file diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3b686a09..1556c2ab 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -178,6 +178,6 @@ end config.after(:suite) do - TourSet.all.each { |ts| ts.destroy } + # TourSet.all.each { |ts| ts.destroy } end end From 1453fe808f0796179ee130fdc08456b46ea8ba1d Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 6 Jul 2021 13:27:00 -0400 Subject: [PATCH 018/160] Move image_url to model for map overlay --- app/models/map_overlay.rb | 6 ++++++ app/serializers/v3/map_overlay_serializer.rb | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/models/map_overlay.rb b/app/models/map_overlay.rb index 2b0a9f3b..8bf160a6 100644 --- a/app/models/map_overlay.rb +++ b/app/models/map_overlay.rb @@ -4,6 +4,12 @@ class MapOverlay < MediumBaseRecord belongs_to :tour, optional: true belongs_to :stop, optional: true + def image_url + return nil unless file.attached? + + file.service_url + end + def set_initial_bounds if tour self.south = self.tour.bounds[:south] diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb index 0762026d..b7f4e7d8 100644 --- a/app/serializers/v3/map_overlay_serializer.rb +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -1,9 +1,4 @@ class V3::MapOverlaySerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers attributes :id, :south, :north, :east, :west, :image_url, :title - - def image_url - return nil unless object.file.attached? - rails_blob_url(object.file) - end end From 865833109ac52464b90fd7cccaf412ab6d32896d Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 7 Jul 2021 16:55:48 -0400 Subject: [PATCH 019/160] Improve images and clean out some junk --- app/controllers/v3/map_icons_controller.rb | 2 +- app/controllers/v3/map_overlays_controller.rb | 2 +- app/controllers/v3/media_controller.rb | 18 +++-- app/models/map_overlay.rb | 11 ++- app/models/medium.rb | 67 ++++++------------- app/models/medium_base_record.rb | 20 +++++- app/models/stop.rb | 11 +-- app/models/tour.rb | 6 +- app/serializers/v3/map_icon_serializer.rb | 2 +- app/serializers/v3/map_overlay_serializer.rb | 2 +- app/serializers/v3/medium_serializer.rb | 2 +- config/initializers/s3.rb | 11 +++ config/storage.yml | 7 +- .../20210702214539_add_file_urls_to_media.rb | 7 ++ .../20210706150527_add_filename_to_media.rb | 5 ++ ...20210707161803_change_title_to_filename.rb | 6 ++ db/schema.rb | 13 ++-- lib/snippets.rb | 52 +++++++++++++- 18 files changed, 161 insertions(+), 83 deletions(-) create mode 100644 config/initializers/s3.rb create mode 100644 db/migrate/20210702214539_add_file_urls_to_media.rb create mode 100644 db/migrate/20210706150527_add_filename_to_media.rb create mode 100644 db/migrate/20210707161803_change_title_to_filename.rb diff --git a/app/controllers/v3/map_icons_controller.rb b/app/controllers/v3/map_icons_controller.rb index 3283d777..01371254 100644 --- a/app/controllers/v3/map_icons_controller.rb +++ b/app/controllers/v3/map_icons_controller.rb @@ -43,7 +43,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :base_sixty_four, :title + :base_sixty_four, :filename ] ) end diff --git a/app/controllers/v3/map_overlays_controller.rb b/app/controllers/v3/map_overlays_controller.rb index b6979f73..0c08221c 100644 --- a/app/controllers/v3/map_overlays_controller.rb +++ b/app/controllers/v3/map_overlays_controller.rb @@ -38,7 +38,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :south, :east, :north, :west, :base_sixty_four, :title, :tour, :stop + :south, :east, :north, :west, :base_sixty_four, :filename, :tour, :stop ] ) end diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 5b78b352..c678d02f 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -9,12 +9,10 @@ class MediaController < V3Controller def index # TODO: This ins not ideal, we use these `not_in_*` scopes to make the list of media avaliable to add # to a stop or tour. But the paramerter does not make sense when just looking at it. Needs clearer language. - @media = if params[:stop_id] - Medium.not_in_stop(params[:stop_id]).or(Medium.no_stops) - elsif params[:tour_id] - Medium.not_in_tour(params[:tour_id]).or(Medium.no_tours) - else + @media = if (current_user && current_user.current_tenant_admin?) Medium.all + else + Medium.all.map { |medium| medium if medium.published }.compact end render json: @media end @@ -56,13 +54,13 @@ def destroy def file if @record&.file&.attached? if params[:context] == 'mobile' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '300x300').processed, only_path: true) + redirect_to @record.file.variant(resize: '300x300').processed.service_url elsif params[:context] == 'tablet' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '400x400').processed, only_path: true) + redirect_to @record.file.variant(resize: '400x400').processed.service_url elsif params[:context] == 'desktop' - redirect_to Rails.application.routes.url_helpers.rails_representation_url(@record.file.variant(resize: '750x750').processed, only_path: true) + redirect_to @record.file.variant(resize: '750x750').processed.service_url else - redirect_to rails_blob_url(@record.file) + redirect_to @record.file.service_url end else head :not_found @@ -79,7 +77,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :title, :caption, :original_image, :stops, :tours, :video, :stop_id, :tour_id, :base_sixty_four, :video_provider, :embed + :title, :caption, :original_image, :stops, :tours, :video, :stop_id, :tour_id, :base_sixty_four, :video_provider, :embed, :filename ] ) end diff --git a/app/models/map_overlay.rb b/app/models/map_overlay.rb index 8bf160a6..ab200a82 100644 --- a/app/models/map_overlay.rb +++ b/app/models/map_overlay.rb @@ -1,15 +1,14 @@ +# frozen_string_literal: true + +# +# Model calss for map overlays. +# class MapOverlay < MediumBaseRecord before_create :set_initial_bounds belongs_to :tour, optional: true belongs_to :stop, optional: true - def image_url - return nil unless file.attached? - - file.service_url - end - def set_initial_bounds if tour self.south = self.tour.bounds[:south] diff --git a/app/models/medium.rb b/app/models/medium.rb index c1c48a08..b2d1482f 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -5,7 +5,7 @@ class Medium < MediumBaseRecord include VideoProps include Rails.application.routes.url_helpers before_create :props - before_update :remove_tmp_file + before_update :replace_video # has_one_attached :file do |attachable| # attachable.variant :mobile, resize: '200x200' @@ -25,54 +25,31 @@ class Medium < MediumBaseRecord attr_accessor :insecure - - - # TODO: This is not ideal, we use these `not_in_*` scopes to make the list of media avaliable to add - # to a stop or tour. But the paramerter does not make sense when just looking at it. Needs clearer language. - scope :not_in_stop, lambda { |stop_id| includes(:stop_media).where.not(stop_media: { stop_id: stop_id }) } - scope :not_in_tour, lambda { |tour_id| includes(:tour_media).where.not(tour_media: { tour_id: tour_id }) } - scope :no_stops, lambda { includes(:stop_media).where(stop_media: { stop_id: nil }) } - scope :no_tours, lambda { includes(:tour_media).where(tour_media: { tour_id: nil }) } - scope :orphan, -> { no_tours.no_stops } - scope :published_by_tour, lambda { includes(:tours).where(tours: { published: true }) } - scope :published_by_stop, -> { joins(:stops).merge(Stop.published) } - - def props return if self.video.nil? || self.video.empty? VideoProps.props(self) end - # def desktop - # original_image.desktop.url - # end - - # def tablet - # original_image.tablet.url - # end - - # def mobile - # original_image.mobile_list_thumb.url - # end - - # def mobile_thumb - # original_image.mobile_list_thumb.url - # end - def published - # This works and is shorter, but I think the longer way is more readable/clear. - # tours.published.present? || stops { |s| s.tours.published }.present? - tours.collect(&:published).include?(true) || stops.map { |s| s.tours.collect(&:published) }.flatten.include?(true) + tours.any? { |tour| tour.published } || stops.any? { |stop| stop.published } end def files return nil if !self.file.attached? - { - mobile: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=mobile", - tablet: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=tablet", - desktop: "#{ENV['BASE_URL']}/#{Apartment::Tenant.current}/media/#{id}/file?context=desktop" - } + begin + { + mobile: file.variant(resize: '300x300').processed.service_url, + tablet: file.variant(resize: '400x400').processed.service_url, + desktop: file.variant(resize: '750x750').processed.service_url + } + rescue ActiveStorage::FileNotFoundError => error + { mobile: nil, tablet: nil, desktop: nil } + end + end + + def orphaned + tours.empty? && stops.empty? end def srcset @@ -83,7 +60,8 @@ def srcset end def srcset_sizes - "(max-width: 680px) #{mobile_width}px, (max-width: 880px) #{tablet_width}px, #{desktop_width}px" + nil + # "(max-width: 680px) #{mobile_width}px, (max-width: 880px) #{tablet_width}px, #{desktop_width}px" end def insecure @@ -91,10 +69,9 @@ def insecure # "#{ENV['INSECURE_IMAGE_BASE_URL']}#{self.desktop}" end - # def base64 - # if self.original_image.file && File.file?(self.original_image.file.path) - # return Base64.encode64(self.original_image.file.read) - # end - # nil - # end + def replace_video + if video.present? && base_sixty_four.present? + attach_file + end + end end diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 7b4b90df..8f3ddc68 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -4,12 +4,19 @@ class MediumBaseRecord < ApplicationRecord self.abstract_class = true before_create :attach_file + before_destroy :purge # has_one_attached "#{Apartment::Tenant.current.underscore}_file" has_one_attached 'file' + def image_url + return nil unless file.attached? + + file.service_url + end + def tmp_file_path - return Rails.root.join('public', 'storage', 'tmp', title) if self.title + return Rails.root.join('public', 'storage', 'tmp', filename) if self.filename nil end @@ -25,6 +32,8 @@ def tmp_file_path def attach_file return if base_sixty_four.nil? + file.blob.delete if file.attached? + headers, self.base_sixty_four = base_sixty_four.split(',') headers =~ /^data:(.*?)$/ content_type = Regexp.last_match(1).split(';base64').first @@ -34,12 +43,17 @@ def attach_file self.file.attach( io: File.open(tmp_file_path), - filename: title, + filename: filename, content_type: content_type ) end def remove_tmp_file - nil + File.delete(tmp_file_path) if File.exists?(tmp_file_path) + end + + def purge + remove_tmp_file + file.blob.delete if file.attached? end end diff --git a/app/models/stop.rb b/app/models/stop.rb index 07555a38..f36451b5 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -20,9 +20,9 @@ class Stop < ApplicationRecord before_validation -> { self.title ||= 'untitled' } - scope :not_in_tour, lambda { |tour_id| includes(:tour_stops).where.not(tour_stops: { tour_id: tour_id }) } - scope :no_tours, lambda { includes(:tour_stops).where(tour_stops: { tour_id: nil }) } - scope :published, lambda { includes(:tours).where(tours: { published: true }) } + # scope :not_in_tour, lambda { |tour_id| includes(:tour_stops).where.not(tour_stops: { tour_id: tour_id }) } + # scope :no_tours, lambda { includes(:tour_stops).where(tour_stops: { tour_id: nil }) } + # scope :published, lambda { includes(:tours).where(tours: { published: true }) } scope :by_slug_and_tour, lambda { |slug, tour_id| joins(:stop_slugs).joins(:tours).where('stop_slugs.slug = ?', slug).where('tour_stops.tour_id = ?', tour_id) } def sanitized_description @@ -47,7 +47,7 @@ def splash end def splash_url - return if splash.nil? + return if splash.nil? || splash.files.nil? splash.files[:desktop] end @@ -75,6 +75,9 @@ def orphaned tours.empty? end + def published + tours.any? { |tour| tour.published } + end private diff --git a/app/models/tour.rb b/app/models/tour.rb index ebdd7993..853dc1af 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -76,9 +76,9 @@ def splash end def splash_url - return if splash.nil? - - splash.files[:desktop] + return if splash.nil? || splash.files.nil? + nil + # splash.files[:desktop] end def splash_height diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb index ad89565a..21863e78 100644 --- a/app/serializers/v3/map_icon_serializer.rb +++ b/app/serializers/v3/map_icon_serializer.rb @@ -1,6 +1,6 @@ class V3::MapIconSerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers - attributes :id, :base_sixty_four, :title, :image_url + attributes :id, :base_sixty_four, :filename, :image_url def image_url return nil unless object.file.attached? diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb index b7f4e7d8..e859364a 100644 --- a/app/serializers/v3/map_overlay_serializer.rb +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -1,4 +1,4 @@ class V3::MapOverlaySerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers - attributes :id, :south, :north, :east, :west, :image_url, :title + attributes :id, :south, :north, :east, :west, :image_url, :filename end diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index a5a66e59..1cee682f 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -2,7 +2,7 @@ class V3::MediumSerializer < ActiveModel::Serializer # include Rails.application.routes.url_helpers - attributes :id, :title, :caption, :video, :provider, :original_image, :embed, :srcset, :srcset_sizes, :insecure, :files + attributes :id, :title, :caption, :video, :provider, :original_image, :embed, :srcset, :srcset_sizes, :insecure, :files, :orphaned, :filename # def files # return nil unless object.file.attached? diff --git a/config/initializers/s3.rb b/config/initializers/s3.rb new file mode 100644 index 00000000..64912b61 --- /dev/null +++ b/config/initializers/s3.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Aws.config.update({ +# credentials: Aws::Credentials.new( +# Rails.application.credentials.s3Staging[:access_key_id], +# Rails.application.credentials.s3Staging[:secret_access_key] +# ) +# }) + +# Sometimes the service URL expires too quickly. +Rails.application.config.active_storage.service_urls_expire_in = 1.hour diff --git a/config/storage.yml b/config/storage.yml index 3bcedc42..432f4289 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -3,8 +3,11 @@ test: root: <%= Rails.root.join("tmp/storage") %> local: - service: Disk - root: <%= Rails.root.join(Apartment::Tenant.current, "storage") %> + service: S3 + access_key_id: <%= Rails.application.credentials.dig(:s3Staging, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:s3Staging, :secret_access_key) %> + region: us-east-1 + bucket: otb-dev staging: service: S3 diff --git a/db/migrate/20210702214539_add_file_urls_to_media.rb b/db/migrate/20210702214539_add_file_urls_to_media.rb new file mode 100644 index 00000000..e645d8c4 --- /dev/null +++ b/db/migrate/20210702214539_add_file_urls_to_media.rb @@ -0,0 +1,7 @@ +class AddFileUrlsToMedia < ActiveRecord::Migration[6.0] + def change + add_column :media, :mobile, :string + add_column :media, :tablet, :string + add_column :media, :desktop, :string + end +end diff --git a/db/migrate/20210706150527_add_filename_to_media.rb b/db/migrate/20210706150527_add_filename_to_media.rb new file mode 100644 index 00000000..125fe154 --- /dev/null +++ b/db/migrate/20210706150527_add_filename_to_media.rb @@ -0,0 +1,5 @@ +class AddFilenameToMedia < ActiveRecord::Migration[6.0] + def change + add_column :media, :filename, :string + end +end diff --git a/db/migrate/20210707161803_change_title_to_filename.rb b/db/migrate/20210707161803_change_title_to_filename.rb new file mode 100644 index 00000000..a2eaa124 --- /dev/null +++ b/db/migrate/20210707161803_change_title_to_filename.rb @@ -0,0 +1,6 @@ +class ChangeTitleToFilename < ActiveRecord::Migration[6.0] + def change + rename_column :map_icons, :title, :filename + rename_column :map_overlays, :title, :filename + end +end diff --git a/db/schema.rb b/db/schema.rb index 864e91ea..bc36abcf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,11 +10,12 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_06_14_154939) do +ActiveRecord::Schema.define(version: 2021_07_07_161803) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" + enable_extension "uuid-ossp" create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false @@ -73,7 +74,7 @@ t.text "base_sixty_four" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false - t.string "title" + t.string "filename" end create_table "map_overlays", force: :cascade do |t| @@ -86,7 +87,7 @@ t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.text "base_sixty_four" - t.text "title" + t.text "filename" t.index ["stop_id"], name: "index_map_overlays_on_stop_id" t.index ["tour_id"], name: "index_map_overlays_on_tour_id" end @@ -108,6 +109,10 @@ t.integer "mobile_height" t.text "base_sixty_four" t.integer "video_provider", default: 0 + t.string "mobile" + t.string "tablet" + t.string "desktop" + t.string "filename" end create_table "modes", force: :cascade do |t| @@ -121,7 +126,7 @@ t.string "title" end - create_table "slugs", force: :cascade do |t| + create_table "slugs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false diff --git a/lib/snippets.rb b/lib/snippets.rb index 038e16fe..56e6d237 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -61,6 +61,40 @@ end end +sites = TourSet.all.map(&:subdir) +sites.each do |ts| + Apartment::Tenant.switch! ts + reload! + ids = Medium.all.map(&:id) + ids.each do |id| + m = Medium.find(id) + next if m.file.attached? + if m.original_image.path && File.exist?(m.original_image.path) + m.file.attach( + io: File.open(m.original_image.path), + filename: m.original_image.path.split('/').last, + content_type: m.original_image.content_type + ) + end + end +end + +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + Apartment::Tenant.switch! ts + reload! + ids = Medium.all.map(&:id) + ids.each do |id| + Apartment::Tenant.switch! ts + m = Medium.find(id) + m.file.purge if m.file.attached? + m.delete + end + TourMedium.all.each {|tm| tm.delete} + StopMedium.all.each {|tm| tm.delete} +end + ActiveStorage::Blob.service.send(:path_for, m.file.key) Apartment::Tenant.switch! 'july-22nd' ids = Medium.all.map(&:id) @@ -116,4 +150,20 @@ t.save end end -end \ No newline at end of file +end + +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + puts ts + Apartment::Tenant.switch! ts + Medium.all.each do |m| + begin + next unless m.public_send("#{ts.underscore}_file").present? + next unless m.public_send("#{ts.underscore}_file").attached? + + m.public_send("#{ts.underscore}_file").purge + rescue NoMethodError => error + end + end +end From e77cde02815cdb055202e339ada3d065fcd60003 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 7 Jul 2021 17:52:53 -0400 Subject: [PATCH 020/160] Remove UUID from Slugs --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index bc36abcf..c991181e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -126,7 +126,7 @@ t.string "title" end - create_table "slugs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "slugs", force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false From 8fcdc89794dc10912a5ede30b38e3749b6b511f5 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 7 Jul 2021 18:03:13 -0400 Subject: [PATCH 021/160] Remove errant test --- spec/requests/v3/stops_spec.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/requests/v3/stops_spec.rb b/spec/requests/v3/stops_spec.rb index 1e709351..6c334d47 100644 --- a/spec/requests/v3/stops_spec.rb +++ b/spec/requests/v3/stops_spec.rb @@ -22,15 +22,15 @@ end end - context 'returns stops not associated with given tour' do - before { - get "/#{Apartment::Tenant.current}/stops?tour_id=#{Tour.last.id}" - } + # context 'returns stops not associated with given tour' do + # before { + # get "/#{Apartment::Tenant.current}/stops?tour_id=#{Tour.last.id}" + # } - it 'returns stops not part of given tour' do - expect(json.size).to eq(Stop.not_in_tour(Tour.last.id).count) - end - end + # it 'returns stops not part of given tour' do + # expect(json.size).to eq(Stop.not_in_tour(Tour.last.id).count) + # end + # end context 'get stop by slug and tour' do before { From 1c461463331205482846ebb7f6878fe7cf21b1f5 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 7 Jul 2021 22:12:08 -0400 Subject: [PATCH 022/160] Update CircleCI config --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 357c8cdc..56bd7c8e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -44,7 +44,9 @@ jobs: - run: name: Database Setup command: | - bundle exec rake db:schema:load + bundle exec rake db:drop RAILS_ENV=test + bundle exec rake db:create RAILS_ENV=test + bundle exec rake db:schema:load RAILS_ENV=test - run: name: Make Tmp Directory From 44b767b0a639175df57658546ba583ffb2b18fb6 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 9 Jul 2021 16:50:50 -0400 Subject: [PATCH 023/160] Clean up --- app/controllers/v3/tour_authors_controller.rb | 58 ++++++++ app/controllers/v3/tours_controller.rb | 2 +- app/models/stop.rb | 22 +-- app/models/tour.rb | 23 ++-- app/serializers/v3/stop_serializer.rb | 24 +++- app/serializers/v3/tour_author_serializer.rb | 5 + app/serializers/v3/tour_base_serializer.rb | 21 ++- app/serializers/v3/tour_serializer.rb | 1 + config/routes.rb | 1 + .../20210709153515_create_tour_authors.rb | 8 ++ spec/requests/tour_authors_spec.rb | 127 ++++++++++++++++++ spec/routing/tour_authors_routing_spec.rb | 30 +++++ 12 files changed, 296 insertions(+), 26 deletions(-) create mode 100644 app/controllers/v3/tour_authors_controller.rb create mode 100644 app/serializers/v3/tour_author_serializer.rb create mode 100644 db/migrate/20210709153515_create_tour_authors.rb create mode 100644 spec/requests/tour_authors_spec.rb create mode 100644 spec/routing/tour_authors_routing_spec.rb diff --git a/app/controllers/v3/tour_authors_controller.rb b/app/controllers/v3/tour_authors_controller.rb new file mode 100644 index 00000000..50076dd4 --- /dev/null +++ b/app/controllers/v3/tour_authors_controller.rb @@ -0,0 +1,58 @@ +module V3 + class TourAuthorsController < ApplicationController + before_action :set_tour_author, only: [:show, :update, :destroy] + + # GET /tour_authors + def index + @tour_authors = TourAuthor.all + + render json: @tour_authors + end + + # GET /tour_authors/1 + def show + render json: @tour_author + end + + # POST /tour_authors + def create + @tour_author = TourAuthor.new(tour_author_params) + + if @tour_author.save + render json: @tour_author, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour_author}" + else + render json: @tour_author.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /tour_authors/1 + def update + if @tour_author.update(tour_author_params) + render json: @tour_author + else + render json: @tour_author.errors, status: :unprocessable_entity + end + end + + # DELETE /tour_authors/1 + def destroy + @tour_author.destroy + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_tour_author + @tour_author = TourAuthor.find(params[:id]) + end + + # Only allow a trusted parameter "white list" through. + def tour_author_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :tour, :user + ] + ) + end + end +end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index dc1e2fcf..d78b36a9 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -81,7 +81,7 @@ def tour_params :title, :description, :is_geo, :modes, :published, :theme_id, :mode, :meta_description, :stops, - :media, :authors, :flat_pages, :map_type, + :media, :users, :flat_pages, :map_type, :theme, :use_directions, :default_lng ] ) diff --git a/app/models/stop.rb b/app/models/stop.rb index f36451b5..528b4379 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -38,26 +38,26 @@ def slug end def splash - if medium.present? - return medium + splash_medium = if medium.present? + medium elsif stop_media.present? - return stop_media.order(:position).first.medium + stop_media.order(:position).first.medium + else + nil end - nil - end - - def splash_url - return if splash.nil? || splash.files.nil? - splash.files[:desktop] + if splash_medium + return { title: splash_medium.title, caption: splash_medium.caption, url: splash_medium.files[:desktop] } + end + nil end def splash_height - splash.nil? ? nil : splash.desktop_height + splash.nil? ? nil : 700 #splash.desktop_height end def splash_width - splash.nil? ? nil : splash.desktop_width + splash.nil? ? nil : 700 #splash.desktop_width end def insecure_splash diff --git a/app/models/tour.rb b/app/models/tour.rb index 853dc1af..4c11ea9f 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -14,10 +14,9 @@ class Tour < ApplicationRecord has_many :tour_flat_pages has_many :flat_pages, through: :tour_flat_pages has_many :tour_authors - has_many :authors, through: :tour_authors, source: :user + has_many :users, through: :tour_authors has_many :slugs, dependent: :delete_all has_one :map_overlay - # has_many :authors, through: :tour_authors, foreign_key: :user_id # belongs_to :splash_image_medium_id, class_name: 'Medium' belongs_to :theme, default: -> { Theme.first } @@ -67,26 +66,26 @@ def theme_title end def splash - if medium.present? - return medium + splash_medium = if medium.present? + medium elsif tour_media.present? - return tour_media.order(:position).first.medium + tour_media.order(:position).first.medium + else + nil end - nil - end - def splash_url - return if splash.nil? || splash.files.nil? + if splash_medium + return { title: splash_medium.title, caption: splash_medium.caption, url: splash_medium.files[:desktop] } + end nil - # splash.files[:desktop] end def splash_height - splash.nil? ? nil : splash.desktop_height + splash.nil? ? nil : 700 #splash.desktop_height end def splash_width - splash.nil? ? nil : splash.desktop_width + splash.nil? ? nil : 700 #splash.desktop_width end def insecure_splash diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index 2ba5f6d7..ecd87045 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -5,5 +5,27 @@ class V3::StopSerializer < ActiveModel::Serializer has_many :stop_media has_many :tours belongs_to :map_icon - attributes :id, :title, :slug, :description, :sanitized_description, :sanitized_direction_notes, :lat, :lng, :address, :meta_description, :article_link, :video_embed, :video_poster, :parking_lat, :parking_lng, :direction_intro, :direction_notes, :splash_url, :insecure_splash, :splash_width, :splash_height, :orphaned, :icon_color + attributes :id, + :title, + :slug, + :description, + :sanitized_description, + :sanitized_direction_notes, + :lat, + :lng, + :address, + :meta_description, + :article_link, + :video_embed, + :video_poster, + :parking_lat, + :parking_lng, + :direction_intro, + :direction_notes, + :splash, + :insecure_splash, + :splash_width, + :splash_height, + :orphaned, + :icon_color end diff --git a/app/serializers/v3/tour_author_serializer.rb b/app/serializers/v3/tour_author_serializer.rb new file mode 100644 index 00000000..5850a7a5 --- /dev/null +++ b/app/serializers/v3/tour_author_serializer.rb @@ -0,0 +1,5 @@ +class V3::TourAuthorSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :user + attributes :id +end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index cf7cddd4..87cb9a60 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -3,5 +3,24 @@ # app/serializers/tour_serializer.rb class V3::TourBaseSerializer < ActiveModel::Serializer has_one :map_overlay - attributes :id, :title, :slug, :description, :is_geo, :published, :sanitized_description, :position, :theme_title, :meta_description, :splash_url, :tenant, :tenant_title, :stop_count, :map_type, :splash_width, :splash_height, :insecure_splash, :use_directions, :default_lng, :stop_count + attributes :id, + :title, + :slug, + :description, + :is_geo, + :published, + :sanitized_description, + :position, + :theme_title, + :meta_description, + :tenant, + :tenant_title, + :stop_count, + :map_type, + :splash_width, + :splash_height, + :insecure_splash, + :use_directions, + :default_lng, + :stop_count end diff --git a/app/serializers/v3/tour_serializer.rb b/app/serializers/v3/tour_serializer.rb index b3890617..b084455e 100644 --- a/app/serializers/v3/tour_serializer.rb +++ b/app/serializers/v3/tour_serializer.rb @@ -12,6 +12,7 @@ class V3::TourSerializer < V3::TourBaseSerializer has_many :tour_media has_many :flat_pages has_many :tour_flat_pages + has_many :users attributes :bounds end diff --git a/config/routes.rb b/config/routes.rb index 77e49517..33d51e67 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ resources :tours, only: :index end scope module: :v3, constraints: ApiVersion.new('v3', true) do + resources :tour_authors, path: 'tour-authors' resources :users resources :modes, only: [:index] resources :tour_sets, path: 'tour-sets' diff --git a/db/migrate/20210709153515_create_tour_authors.rb b/db/migrate/20210709153515_create_tour_authors.rb new file mode 100644 index 00000000..ee9b5494 --- /dev/null +++ b/db/migrate/20210709153515_create_tour_authors.rb @@ -0,0 +1,8 @@ +class CreateTourAuthors < ActiveRecord::Migration[6.0] + def change + create_table :tour_authors do |t| + + t.timestamps + end + end +end diff --git a/spec/requests/tour_authors_spec.rb b/spec/requests/tour_authors_spec.rb new file mode 100644 index 00000000..7eb11eef --- /dev/null +++ b/spec/requests/tour_authors_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +# This spec was generated by rspec-rails when you ran the scaffold generator. +# It demonstrates how one might use RSpec to test the controller code that +# was generated by Rails when you ran the scaffold generator. +# +# It assumes that the implementation code is generated by the rails scaffold +# generator. If you are using any extension libraries to generate different +# controller code, this generated spec may or may not pass. +# +# It only uses APIs available in rails and/or rspec-rails. There are a number +# of tools you can use to make these specs even more expressive, but we're +# sticking to rails and rspec-rails APIs to keep things simple and stable. + +RSpec.describe "/tour_authors", type: :request do + # This should return the minimal set of attributes required to create a valid + # TourAuthor. As you add validations to TourAuthor, be sure to + # adjust the attributes here as well. + let(:valid_attributes) { + skip("Add a hash of attributes valid for your model") + } + + let(:invalid_attributes) { + skip("Add a hash of attributes invalid for your model") + } + + # This should return the minimal set of values that should be in the headers + # in order to pass any filters (e.g. authentication) defined in + # TourAuthorsController, or in your router and rack + # middleware. Be sure to keep this updated too. + let(:valid_headers) { + {} + } + + describe "GET /index" do + it "renders a successful response" do + TourAuthor.create! valid_attributes + get tour_authors_url, headers: valid_headers, as: :json + expect(response).to be_successful + end + end + + describe "GET /show" do + it "renders a successful response" do + tour_author = TourAuthor.create! valid_attributes + get tour_author_url(tour_author), as: :json + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new TourAuthor" do + expect { + post tour_authors_url, + params: { tour_author: valid_attributes }, headers: valid_headers, as: :json + }.to change(TourAuthor, :count).by(1) + end + + it "renders a JSON response with the new tour_author" do + post tour_authors_url, + params: { tour_author: valid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:created) + expect(response.content_type).to match(a_string_including("application/json")) + end + end + + context "with invalid parameters" do + it "does not create a new TourAuthor" do + expect { + post tour_authors_url, + params: { tour_author: invalid_attributes }, as: :json + }.to change(TourAuthor, :count).by(0) + end + + it "renders a JSON response with errors for the new tour_author" do + post tour_authors_url, + params: { tour_author: invalid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq("application/json") + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) { + skip("Add a hash of attributes valid for your model") + } + + it "updates the requested tour_author" do + tour_author = TourAuthor.create! valid_attributes + patch tour_author_url(tour_author), + params: { tour_author: new_attributes }, headers: valid_headers, as: :json + tour_author.reload + skip("Add assertions for updated state") + end + + it "renders a JSON response with the tour_author" do + tour_author = TourAuthor.create! valid_attributes + patch tour_author_url(tour_author), + params: { tour_author: new_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:ok) + expect(response.content_type).to match(a_string_including("application/json")) + end + end + + context "with invalid parameters" do + it "renders a JSON response with errors for the tour_author" do + tour_author = TourAuthor.create! valid_attributes + patch tour_author_url(tour_author), + params: { tour_author: invalid_attributes }, headers: valid_headers, as: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq("application/json") + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested tour_author" do + tour_author = TourAuthor.create! valid_attributes + expect { + delete tour_author_url(tour_author), headers: valid_headers, as: :json + }.to change(TourAuthor, :count).by(-1) + end + end +end diff --git a/spec/routing/tour_authors_routing_spec.rb b/spec/routing/tour_authors_routing_spec.rb new file mode 100644 index 00000000..2945dc40 --- /dev/null +++ b/spec/routing/tour_authors_routing_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe TourAuthorsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/tour_authors").to route_to("tour_authors#index") + end + + it "routes to #show" do + expect(get: "/tour_authors/1").to route_to("tour_authors#show", id: "1") + end + + + it "routes to #create" do + expect(post: "/tour_authors").to route_to("tour_authors#create") + end + + it "routes to #update via PUT" do + expect(put: "/tour_authors/1").to route_to("tour_authors#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/tour_authors/1").to route_to("tour_authors#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/tour_authors/1").to route_to("tour_authors#destroy", id: "1") + end + end +end From 96baf22e1b895c2e015084a87b02f400ff02c5dc Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 14 Jul 2021 14:38:45 -0400 Subject: [PATCH 024/160] Add tour duration, bump Rails --- .circleci/config.yml | 1 + .gitignore | 1 + Gemfile | 6 +- Gemfile.lock | 178 ++++++++++-------- app/models/stop.rb | 2 +- app/models/tour.rb | 21 +++ app/serializers/v3/tour_base_serializer.rb | 11 +- config/credentials.yml.enc | 2 +- config/initializers/g_maps.rb | 3 + .../20210709153515_create_tour_authors.rb | 8 - ..._to_active_storage_blobs.active_storage.rb | 18 ++ ..._storage_variant_records.active_storage.rb | 12 ++ db/schema.rb | 14 +- ..._authors_spec.rb => tour_authors_spec.fix} | 0 14 files changed, 180 insertions(+), 97 deletions(-) create mode 100644 config/initializers/g_maps.rb delete mode 100644 db/migrate/20210709153515_create_tour_authors.rb create mode 100644 db/migrate/20210712234206_add_service_name_to_active_storage_blobs.active_storage.rb create mode 100644 db/migrate/20210712234207_create_active_storage_variant_records.active_storage.rb rename spec/requests/{tour_authors_spec.rb => tour_authors_spec.fix} (100%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 56bd7c8e..48b0c9a6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -47,6 +47,7 @@ jobs: bundle exec rake db:drop RAILS_ENV=test bundle exec rake db:create RAILS_ENV=test bundle exec rake db:schema:load RAILS_ENV=test + bundle exec rake db:migrate RAILS_ENV=test - run: name: Make Tmp Directory diff --git a/.gitignore b/.gitignore index 6268811f..beb7d5ab 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ public/uploads/ public/storage/* .vscode *.env* +.bundle /config/master.key diff --git a/Gemfile b/Gemfile index 462cbf8b..f4527eed 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 6.0.0' +gem 'rails', '~> 6.1.0' gem "rack", ">= 2.0.6" gem 'pg' gem 'mysql2' @@ -34,14 +34,16 @@ gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 gem 'carrierwave', '~> 1.0' -gem 'mini_magick' gem 'carrierwave-base64' +gem 'mini_magick' +gem 'image_processing', '~> 1.2' gem 'ferrum' gem 'aws-sdk-s3', '~> 1' # RGeo is a geospatial data library for Ruby. # https://github.com/rgeo/rgeo gem('rgeo') +gem 'google_maps_service' # Vidoe provider APIs diff --git a/Gemfile.lock b/Gemfile.lock index f49a8fe6..e724376b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT GIT remote: https://github.com/stympy/faker.git - revision: e1bd4a5a57775b724e8441ffa14cce0861b5a4b6 + revision: 4ef9a869544e42c9b51f85e16012dc98a137d174 branch: master specs: faker (2.18.0) @@ -20,38 +20,40 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.0.3.7) - actionpack (= 6.0.3.7) + actioncable (6.1.4) + actionpack (= 6.1.4) + activesupport (= 6.1.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.3.7) - actionpack (= 6.0.3.7) - activejob (= 6.0.3.7) - activerecord (= 6.0.3.7) - activestorage (= 6.0.3.7) - activesupport (= 6.0.3.7) + actionmailbox (6.1.4) + actionpack (= 6.1.4) + activejob (= 6.1.4) + activerecord (= 6.1.4) + activestorage (= 6.1.4) + activesupport (= 6.1.4) mail (>= 2.7.1) - actionmailer (6.0.3.7) - actionpack (= 6.0.3.7) - actionview (= 6.0.3.7) - activejob (= 6.0.3.7) + actionmailer (6.1.4) + actionpack (= 6.1.4) + actionview (= 6.1.4) + activejob (= 6.1.4) + activesupport (= 6.1.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.3.7) - actionview (= 6.0.3.7) - activesupport (= 6.0.3.7) - rack (~> 2.0, >= 2.0.8) + actionpack (6.1.4) + actionview (= 6.1.4) + activesupport (= 6.1.4) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.3.7) - actionpack (= 6.0.3.7) - activerecord (= 6.0.3.7) - activestorage (= 6.0.3.7) - activesupport (= 6.0.3.7) + actiontext (6.1.4) + actionpack (= 6.1.4) + activerecord (= 6.1.4) + activestorage (= 6.1.4) + activesupport (= 6.1.4) nokogiri (>= 1.8.5) - actionview (6.0.3.7) - activesupport (= 6.0.3.7) + actionview (6.1.4) + activesupport (= 6.1.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -61,46 +63,48 @@ GEM activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (6.0.3.7) - activesupport (= 6.0.3.7) + activejob (6.1.4) + activesupport (= 6.1.4) globalid (>= 0.3.6) - activemodel (6.0.3.7) - activesupport (= 6.0.3.7) - activerecord (6.0.3.7) - activemodel (= 6.0.3.7) - activesupport (= 6.0.3.7) - activestorage (6.0.3.7) - actionpack (= 6.0.3.7) - activejob (= 6.0.3.7) - activerecord (= 6.0.3.7) + activemodel (6.1.4) + activesupport (= 6.1.4) + activerecord (6.1.4) + activemodel (= 6.1.4) + activesupport (= 6.1.4) + activestorage (6.1.4) + actionpack (= 6.1.4) + activejob (= 6.1.4) + activerecord (= 6.1.4) + activesupport (= 6.1.4) marcel (~> 1.0.0) - activesupport (6.0.3.7) + mini_mime (>= 1.1.0) + activesupport (6.1.4) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2, >= 2.2.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) acts-as-taggable-on (5.0.0) activerecord (>= 4.2.8) - addressable (2.7.0) + addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) aws-eventstream (1.1.1) - aws-partitions (1.468.0) - aws-sdk-core (3.114.3) + aws-partitions (1.477.0) + aws-sdk-core (3.117.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.43.0) + aws-sdk-kms (1.44.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) aws-sdk-s3 (1.96.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.3) + aws-sigv4 (1.2.4) aws-eventstream (~> 1, >= 1.0.2) builder (3.2.4) cancancan (2.3.0) @@ -131,7 +135,7 @@ GEM case_transform (0.2) activesupport cliver (0.3.2) - concurrent-ruby (1.1.8) + concurrent-ruby (1.1.9) coveralls (0.8.23) json (>= 1.8, < 3) simplecov (~> 0.16.1) @@ -160,24 +164,33 @@ GEM cliver (~> 0.3) concurrent-ruby (~> 1.1) websocket-driver (>= 0.6, < 0.8) - ffi (1.15.1) + ffi (1.15.3) globalid (0.4.2) activesupport (>= 4.2.0) + google_maps_service (0.4.2) + hurley (~> 0.1) + multi_json (~> 1.11) + retriable (~> 2.0) hashdiff (1.0.1) httparty (0.18.1) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) + hurley (0.2) i18n (1.8.10) concurrent-ruby (~> 1.0) + image_processing (1.12.1) + mini_magick (>= 4.9.5, < 5) + ruby-vips (>= 2.0.17, < 3) jmespath (1.4.0) json (2.5.1) jsonapi-renderer (0.2.2) jwt (2.2.3) - listen (3.0.8) + listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - loofah (2.9.1) + ruby_dep (~> 1.2) + loofah (2.10.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -186,7 +199,7 @@ GEM method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.0225) + mime-types-data (3.2021.0704) mimemagic (0.3.10) nokogiri (~> 1) rake @@ -194,6 +207,7 @@ GEM mini_mime (1.1.0) mini_portile2 (2.5.3) minitest (5.14.4) + multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.1.1) mysql2 (0.5.3) @@ -201,11 +215,9 @@ GEM net-ssh (>= 2.6.5, < 7.0.0) net-ssh (6.1.0) nio4r (2.5.7) - nokogiri (1.11.6) + nokogiri (1.11.7) mini_portile2 (~> 2.5.0) racc (~> 1.4) - nokogiri (1.11.6-x86_64-linux) - racc (~> 1.4) oauth (0.5.6) parallel (1.20.1) pg (1.2.3) @@ -218,37 +230,38 @@ GEM rack (>= 2.0.0) rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.3.7) - actioncable (= 6.0.3.7) - actionmailbox (= 6.0.3.7) - actionmailer (= 6.0.3.7) - actionpack (= 6.0.3.7) - actiontext (= 6.0.3.7) - actionview (= 6.0.3.7) - activejob (= 6.0.3.7) - activemodel (= 6.0.3.7) - activerecord (= 6.0.3.7) - activestorage (= 6.0.3.7) - activesupport (= 6.0.3.7) - bundler (>= 1.3.0) - railties (= 6.0.3.7) + rails (6.1.4) + actioncable (= 6.1.4) + actionmailbox (= 6.1.4) + actionmailer (= 6.1.4) + actionpack (= 6.1.4) + actiontext (= 6.1.4) + actionview (= 6.1.4) + activejob (= 6.1.4) + activemodel (= 6.1.4) + activerecord (= 6.1.4) + activestorage (= 6.1.4) + activesupport (= 6.1.4) + bundler (>= 1.15.0) + railties (= 6.1.4) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (6.0.3.7) - actionpack (= 6.0.3.7) - activesupport (= 6.0.3.7) + railties (6.1.4) + actionpack (= 6.1.4) + activesupport (= 6.1.4) method_source - rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) - rake (13.0.3) + rake (>= 0.13) + thor (~> 1.0) + rake (13.0.6) rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) redis (3.3.5) + retriable (2.1.0) rexml (3.2.5) rgeo (2.3.0) ros-apartment (2.9.0) @@ -273,6 +286,9 @@ GEM rspec-mocks (~> 3.10) rspec-support (~> 3.10) rspec-support (3.10.2) + ruby-vips (2.1.2) + ffi (~> 1.12) + ruby_dep (1.5.0) shoulda-matchers (4.5.1) activesupport (>= 4.2.0) simplecov (0.16.1) @@ -298,13 +314,12 @@ GEM sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) - test-prof (1.0.5) + test-prof (1.0.6) thor (1.1.0) - thread_safe (0.3.6) tins (1.29.1) sync - tzinfo (1.2.9) - thread_safe (~> 0.1) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) vimeo (1.5.4) httparty (>= 0.4.5) httpclient (>= 2.1.5.2) @@ -315,7 +330,7 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.4) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) youtube_rails (1.2.2) @@ -325,7 +340,6 @@ GEM PLATFORMS ruby - x86_64-linux DEPENDENCIES actionview (>= 5.2.2.1) @@ -345,6 +359,8 @@ DEPENDENCIES factory_bot_rails faker! ferrum + google_maps_service + image_processing (~> 1.2) listen (>= 3.0.5, < 3.2) mini_magick mysql2 @@ -352,7 +368,7 @@ DEPENDENCIES puma (~> 4.3.0) rack (>= 2.0.6) rack-cors - rails (~> 6.0.0) + rails (~> 6.1.0) redis (~> 3.0) rgeo ros-apartment @@ -368,4 +384,4 @@ DEPENDENCIES yt BUNDLED WITH - 2.2.3 + 2.1.4 diff --git a/app/models/stop.rb b/app/models/stop.rb index 528b4379..18f6e6d0 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -46,7 +46,7 @@ def splash nil end - if splash_medium + if splash_medium&.files return { title: splash_medium.title, caption: splash_medium.caption, url: splash_medium.files[:desktop] } end nil diff --git a/app/models/tour.rb b/app/models/tour.rb index 4c11ea9f..a6950d3f 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'google_maps_service' + # Model class for a tour. class Tour < ApplicationRecord include HtmlSaintizer @@ -116,6 +118,25 @@ def bounds } end + def duration + return nil if stops.count < 2 + + return nil if mode.nil? + + return nil if mode.title.nil? + + gmaps = GoogleMapsService::Client.new + destinations = tour_stops.order(:position).map {|tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng]} + origin = destinations.shift + matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) + return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' + + puts matrix + + matrix[:rows].first[:elements].map { |e| e[:duration][:value] }.sum + 600 + (stops.count * 600) + # ActiveSupport::Duration.build(seconds).parts + end + private def ensure_slug diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 87cb9a60..0800db50 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +include ActionView::Helpers::DateHelper + # app/serializers/tour_serializer.rb class V3::TourBaseSerializer < ActiveModel::Serializer has_one :map_overlay @@ -22,5 +24,12 @@ class V3::TourBaseSerializer < ActiveModel::Serializer :insecure_splash, :use_directions, :default_lng, - :stop_count + :stop_count, + :est_time + + def est_time + return nil if object.duration.nil? + + distance_of_time_in_words(object.duration) + end end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 63022a9d..4270428d 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -kTGzASpH/+qir2pEMMCG2KDp/eFBheoH7Gbuo+zGbvbTsOQGfMjZR1vD8B8j3t/k9AplkIDBSFXd+/h1pND0pDOAVeavd0b6+FjBslSwBPwHVuP5XL1D47qH5ih9VSV9PJ5+WsIHM6XV6paaDoHCd50hq3sbi1uP+lMsDZTLVerDP6GU/KrInmTbJKMwDF4anU/Wx9oZ482CILzjqwjVLYTfi7xe4U4jWwqmCcZfCbmzl7KcntIwZt/7sGm1lLt6H/bGGJzfOuBYJXIR/eNr9PULeI/LS+XbiBPpnAGxwrDKP7Pd8vlMsQtRDi//mHf60MHQSbz0vTC7QFM3MM54H/PQqTDZgZK4QinIJI43m2qiFQF3BCNb4j4E8Z/8ozIEpcArzZrt/eROhVrYjEKqSjskqlS0oTMTMWBTV2FjY4aRiT1xnfbh0lS6i17zOsVfQRRcVoYbkiUh9qxl8Yi7I15JFzm3HzWBpj/zQGfkE5S30/62KVd5KPN1lW1PBsunQiqagVs1kw/klt9R/9sNLrKyq0amf2gAVZ1j7sHKPUZ+3yvTBDjp3rNz2YNMQJLumoSVuFO3yG57jD60jVLh2huOIdcXe5G9jZ7gRNdDBC8XuGNA2keJoUluwrE8KP/6dhNjJPuZInWI5SHnpcAGhXno/Alxdtp55Uu7+SR9mr1MPuxssVAGjoQY+cwUdyuLgk2sHSxuoixXYMy1fohaOhxvaTMRuu4m0xrm/TK3ZItaEAhfnmKNPI7h6rV95p8Zl8bbbmlVjK+3psB2r8ayLef6OG5Ud8CZvhYWF5qpT0+rO6VhJPGMsaHSHqRDk/MmwAK/Z9qhsmlJj+fvW2StzdCUX7cuebpfooYA5Qjrz4NzswiH8d8dDLKQIMDuqlFwLPKXG2j21TES93LZ74DzN3+8UTogtPuBTqTQ/nyksOus8zYB2kMJ4eqwLj4lnIfOp60wmKpO+U/vUYwKXJj027DP5KNeZdpNHGwgZsFTVKQY--JiwBUeScI8Uu5Jtq--rsXoM3m5pFDy/09cp9H++Q== \ No newline at end of file +v3Zk8uUcd9JE2uidmAz2kUeOFYP8xdBu0Vd8NROcY19Gs574laOX45n3hku4poOTOJvdgAJ8q8lH0l4iB+xtRO65pIfFFddcpTzbaLMqfsNeEufaAjQRdLBMIVEJYYVgC8q1r4Utemy0SilmD9Je1tOVHG20wXZhFoIfjSNuv5gJImcfvMgEJpvoN3QLRb4TQQEAnodFDYKWAsiHQjf4YiyhzYzhlcS14jFLZbxgpvJNzaKFpmok33JON9X3goJEFGyT39adxgsM3lzf/G6zhKVujV7eKgsqIW413GDGtQxfnBHS11AONFlVwhiVzsYFtwnP95wld5NXFRdf9dwow68tr8RV+vRwv5KfUvn4A2z08lBZ89VAnRi8Meh3Z42ysTN7ZzqsUj0ZmIrxUH4mEX4qGli1sbs/Pw/2BpIzsk+9Dg6O0DaBoEgViWnxBSehNPRfg27ctWI1wOa+7rZs7K+15m3zS6r9etQu1mg2zY1synoEN7x3i7hkaXMw4J2dVHY5bUYizoTHiAAI6xnb9gQDUYxmZFKPGuX3LbDsgKy/1Nm9bhhxbcTHOrC80IFmA1DuY1H9sVEP8O7HDo6AjmvyOV5UFw+ufFBKViTxmsArZ5EDGj/HlgugMLP8aph2qdiEYDBtm8tqkmM4AfeMbra/MtRfzsvoyUyWEu5S7SZAxVE8Iyza+ro7PIq0+78EEbHoAwiUvDf+u+Z3p0JuLgIYO1wQXDFJojSy0ntKhCOFShZd5R4NdXtXd2s4vW1wtZ2ss2pczM16jU2MuuEixw0697RolGfCXacJFLk3LR6OtGCsvBZmkJHSb1HzDgC1QELHq8jZsjer/mmquiB67xNJTixczX99chgOOrfcSVrHddSdHgH+Btwp//DaSh7xaX0hVC8L5zzWwF5hQz82r3OMRWwTUwS0eZZFs9hBO7X6tFAIKqdLFEEfMKZ2Lz11HaQHgW/aCSS25YbGvt2el46Mg4y8cGh8u839M47WBfZJwuyOorHvb5AbhN1zDyYifiJjuaLm7UDGLVO8WS8ayh3kjbO7n/f8CSSnoq41fUZtz56pZwc=--H0tnt9JBS53DSfPc--biFThBnIBGh5E9N5LonmQg== \ No newline at end of file diff --git a/config/initializers/g_maps.rb b/config/initializers/g_maps.rb new file mode 100644 index 00000000..c20cbef7 --- /dev/null +++ b/config/initializers/g_maps.rb @@ -0,0 +1,3 @@ +GoogleMapsService.configure do |config| + config.key = Rails.application.credentials.dig(:g_maps_key) +end diff --git a/db/migrate/20210709153515_create_tour_authors.rb b/db/migrate/20210709153515_create_tour_authors.rb deleted file mode 100644 index ee9b5494..00000000 --- a/db/migrate/20210709153515_create_tour_authors.rb +++ /dev/null @@ -1,8 +0,0 @@ -class CreateTourAuthors < ActiveRecord::Migration[6.0] - def change - create_table :tour_authors do |t| - - t.timestamps - end - end -end diff --git a/db/migrate/20210712234206_add_service_name_to_active_storage_blobs.active_storage.rb b/db/migrate/20210712234206_add_service_name_to_active_storage_blobs.active_storage.rb new file mode 100644 index 00000000..9967a132 --- /dev/null +++ b/db/migrate/20210712234206_add_service_name_to_active_storage_blobs.active_storage.rb @@ -0,0 +1,18 @@ +# This migration comes from active_storage (originally 20190112182829) +class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] + def up + unless column_exists?(:active_storage_blobs, :service_name) + add_column :active_storage_blobs, :service_name, :string + + if configured_service = ActiveStorage::Blob.service.name + ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) + end + + change_column :active_storage_blobs, :service_name, :string, null: false + end + end + + def down + remove_column :active_storage_blobs, :service_name + end +end diff --git a/db/migrate/20210712234207_create_active_storage_variant_records.active_storage.rb b/db/migrate/20210712234207_create_active_storage_variant_records.active_storage.rb new file mode 100644 index 00000000..a2862695 --- /dev/null +++ b/db/migrate/20210712234207_create_active_storage_variant_records.active_storage.rb @@ -0,0 +1,12 @@ +# This migration comes from active_storage (originally 20191206030411) +class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] + def change + 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 c991181e..e0601828 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,15 +2,15 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# This file is the source Rails uses to define your schema when running `rails -# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_07_07_161803) do +ActiveRecord::Schema.define(version: 2021_07_12_234207) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -35,9 +35,16 @@ t.bigint "byte_size", null: false t.string "checksum", null: false t.datetime "created_at", null: false + t.string "service_name", 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 "ecds_rails_auth_engine_logins", force: :cascade do |t| t.string "who" t.string "token" @@ -333,6 +340,7 @@ 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" add_foreign_key "stop_slugs", "tours" add_foreign_key "stops", "map_icons" add_foreign_key "stops", "media" diff --git a/spec/requests/tour_authors_spec.rb b/spec/requests/tour_authors_spec.fix similarity index 100% rename from spec/requests/tour_authors_spec.rb rename to spec/requests/tour_authors_spec.fix From 90c269a9bbf97abfb97959471597a202c4de1d9a Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 16 Jul 2021 15:43:42 -0400 Subject: [PATCH 025/160] Don't send 404 --- app/controllers/v3/tour_stops_controller.rb | 13 ++++++++++--- app/controllers/v3/tours_controller.rb | 8 ++++++-- app/models/tour.rb | 15 +++++++++------ app/serializers/v3/tour_base_serializer.rb | 3 +-- lib/snippets.rb | 13 +++++++++++++ spec/requests/v3/tours_spec.rb | 2 +- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index 1bb3a59c..61a888db 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -4,9 +4,11 @@ class V3::TourStopsController < V3Controller # GET /stops def index - @records = if params[:tour_id] && params[:stop_id] + @records = if params[:fastboot] == 'true' + nil + elsif params[:tour_id] && params[:stop_id] TourStop.where(tour: Tour.find(params[:tour_id])).where(stop: Stop.find(params[:stop_id])).first || {} - elsif params[:tour] && params[:slug] + elsif params[:tour] && params[:tour] != '0' && params[:slug] # stop = StopSlug.find_by(slug: params[:slug]) stop = Stop.by_slug_and_tour(params[:slug], params[:tour]).first # TourStop.where(tour: Tour.find(params[:tour])).where(stop: stop).first @@ -14,11 +16,16 @@ def index else TourStop.all end - render json: @records, include: ['stop'] + if @records.nil? + render json: { data: {type: 'tour_stops', id: 0 } } + else + render json: @records, include: ['stop'] + end end # GET /stops/1 def show + render json: { data: {} } if @record.nil? render json: @record, include: ['stop'] end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index d78b36a9..c4f21032 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -20,7 +20,7 @@ def index Tour.published end if @records.nil? - render json: { error: 'not found' }.to_json, status: 404 + render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } else render json: @records, each_serializer: V3::TourBaseSerializer end @@ -28,7 +28,11 @@ def index # GET /tours/1 def show - render json: @record + if @record.nil? + render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } + else + render json: @record + end end # POST /tours diff --git a/app/models/tour.rb b/app/models/tour.rb index a6950d3f..18cd28b5 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -126,15 +126,18 @@ def duration return nil if mode.title.nil? gmaps = GoogleMapsService::Client.new - destinations = tour_stops.order(:position).map {|tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng]} + destinations = tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } origin = destinations.shift - matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) - return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' - puts matrix + begin + matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) + return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' - matrix[:rows].first[:elements].map { |e| e[:duration][:value] }.sum + 600 + (stops.count * 600) - # ActiveSupport::Duration.build(seconds).parts + matrix[:rows].first[:elements].map { |e| e[:duration][:value] }.sum + 600 + (stops.count * 600) + # ActiveSupport::Duration.build(seconds).parts + rescue GoogleMapsService::Error::ApiError => error + nil + end end private diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 0800db50..7d51e856 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -19,8 +19,7 @@ class V3::TourBaseSerializer < ActiveModel::Serializer :tenant_title, :stop_count, :map_type, - :splash_width, - :splash_height, + :splash, :insecure_splash, :use_directions, :default_lng, diff --git a/lib/snippets.rb b/lib/snippets.rb index 56e6d237..d1671127 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -167,3 +167,16 @@ end end end + + +sites = TourSet.all.map(&:subdir) +sites.each do |ts| + Apartment::Tenant.switch! ts + reload! + ids = Medium.all.map(&:id) + ids.each do |id| + m = Medium.find(id) + m.save + end + end +end \ No newline at end of file diff --git a/spec/requests/v3/tours_spec.rb b/spec/requests/v3/tours_spec.rb index a472bc89..f238786d 100644 --- a/spec/requests/v3/tours_spec.rb +++ b/spec/requests/v3/tours_spec.rb @@ -108,7 +108,7 @@ } it 'returns nothing' do - expect(response).to have_http_status(404) + expect(response).to have_http_status(200) end end end From a9706be85e16c9970f2f4ca2141ac7bd242f3d82 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 30 Jul 2021 10:13:53 -0400 Subject: [PATCH 026/160] Add all tours method to user model --- app/controllers/v3/users_controller.rb | 2 +- app/models/user.rb | 16 +++++++++++++++- app/serializers/v3/user_serializer.rb | 5 +++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index 9c66e8dc..c6156197 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -12,7 +12,7 @@ class UsersController < V3Controller def index if current_user.present? if params['me'] - render json: current_user, include: ['tours', 'tour_sets'] + render json: current_user elsif current_user.current_tenant_admin? render json: User.all end diff --git a/app/models/user.rb b/app/models/user.rb index 531d95c9..5f1cf2cc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,7 +24,21 @@ def provider login.provider end - private + # private + + def all_tours + all = [] + TourSet.all.each do |tour_set| + Apartment::Tenant.switch! tour_set.subdir + next if tours.empty? || current_tenant_admin? + Apartment::Tenant.switch! tour_set.subdir + _tours = TourAuthor.where(user: self) + # puts tours.ma + all.push(_tours.map { |ta| { id: ta.tour.id, tenant: ta.tour.tenant, title: ta.tour.title } }) + end + Apartment::Tenant.reset + all.flatten.uniq + end def login EcdsRailsAuthEngine::Login.find_by(user_id: self.id) diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index f11476c8..1e5d93b9 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true -# app/serializer/user_serializer.rb +# app/serializer/v3/user_serializer.rb class V3::UserSerializer < ActiveModel::Serializer has_many :tours + has_many :tour_authors has_many :tour_sets - attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email + attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email, :all_tours def current_tenant_admin object.current_tenant_admin? From 2abccda6ce9ca2fa7253f3d2bb15880070f2012a Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 30 Jul 2021 10:16:14 -0400 Subject: [PATCH 027/160] Fixed and added tests --- app/controllers/v3/flat_pages_controller.rb | 26 +- app/controllers/v3/stops_controller.rb | 56 ++- app/controllers/v3/tour_stops_controller.rb | 60 +-- app/controllers/v3/tours_controller.rb | 47 +-- app/controllers/v3_controller.rb | 8 + app/models/tour_stop.rb | 2 + .../v3/flat_pages_controller_spec.rb | 309 +++++++++----- spec/controllers/v3/stops_controller_spec.rb | 306 +++++++++----- .../v3/tour_stops_controller_spec.rb | 349 ++++++++++++++++ spec/controllers/v3/tours_controller_spec.rb | 376 ++++++++++++++---- spec/rails_helper.rb | 3 + spec/requests/v3/flat_pages_spec.rb | 19 +- spec/requests/v3/stops_spec.rb | 39 +- spec/requests/v3/tour_stops_spec.rb | 55 +-- spec/requests/v3/tours_spec.rb | 6 +- ..._spec.rb => map_icons_routing_spec.rb.fix} | 0 ...ec.rb => tour_authors_routing_spec.rb.fix} | 0 spec/support/request_spec_helper.rb | 7 +- spec/support/signed_cookie.rb | 19 + 19 files changed, 1277 insertions(+), 410 deletions(-) rename spec/routing/{map_icons_routing_spec.rb => map_icons_routing_spec.rb.fix} (100%) rename spec/routing/{tour_authors_routing_spec.rb => tour_authors_routing_spec.rb.fix} (100%) create mode 100755 spec/support/signed_cookie.rb diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index 9370481a..ec26e469 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -6,14 +6,22 @@ class V3::FlatPagesController < V3Controller # GET /v3/records def index - @records = FlatPage.all - + @records = if current_user.current_tenant_admin? + FlatPage.all + elsif current_user.tours.present? + current_user.tours.map { |tour| tour.flat_pages }.flatten.uniq + else + Tour.published.map { |tour| tour.flat_pages }.flatten.uniq + end render json: @records end # GET /v3/records/1 + # def show + # render json: @record + # end def show - render json: @record + render json: {} end # POST /v3/records @@ -45,13 +53,7 @@ def update end # DELETE /v3/records/1 - def destroy - if @allowed - @record.destroy - else - head 401 - end - end + private # Use callbacks to share common setup or constraints between actions. @@ -68,4 +70,8 @@ def record_params ] ) end + + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + end end diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index d93a52cf..7a12580d 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -5,55 +5,49 @@ class V3::StopsController < V3Controller # GET /stops def index - @records = if params[:tour_id] - Stop.not_in_tour(params[:tour_id]).or(Stop.no_tours) - elsif params[:slug] - # stop = StopSlug.find_by(slug: params[:slug]).stop - stop = Stop.by_slug_and_tour(params[:slug], params[:tour_id]) - else + @records = if current_user.current_tenant_admin? Stop.all + elsif current_user.tours.present? + current_user.tours.map { |tour| tour.stops }.flatten.uniq + else + Tour.published.map { |tour| tour.stops }.flatten.uniq end - render json: @records, - include: [ - 'media', - 'stop_media' - ] + render json: @records end # GET /stops/1 + # Direct access to stops goes throught V3:TourStopsController def show - render json: @record, - include: [ - 'media', - 'stop_media', - 'map_icon' - ] + render json: {} end # POST /stops def create - @record = Stop.new(stop_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + if @allowed + @record = Stop.new(stop_params) + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # PATCH/PUT /stops/1 def update - if @record.update(stop_params) - render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" + if @allowed + if @record.update(stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end - # DELETE /stops/1 - def destroy - @record.destroy - end - private # Only allow a trusted parameter "white list" through. @@ -83,4 +77,8 @@ def set_record def set_tour_stop @record = @tour.stops.find_by!(id: params[:id]) if @tour end + + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + end end diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index 61a888db..c3290f2e 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -6,18 +6,21 @@ class V3::TourStopsController < V3Controller def index @records = if params[:fastboot] == 'true' nil - elsif params[:tour_id] && params[:stop_id] - TourStop.where(tour: Tour.find(params[:tour_id])).where(stop: Stop.find(params[:stop_id])).first || {} - elsif params[:tour] && params[:tour] != '0' && params[:slug] - # stop = StopSlug.find_by(slug: params[:slug]) - stop = Stop.by_slug_and_tour(params[:slug], params[:tour]).first - # TourStop.where(tour: Tour.find(params[:tour])).where(stop: stop).first - TourStop.find_by(tour: Tour.find(params[:tour]), stop: stop) - else + elsif params[:tour] && params[:slug] + tour = Tour.find(params[:tour]) + if tour.published || allowed? + stop = Stop.by_slug_and_tour(params[:slug], params[:tour]).first + TourStop.find_by(tour: Tour.find(params[:tour]), stop: stop) + else + {} + end + elsif current_user.current_tenant_admin? TourStop.all + else + Tour.published.map { |tour| tour.tour_stops }.flatten.uniq end if @records.nil? - render json: { data: {type: 'tour_stops', id: 0 } } + render json: { data: { type: 'tour_stops', id: 0 } } else render json: @records, include: ['stop'] end @@ -25,36 +28,38 @@ def index # GET /stops/1 def show - render json: { data: {} } if @record.nil? - render json: @record, include: ['stop'] + if @record&.tour.published || allowed? + render json: @record + else + render json: { data: {} } + end + # render json: { data: {} } if @record.nil? + # render json: @record, include: ['stop'] end # POST /stops def create - @record = TourStop.new(tour_stop_params) - if @record.save - render json: @record, status: :created, location: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + # Not created via the API + head 401 end # PATCH/PUT /stops/1 def update - if @record.update(tour_stop_params) - # render json: @stop - head :no_content + if @allowed + if @record.update(tour_stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # DELETE /stops/1 def destroy - if @record - @record.destroy - end - head :no_content + # Not deleted via the API + head 401 end private @@ -72,4 +77,9 @@ def tour_stop_params def set_record @record = TourStop.find(params[:id]) end + + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + return @allowed + end end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index c4f21032..da985c00 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -6,12 +6,15 @@ class V3::ToursController < V3Controller # GET /tours def index @records = if (params[:slug]) - tour = Slug.find_by(slug: params[:slug]).tour - if tour.published || (current_user && current_user.current_tenant_admin?) - tour + @record = Slug.find_by(slug: params[:slug]).tour + if @record.published || allowed? + @record else nil end + elsif (current_user && params[:tourTenant]) + Apartment::Tenant.switch! params[:tourTenant] + Tour.find(params[:tour]) elsif (current_user && current_user.current_tenant_admin?) Tour.all elsif (current_user && current_user.id) @@ -28,10 +31,10 @@ def index # GET /tours/1 def show - if @record.nil? - render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } - else + if @record&.published || allowed? render json: @record + else + render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } end end @@ -51,30 +54,19 @@ def create # PATCH/PUT /tours/1 def update - if @record.update(tour_params) - render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}", include: [ - 'tour_modes', - 'tour_stops', - 'stops', - 'stops.media', - 'stops.stop_media', - 'mode', - 'modes', - 'theme', - 'media', - 'tour_media', - 'flat_pages', - 'tour_flat_pages' - ] + if @allowed + if @record.update(tour_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # DELETE /tours/1 - def destroy - @record.destroy - end + private # Only allow a trusted parameter "white list" through. @@ -94,4 +86,9 @@ def tour_params def set_record @record = Tour.find(params[:id]) end + + def allowed? + @allowed = current_user && current_user.current_tenant_admin? || current_user.tours.include?(@record) + return @allowed + end end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index daa3b8d5..a875522c 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -5,6 +5,14 @@ class V3Controller < ApplicationController before_action :set_record, only: [:show, :update, :destroy] before_action :allowed?, only: [:create, :update, :destroy] + def destroy + if @allowed + @record.destroy + else + head 401 + end + end + def serialize_errors errors = [] @record.errors.messages[:base].each do |error| diff --git a/app/models/tour_stop.rb b/app/models/tour_stop.rb index ce93b185..90a3265f 100644 --- a/app/models/tour_stop.rb +++ b/app/models/tour_stop.rb @@ -38,6 +38,8 @@ def previous_slug private def _set_position + return if tour.nil? + self.position = self.position || self.tour.stops.length + 1 end diff --git a/spec/controllers/v3/flat_pages_controller_spec.rb b/spec/controllers/v3/flat_pages_controller_spec.rb index ec7f46b2..949f3364 100644 --- a/spec/controllers/v3/flat_pages_controller_spec.rb +++ b/spec/controllers/v3/flat_pages_controller_spec.rb @@ -1,129 +1,260 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::FlatPagesController, type: :controller do + describe 'GET #index' do + it 'returns a 200 response with flat_pages connected to published tours' do + create_list(:tour_with_flat_pages, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.count).to be > Tour.published.count + json.each do |flat_page| + expect(FlatPage.find(flat_page[:id]).tours.any? { |tour| tour.published }) + end + expect(json.count).to be < FlatPage.count + end - # This should return the minimal set of attributes required to create a valid - # V3::FlatPage. As you add validations to V3::FlatPage, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') - } - - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } + it 'returns a 200 response with no flat_pages when request is authenticated by person with no access' do + create_list(:tour_with_flat_pages, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + user = create(:user) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.count).to be > Tour.published.count + json.each do |flat_page| + expect(FlatPage.find(flat_page[:id]).tours.any? { |tour| tour.published }) + end + expect(json.count).to be < FlatPage.count + end - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # V3::FlatPagesController. Be sure to keep this updated too. - let(:valid_session) { {} } + it 'returns a 200 response with flat_pages when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour_with_flat_pages, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: tour.tenant } + expect(FlatPage.count).to be > 1 + expect(response.status).to eq(200) + expect(json.count).to eq(FlatPage.count) + end - describe 'GET #index' do - it 'returns a success response' do - flat_page = FlatPage.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + create_list(:flat_page, 7) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(tour.flat_pages.count) + expect(json.count).to be < FlatPage.count end end describe 'GET #show' do - it 'returns a success response' do - flat_page = FlatPage.create! valid_attributes - get :show, params: { id: flat_page.to_param }, session: valid_session - expect(response).to be_success + it 'returns a 200 response' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + get :show, params: { tenant: tour.tenant, id: tour.flat_pages.last.id } + expect(response.status).to eq(200) + expect(json).to be_nil end end describe 'POST #create' do context 'with valid params' do - it 'creates a new V3::FlatPage' do - expect { - post :create, params: { v3_flat_page: valid_attributes }, session: valid_session - }.to change(FlatPage, :count).by(1) - end + it 'return 401 when unauthenciated' do + post :create, params: { data: { type: 'flat_pages', attributes: { title: 'Burrito FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - it 'renders a JSON response with the new v3_flat_page' do + it 'return 401 when authenciated but not an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :create, params: { data: { type: 'flat_pages', attributes: { title: 'Burrito FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - post :create, params: { v3_flat_page: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(v3_flat_page_url(FlatPage.last)) + it 'return 201 when authenciated but an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + original_flat_page_count = FlatPage.count + post :create, params: { data: { type: 'flat_pages', attributes: { title: 'Burrito FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Burrito FlatPage') + expect(FlatPage.count).to eq(original_flat_page_count + 1) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the new v3_flat_page' do + it 'return 201 when authenciated by super' do + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + original_flat_page_count = FlatPage.count + post :create, params: { data: { type: 'flat_pages', attributes: { title: 'Taco FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Taco FlatPage') + expect(FlatPage.count).to eq(original_flat_page_count + 1) + end - post :create, params: { v3_flat_page: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'return 201 when authenciated by a tour author' do + user = create(:user) + user.tour_sets = [] + user.tours << Tour.last + user.update(super: false) + signed_cookie(user) + original_flat_page_count = FlatPage.count + post :create, params: { data: { type: 'flat_pages', attributes: { title: 'Elmyr' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Elmyr') + expect(FlatPage.count).to eq(original_flat_page_count + 1) end end end describe 'PUT #update' do context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested v3_flat_page' do - flat_page = FlatPage.create! valid_attributes - put :update, params: { id: flat_page.to_param, v3_flat_page: new_attributes }, session: valid_session - flat_page.reload - skip('Add assertions for updated state') + it 'return 401 when unauthenciated' do + tour = create(:tour_with_flat_pages) + post :update, params: { id: tour.flat_pages.last.id, data: { type: 'flat_pages', attributes: { title: 'Burrito FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) end - it 'renders a JSON response with the v3_flat_page' do - flat_page = FlatPage.create! valid_attributes + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour_with_flat_pages, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :update, params: { id: tour.flat_pages.first.id, data: { type: 'flat_pages', attributes: { title: 'Burrito FlatPage' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - put :update, params: { id: flat_page.to_param, v3_flat_page: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + it 'return 200 and updated tour when authenciated but an admin for current tenant' do + tour = create(:tour_with_flat_pages, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + original_flat_page_title = FlatPage.find(tour.flat_pages.last.id).title + new_title = Faker::Name.unique.name + post :update, params: { id: tour.flat_pages.first.id, data: { type: 'flat_pages', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_flat_page_title) + expect(attributes[:title]).to eq(new_title) + expect(FlatPage.find(tour.flat_pages.first.id).title).to eq(new_title) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the v3_flat_page' do - flat_page = FlatPage.create! valid_attributes + it 'return 200 and updated tour when authenciated by super' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + original_flat_page_title = FlatPage.find(tour.flat_pages.last.id).title + new_title = Faker::Name.unique.name + post :update, params: { id: tour.flat_pages.last.id, data: { type: 'flat_pages', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_flat_page_title) + expect(attributes[:title]).to eq(new_title) + expect(FlatPage.find(tour.flat_pages.last.id).title).to eq(new_title) + end - put :update, params: { id: flat_page.to_param, v3_flat_page: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'return 200 and updated tour when authenciated by tour author' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + original_flat_page_title = FlatPage.find(tour.flat_pages.last.id).title + new_title = Faker::Name.unique.name + post :update, params: { id: tour.flat_pages.first.id, data: { type: 'flat_pages', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_flat_page_title) + expect(attributes[:title]).to eq(new_title) + expect(FlatPage.find(tour.flat_pages.first.id).title).to eq(new_title) end end + + # context 'with invalid params' do + # it 'renders a JSON response with errors for the tour' do + # tour = FlatPage.create! valid_attributes + + # put :update, params: { id: tour.to_param, tour: invalid_attributes } + # expect(response).to have_http_status(:unprocessable_entity) + # expect(response.content_type).to eq('application/json') + # end + # end end describe 'DELETE #destroy' do - it 'destroys the requested v3_flat_page' do - flat_page = FlatPage.create! valid_attributes - expect { - delete :destroy, params: { id: flat_page.to_param }, session: valid_session - }.to change(FlatPage, :count).by(-1) + it 'return 401 when unauthenciated' do + tour = create(:tour_with_flat_pages) + post :destroy, params: { id: Tour.find(tour.id).flat_pages.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :destroy, params: { id: tour.flat_pages.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) end - end + it 'return 204 and one less tour when authenciated but an admin for current tenant' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + flat_page_count = FlatPage.count + post :destroy, params: { id: tour.flat_pages.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(FlatPage.count).to eq(flat_page_count - 1) + end + + it 'return 204 and one less tour when authenciated by super' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + flat_page_count = FlatPage.count + post :destroy, params: { id: tour.flat_pages.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(FlatPage.count).to eq(flat_page_count - 1) + end + + it 'return 204 and one less tour when authenciated by tour author' do + tour = create(:tour_with_flat_pages) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + flat_page_count = FlatPage.count + post :destroy, params: { id: tour.flat_pages.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(FlatPage.count).to eq(flat_page_count - 1) + end + end end diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index 155cc5ad..df29241c 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -2,130 +2,258 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::StopsController, type: :controller do + describe 'GET #index' do + it 'returns a 200 response with stops connected to published tours' do + create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.count).to be > Tour.published.count + json.each do |stop| + expect(Stop.find(stop[:id]).tours.any? { |tour| tour.published }) + end + expect(json.count).to be < Stop.count + end - # This should return the minimal set of attributes required to create a valid - # Stop. As you add validations to Stop, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') - } - - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } + it 'returns a 200 response with no stops when request is authenticated by person with no access' do + create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + user = create(:user) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.count).to be > Tour.published.count + json.each do |stop| + expect(Stop.find(stop[:id]).tours.any? { |tour| tour.published }) + end + expect(json.count).to be < Stop.count + end - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # StopsController. Be sure to keep this updated too. - let(:valid_session) { {} } + it 'returns a 200 response with stops when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: tour.tenant } + expect(Stop.count).to be > 1 + expect(response.status).to eq(200) + expect(json.count).to eq(Stop.count) + end - describe 'GET #index' do - it 'returns a success response' do - stop = Stop.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) + user = create(:user) + user.tour_sets = [] + user.tours << Tour.first + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(Tour.first.stops.count) + expect(json.count).to be < Stop.count end end describe 'GET #show' do - it 'returns a success response' do - stop = Stop.create! valid_attributes - get :show, params: { id: stop.to_param }, session: valid_session - expect(response).to be_success + it 'returns a 200 response' do + tour = create(:tour) + get :show, params: { tenant: tour.tenant, id: Stop.last.id } + expect(response.status).to eq(200) + expect(json).to be_nil end end describe 'POST #create' do context 'with valid params' do - it 'creates a new Stop' do - expect { - post :create, params: { stop: valid_attributes }, session: valid_session - }.to change(Stop, :count).by(1) - end + it 'return 401 when unauthenciated' do + post :create, params: { data: { type: 'stops', attributes: { title: 'Burrito Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - it 'renders a JSON response with the new stop' do + it 'return 401 when authenciated but not an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :create, params: { data: { type: 'stops', attributes: { title: 'Burrito Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - post :create, params: { stop: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - # expect(response.location).to eq(stop_url(Stop.last)) + it 'return 201 when authenciated but an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + original_stop_count = Stop.count + post :create, params: { data: { type: 'stops', attributes: { title: 'Burrito Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Burrito Stop') + expect(Stop.count).to eq(original_stop_count + 1) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the new stop' do + it 'return 201 when authenciated by super' do + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + original_stop_count = Stop.count + post :create, params: { data: { type: 'stops', attributes: { title: 'Taco Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Taco Stop') + expect(Stop.count).to eq(original_stop_count + 1) + end - post :create, params: { stop: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'return 201 when authenciated by a tour author' do + user = create(:user) + user.tour_sets = [] + user.tours << Tour.last + user.update(super: false) + signed_cookie(user) + original_stop_count = Stop.count + post :create, params: { data: { type: 'stops', attributes: { title: 'Elmyr' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(attributes[:title]).to eq('Elmyr') + expect(Stop.count).to eq(original_stop_count + 1) end end end describe 'PUT #update' do context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested stop' do - stop = Stop.create! valid_attributes - put :update, params: { id: stop.to_param, stop: new_attributes }, session: valid_session - stop.reload - skip('Add assertions for updated state') + it 'return 401 when unauthenciated' do + create(:tour) + post :update, params: { id: Stop.last.id, data: { type: 'stops', attributes: { title: 'Burrito Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) end - it 'renders a JSON response with the stop' do - stop = Stop.create! valid_attributes + it 'return 401 when authenciated but not an admin for current tenant' do + create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :update, params: { id: Stop.first.id, data: { type: 'stops', attributes: { title: 'Burrito Stop' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end - put :update, params: { id: stop.to_param, stop: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + it 'return 200 and updated tour when authenciated but an admin for current tenant' do + create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + original_stop_title = Stop.last.title + new_title = Faker::Name.unique.name + post :update, params: { id: Stop.first.id, data: { type: 'stops', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_stop_title) + expect(attributes[:title]).to eq(new_title) + expect(Stop.first.title).to eq(new_title) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the stop' do - stop = Stop.create! valid_attributes + it 'return 200 and updated tour when authenciated by super' do + create(:tour) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + original_stop_title = Stop.last.title + new_title = Faker::Name.unique.name + post :update, params: { id: Stop.last.id, data: { type: 'stops', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_stop_title) + expect(attributes[:title]).to eq(new_title) + expect(Stop.last.title).to eq(new_title) + end - put :update, params: { id: stop.to_param, stop: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'return 200 and updated tour when authenciated by tour author' do + tour = create(:tour) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + original_stop_title = Stop.last.title + new_title = Faker::Name.unique.name + post :update, params: { id: Stop.first.id, data: { type: 'stops', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(original_stop_title) + expect(attributes[:title]).to eq(new_title) + expect(Stop.first.title).to eq(new_title) end end + + # context 'with invalid params' do + # it 'renders a JSON response with errors for the tour' do + # tour = Stop.create! valid_attributes + + # put :update, params: { id: tour.to_param, tour: invalid_attributes } + # expect(response).to have_http_status(:unprocessable_entity) + # expect(response.content_type).to eq('application/json') + # end + # end end describe 'DELETE #destroy' do - it 'destroys the requested stop' do - stop = Stop.create! valid_attributes - expect { - delete :destroy, params: { id: stop.to_param }, session: valid_session - }.to change(Stop, :count).by(-1) + it 'return 401 when unauthenciated' do + create(:tour) + post :destroy, params: { id: Stop.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :destroy, params: { id: Stop.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) end - end + it 'return 204 and one less tour when authenciated but an admin for current tenant' do + tour = create(:tour) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + stop_count = Stop.count + post :destroy, params: { id: Stop.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Stop.count).to eq(stop_count - 1) + end + + it 'return 204 and one less tour when authenciated by super' do + tour = create(:tour) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + stop_count = Stop.count + post :destroy, params: { id: Stop.first.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Stop.count).to eq(stop_count - 1) + end + + it 'return 204 and one less tour when authenciated by tour author' do + tour = create(:tour) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + stop_count = Stop.count + post :destroy, params: { id: Stop.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Stop.count).to eq(stop_count - 1) + end + end end diff --git a/spec/controllers/v3/tour_stops_controller_spec.rb b/spec/controllers/v3/tour_stops_controller_spec.rb index 98b7db9f..dccee79a 100644 --- a/spec/controllers/v3/tour_stops_controller_spec.rb +++ b/spec/controllers/v3/tour_stops_controller_spec.rb @@ -3,5 +3,354 @@ require 'rails_helper' RSpec.describe V3::TourStopsController, type: :controller do + def data(tour, stop, position = 1) + { + type: 'tour_stops', + attributes: { position: position }, + relationships: { + tour: { data: { type: 'tours', id: tour.id } }, + stop: { data: { type: 'stops', id: stop.id } } + } + } + end + describe 'GET #index' do + it 'returns a 200 response and empty tour when none are part of a published tour' do + Tour.all.each { |tour| tour.update(published: false) } + get :index, params: { tenant: Apartment::Tenant.current } + expect(json).to be_empty + expect(response.status).to eq(200) + end + + it 'returns a 200 response and only tour stops that are part of a published tour' do + create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(Tour.published.map { |tour| tour.tour_stops.count }.sum) + end + + it 'returns a 200 response when requeted by slug' do + tour = create(:tour_with_stops) + tour.update(published: true) + get :index, params: { tenant: Apartment::Tenant.current, slug: tour.tour_stops.first.stop.slug, tour: tour.id } + expect(response.status).to eq(200) + expect(included.first[:attributes][:title]).to eq(tour.tour_stops.first.stop.title) + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour_with_stops, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current, slug: tour.tour_stops.first.stop.slug, tour: tour.id } + expect(response.status).to eq(200) + expect(included.first[:attributes][:title]).to eq(tour.tour_stops.first.stop.title) + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour_with_stops, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current, slug: tour.tour_stops.first.stop.slug, tour: tour.id } + expect(response.status).to eq(200) + expect(included.first[:attributes][:title]).to eq(tour.tour_stops.first.stop.title) + end + end + + describe 'GET #show' do + it 'returns a 200 response' do + tours = create(:tour_with_stops) + tour = Tour.last + tour.update(published: true) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_stops.last.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour_with_stops) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_stops.last.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour_with_stops) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_stops.last.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response and empty json when tour is unpublished and request is not authenticated' do + tour = create(:tour_with_stops) + tour.update(published: false) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_stops.last.id } + expect(response.status).to eq(200) + expect(json).to be_empty + end + + it 'returns a 200 response and empty json when tour is unpublished and request is authenticated by someone who is nither a tenant admin or tour author' do + tour = create(:tour_with_stops) + tour.update(published: false) + user = create(:user) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_stops.last.id } + expect(response.status).to eq(200) + expect(json).to be_empty + end + end + + # TourStop objects are NOT created via tha API. Every test should return 401 + describe 'POST #create' do + context 'with valid params' do + it 'return 401 when unauthenciated' do + tour = create(:tour) + stop = create(:stop) + post :create, params: { data: data(tour, stop), tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour) + stop = create(:stop) + original_tour_stop_count = TourStop.count + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(original_tour_stop_count).to eq(TourStop.count) + end + + it 'return 401 when authenciated but an admin for current tenant' do + tour = create(:tour) + stop = create(:stop) + original_tour_stop_count = TourStop.count + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(original_tour_stop_count).to eq(TourStop.count) + end + + it 'return 401 when authenciated by super' do + tour = create(:tour) + stop = create(:stop) + original_tour_stop_count = TourStop.count + user = create(:user) + user.tours = [] + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(original_tour_stop_count).to eq(TourStop.count) + end + + it 'return 401 when authenciated by tour author' do + tour = create(:tour) + stop = create(:stop) + original_tour_stop_count = TourStop.count + user = create(:user) + user.tours << tour + user.tour_sets = [] + user.update(super: false) + signed_cookie(user) + post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(original_tour_stop_count).to eq(TourStop.count) + end + end + end + + describe 'PUT #update' do + context 'with valid params' do + it 'return 401 when unauthenciated' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + request_data = data(tour, stop, 4) + request_data[:id] = TourStop.find_by(tour: tour, stop: stop).id + post :update, params: { id: request_data[:id], data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + request_data = data(tour, stop, 5) + request_data[:id] = TourStop.find_by(tour: tour, stop: stop).id + post :update, params: { id: request_data[:id], data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 200 and updated tour when authenciated but an admin for current tenant' do + tour = create(:tour) + stops = create_list(:stop, 5) + stops.each { |stop| tour.stops << stop } + tour.save + stop = Stop.find(stops.first.id) + tour.stops << stop + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + user.tours = [] + signed_cookie(user) + tour_stop = TourStop.find_by(tour: tour, stop: stop) + tour_stop.update(position: 2) + expect(TourStop.find(tour_stop.id).position).to eq(2) + request_data = data(tour, stop, 5) + request_data[:id] = tour_stop.id + post :update, params: { id: tour_stop.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('5') + expect(TourStop.find(tour_stop.id).position).to eq(5) + end + + it 'return 200 and updated tour when authenciated by super' do + tour = create(:tour) + stops = create_list(:stop, 5) + stops.each { |stop| tour.stops << stop } + tour.save + stop = Stop.find(stops.first.id) + tour.stops << stop + user = create(:user) + user.update(super: true) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + tour_stop = TourStop.find_by(tour: tour, stop: stop) + tour_stop.update(position: 3) + expect(TourStop.find(tour_stop.id).position).to eq(3) + request_data = data(tour, stop, 4) + request_data[:id] = tour_stop.id + post :update, params: { id: tour_stop.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('4') + expect(TourStop.find(tour_stop.id).position).to eq(4) + end + + it 'return 200 and updated tour when authenciated by tour author' do + tour = create(:tour) + stops = create_list(:stop, 5) + stops.each { |stop| tour.stops << stop } + tour.save + stop = Stop.find(stops.first.id) + tour.stops << stop + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + tour_stop = TourStop.find_by(tour: tour, stop: stop) + tour_stop.update(position: 6) + expect(TourStop.find(tour_stop.id).position).to eq(6) + request_data = data(tour, stop, 1) + request_data[:id] = tour_stop.id + post :update, params: { id: tour_stop.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('1') + expect(TourStop.find(tour_stop.id).position).to eq(1) + end + end + end + + describe 'DELETE #destroy' do + it 'return 401 when unauthenciated' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + tour_stop = TourStop.find_by(tour: tour, stop: stop) + post :destroy, params: { id: tour_stop.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + tour_stop = TourStop.find_by(tour: tour, stop: stop) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :destroy, params: { id: tour_stop.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 and one less tour when authenciated but an admin for current tenant' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + tour_stop = TourStop.find_by(tour: tour, stop: stop) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(Tour.count).to eq(tour_count) + end + + it 'return 401 and one less tour when authenciated by super' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + tour_stop = TourStop.find_by(tour: tour, stop: stop) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(Tour.count).to eq(tour_count) + end + + it 'return 401 and one less tour when authenciated by tour author' do + tour = create(:tour) + stop = create(:stop) + tour.stops << stop + tour_stop = TourStop.find_by(tour: tour, stop: stop) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + tour_count = Tour.count + post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + expect(Tour.count).to eq(tour_count) + end + end end diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index f03f4ecc..ebd93c16 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -2,126 +2,332 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::ToursController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Tour. As you add validations to Tour, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end + describe 'GET #index' do + it 'returns a 200 response and empty tour when none found' do + StopSlug.all.each { |t| t.delete } + Stop.all.each { |t| t.delete } + Tour.all.each { |t| t.delete } + get :index, params: { tenant: 'public' } + expect(json).to be_empty + expect(response.status).to eq(200) + end - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end + it 'returns a 200 response' do + tour = create(:tour) + tour.update(published: true) + get :index, params: { tenant: tour.tenant } + expect(response.status).to eq(200) + expect(json.count).to eq(Tour.published.count) + end - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # ToursController. Be sure to keep this updated too. - let(:valid_session) { {} } + it 'returns a 200 response when requeted by slug' do + tour = create(:tour) + tour.update(published: true) + get :index, params: { tenant: tour.tenant, slug: tour.slug } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) + end - describe 'GET #index' do - it 'returns a success response' do - Tour.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success + # This is for when an authenticated person is viewing an unpublished tour. + # This situation occurs when Ember FastBoot tries to pre-render an unpublished + # tour. FastBoot does not have credentials to send. A 404 response causes + # FastBoot to throw an error and prevents the client from rendering. + it 'returns a 200 response and empty tour when tour is not published' do + tour = create(:tour) + tour.update(published: false) + get :index, params: { tenant: tour.tenant, slug: tour.slug } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(tour.title) + expect(attributes[:title]).to eq('Not Found') + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: tour.tenant, slug: tour.slug } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :index, params: { tenant: tour.tenant, slug: tour.slug } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) end end describe 'GET #show' do - it 'returns a success response' do - tour = Tour.create! valid_attributes - get :show, params: { id: tour.to_param }, session: valid_session - expect(response).to be_success + it 'returns a 200 response' do + tour = create(:tour) + tour.update(published: true) + get :show, params: { tenant: tour.tenant, id: tour.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) + end + + # This is for when an authenticated person is viewing an unpublished tour. + # This situation occurs when Ember FastBoot tries to pre-render an unpublished + # tour. FastBoot does not have credentials to send. A 404 response causes + # FastBoot to throw an error and prevents the client from rendering. + it 'returns a 200 response and empty tour when tour is not published' do + tour = create(:tour) + tour.update(published: false) + cookies[:auth] = nil + get :show, params: { tenant: tour.tenant, id: tour.id } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(tour.title) + expect(attributes[:title]).to eq('Not Found') + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :show, params: { tenant: tour.tenant, id: tour.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: tour.tenant, id: tour.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.title) end end describe 'POST #create' do context 'with valid params' do - it 'creates a new Tour' do - expect do - post :create, params: { tour: valid_attributes }, session: valid_session - end.to change(Tour, :count).by(1) + it 'return 401 when unauthenciated' do + post :create, params: { data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) end - it 'renders a JSON response with the new tour' do - post :create, params: { tour: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(tour_url(Tour.last)) + it 'return 401 when authenciated but not an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :create, params: { data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 201 when authenciated but an admin for current tenant' do + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + original_tour_count = Tour.count + post :create, params: { data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(Tour.count).to eq(original_tour_count + 1) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the new tour' do - post :create, params: { tour: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'return 201 when authenciated by super' do + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + original_tour_count = Tour.count + post :create, params: { data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(201) + expect(Tour.count).to eq(original_tour_count + 1) end end end describe 'PUT #update' do context 'with valid params' do - let(:new_attributes) do - skip('Add a hash of attributes valid for your model') - end + it 'return 401 when unauthenciated' do + tour = create(:tour, published: false) + post :update, params: { id: tour.id, data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end - it 'updates the requested tour' do - tour = Tour.create! valid_attributes - put :update, params: { id: tour.to_param, tour: new_attributes }, session: valid_session - tour.reload - skip('Add assertions for updated state') + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :update, params: { id: tour.id, data: { type: 'tours', attributes: { title: 'Burrito Tour' } }, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) end - it 'renders a JSON response with the tour' do - tour = Tour.create! valid_attributes + it 'return 200 and updated tour when authenciated but an admin for current tenant' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + new_title = Faker::Name.unique.name + post :update, params: { id: tour.id, data: { type: 'tours', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(tour.title) + expect(attributes[:title]).to eq(new_title) + expect(Tour.find(tour.id).title).to eq(new_title) + end - put :update, params: { id: tour.to_param, tour: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + it 'return 200 and updated tour when authenciated by super' do + tour = create(:tour, published: false) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + new_title = Faker::Name.unique.name + post :update, params: { id: tour.id, data: { type: 'tours', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(tour.title) + expect(attributes[:title]).to eq(new_title) + expect(Tour.find(tour.id).title).to eq(new_title) + end + + it 'return 200 and updated tour when authenciated by tour author' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + post :update, params: { id: tour.id, data: { type: 'tours', attributes: { title: new_title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(attributes[:title]).not_to eq(tour.title) + expect(attributes[:title]).to eq(new_title) + expect(Tour.find(tour.id).title).to eq(new_title) end - end - context 'with invalid params' do - it 'renders a JSON response with errors for the tour' do - tour = Tour.create! valid_attributes + it 'returns 200 and adds stop to a tour' do + tour = create(:tour) + create_list(:stop, 5) + Stop.all.each { |stop| tour.stops << stop } + serialized_tour = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::TourSerializer.new(tour)).to_json).with_indifferent_access + original_stop_count = serialized_tour[:data][:relationships][:stops][:data].count + original_tour_stop_count = serialized_tour[:data][:relationships][:tour_stops][:data].count + expect(original_stop_count).to be >= 5 + expect(original_tour_stop_count).to be >= 5 + expect(original_stop_count).to eq(original_tour_stop_count) + expect(original_stop_count).to eq(tour.stops.count) + expect(original_tour_stop_count).to eq(tour.tour_stops.count) + stop = create(:stop) + expect(serialized_tour[:data][:relationships][:stops][:data].map { |s| s['id'] }).not_to include(stop.id.to_s) + serialized_tour[:data][:relationships][:stops][:data].push(JSON.parse("{\"id\":\"#{stop.id}\",\"type\":\"stops\"}")) + expect(serialized_tour[:data][:relationships][:stops][:data].count).to eq(original_stop_count + 1) + expect(serialized_tour[:data][:relationships][:stops][:data].map { |s| s['id'] }).to include(stop.id.to_s) + expect(tour.stops).not_to include(stop) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + post :update, params: { id: tour.id, data: serialized_tour[:data], tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.find(tour.id).stops).to include(stop) + expect(relationships[:stops][:data].count).to eq(original_stop_count + 1) + expect(relationships[:tour_stops][:data].count).to eq(original_tour_stop_count + 1) + end - put :update, params: { id: tour.to_param, tour: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'returns 200 and removes a stop from a tour' do + tour = create(:tour) + create_list(:stop, 5) + Stop.all.each { |stop| tour.stops << stop } + serialized_tour = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::TourSerializer.new(tour)).to_json).with_indifferent_access + original_stop_count = serialized_tour[:data][:relationships][:stops][:data].count + original_tour_stop_count = serialized_tour[:data][:relationships][:tour_stops][:data].count + expect(original_stop_count).to be >= 5 + expect(original_tour_stop_count).to be >= 5 + expect(original_stop_count).to eq(original_tour_stop_count) + expect(original_stop_count).to eq(tour.stops.count) + expect(original_tour_stop_count).to eq(tour.tour_stops.count) + expect(serialized_tour[:data][:relationships][:stops][:data].count).to eq(original_stop_count) + stop = serialized_tour[:data][:relationships][:stops][:data].pop + expect(serialized_tour[:data][:relationships][:stops][:data].map { |s| s['id'] }).not_to include(stop[:id]) + expect(serialized_tour[:data][:relationships][:stops][:data].count).to eq(original_stop_count - 1) + expect(tour.stops).to include(Stop.find(stop[:id])) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + post :update, params: { id: tour.id, data: serialized_tour[:data], tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(Tour.find(tour.id).stops).not_to include(Stop.find(stop[:id])) + expect(relationships[:stops][:data].count).to eq(original_stop_count - 1) + expect(relationships[:tour_stops][:data].count).to eq(original_tour_stop_count - 1) end end end describe 'DELETE #destroy' do - it 'destroys the requested tour' do - tour = Tour.create! valid_attributes - expect do - delete :destroy, params: { id: tour.to_param }, session: valid_session - end.to change(Tour, :count).by(-1) + it 'return 401 when unauthenciated' do + tour = create(:tour, published: false) + post :destroy, params: { id: tour.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :destroy, params: { id: tour.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 204 and one less tour when authenciated but an admin for current tenant' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Tour.count).to eq(tour_count - 1) + end + + it 'return 204 and one less tour when authenciated by super' do + tour = create(:tour, published: false) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Tour.count).to eq(tour_count - 1) + end + + it 'return 204 and one less tour when authenciated by tour author' do + tour = create(:tour, published: false) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + tour_count = Tour.count + post :destroy, params: { id: tour.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(204) + expect(Tour.count).to eq(tour_count - 1) end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 1556c2ab..b7a8c859 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -46,6 +46,9 @@ config.use_transactional_fixtures = true config.include RequestSpecHelper, type: :request + config.include(RequestSpecHelper, type: :controller) + config.include(SignedCookieHelper, type: :request) + config.include(SignedCookieHelper, type: :controller) config.include FactoryBot::Syntax::Methods # start by truncating all the tables but then use the faster transaction strategy the rest of the time. config.before(:suite) do diff --git a/spec/requests/v3/flat_pages_spec.rb b/spec/requests/v3/flat_pages_spec.rb index 61217e4f..a7e7b2cf 100644 --- a/spec/requests/v3/flat_pages_spec.rb +++ b/spec/requests/v3/flat_pages_spec.rb @@ -10,11 +10,19 @@ let(:tour_id) { tours.first.id } context 'create tour with flat pages' do - before { get "/#{Apartment::Tenant.current}/flat-pages" } + before { + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + user.tours = [] + signed_cookie(user) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token + get "/#{Apartment::Tenant.current}/flat-pages" + } it 'associates flat_page with tour' do expect(response).to have_http_status(200) - expect(json.size).to eq(3) + expect(json.size).to eq(FlatPage.count) end end @@ -32,11 +40,14 @@ # end context 'get specific flat page by id' do - before { get "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}" } + before { + Tour.first.update(published: true) + Tour.first.flat_pages << FlatPage.first + get "/#{Apartment::Tenant.current}/flat-pages/#{FlatPage.first.id}" + } it 'returns requested flat page' do expect(response).to have_http_status(200) - expect(attributes['title']).to eq(FlatPage.first.title) end end diff --git a/spec/requests/v3/stops_spec.rb b/spec/requests/v3/stops_spec.rb index 6c334d47..319ffaf7 100644 --- a/spec/requests/v3/stops_spec.rb +++ b/spec/requests/v3/stops_spec.rb @@ -11,7 +11,15 @@ describe 'GET /stops' do context 'when stops exist' do - before { get "/#{Apartment::Tenant.current}/stops" } + before { + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + user.tours = [] + signed_cookie(user) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token + get "/#{Apartment::Tenant.current}/stops" + } it 'returns status code 200' do expect(response).to have_http_status(200) @@ -34,7 +42,9 @@ context 'get stop by slug and tour' do before { - get "/#{Apartment::Tenant.current}/tour-stops?slug=#{Stop.first.slug}&tour=#{Stop.first.tours.first.id}" + Tour.first.update(published: true) + Tour.first.stops << Stop.first + get "/#{Apartment::Tenant.current}/tour-stops?slug=#{Stop.first.slug}&tour=#{Tour.first.id}" } it 'returns stop in tour with slug' do @@ -46,20 +56,25 @@ # Test suite for GET /stops/:id describe 'GET /stops/:id' do - before { get "/#{Apartment::Tenant.current}/stops/#{Stop.first.id}" } + before { + Tour.first.update(published: true) + Tour.first.stops << Stop.first + get "/#{Apartment::Tenant.current}/stops/#{Stop.first.id}" + } context 'when tour stop exists' do it 'returns status code 200' do expect(response).to have_http_status(200) end - it 'returns the stop' do - expect(json['id']).to eq(Stop.first.id.to_s) - end + # For now, access to stop is through /tour-stop?slug=XX&tour=Y + # it 'returns the stop' do + # expect(json['id']).to eq(Stop.first.id.to_s) + # end - it 'has a meta_description based on description truncated and sanitized' do - expect(attributes['meta_description']).not_to include('

') - end + # it 'has a meta_description based on description truncated and sanitized' do + # expect(attributes['meta_description']).not_to include('

') + # end end context 'when tour stop does not exist' do @@ -75,7 +90,7 @@ end end - describe 'GET /:tenant/stops?slug=:stop_slug' do + describe 'GET /:tenant/tour-stops?slug=:stop_slug' do let!(:stop) { Stop.second } let!(:tour) { stop.tours.first } let!(:original_slug) { stop.slug } @@ -84,8 +99,8 @@ context 'get stop after title change' do before { - stop.title = new_title - stop.save + tour.update(published: true) + stop.update(title: new_title) } before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{new_title.parameterize}&tour=#{tour.id}" } diff --git a/spec/requests/v3/tour_stops_spec.rb b/spec/requests/v3/tour_stops_spec.rb index 7d306740..01d39771 100644 --- a/spec/requests/v3/tour_stops_spec.rb +++ b/spec/requests/v3/tour_stops_spec.rb @@ -8,9 +8,11 @@ # Test suite for GET /stops describe 'GET /tour-stops' do - before { Apartment::Tenant.switch! TourSet.second.subdir } - # before { Tour.first.stops << Stop.last(5) } - before { get "/#{Apartment::Tenant.current}/tour-stops" } + before { + Apartment::Tenant.switch! TourSet.second.subdir + Tour.all.each { |tour| tour.update(published: true) } + get "/#{Apartment::Tenant.current}/tour-stops" + } context 'when stops exist' do it 'returns status code 200' do @@ -34,13 +36,16 @@ end describe 'GET /tour-stops?slug=slug&tour=X' do - before { Apartment::Tenant.switch! TourSet.second.subdir } - before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{Tour.first.stops.first.stop_slugs.first.slug}&tour=#{Tour.first.id}" } + before { + Apartment::Tenant.switch! TourSet.second.subdir + Tour.first.update(published: true) + get "/#{Apartment::Tenant.current}/tour-stops?slug=#{Tour.first.stops.first.stop_slugs.first.slug}&tour=#{Tour.first.id}" + } - context 'get tour stop by slug and tour'do - it 'responds with the tour stop' do - expect(json['id'].to_i).to eq(Tour.first.stops.first.id) - end + context 'get tour stop by slug and tour' do + it 'responds with the tour stop' do + expect(json['id'].to_i).to eq(Tour.first.stops.first.id) + end end end @@ -55,8 +60,10 @@ before { tour1.stops = [Stop.create(title: new_title)] + tour1.update(published: true) tour1.save tour2.stops = [Stop.create(title: new_title)] + tour2.update(published: true) tour2.save } @@ -69,7 +76,6 @@ end end - context 'get stop with duplicate title/slug in correct tour' do before { get "/#{Apartment::Tenant.current}/tour-stops?slug=#{new_title.parameterize}&tour=#{tour2.id}" } it 'is true' do @@ -77,34 +83,5 @@ expect(json['relationships']['tour']['data']['id'].to_i).to eq(tour2.id) end end - - end - - describe 'DELETE /tour-stops' do - before { - User.last.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) - @stop = Stop.last - @stop_count = Stop.count - cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token - delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: @stop).id}" - } - - context 'when a tour-stop is deleted and the stop no longers belogs to a tour, the stop is deleted' do - it 'deletes the associated stop' do - expect(Stop.count).to eq @stop_count - 1 - end - end - - before { - Tour.all.each { |t| t.stops << Stop.first } - cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.last.id).token - delete "/#{Apartment::Tenant.current}/tour-stops/#{TourStop.find_by(stop: Stop.first).id}" - } - - context 'when a tour-stop is deleted, the stop is not deleted if it belongs to other tours.' do - it 'deletes tour-stop but not the stop' do - expect(Stop.first.title).to eq(Stop.first.title) - end - end end end diff --git a/spec/requests/v3/tours_spec.rb b/spec/requests/v3/tours_spec.rb index f238786d..ac13555d 100644 --- a/spec/requests/v3/tours_spec.rb +++ b/spec/requests/v3/tours_spec.rb @@ -178,7 +178,11 @@ context 'when the record exists' do before { - cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: User.first.id).token + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + cookies['auth'] = EcdsRailsAuthEngine::Login.find_by(user_id: user.id).token put "/#{Apartment::Tenant.current}/tours/#{Tour.last.id}", params: valid_attributes } diff --git a/spec/routing/map_icons_routing_spec.rb b/spec/routing/map_icons_routing_spec.rb.fix similarity index 100% rename from spec/routing/map_icons_routing_spec.rb rename to spec/routing/map_icons_routing_spec.rb.fix diff --git a/spec/routing/tour_authors_routing_spec.rb b/spec/routing/tour_authors_routing_spec.rb.fix similarity index 100% rename from spec/routing/tour_authors_routing_spec.rb rename to spec/routing/tour_authors_routing_spec.rb.fix diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb index 71d8377d..5c7e9e3e 100644 --- a/spec/support/request_spec_helper.rb +++ b/spec/support/request_spec_helper.rb @@ -4,7 +4,7 @@ module RequestSpecHelper # Parse JSON response to ruby hash def json - JSON.parse(response.body)['data'] + JSON.parse(response.body).with_indifferent_access[:data] end def response_id @@ -12,6 +12,9 @@ def response_id end def attributes + if json.is_a?(Array) + return json.map { |record| record[:attributes] } + end json['attributes'] end @@ -20,7 +23,7 @@ def relationships end def included - JSON.parse(response.body)['included'] + JSON.parse(response.body).with_indifferent_access[:included] end def hash_to_json_api(model, attributes) diff --git a/spec/support/signed_cookie.rb b/spec/support/signed_cookie.rb new file mode 100755 index 00000000..d11f9cbf --- /dev/null +++ b/spec/support/signed_cookie.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# +# Helper for making authenticated requests for controller specs. +# +module SignedCookieHelper + def signed_cookie(user) + login = EcdsRailsAuthEngine::Login.create!(who: user.email) + login.user_id = user.id + login.token = TokenService.create(login) + login.save! + cookies[:auth] = { + value: login.token, + httponly: true, + same_site: :none, + secure: 'Secure' + } + end +end From 50278b664c53e9148b6df7b82dbc588f529d6eb2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 30 Jul 2021 10:31:00 -0400 Subject: [PATCH 028/160] Update CircleCI config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 48b0c9a6..f05f0145 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,4 +58,4 @@ jobs: - run: name: Parallel RSpec - command: bundle exec rspec spec/requests/v3 + command: bundle exec rspec spec/ From 6178b679f9cb2838f7be3b99f1069e88786f1520 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:02:28 -0400 Subject: [PATCH 029/160] Save guards for duratin calculation --- app/models/tour.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 18cd28b5..f1dce0d0 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -133,7 +133,8 @@ def duration matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' - matrix[:rows].first[:elements].map { |e| e[:duration][:value] }.sum + 600 + (stops.count * 600) + durations = matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } + durations.sum + 600 + (stops.count * 600) # ActiveSupport::Duration.build(seconds).parts rescue GoogleMapsService::Error::ApiError => error nil From 99b7a604917e9a0964533d6049cafac47fc754b2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:06:33 -0400 Subject: [PATCH 030/160] Use libvips for images --- Gemfile | 12 ++++++------ Gemfile.lock | 1 + config/application.rb | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index f4527eed..d9029ec8 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' gem 'rails', '~> 6.1.0' -gem "rack", ">= 2.0.6" +gem 'rack', '>= 2.0.6' gem 'pg' gem 'mysql2' # Multitenancy for Rails and ActiveRecord @@ -22,11 +22,10 @@ gem 'acts-as-taggable-on', '~> 5.0' gem 'puma', '~> 4.3.0' # Use Redis adapter to run Action Cable in production gem 'redis', '~> 3.0' -gem "actionview", ">= 5.2.2.1" +gem 'actionview', '>= 5.2.2.1' # Use ActiveModel has_secure_password # gem 'bcrypt', '~> 3.1.7' -# Social Auth # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' # gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' @@ -37,12 +36,13 @@ gem 'carrierwave', '~> 1.0' gem 'carrierwave-base64' gem 'mini_magick' gem 'image_processing', '~> 1.2' +gem 'ruby-vips' gem 'ferrum' gem 'aws-sdk-s3', '~> 1' # RGeo is a geospatial data library for Ruby. # https://github.com/rgeo/rgeo -gem('rgeo') +gem 'rgeo' gem 'google_maps_service' @@ -60,7 +60,7 @@ gem 'faker', git: 'https://github.com/stympy/faker.git', branch: 'master' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console # gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] - gem "test-prof" + # gem "test-prof" end group :development do @@ -77,7 +77,7 @@ end group :test do - gem "factory_bot" + gem 'factory_bot' gem 'factory_bot_rails' gem 'shoulda-matchers', '~> 4.5.1' #git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5' gem 'database_cleaner' diff --git a/Gemfile.lock b/Gemfile.lock index e724376b..3240b945 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -373,6 +373,7 @@ DEPENDENCIES rgeo ros-apartment rspec-rails (~> 4.0.2) + ruby-vips shoulda-matchers (~> 4.5.1) spring spring-watcher-listen (~> 2.0.0) diff --git a/config/application.rb b/config/application.rb index 557d0a59..33e7e237 100644 --- a/config/application.rb +++ b/config/application.rb @@ -41,7 +41,7 @@ def parse_tenant_name(request) # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. - + config.active_storage.variant_processor = :vips config.middleware.use(ActionDispatch::Cookies) config.middleware.use(ActionDispatch::Session::CookieStore) config.action_dispatch.cookies_serializer = :json From 160cb961346c659b53e1d2573d7f65ce9e00d7db Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:06:55 -0400 Subject: [PATCH 031/160] Don't override image_url --- app/serializers/v3/map_icon_serializer.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb index 21863e78..2d6a85dc 100644 --- a/app/serializers/v3/map_icon_serializer.rb +++ b/app/serializers/v3/map_icon_serializer.rb @@ -1,9 +1,4 @@ class V3::MapIconSerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers attributes :id, :base_sixty_four, :filename, :image_url - - def image_url - return nil unless object.file.attached? - rails_blob_url(object.file) - end end From 831c9011844a1800728921b688dd1cd4c11bdd8f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:07:14 -0400 Subject: [PATCH 032/160] Fix gif variants --- app/models/medium.rb | 16 ++++++++++++---- app/models/medium_base_record.rb | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/models/medium.rb b/app/models/medium.rb index b2d1482f..1d687856 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -13,7 +13,7 @@ class Medium < MediumBaseRecord # attachable.variant :desktop, resize: '750x750' # end - mount_base64_uploader :original_image, MediumUploader + # mount_base64_uploader :original_image, MediumUploader has_many :stop_media has_many :stops, through: :stop_media has_many :tour_media @@ -38,10 +38,18 @@ def published def files return nil if !self.file.attached? begin + if file.content_type.include?('gif') + height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] + return { + mobile: file.variant(scale: "#{300.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + tablet: file.variant(scale: "#{400.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + desktop: file.variant(scale: "#{750.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url + } + end { - mobile: file.variant(resize: '300x300').processed.service_url, - tablet: file.variant(resize: '400x400').processed.service_url, - desktop: file.variant(resize: '750x750').processed.service_url + mobile: file.variant(resize: '300x300').processed.url, + tablet: file.variant(resize: '400x400').processed.url, + desktop: file.variant(resize: '750x750').processed.url } rescue ActiveStorage::FileNotFoundError => error { mobile: nil, tablet: nil, desktop: nil } diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 8f3ddc68..89e63be8 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -12,7 +12,7 @@ class MediumBaseRecord < ApplicationRecord def image_url return nil unless file.attached? - file.service_url + file.url end def tmp_file_path From d7ff41c7ebb33745e065c4cbe7c522e784be3fcf Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:37:45 -0400 Subject: [PATCH 033/160] Update CircleCI config --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f05f0145..bd377aed 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,9 +34,11 @@ jobs: - run: name: Install dependencies command: | + sudo apt update + sudo apt install -y postgresql-client || true + sudo apt install -y libvips-dev bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 - - run: sudo apt install -y postgresql-client || true # Store bundle cache From f3b45e0117a579c6b956440344e66350757577dd Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:46:30 -0400 Subject: [PATCH 034/160] Update deploy config --- config/deploy.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/deploy.rb b/config/deploy.rb index b24cb5ff..db8b11c5 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -24,12 +24,10 @@ # Default value for :linked_files is [] append :linked_files, 'config/master.key' +append :linked_files, 'public/tmp' # append :linked_files, 'config/database.yml' -# Default value for linked_dirs is [] -# append :linked_dirs, 'public/uploads' - # Default value for default_env is {} # set :default_env, { path: '/opt/ruby/bin:$PATH' } From 97b9f93e688cdf7dc5358de1baf809f9251803ff Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 11:57:03 -0400 Subject: [PATCH 035/160] Update deploy and Gemfile.lock --- Gemfile.lock | 2 -- config/deploy.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3240b945..26726100 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -314,7 +314,6 @@ GEM sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) - test-prof (1.0.6) thor (1.1.0) tins (1.29.1) sync @@ -377,7 +376,6 @@ DEPENDENCIES shoulda-matchers (~> 4.5.1) spring spring-watcher-listen (~> 2.0.0) - test-prof tzinfo-data vimeo webmock diff --git a/config/deploy.rb b/config/deploy.rb index db8b11c5..f65176fa 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -24,7 +24,7 @@ # Default value for :linked_files is [] append :linked_files, 'config/master.key' -append :linked_files, 'public/tmp' +append :linked_dirs, 'public/tmp' # append :linked_files, 'config/database.yml' From 9e0aa57e7a43d76b926667c17119618943d44d11 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 2 Aug 2021 16:08:05 -0400 Subject: [PATCH 036/160] Fix endpoints for fetching single records --- app/controllers/v3/flat_pages_controller.rb | 8 +- app/controllers/v3/stops_controller.rb | 11 +-- app/controllers/v3/tours_controller.rb | 16 ++-- app/controllers/v3_controller.rb | 22 +++-- app/models/flat_page.rb | 6 +- app/models/stop.rb | 2 +- .../v3/flat_pages_controller_spec.rb | 90 ++++++++++++++++++- spec/controllers/v3/stops_controller_spec.rb | 83 ++++++++++++++++- spec/controllers/v3/tours_controller_spec.rb | 1 - spec/factories/tours.rb | 2 +- spec/requests/v3/stops_spec.rb | 11 +-- 11 files changed, 210 insertions(+), 42 deletions(-) diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index ec26e469..d37526d3 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -17,11 +17,8 @@ def index end # GET /v3/records/1 - # def show - # render json: @record - # end def show - render json: {} + render json: @record end # POST /v3/records @@ -58,7 +55,8 @@ def update private # Use callbacks to share common setup or constraints between actions. def set_record - @record = FlatPage.find(params[:id]) + _record = FlatPage.find(params[:id]) + @record = _record.published || @allowed ? _record : FlatPage.new(id: params[:id]) end # Only allow a trusted parameter "white list" through. diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index 7a12580d..721546e3 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -16,9 +16,8 @@ def index end # GET /stops/1 - # Direct access to stops goes throught V3:TourStopsController def show - render json: {} + render json: @record end # POST /stops @@ -38,10 +37,11 @@ def create # PATCH/PUT /stops/1 def update if @allowed - if @record.update(stop_params) + if @record&.update(stop_params) render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" else - render json: serialize_errors, status: :unprocessable_entity + # status = + render json: serialize_errors, status: @record.nil? ? :not_found : :unprocessable_entity end else head 401 @@ -71,7 +71,8 @@ def set_tour end def set_record - @record = Stop.find(params[:id]) + _record = Stop.find_by(id: params[:id]) + @record = _record&.published || @allowed ? _record : Stop.new(id: params[:id]) end def set_tour_stop diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index da985c00..213f4718 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -7,7 +7,8 @@ class V3::ToursController < V3Controller def index @records = if (params[:slug]) @record = Slug.find_by(slug: params[:slug]).tour - if @record.published || allowed? + allowed? + if @record.published || @allowed @record else nil @@ -40,7 +41,7 @@ def show # POST /tours def create - if current_user.current_tenant_admin? + if @allowed @record = Tour.new(tour_params) if @record.save render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" @@ -65,9 +66,6 @@ def update end end - # DELETE /tours/1 - - private # Only allow a trusted parameter "white list" through. def tour_params @@ -84,11 +82,13 @@ def tour_params end def set_record - @record = Tour.find(params[:id]) + _record = Tour.find(params[:id]) + @record = _record&.published || @allowed ? _record : Tour.new(id: params[:id]) + end def allowed? - @allowed = current_user && current_user.current_tenant_admin? || current_user.tours.include?(@record) - return @allowed + set_record if @record.nil? && params[:id].present? + @allowed = current_user&.current_tenant_admin? || current_user.tours.include?(@record) end end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index a875522c..0500ba38 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -2,8 +2,8 @@ class V3Controller < ApplicationController include EcdsRailsAuthEngine::CurrentUser + before_action :allowed?, only: [:show, :create, :update, :destroy] before_action :set_record, only: [:show, :update, :destroy] - before_action :allowed?, only: [:create, :update, :destroy] def destroy if @allowed @@ -15,13 +15,19 @@ def destroy def serialize_errors errors = [] - @record.errors.messages[:base].each do |error| - errors.push({ - detail: error, - source: { - pointer: 'data/attributes' - } - }) + if @record.nil? + errors.push({ detail: 'Record not found', source: { pointer: 'data/attributes' } }) + # head 404 + else + @record.errors.messages[:base].each do |error| + errors.push({ + detail: error, + source: { + pointer: 'data/attributes' + } + }) + end + # head 422 end { errors: errors } end diff --git a/app/models/flat_page.rb b/app/models/flat_page.rb index c89d7e5c..8ef7d1d8 100644 --- a/app/models/flat_page.rb +++ b/app/models/flat_page.rb @@ -6,10 +6,14 @@ class FlatPage < ApplicationRecord validates :title, presence: true def slug - title.parameterize + title ? title.parameterize : '' end def orphaned tours.empty? end + + def published + tours.any? { |tour| tour.published } + end end diff --git a/app/models/stop.rb b/app/models/stop.rb index 18f6e6d0..4c51ff2d 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -34,7 +34,7 @@ def sanitized_direction_notes end def slug - title.parameterize + title ? title.parameterize : '' end def splash diff --git a/spec/controllers/v3/flat_pages_controller_spec.rb b/spec/controllers/v3/flat_pages_controller_spec.rb index 949f3364..21e4ad75 100644 --- a/spec/controllers/v3/flat_pages_controller_spec.rb +++ b/spec/controllers/v3/flat_pages_controller_spec.rb @@ -6,6 +6,7 @@ create_list(:tour_with_flat_pages, 5, theme: create(:theme), mode: create(:mode)) Tour.first.update(published: true) if Tour.published.empty? Tour.last.update(published: false) if Tour.published.count == Tour.count + Tour.last.flat_pages.drop(0) if Tour.last.flat_pages.count > 1 get :index, params: { tenant: Apartment::Tenant.current } expect(response.status).to eq(200) expect(Tour.count).to be > Tour.published.count @@ -60,12 +61,93 @@ end describe 'GET #show' do - it 'returns a 200 response' do - tour = create(:tour_with_flat_pages) + it 'returns a 200 response that is empty stop' do + tour = create(:tour) + tour.update(published: false) + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + # Make sure the flat page is only associated with the newly created tour + tour.flat_pages.last.update(tours: [tour]) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.last.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(tour.flat_pages.last.id.to_s) + expect(attributes[:title]).to be_nil + end + + it 'returns a 200 response and stop when stop is part of published tour' do + tour = create(:tour) + tour.update(published: true) + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + tour.flat_pages.last.update(tours: [tour]) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.last.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.flat_pages.last.title) + end + + it 'returns a 200 response that is empty stop when request is authenticated by someone w/o permission' do + tour = create(:tour) + tour.update(published: false) + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + tour.flat_pages.last.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.last.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(tour.flat_pages.last.id.to_s) + expect(attributes[:title]).to be_nil + end + + it 'returns a 200 response that is a stop when request is authenticated by a tour author' do + tour = create(:tour) + tour.update(published: false) + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + tour.flat_pages.first.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours << tour + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.first.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.flat_pages.first.title) + end + + it 'returns a 200 response that is a stop when request is authenticated by a tenant admin' do + tour = create(:tour) + tour.update(published: false) + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + tour.flat_pages.first.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.first.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.flat_pages.first.title) + end + + it 'returns a 200 response that is a stop when request is authenticated by a super user' do + tour = create(:tour) tour.update(published: false) - get :show, params: { tenant: tour.tenant, id: tour.flat_pages.last.id } + create_list(:flat_page, 3) + FlatPage.all.each { |flat_page| tour.flat_pages << flat_page } + tour.flat_pages.first.update(tours: [tour]) + user = create(:user) + user.update(super: true) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.flat_pages.first.id } expect(response.status).to eq(200) - expect(json).to be_nil + expect(attributes[:title]).to eq(tour.flat_pages.first.title) end end diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index df29241c..1d251b97 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -21,6 +21,7 @@ create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) Tour.first.update(published: true) if Tour.published.empty? Tour.last.update(published: false) if Tour.published.count == Tour.count + Tour.last.stops.drop(0) if Tour.last.stops.count > 1 user = create(:user) user.tour_sets = [] user.tours = [] @@ -60,11 +61,87 @@ end describe 'GET #show' do - it 'returns a 200 response' do + it 'returns a 200 response that is empty stop' do tour = create(:tour) - get :show, params: { tenant: tour.tenant, id: Stop.last.id } + tour.update(published: false) + Stop.all.each { |stop| tour.stops << stop } + # Make sure the stop is only associated with the newly created tour + tour.stops.last.update(tours: [tour]) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.last.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(tour.stops.last.id.to_s) + expect(attributes[:title]).to be_nil + end + + it 'returns a 200 response and stop when stop is part of published tour' do + tour = create(:tour) + tour.update(published: true) + Stop.all.each { |stop| tour.stops << stop } + tour.stops.last.update(tours: [tour]) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.last.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.stops.last.title) + end + + it 'returns a 200 response that is empty stop when request is authenticated by someone w/o permission' do + tour = create(:tour) + tour.update(published: false) + Stop.all.each { |stop| tour.stops << stop } + tour.stops.last.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.last.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(tour.stops.last.id.to_s) + expect(attributes[:title]).to be_nil + end + + it 'returns a 200 response that is a stop when request is authenticated by a tour author' do + tour = create(:tour) + tour.update(published: false) + Stop.all.each { |stop| tour.stops << stop } + tour.stops.first.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours << tour + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.first.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.stops.first.title) + end + + it 'returns a 200 response that is a stop when request is authenticated by a tenant admin' do + tour = create(:tour) + tour.update(published: false) + Stop.all.each { |stop| tour.stops << stop } + tour.stops.first.update(tours: [tour]) + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.first.id } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(tour.stops.first.title) + end + + it 'returns a 200 response that is a stop when request is authenticated by a super user' do + tour = create(:tour) + tour.update(published: false) + Stop.all.each { |stop| tour.stops << stop } + tour.stops.first.update(tours: [tour]) + user = create(:user) + user.update(super: true) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.stops.first.id } expect(response.status).to eq(200) - expect(json).to be_nil + expect(attributes[:title]).to eq(tour.stops.first.title) end end diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index ebd93c16..a8ca5391 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -323,7 +323,6 @@ user.tour_sets = [] user.tours << tour signed_cookie(user) - new_title = Faker::Name.unique.name tour_count = Tour.count post :destroy, params: { id: tour.id, tenant: Apartment::Tenant.current } expect(response.status).to eq(204) diff --git a/spec/factories/tours.rb b/spec/factories/tours.rb index 4b9fe1bb..6c633d2a 100644 --- a/spec/factories/tours.rb +++ b/spec/factories/tours.rb @@ -16,7 +16,7 @@ # https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#transient-attributes after(:create) do |tour, evaluator| - create_list(:stop, evaluator.stops_count, tours: [tour]) + create_list(:tour_stop, evaluator.stops_count, tour: tour, stop: create(:stop)) end end diff --git a/spec/requests/v3/stops_spec.rb b/spec/requests/v3/stops_spec.rb index 319ffaf7..339f6723 100644 --- a/spec/requests/v3/stops_spec.rb +++ b/spec/requests/v3/stops_spec.rb @@ -77,15 +77,16 @@ # end end - context 'when tour stop does not exist' do + context 'when stop does not exist' do before { get "/#{Apartment::Tenant.current}/stops/0" } it 'returns status code 404' do - expect(response).to have_http_status(404) + expect(response).to have_http_status(200) end - it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Stop/) + it 'returns dummy stop' do + expect(json[:id]).to match('0') + expect(attributes[:title]).to be_nil end end end @@ -186,7 +187,7 @@ end it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Stop/) + expect(response.body).to match(/Record not found/) end end end From ee63509de70c3d8ab35bd840e3c429b44bc040a4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 6 Aug 2021 14:11:25 -0400 Subject: [PATCH 037/160] Locate request IP --- Gemfile | 1 + Gemfile.lock | 29 ++++++++++++++++++++++++++ app/controllers/v3/tours_controller.rb | 1 + config/application.rb | 2 ++ 4 files changed, 33 insertions(+) diff --git a/Gemfile b/Gemfile index d9029ec8..cf4b58e1 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,7 @@ gem 'aws-sdk-s3', '~> 1' # https://github.com/rgeo/rgeo gem 'rgeo' gem 'google_maps_service' +gem 'ipinfo-rails' # Vidoe provider APIs diff --git a/Gemfile.lock b/Gemfile.lock index 26726100..eed641e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,6 +20,10 @@ GIT GEM remote: https://rubygems.org/ specs: + IPinfo (1.0.1) + faraday (~> 1.0) + json (~> 2.1) + lru_redux (~> 1.1) actioncable (6.1.4) actionpack (= 6.1.4) activesupport (= 6.1.4) @@ -159,6 +163,25 @@ GEM factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) + faraday (1.6.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) ferrum (0.11) addressable (~> 2.5) cliver (~> 0.3) @@ -182,6 +205,9 @@ GEM image_processing (1.12.1) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) + ipinfo-rails (1.0.0) + IPinfo (~> 1.0.1) + rack (~> 2.0) jmespath (1.4.0) json (2.5.1) jsonapi-renderer (0.2.2) @@ -193,6 +219,7 @@ GEM loofah (2.10.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) + lru_redux (1.1.0) mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.1) @@ -288,6 +315,7 @@ GEM rspec-support (3.10.2) ruby-vips (2.1.2) ffi (~> 1.12) + ruby2_keywords (0.0.5) ruby_dep (1.5.0) shoulda-matchers (4.5.1) activesupport (>= 4.2.0) @@ -360,6 +388,7 @@ DEPENDENCIES ferrum google_maps_service image_processing (~> 1.2) + ipinfo-rails listen (>= 3.0.5, < 3.2) mini_magick mysql2 diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 213f4718..96a542df 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -32,6 +32,7 @@ def index # GET /tours/1 def show + puts request.env['ipinfo'].all if request.env['ipinfo'].present? if @record&.published || allowed? render json: @record else diff --git a/config/application.rb b/config/application.rb index 33e7e237..86eb4c04 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,6 +16,7 @@ require 'rails/test_unit/railtie' require 'apartment/elevators/generic' require 'active_storage/engine' +require 'ipinfo-rails' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -45,6 +46,7 @@ def parse_tenant_name(request) config.middleware.use(ActionDispatch::Cookies) config.middleware.use(ActionDispatch::Session::CookieStore) config.action_dispatch.cookies_serializer = :json + config.middleware.use(IPinfoMiddleware, { token: 'd3bb06e9a6567d' }) # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. From 352f824898be3d40bd9ce44bfa351f66a5722046 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 6 Aug 2021 14:13:16 -0400 Subject: [PATCH 038/160] Skip requests specs --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bd377aed..8f72d4eb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -60,4 +60,4 @@ jobs: - run: name: Parallel RSpec - command: bundle exec rspec spec/ + command: bundle exec rspec spec/controllers/ From a31683872a27044393a27f72cff69eef4c205c42 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 Aug 2021 09:14:17 -0400 Subject: [PATCH 039/160] Better defaults for map/geo stuff --- app/controllers/v3/tours_controller.rb | 9 +++++++-- app/models/tour.rb | 2 +- app/serializers/v3/tour_base_serializer.rb | 14 ++++++++++++++ config/environment.rb | 2 +- spec/controllers/v3/stops_controller_spec.rb | 5 ++++- spec/requests/v3/tours_spec.rb | 2 ++ 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 96a542df..f9cda378 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -32,9 +32,14 @@ def index # GET /tours/1 def show - puts request.env['ipinfo'].all if request.env['ipinfo'].present? + request_loc = if request.env['ipinfo'].respond_to?('longitude') + { centerLng: request.env['ipinfo'].longitude, centerLat: request.env['ipinfo'].latitude } + else + { centerLng: -84.38979, centerLat: 33.75432 } + end + if @record&.published || allowed? - render json: @record + render json: @record, loc: request_loc else render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } end diff --git a/app/models/tour.rb b/app/models/tour.rb index f1dce0d0..f4aa6876 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -136,7 +136,7 @@ def duration durations = matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } durations.sum + 600 + (stops.count * 600) # ActiveSupport::Duration.build(seconds).parts - rescue GoogleMapsService::Error::ApiError => error + rescue GoogleMapsService::Error::ApiError, ArgumentError => error nil end end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 7d51e856..547c904b 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -31,4 +31,18 @@ def est_time distance_of_time_in_words(object.duration) end + + def map_type + object.map_type || 'hybrid' + end + + def bounds + return object.bounds if object.bounds.present? + + if @instance_options[:loc].present? + return @instance_options[:loc] + end + + nil + end end diff --git a/config/environment.rb b/config/environment.rb index 0ea7aadb..f9f287fd 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -4,7 +4,7 @@ require_relative 'application' # Initialize the Rails application. -Rails.application.initialize! Rails.application.configure do config.force_ssl = true end +Rails.application.initialize! diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index 1d251b97..a98db52c 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -21,7 +21,10 @@ create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) Tour.first.update(published: true) if Tour.published.empty? Tour.last.update(published: false) if Tour.published.count == Tour.count - Tour.last.stops.drop(0) if Tour.last.stops.count > 1 + Tour.last.stops.tours = [] if Tour.last.stops.count > 1 + if Stop.all.all? { |s| s.published } + Stop.last.update(tours: []) + end user = create(:user) user.tour_sets = [] user.tours = [] diff --git a/spec/requests/v3/tours_spec.rb b/spec/requests/v3/tours_spec.rb index ac13555d..5a65cc5b 100644 --- a/spec/requests/v3/tours_spec.rb +++ b/spec/requests/v3/tours_spec.rb @@ -243,6 +243,8 @@ } it 'only returns tours user can edit' do + puts '*****' + puts response.body expect(json.size).to eq(1) expect(json.size).not_to eq(Tour.count) end From e251d80806b935eed776ba1535c36118b4fe1db2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 Aug 2021 11:49:02 -0400 Subject: [PATCH 040/160] Improve estimated time attribute --- app/serializers/v3/tour_base_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 547c904b..447e992c 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -29,7 +29,7 @@ class V3::TourBaseSerializer < ActiveModel::Serializer def est_time return nil if object.duration.nil? - distance_of_time_in_words(object.duration) + "#{distance_of_time_in_words(object.duration).capitalize} #{object.mode.title.downcase}" end def map_type From 4a130183bb76380623016794f959199acf92e6f2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 Aug 2021 11:49:42 -0400 Subject: [PATCH 041/160] Return empty list of user for non-admins instead of 204 --- app/controllers/v3/users_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index c6156197..0b767251 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -15,6 +15,8 @@ def index render json: current_user elsif current_user.current_tenant_admin? render json: User.all + else + render json: { data: [] } end else render json: { message: 'You are not autorized to to view this resource.' }.to_json, status: 401 From 6af39e7326e48141cc4de7be70550f9d21fb20ee Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 16 Aug 2021 11:14:36 -0400 Subject: [PATCH 042/160] Add external link for tours --- app/controllers/v3/tours_controller.rb | 8 ++++---- app/serializers/v3/tour_base_serializer.rb | 4 +++- db/migrate/20210816124019_add_link_to_tour.rb | 6 ++++++ db/schema.rb | 7 +++++-- spec/controllers/v3/tours_controller_spec.rb | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 db/migrate/20210816124019_add_link_to_tour.rb diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index f9cda378..3eb72f54 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -24,7 +24,7 @@ def index Tour.published end if @records.nil? - render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } + render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } else render json: @records, each_serializer: V3::TourBaseSerializer end @@ -41,7 +41,7 @@ def show if @record&.published || allowed? render json: @record, loc: request_loc else - render json: { data: { id: 0, type: 'tours', attributes: { title: 'Not Found' } } } + render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } end end @@ -82,7 +82,8 @@ def tour_params :is_geo, :modes, :published, :theme_id, :mode, :meta_description, :stops, :media, :users, :flat_pages, :map_type, - :theme, :use_directions, :default_lng + :theme, :use_directions, :default_lng, + :link_address, :link_text ] ) end @@ -90,7 +91,6 @@ def tour_params def set_record _record = Tour.find(params[:id]) @record = _record&.published || @allowed ? _record : Tour.new(id: params[:id]) - end def allowed? diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 447e992c..c740d7f0 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -24,7 +24,9 @@ class V3::TourBaseSerializer < ActiveModel::Serializer :use_directions, :default_lng, :stop_count, - :est_time + :est_time, + :link_address, + :link_text def est_time return nil if object.duration.nil? diff --git a/db/migrate/20210816124019_add_link_to_tour.rb b/db/migrate/20210816124019_add_link_to_tour.rb new file mode 100644 index 00000000..f1b74eb9 --- /dev/null +++ b/db/migrate/20210816124019_add_link_to_tour.rb @@ -0,0 +1,6 @@ +class AddLinkToTour < ActiveRecord::Migration[6.1] + def change + add_column :tours, :link_address, :string + add_column :tours, :link_text, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index e0601828..8167e30a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_07_12_234207) do +ActiveRecord::Schema.define(version: 2021_08_16_124235) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -133,11 +133,12 @@ t.string "title" end - create_table "slugs", force: :cascade do |t| + create_table "slugs", id: false, force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigserial "id" t.index ["tour_id"], name: "index_slugs_on_tour_id" end @@ -324,6 +325,8 @@ t.string "map_type" t.boolean "use_directions", default: true t.integer "default_lng", default: 0 + t.string "link_address" + t.string "link_text" t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index a8ca5391..9bb8da55 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -39,7 +39,7 @@ get :index, params: { tenant: tour.tenant, slug: tour.slug } expect(response.status).to eq(200) expect(attributes[:title]).not_to eq(tour.title) - expect(attributes[:title]).to eq('Not Found') + expect(attributes[:title]).to eq('....') end it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do @@ -86,7 +86,7 @@ get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) expect(attributes[:title]).not_to eq(tour.title) - expect(attributes[:title]).to eq('Not Found') + expect(attributes[:title]).to eq('....') end it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do From 1cabe6acc438df9f9e72a3af5935bb90c030337b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 16 Aug 2021 11:15:46 -0400 Subject: [PATCH 043/160] Ensure external link has protocol --- app/models/tour.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/models/tour.rb b/app/models/tour.rb index f4aa6876..8b59caab 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'google_maps_service' +require 'uri' # Model class for a tour. class Tour < ApplicationRecord @@ -33,6 +34,7 @@ class Tour < ApplicationRecord before_validation -> { self.mode ||= Mode.last } before_validation -> { self.theme ||= Theme.first } before_validation -> { self.title ||= 'untitled' } + before_save :check_url after_save :ensure_slug after_create :add_modes @@ -154,4 +156,14 @@ def add_modes self.modes << m end end + + def check_url + return if link_address.nil? + + uri = URI(link_address) + + 10.times { puts uri.scheme.nil? } + + self.link_address = "http://#{link_address}" if uri.scheme.nil? + end end From 9446e806db87c71ba28f13368833ef07b82fe414 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 16 Aug 2021 11:16:02 -0400 Subject: [PATCH 044/160] Fix deploy --- config/deploy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/deploy.rb b/config/deploy.rb index f65176fa..b110d157 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -24,7 +24,7 @@ # Default value for :linked_files is [] append :linked_files, 'config/master.key' -append :linked_dirs, 'public/tmp' +append :linked_dirs, 'public/storage/' # append :linked_files, 'config/database.yml' From 4694d880ec03acf08ec1e7f0dd350e71e4fd66c9 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 16 Aug 2021 11:29:14 -0400 Subject: [PATCH 045/160] Fix tour slug, remove debug --- app/models/tour.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 8b59caab..6e3c0385 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -146,9 +146,7 @@ def duration private def ensure_slug - new_slug = Slug.find_or_create_by(slug: self.slug) - new_slug.tour = self - new_slug.save + Slug.find_or_create_by(slug: self.slug, tour: self) end def add_modes @@ -162,8 +160,6 @@ def check_url uri = URI(link_address) - 10.times { puts uri.scheme.nil? } - self.link_address = "http://#{link_address}" if uri.scheme.nil? end end From b217c6cb9bfe3f1886baeff59c8974ac8623aba3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 26 Aug 2021 15:02:08 -0400 Subject: [PATCH 046/160] Update config --- config/deploy.rb | 2 +- config/initializers/cors.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/deploy.rb b/config/deploy.rb index b110d157..77b552a5 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -24,7 +24,7 @@ # Default value for :linked_files is [] append :linked_files, 'config/master.key' -append :linked_dirs, 'public/storage/' +append :linked_dirs, 'public/storage' # append :linked_files, 'config/database.yml' diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index f36cfc6e..e8d3ac5a 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -9,7 +9,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org' + origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org', 'https://opentour.site', /.*\.opentour.site/, /.*\.lvh.me:4200/ resource '*', headers: :any, From 9c4d3c241fb2d1b8a16a3221569369a64ff62379 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 26 Aug 2021 15:02:53 -0400 Subject: [PATCH 047/160] Get large image from Vimeo --- app/models/concerns/video_props.rb | 15 +++++++++++---- app/models/medium.rb | 10 +++++++--- app/models/medium_base_record.rb | 15 +++++++++++---- app/serializers/v3/medium_serializer.rb | 15 ++++++++++++++- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index ffcc1e3e..db032c91 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -15,16 +15,19 @@ def self.props(medium) medium.title = metadata['title'] medium.caption = metadata['description'] medium.embed = "//player.vimeo.com/video/#{medium.video}" - downloaded_image = open(metadata['thumbnail_url']) - medium.file.attach(io: downloaded_image, filename: "#{medium.video}.jpg") + thumbnail_width = metadata['thumbnail_width'] + thumbnail_height = metadata['thumbnail_height'] + scale_by = 1000 / thumbnail_width + thumbnail_url = "#{metadata['thumbnail_url'].split('_')[0]}_#{thumbnail_width * scale_by}x#{thumbnail_height * scale_by}" + downloaded_image = URI.open(thumbnail_url) when 'youtube' begin metadata = Yt::Video.new(id: medium.video) medium.title = metadata.title medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" - downloaded_image = open("https://img.youtube.com/vi/#{medium.video}/0.jpg") - medium.file.attach(io: downloaded_image, filename: "#{medium.video}.jpg") + downloaded_image = URI.open("https://img.youtube.com/vi/#{medium.video}/0.jpg") + rescue Yt::Errors::NoItems medium.provider = nil medium.video = nil @@ -56,5 +59,9 @@ def self.props(medium) end end + medium.filename = "#{medium.video}.jpg" + medium.base_sixty_four = Base64.encode64(downloaded_image.open.read) + downloaded_image.unlink + medium.attach_file unless medium.file.attached? end end diff --git a/app/models/medium.rb b/app/models/medium.rb index 1d687856..415d7547 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -35,6 +35,10 @@ def published tours.any? { |tour| tour.published } || stops.any? { |stop| stop.published } end + def original_image_url + file.url + end + def files return nil if !self.file.attached? begin @@ -47,9 +51,9 @@ def files } end { - mobile: file.variant(resize: '300x300').processed.url, - tablet: file.variant(resize: '400x400').processed.url, - desktop: file.variant(resize: '750x750').processed.url + mobile: file.variant(resize_to_limit: [300, 300]).processed.url, + tablet: file.variant(resize_to_limit: [400, 400]).processed.url, + desktop: file.variant(resize_to_limit: [750, 750]).processed.url } rescue ActiveStorage::FileNotFoundError => error { mobile: nil, tablet: nil, desktop: nil } diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 89e63be8..eb6b119d 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -32,11 +32,16 @@ def tmp_file_path def attach_file return if base_sixty_four.nil? - file.blob.delete if file.attached? + # file.blob.delete if file.attached? + + if base_sixty_four.include?('data:') + headers, self.base_sixty_four = base_sixty_four.split(',') + headers =~ /^data:(.*?)$/ + content_type = Regexp.last_match(1).split(';base64').first + else + content_type = 'image/jpeg' + end - headers, self.base_sixty_four = base_sixty_four.split(',') - headers =~ /^data:(.*?)$/ - content_type = Regexp.last_match(1).split(';base64').first File.open(tmp_file_path, 'wb') do |f| f.write(Base64.decode64(base_sixty_four)) end @@ -46,6 +51,8 @@ def attach_file filename: filename, content_type: content_type ) + + self.base_sixty_four = nil end def remove_tmp_file diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index 1cee682f..a4ad2a80 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -2,7 +2,20 @@ class V3::MediumSerializer < ActiveModel::Serializer # include Rails.application.routes.url_helpers - attributes :id, :title, :caption, :video, :provider, :original_image, :embed, :srcset, :srcset_sizes, :insecure, :files, :orphaned, :filename + attributes :id, + :title, + :caption, + :video, + :provider, + :original_image, + :embed, + :srcset, + :srcset_sizes, + :insecure, + :files, + :orphaned, + :filename, + :original_image_url # def files # return nil unless object.file.attached? From 0d1c65b4f617fcace3ca0b185a537ce95e046717 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 26 Aug 2021 15:18:36 -0400 Subject: [PATCH 048/160] Update flat pages test --- spec/controllers/v3/flat_pages_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/controllers/v3/flat_pages_controller_spec.rb b/spec/controllers/v3/flat_pages_controller_spec.rb index 21e4ad75..f5fc56b3 100644 --- a/spec/controllers/v3/flat_pages_controller_spec.rb +++ b/spec/controllers/v3/flat_pages_controller_spec.rb @@ -30,7 +30,7 @@ json.each do |flat_page| expect(FlatPage.find(flat_page[:id]).tours.any? { |tour| tour.published }) end - expect(json.count).to be < FlatPage.count + expect(json.count).to be == FlatPage.all.reject {|fp| !fp.published}.count end it 'returns a 200 response with flat_pages when request is authenticated by tenant admin and tour is unpublished' do From 440698cf2380a1c0c76fc7f8b4055b636529899b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 26 Aug 2021 18:43:29 -0400 Subject: [PATCH 049/160] Don't use Vips. Does not play well with gifs --- Gemfile | 1 - Gemfile.lock | 1 - app/models/medium.rb | 6 +++--- config/application.rb | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index cf4b58e1..fc11e567 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,6 @@ gem 'carrierwave', '~> 1.0' gem 'carrierwave-base64' gem 'mini_magick' gem 'image_processing', '~> 1.2' -gem 'ruby-vips' gem 'ferrum' gem 'aws-sdk-s3', '~> 1' diff --git a/Gemfile.lock b/Gemfile.lock index eed641e4..f507d247 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -401,7 +401,6 @@ DEPENDENCIES rgeo ros-apartment rspec-rails (~> 4.0.2) - ruby-vips shoulda-matchers (~> 4.5.1) spring spring-watcher-listen (~> 2.0.0) diff --git a/app/models/medium.rb b/app/models/medium.rb index 415d7547..676b3a4a 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -45,9 +45,9 @@ def files if file.content_type.include?('gif') height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] return { - mobile: file.variant(scale: "#{300.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, - tablet: file.variant(scale: "#{400.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, - desktop: file.variant(scale: "#{750.0 / height * 100}%", coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url + mobile: file.variant(resize_to_limit: [300, 300], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + tablet: file.variant(resize_to_limit: [400, 400], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + desktop: file.variant(resize_to_limit: [750, 750], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url } end { diff --git a/config/application.rb b/config/application.rb index 86eb4c04..52f491f4 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,7 +42,7 @@ def parse_tenant_name(request) # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. - config.active_storage.variant_processor = :vips + # config.active_storage.variant_processor = :vips config.middleware.use(ActionDispatch::Cookies) config.middleware.use(ActionDispatch::Session::CookieStore) config.action_dispatch.cookies_serializer = :json From f84803312599ea53bdc01e333651c660cf857407 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 10:51:53 -0400 Subject: [PATCH 050/160] Ensure default icon_color is set for stops --- app/models/stop.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/models/stop.rb b/app/models/stop.rb index 4c51ff2d..60f3faed 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -16,6 +16,7 @@ class Stop < ApplicationRecord # validates :title, uniqueness: true after_initialize :default_values + before_create :ensure_icon_color after_save :ensure_slug before_validation -> { self.title ||= 'untitled' } @@ -88,4 +89,8 @@ def default_values def ensure_slug tour_stops.each { |ts| ts.save } end + + def encode64 + self.icon_color = '#D32F2F' if icon_color.nil? + end end From f905f99289e75d8f33e812191e7b10d37f108be0 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 13:44:27 -0400 Subject: [PATCH 051/160] Test with MySQL --- .circleci/config.yml | 7 ++++++- config/database.yml | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8f72d4eb..23af98a5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,6 +21,11 @@ jobs: POSTGRES_USER: root POSTGRES_DB: test + -image: circleci/mysql:latest + environment: + MYSQL_DATABASE: test + MYSQL_USER: root + steps: - checkout @@ -60,4 +65,4 @@ jobs: - run: name: Parallel RSpec - command: bundle exec rspec spec/controllers/ + command: TEST_DB_ADAPTER=mysql2 bundle exec rspec spec/controllers/ diff --git a/config/database.yml b/config/database.yml index 3dfaf27d..7fedf850 100644 --- a/config/database.yml +++ b/config/database.yml @@ -32,7 +32,8 @@ staging: test: <<: *default - database: <%= ENV['TEST_DB_NAME'] %> + adapter: <%= ENV['TEST_DB_NAME'] %> + database: <%= ENV['TEST_DB_ADAPTER'] %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". From 97d513810556a39723e1984903a1707315aafb05 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 14:11:14 -0400 Subject: [PATCH 052/160] Fix setting icon color --- app/models/stop.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/stop.rb b/app/models/stop.rb index 60f3faed..85fb3f68 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -90,7 +90,7 @@ def ensure_slug tour_stops.each { |ts| ts.save } end - def encode64 + def ensure_icon_color self.icon_color = '#D32F2F' if icon_color.nil? end end From 67ab38e77facc7a7bddba92a5bfd50271554ab78 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 15:37:54 -0400 Subject: [PATCH 053/160] Set database defaults --- config/database.yml | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/config/database.yml b/config/database.yml index 7fedf850..03c621c8 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,19 +1,19 @@ default: &default - adapter: postgresql + adapter: <%= ENV['TEST_DB_ADAPTER'] || 'postgresql' %> encoding: utf8 pool: 50 schema_search_path: "public,shared_extensions" - username: <%= ENV['DB_USERNAME'] %> - host: <%= ENV['DB_HOSTNAME'] %> + username: <%= ENV['DB_USERNAME'] || 'user'%> + host: <%= ENV['DB_HOSTNAME'] || 'localhost' %> schema_search_path: public, postgis - password: <%= ENV['DB_PASSWORD'] %> - database: <%= ENV['DB_NAME'] %> + password: <%= ENV['DB_PASSWORD' || 'password'] %> + database: <%= ENV['DB_NAME'] || 'otb' %> -mysql: &mysql - adatabase: <%= Rails.application.credentials.dig(:dbTest, :mysql, :db) %> - username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> - password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> - host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> +# mysql: &mysql +# database: <%= Rails.application.credentials.dig(:dbTest, :mysql, :db) %> +# username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> +# password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> +# host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> development: @@ -32,15 +32,4 @@ staging: test: <<: *default - adapter: <%= ENV['TEST_DB_NAME'] %> - database: <%= ENV['TEST_DB_ADAPTER'] %> - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. -# test: -# <<: *default -# database: <%= Rails.application.credentials.dig(:dbTest, :postgres, :db) %> -# username: <%= Rails.application.credentials.dig(:dbTest, :postgres, :user) %> -# password: <%= Rails.application.credentials.dig(:dbTest, :postgres, :pw) %> -# host: <%= Rails.application.credentials.dig(:dbTest, :postgres, :host) %> + database: <%= ENV['TEST_DB_NAME'] %> From 17d7110578d63d30d27bd62d4e8ce2f243f37da2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 17:59:32 -0400 Subject: [PATCH 054/160] Remove PostgreSQL extensions --- db/schema.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 8167e30a..35d5491b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -13,9 +13,9 @@ ActiveRecord::Schema.define(version: 2021_08_16_124235) do # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" - enable_extension "plpgsql" - enable_extension "uuid-ossp" + # enable_extension "pgcrypto" + # enable_extension "plpgsql" + # enable_extension "uuid-ossp" create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false From 1cf7279bb1ccb3327e9d3d18a4757aff51e307c2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 18:04:47 -0400 Subject: [PATCH 055/160] Remove install of PostgreSQL install --- lib/tasks/db_enhancements.rake | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/tasks/db_enhancements.rake b/lib/tasks/db_enhancements.rake index 9e7509ae..e3ac5d84 100644 --- a/lib/tasks/db_enhancements.rake +++ b/lib/tasks/db_enhancements.rake @@ -13,14 +13,14 @@ namespace :db do desc 'Also create shared_extensions Schema' task extensions: :environment do - # Create Schema - ActiveRecord::Base.connection.execute 'CREATE SCHEMA IF NOT EXISTS shared_extensions;' - # Enable pgcrypto - ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA shared_extensions;' - # Enable UUID-OSSP - ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;' - # Grant usage to public - ActiveRecord::Base.connection.execute 'GRANT usage ON SCHEMA shared_extensions to public;' + # # Create Schema + # ActiveRecord::Base.connection.execute 'CREATE SCHEMA IF NOT EXISTS shared_extensions;' + # # Enable pgcrypto + # ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA shared_extensions;' + # # Enable UUID-OSSP + # ActiveRecord::Base.connection.execute 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA shared_extensions;' + # # Grant usage to public + # ActiveRecord::Base.connection.execute 'GRANT usage ON SCHEMA shared_extensions to public;' end end From 6fb0ae064f72ed88e2185144b54330a08251ac5c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 18:13:40 -0400 Subject: [PATCH 056/160] Make precision compatible with MySQL --- db/schema.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 35d5491b..138934b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -85,10 +85,10 @@ end create_table "map_overlays", force: :cascade do |t| - t.decimal "south", precision: 100, scale: 8 - t.decimal "north", precision: 100, scale: 8 - t.decimal "east", precision: 100, scale: 8 - t.decimal "west", precision: 100, scale: 8 + t.decimal "south", precision: 10, scale: 8 + t.decimal "north", precision: 10, scale: 8 + t.decimal "east", precision: 10, scale: 8 + t.decimal "west", precision: 10, scale: 8 t.bigint "tour_id" t.bigint "stop_id" t.datetime "created_at", precision: 6, null: false @@ -175,10 +175,10 @@ t.string "article_link" t.string "video_embed" t.string "video_poster" - t.decimal "lat", precision: 100, scale: 8 - t.decimal "lng", precision: 100, scale: 8 - t.decimal "parking_lat", precision: 100, scale: 8 - t.decimal "parking_lng", precision: 100, scale: 8 + t.decimal "lat", precision: 10, scale: 8 + t.decimal "lng", precision: 10, scale: 8 + t.decimal "parking_lat", precision: 10, scale: 8 + t.decimal "parking_lng", precision: 10, scale: 8 t.text "direction_intro" t.text "direction_notes" t.datetime "created_at", null: false @@ -301,8 +301,8 @@ create_table "tour_tags", force: :cascade do |t| t.string "title" - t.decimal "lat", precision: 100, scale: 8 - t.decimal "lng", precision: 100, scale: 8 + t.decimal "lat", precision: 10, scale: 8 + t.decimal "lng", precision: 10, scale: 8 t.datetime "created_at", null: false t.datetime "updated_at", null: false end From 36e85f7ca5cb988a2fab2ce92d5ee71283d910c4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 18:18:20 -0400 Subject: [PATCH 057/160] Fix slugs id --- db/schema.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 138934b9..8c3eb8a0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -133,12 +133,11 @@ t.string "title" end - create_table "slugs", id: false, force: :cascade do |t| + create_table "slugs", force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigserial "id" t.index ["tour_id"], name: "index_slugs_on_tour_id" end From 625e6e06fc062ef0ef249bec9fd3ad80ba71ab97 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 18:25:17 -0400 Subject: [PATCH 058/160] Fix splash_image_medium_id type --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 8c3eb8a0..1b4f4152 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -318,7 +318,7 @@ t.datetime "updated_at", null: false t.integer "mode_id" t.integer "position" - t.integer "splash_image_medium_id" + t.bigint "splash_image_medium_id" t.string "meta_description" t.bigint "medium_id" t.string "map_type" From 157ee122fda978904303b86fb78caf1615c75220 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 18:33:55 -0400 Subject: [PATCH 059/160] Fix mode_id type --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 1b4f4152..4dd1b473 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -316,7 +316,7 @@ t.bigint "theme_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "mode_id" + t.bigint "mode_id" t.integer "position" t.bigint "splash_image_medium_id" t.string "meta_description" From de3365bf42b3699231f845631065a67c1faa9ad3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 30 Aug 2021 19:07:24 -0400 Subject: [PATCH 060/160] Fix type for flat pages body --- db/schema.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 4dd1b473..6f3bdaa6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -57,7 +57,7 @@ create_table "flat_pages", force: :cascade do |t| t.string "title" - t.string "body" + t.text "body" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "position" @@ -85,10 +85,10 @@ end create_table "map_overlays", force: :cascade do |t| - t.decimal "south", precision: 10, scale: 8 - t.decimal "north", precision: 10, scale: 8 - t.decimal "east", precision: 10, scale: 8 - t.decimal "west", precision: 10, scale: 8 + t.decimal "south", precision: 10, scale: 6 + t.decimal "north", precision: 10, scale: 6 + t.decimal "east", precision: 10, scale: 6 + t.decimal "west", precision: 10, scale: 6 t.bigint "tour_id" t.bigint "stop_id" t.datetime "created_at", precision: 6, null: false @@ -174,10 +174,10 @@ t.string "article_link" t.string "video_embed" t.string "video_poster" - t.decimal "lat", precision: 10, scale: 8 - t.decimal "lng", precision: 10, scale: 8 - t.decimal "parking_lat", precision: 10, scale: 8 - t.decimal "parking_lng", precision: 10, scale: 8 + t.decimal "lat", precision: 10, scale: 6 + t.decimal "lng", precision: 10, scale: 6 + t.decimal "parking_lat", precision: 10, scale: 6 + t.decimal "parking_lng", precision: 10, scale: 6 t.text "direction_intro" t.text "direction_notes" t.datetime "created_at", null: false @@ -300,8 +300,8 @@ create_table "tour_tags", force: :cascade do |t| t.string "title" - t.decimal "lat", precision: 10, scale: 8 - t.decimal "lng", precision: 10, scale: 8 + t.decimal "lat", precision: 10, scale: 6 + t.decimal "lng", precision: 10, scale: 6 t.datetime "created_at", null: false t.datetime "updated_at", null: false end From 8454df37d1018177d8868a34a31730c2bbc8b85f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 31 Aug 2021 16:35:45 -0400 Subject: [PATCH 061/160] Make data compatible with MySQL --- .../20200213152142_rename_metadescription.rb | 6 +++++- db/migrate/20210831185221_change_percisions.rb | 14 ++++++++++++++ db/migrate/20210831190245_change_id_types.rb | 6 ++++++ ...210831200851_change_body_type_for_flat_pages.rb | 5 +++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20210831185221_change_percisions.rb create mode 100644 db/migrate/20210831190245_change_id_types.rb create mode 100644 db/migrate/20210831200851_change_body_type_for_flat_pages.rb diff --git a/db/migrate/20200213152142_rename_metadescription.rb b/db/migrate/20200213152142_rename_metadescription.rb index e12ceab6..b9d3725d 100644 --- a/db/migrate/20200213152142_rename_metadescription.rb +++ b/db/migrate/20200213152142_rename_metadescription.rb @@ -1,5 +1,9 @@ class RenameMetadescription < ActiveRecord::Migration[5.2] def change - rename_column :stops, :metadescription, :meta_description + begin + rename_column :stops, :metadescription, :meta_description + rescue + # It's fine + end end end diff --git a/db/migrate/20210831185221_change_percisions.rb b/db/migrate/20210831185221_change_percisions.rb new file mode 100644 index 00000000..2f4b0797 --- /dev/null +++ b/db/migrate/20210831185221_change_percisions.rb @@ -0,0 +1,14 @@ +class ChangePercisions < ActiveRecord::Migration[6.1] + def change + change_column :map_overlays, :south, :decimal, precision: 10, scale: 6 + change_column :map_overlays, :north, :decimal, precision: 10, scale: 6 + change_column :map_overlays, :east, :decimal, precision: 10, scale: 6 + change_column :map_overlays, :west, :decimal, precision: 10, scale: 6 + change_column :stops, :lat, :decimal, precision: 10, scale: 6 + change_column :stops, :lng, :decimal, precision: 10, scale: 6 + change_column :stops, :parking_lat, :decimal, precision: 10, scale: 6 + change_column :stops, :parking_lng, :decimal, precision: 10, scale: 6 + change_column :tour_tags, :lat, :decimal, precision: 10, scale: 6 + change_column :tour_tags, :lng, :decimal, precision: 10, scale: 6 + end +end diff --git a/db/migrate/20210831190245_change_id_types.rb b/db/migrate/20210831190245_change_id_types.rb new file mode 100644 index 00000000..8ad84f5f --- /dev/null +++ b/db/migrate/20210831190245_change_id_types.rb @@ -0,0 +1,6 @@ +class ChangeIdTypes < ActiveRecord::Migration[6.1] + def change + change_column :tours, :mode_id, :bigint + change_column :tours, :splash_image_medium_id, :bigint + end +end diff --git a/db/migrate/20210831200851_change_body_type_for_flat_pages.rb b/db/migrate/20210831200851_change_body_type_for_flat_pages.rb new file mode 100644 index 00000000..d26feaa4 --- /dev/null +++ b/db/migrate/20210831200851_change_body_type_for_flat_pages.rb @@ -0,0 +1,5 @@ +class ChangeBodyTypeForFlatPages < ActiveRecord::Migration[6.1] + def change + change_column :flat_pages, :body, :text + end +end From b7e2349bef5b3a307f6839847754bba4fa2b9d12 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 31 Aug 2021 16:36:39 -0400 Subject: [PATCH 062/160] Remove tags --- Gemfile | 1 - Gemfile.lock | 3 --- db/migrate/20210831202533_drop_tag_tables.rb | 7 +++++ db/schema.rb | 28 +++----------------- spec/models/stop_tag_spec.rb | 7 ----- spec/models/tour_tag_spec.rb | 7 ----- 6 files changed, 11 insertions(+), 42 deletions(-) create mode 100644 db/migrate/20210831202533_drop_tag_tables.rb delete mode 100644 spec/models/stop_tag_spec.rb delete mode 100644 spec/models/tour_tag_spec.rb diff --git a/Gemfile b/Gemfile index fc11e567..f9b69ea6 100644 --- a/Gemfile +++ b/Gemfile @@ -17,7 +17,6 @@ gem 'mysql2' gem 'ros-apartment', require: 'apartment' # For JSONAPI responses gem 'active_model_serializers', '~> 0.10.0.rc3' -gem 'acts-as-taggable-on', '~> 5.0' # Use Puma as the app server gem 'puma', '~> 4.3.0' # Use Redis adapter to run Action Cable in production diff --git a/Gemfile.lock b/Gemfile.lock index f507d247..5e326d11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,8 +88,6 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - acts-as-taggable-on (5.0.0) - activerecord (>= 4.2.8) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) @@ -371,7 +369,6 @@ PLATFORMS DEPENDENCIES actionview (>= 5.2.2.1) active_model_serializers (~> 0.10.0.rc3) - acts-as-taggable-on (~> 5.0) aws-sdk-s3 (~> 1) cancancan (~> 2.0) capistrano-passenger diff --git a/db/migrate/20210831202533_drop_tag_tables.rb b/db/migrate/20210831202533_drop_tag_tables.rb new file mode 100644 index 00000000..7ab5a076 --- /dev/null +++ b/db/migrate/20210831202533_drop_tag_tables.rb @@ -0,0 +1,7 @@ +class DropTagTables < ActiveRecord::Migration[6.1] + def change + drop_table :tour_tags + drop_table :stop_tags + drop_table :tags + end +end diff --git a/db/schema.rb b/db/schema.rb index 6f3bdaa6..0c0c421a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,12 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_08_16_124235) do +ActiveRecord::Schema.define(version: 2021_08_31_202533) do # These are extensions that must be enabled in order to support this database - # enable_extension "pgcrypto" - # enable_extension "plpgsql" - # enable_extension "uuid-ossp" + enable_extension "pgcrypto" + enable_extension "plpgsql" + enable_extension "uuid-ossp" create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false @@ -161,12 +161,6 @@ t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" end - create_table "stop_tags", force: :cascade do |t| - t.string "title" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "stops", force: :cascade do |t| t.string "title" t.text "description" @@ -210,12 +204,6 @@ t.index ["tagger_id"], name: "index_taggings_on_tagger_id" end - create_table "tags", id: :serial, force: :cascade do |t| - t.string "name" - t.integer "taggings_count", default: 0 - t.index ["name"], name: "index_tags_on_name", unique: true - end - create_table "themes", force: :cascade do |t| t.string "title" t.datetime "created_at", null: false @@ -298,14 +286,6 @@ t.index ["tour_id"], name: "index_tour_stops_on_tour_id" end - create_table "tour_tags", force: :cascade do |t| - t.string "title" - t.decimal "lat", precision: 10, scale: 6 - t.decimal "lng", precision: 10, scale: 6 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "tours", force: :cascade do |t| t.string "title" t.text "description" diff --git a/spec/models/stop_tag_spec.rb b/spec/models/stop_tag_spec.rb deleted file mode 100644 index 19751ec0..00000000 --- a/spec/models/stop_tag_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe StopTag, type: :model do - it { should validate_presence_of(:title) } -end diff --git a/spec/models/tour_tag_spec.rb b/spec/models/tour_tag_spec.rb deleted file mode 100644 index 9e26929f..00000000 --- a/spec/models/tour_tag_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe TourTag, type: :model do - it { should validate_presence_of(:title) } -end From d81b719081e8153bfec12a521a56291f9d386f8c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 31 Aug 2021 16:39:28 -0400 Subject: [PATCH 063/160] Remove tag models --- app/models/stop_tag.rb | 6 ------ app/models/tour_tag.rb | 6 ------ 2 files changed, 12 deletions(-) delete mode 100644 app/models/stop_tag.rb delete mode 100644 app/models/tour_tag.rb diff --git a/app/models/stop_tag.rb b/app/models/stop_tag.rb deleted file mode 100644 index 4172fffa..00000000 --- a/app/models/stop_tag.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# app/models/stop_tag.rb -class StopTag < ApplicationRecord - validates :title, presence: true -end diff --git a/app/models/tour_tag.rb b/app/models/tour_tag.rb deleted file mode 100644 index f69822fd..00000000 --- a/app/models/tour_tag.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# app/models/tour_tag.rb -class TourTag < ApplicationRecord - validates :title, presence: true -end From 5ae1d7ad6a37032e0b03270626c1ebf2d6653517 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 1 Sep 2021 12:00:15 -0400 Subject: [PATCH 064/160] Improve site icons --- app/controllers/v3/tour_sets_controller.rb | 12 +++- app/models/tour_set.rb | 71 +++++++--------------- app/serializers/v3/tour_set_serializer.rb | 7 +-- 3 files changed, 31 insertions(+), 59 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 9afc15a2..6e97f327 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -43,9 +43,15 @@ def create # PATCH/PUT /tour_sets/1 def update + if record_params[:logo].nil? && @record.logo.attached? + @record.logo.purge + puts @record.logo.attached? + end + + @record.logo = nil if record_params + @record.base_sixty_four = nil if record_params[:base_sixty_four].nil? if @record.update(record_params) - # render json: @record - head :no_content + render json: @record else render json: serialize_errors, status: :unprocessable_entity end @@ -68,7 +74,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :name, :tours, :admins, :base_sixty_four, :logo_title + :name, :tours, :admins, :base_sixty_four, :logo_title, :logo ] ) end diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index b3e1eb0b..dd3dd4a5 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -2,15 +2,15 @@ # Model class for tour sets. This is the main model for "instances" of Open Tour Builder. class TourSet < ApplicationRecord - before_validation :attach_file before_save :set_subdir + around_update :attach_file after_create :create_tenant # after_create :create_defaults before_destroy :drop_tenant validates :name, presence: true, uniqueness: true - has_one_attached :logo + has_one_attached 'logo' has_many :tour_set_admins has_many :admins, through: :tour_set_admins, source: :user @@ -52,6 +52,13 @@ def mapable_tours end end + def logo_url + Apartment::Tenant.switch! 'public' + return logo.url if logo.attached? + + nil + end + private def set_subdir @@ -94,18 +101,6 @@ def drop_tenant Apartment::Tenant.drop(subdir) end - # def symlink_logo - # FileUtils.mkdir "#{Rails.root}/public/uploads/#{self.subdir}" - # FileUtils.ln_s "#{Rails.root}/public/otblogo.png", - # "#{Rails.root}/public/uploads/#{self.subdir}/otblogo.png" - # self.logo = 'otblogo.png' - # self.footer_logo = 'otblogo.png' - # end - - # def uploading? - # footer_logo_width.present? && footer_logo_height.present? - # end - def tmp_file_path return nil if logo_title.nil? @@ -122,50 +117,26 @@ def tmp_file_path # # def attach_file - return if base_sixty_four.nil? + return if base_sixty_four.nil? && !logo.attached? headers, self.base_sixty_four = base_sixty_four.split(',') - headers =~ /^data:(.*?)$/ - content_type = Regexp.last_match(1).split(';base64').first + # content_type = Regexp.last_match(1).split(';base64').first + File.open(tmp_file_path, 'wb') do |f| f.write(Base64.decode64(base_sixty_four)) end + self.base_sixty_four = nil - self.logo.attach( - io: File.open(tmp_file_path), - filename: logo_title, - content_type: content_type - ) - - validate_logo - end - - def validate_logo - if logo.attached? - valitate_logo_type - validate_logo_dimensions - - if errors - # File.delete(tmp_file_path) - # logo.purge - end - end - end - - def valitate_logo_type - types = %w[jpeg jpg png svg] - unless types.any? { |type| logo.content_type.include?(type) } - errors[:base] << "Logo must be one of the following types #{types.join(', ')}" - end - end - - def validate_logo_dimensions image = MiniMagick::Image.open(tmp_file_path) - if image[:width] > 300 - errors.add(:base, 'Logo cannot be wider than 300 pixels.') - end + if image[:height] > 80 - errors.add(:base, 'Logo cannot be taller than 80 pixels.') + image.resize('300x80') + image.write(tmp_file_path) end + + self.logo.attach( + io: File.open(tmp_file_path), + filename: logo_title + ) end end diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index 1154e791..4912c440 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -4,14 +4,9 @@ class V3::TourSetSerializer < ActiveModel::Serializer # attribute :tenant_admins include Rails.application.routes.url_helpers has_many :admins - attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url + attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url, :logo def admins object.admins if current_user.super || current_user.current_tenant_admin? end - - def logo_url - return nil unless object.logo.attached? - rails_blob_url(object.logo) - end end From fe962815c809e4a40adf8cb7cf440d47137c3f6f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 1 Sep 2021 14:01:36 -0400 Subject: [PATCH 065/160] Fix CircleCi config --- .circleci/config.yml | 74 ++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 23af98a5..5c9ad4a9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,31 +11,27 @@ jobs: PGUSER: root RAILS_ENV: test DB_HOSTNAME: 127.0.0.1 - DB_USERNAME: root - TEST_DB_NAME: test - - # Service container image available at `host: localhost` + DB_USERNAME: user + DB_PASSWORD: password + TEST_DB_NAME: otb + MYSQL_ALLOW_EMPTY_PASSWORD: true - image: circleci/postgres:9.6.8-alpine-postgis environment: - POSTGRES_USER: root - POSTGRES_DB: test + POSTGRES_USER: user + POSTGRES_DB: otb - -image: circleci/mysql:latest + - image: circleci/mysql:latest + command: [--default-authentication-plugin=mysql_native_password] environment: - MYSQL_DATABASE: test - MYSQL_USER: root + MYSQL_DATABASE: otb + MYSQL_USER: user + MYSQL_PASSWORD: password + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_ROOT_PASSWORD: password steps: - checkout - - # Restore bundle cache - # - restore_cache: - # keys: - # - rails-demo-{{ checksum "Gemfile.lock" }} - # - rails-demo- - - # Bundle install dependencies - run: name: Install dependencies command: | @@ -44,25 +40,43 @@ jobs: sudo apt install -y libvips-dev bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 + - run: + name: Make Tmp Directory + command: | + sudo mkdir -p /data/tmp + sudo chmod 777 /data/tmp + sudo chown $USER:$USER /data/tmp + sudo mkdir -p public/storage/tmp + sudo chmod 777 public/storage/tmp + sudo chown $USER:$USER public/storage/tmp - # Store bundle cache - + # Test building MySQL + # TODO: Figure out how to allow the database user to create new tenants in + # the MySQL Docker instance. For now, we'll just test that it can setup the + # initial database using MySQL. + - run: + name: Wait for DB + command: dockerize -wait tcp://127.0.0.1:3306 -timeout 1m - run: - name: Database Setup + name: MySQL Setup command: | - bundle exec rake db:drop RAILS_ENV=test - bundle exec rake db:create RAILS_ENV=test - bundle exec rake db:schema:load RAILS_ENV=test - bundle exec rake db:migrate RAILS_ENV=test + export TEST_DB_ADAPTER=mysql2 + bundle exec rake db:drop RAILS_ENV=test TEST_DB_ADAPTER=mysql2 + bundle exec rake db:create RAILS_ENV=test TEST_DB_ADAPTER=mysql2 + bundle exec rake db:schema:load RAILS_ENV=test TEST_DB_ADAPTER=mysql2 + bundle exec rake db:migrate RAILS_ENV=test TEST_DB_ADAPTER=mysql2 + # Test using PostgreSQL - run: - name: Make Tmp Directory + name: PostgreSQL Setup command: | - sudo mkdir -p /data/tmp - sudo chmod 777 /data/tmp - sudo chown $USER:$USER /data/tmp + export TEST_DB_ADAPTER=postgresql + bundle exec rake db:drop RAILS_ENV=test TEST_DB_ADAPTER=postgresql + bundle exec rake db:create RAILS_ENV=test TEST_DB_ADAPTER=postgresql + bundle exec rake db:schema:load RAILS_ENV=test TEST_DB_ADAPTER=postgresql + bundle exec rake db:migrate RAILS_ENV=test TEST_DB_ADAPTER=postgresql - run: - name: Parallel RSpec - command: TEST_DB_ADAPTER=mysql2 bundle exec rspec spec/controllers/ + name: Parallel RSpec with PostgreSQL + command: TEST_DB_ADAPTER=postgresql bundle exec rspec spec/controllers/ From 85538f6a18eed33b8322e6e028f4321fea85714e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 2 Sep 2021 14:42:54 -0400 Subject: [PATCH 066/160] Added LQIP to Medium, fixed slugs id and some tests --- .coveralls.yml | 2 +- Gemfile | 5 ++-- Gemfile.lock | 3 +- app/controllers/v3/media_controller.rb | 28 ++++++------------- app/models/medium.rb | 14 ++++++++-- app/models/medium_base_record.rb | 2 ++ config/database.yml | 4 +-- .../20210902145305_add_widths_to_medium.rb | 5 ++++ db/migrate/20210902164843_fix_slug_id.rb | 6 ++++ db/schema.rb | 3 +- lib/snippets.rb | 20 +++++++++++++ spec/factories/media.rb | 4 +-- spec/rails_helper.rb | 10 ++++++- 13 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 db/migrate/20210902145305_add_widths_to_medium.rb create mode 100644 db/migrate/20210902164843_fix_slug_id.rb diff --git a/.coveralls.yml b/.coveralls.yml index 6e649991..7c01afa0 100644 --- a/.coveralls.yml +++ b/.coveralls.yml @@ -1 +1 @@ -service_name: travis-ci \ No newline at end of file +COVERALLS_REPO_TOKEN=rhljTPmfhPi03bd4p1lefUfztWDy83Vce \ No newline at end of file diff --git a/Gemfile b/Gemfile index f9b69ea6..4413a24d 100644 --- a/Gemfile +++ b/Gemfile @@ -16,7 +16,7 @@ gem 'mysql2' # Multitenancy for Rails and ActiveRecord gem 'ros-apartment', require: 'apartment' # For JSONAPI responses -gem 'active_model_serializers', '~> 0.10.0.rc3' +gem 'active_model_serializers', '~> 0.10.12' # Use Puma as the app server gem 'puma', '~> 4.3.0' # Use Redis adapter to run Action Cable in production @@ -80,8 +80,9 @@ group :test do gem 'factory_bot_rails' gem 'shoulda-matchers', '~> 4.5.1' #git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5' gem 'database_cleaner' - gem 'coveralls', require: false gem 'webmock' + gem 'coveralls', require: false + gem 'term-ansicolor' end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index 5e326d11..172a974b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -368,7 +368,7 @@ PLATFORMS DEPENDENCIES actionview (>= 5.2.2.1) - active_model_serializers (~> 0.10.0.rc3) + active_model_serializers (~> 0.10.12) aws-sdk-s3 (~> 1) cancancan (~> 2.0) capistrano-passenger @@ -401,6 +401,7 @@ DEPENDENCIES shoulda-matchers (~> 4.5.1) spring spring-watcher-listen (~> 2.0.0) + term-ansicolor tzinfo-data vimeo webmock diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index c678d02f..052dba85 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -22,35 +22,25 @@ def show if @record.published || current_user.id.present? render json: @record else - head 401 + render json: { data: { id: 0, type: 'media', attributes: { title: '....' } } } end end # POST /media def create - @record = Medium.new(record_params) + if allowed? + @record = Medium.new(record_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/media/#{@record.id}" - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # PATCH/PUT /media/1 - def update - if @record.update(record_params) - render json: @record + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/media/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + render json: {}, status: :unauthorized end end - # DELETE /media/1 - def destroy - @record.destroy - end - def file if @record&.file&.attached? if params[:context] == 'mobile' diff --git a/app/models/medium.rb b/app/models/medium.rb index 676b3a4a..4c502d5d 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -5,6 +5,7 @@ class Medium < MediumBaseRecord include VideoProps include Rails.application.routes.url_helpers before_create :props + before_save :add_widths before_update :replace_video # has_one_attached :file do |attachable| @@ -21,8 +22,6 @@ class Medium < MediumBaseRecord enum video_provider: { keiner: 0, vimeo: 1, youtube: 2, soundcloud: 3 } - # validates_presence_of :original_image - attr_accessor :insecure def props @@ -45,12 +44,14 @@ def files if file.content_type.include?('gif') height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] return { + lqip: file.variant(resize_to_limit: [50, 50], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, mobile: file.variant(resize_to_limit: [300, 300], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, tablet: file.variant(resize_to_limit: [400, 400], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, desktop: file.variant(resize_to_limit: [750, 750], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url } end { + lqip: file.variant(resize_to_limit: [50, 50]).processed.url, mobile: file.variant(resize_to_limit: [300, 300]).processed.url, tablet: file.variant(resize_to_limit: [400, 400]).processed.url, desktop: file.variant(resize_to_limit: [750, 750]).processed.url @@ -86,4 +87,13 @@ def replace_video attach_file end end + + def add_widths + return unless file.attached? + + self.lqip_width = MiniMagick::Image.open(files[:lqip])[:width] || 50 + self.mobile_width = MiniMagick::Image.open(files[:mobile])[:width] || 300 + self.tablet_width = MiniMagick::Image.open(files[:tablet])[:width] || 400 + self.desktop_width = MiniMagick::Image.open(files[:desktop])[:width] || 750 + end end diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index eb6b119d..0ed63ed6 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -6,6 +6,8 @@ class MediumBaseRecord < ApplicationRecord before_create :attach_file before_destroy :purge + validates_presence_of :filename + # has_one_attached "#{Apartment::Tenant.current.underscore}_file" has_one_attached 'file' diff --git a/config/database.yml b/config/database.yml index 03c621c8..d1b5d2bf 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,5 +1,5 @@ default: &default - adapter: <%= ENV['TEST_DB_ADAPTER'] || 'postgresql' %> + adapter: <%= ENV['DB_ADAPTER'] || 'postgresql' %> encoding: utf8 pool: 50 schema_search_path: "public,shared_extensions" @@ -32,4 +32,4 @@ staging: test: <<: *default - database: <%= ENV['TEST_DB_NAME'] %> + database: <%= ENV['TEST_DB_NAME'] || 'otb_test' %> diff --git a/db/migrate/20210902145305_add_widths_to_medium.rb b/db/migrate/20210902145305_add_widths_to_medium.rb new file mode 100644 index 00000000..f37112ea --- /dev/null +++ b/db/migrate/20210902145305_add_widths_to_medium.rb @@ -0,0 +1,5 @@ +class AddWidthsToMedium < ActiveRecord::Migration[6.1] + def change + add_column :media, :lqip_width, :integer + end +end diff --git a/db/migrate/20210902164843_fix_slug_id.rb b/db/migrate/20210902164843_fix_slug_id.rb new file mode 100644 index 00000000..8e117d92 --- /dev/null +++ b/db/migrate/20210902164843_fix_slug_id.rb @@ -0,0 +1,6 @@ +class FixSlugId < ActiveRecord::Migration[6.1] + def change + remove_column :slugs, :id + add_column :slugs, :id, :primary_key + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c0c421a..afad103e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_08_31_202533) do +ActiveRecord::Schema.define(version: 2021_09_02_164843) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -120,6 +120,7 @@ t.string "tablet" t.string "desktop" t.string "filename" + t.integer "lqip_width" end create_table "modes", force: :cascade do |t| diff --git a/lib/snippets.rb b/lib/snippets.rb index d1671127..8ef6fad4 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -179,4 +179,24 @@ m.save end end +end + +sites = TourSet.all.map(&:subdir) + +sites.each do |ts| + puts ts + Apartment::Tenant.switch! ts + Stop.all.each do |s| + if s.lat + s.update(lat: s.lat.round(7).to_f, lng: s.lng.round(7).to_f) + end + if s.parking_lat + s.update(parking_lat: s.parking_lat.round(7).to_f, parking_lng: s.parking_lng.round(7).to_f) + end + end +end + +Medium.all.each do |m| + next unless m.desktop_width.nil? + m.save end \ No newline at end of file diff --git a/spec/factories/media.rb b/spec/factories/media.rb index 4b81b8e7..6c4b44f6 100644 --- a/spec/factories/media.rb +++ b/spec/factories/media.rb @@ -5,8 +5,8 @@ factory :medium do title { Faker::TvShows::RickAndMorty.character } caption { Faker::TvShows::RickAndMorty.quote } - # original_image { Rack::Test::UploadedFile.new(Rails.root.join('spec', 'support', 'images', 'otblogo.png'), 'image/png') } - remote_original_image_url { Faker::Placeholdit.image } + filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } + base_sixty_four { File.read(Rails.root.join('spec/factories/base64_image.txt')) } created_at { Faker::Number.number(digits: 10) } end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b7a8c859..19b40b7d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -43,7 +43,9 @@ # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false # instead of true. - config.use_transactional_fixtures = true + if ENV['DB_ADAPTER'] == 'postgresql' + config.use_transactional_fixtures = true + end config.include RequestSpecHelper, type: :request config.include(RequestSpecHelper, type: :controller) @@ -120,6 +122,12 @@ status: 200 ) + stub_request(:get, /http:\/\/test\.host\/rails\/active_storage\/.*/) + .to_return( + body: File.open(Rails.root + 'spec/factories/images/0.jpg'), + status: 200 + ) + stub_request( :get, 'https://www.googleapis.com/youtube/v3/videos?id=F9ULbmCvmxY&key=AIzaSyAafrj3VvNLJNXeW5-NNCVwY5cdB06p1_s&part=snippet' From 9b0454ca0afaf7d67dcabc2877c400d5cc1dfca0 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 2 Sep 2021 14:43:26 -0400 Subject: [PATCH 067/160] Remove TourCollection --- .../v3/tour_collections_controller.rb | 50 ------- app/models/tour_collection.rb | 4 - .../v3/tour_collection_serializer.rb | 5 - .../v3/tour_collections_controller_spec.rb | 131 ------------------ spec/models/tour_collection_spec.rb | 7 - 5 files changed, 197 deletions(-) delete mode 100644 app/controllers/v3/tour_collections_controller.rb delete mode 100644 app/models/tour_collection.rb delete mode 100644 app/serializers/v3/tour_collection_serializer.rb delete mode 100644 spec/controllers/v3/tour_collections_controller_spec.rb delete mode 100644 spec/models/tour_collection_spec.rb diff --git a/app/controllers/v3/tour_collections_controller.rb b/app/controllers/v3/tour_collections_controller.rb deleted file mode 100644 index 4c33af4f..00000000 --- a/app/controllers/v3/tour_collections_controller.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - - class V3::TourCollectionsController < V3Controller - # GET /v3/tour_collections - def index - @records = TourCollection.all - - render json: @records - end - - # GET /v3/tour_collections/1 - def show - render json: @record - end - - # POST /v3/tour_collections - def create - @record = TourCollection.new(tour_collection_params) - - if @record.save - render json: @record, status: :created, location: @record - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # PATCH/PUT /v3/tour_collections/1 - def update - if @record.update(tour_collection_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # DELETE /v3/tour_collections/1 - def destroy - @record.destroy - end - - private - # Only allow a trusted parameter "white list" through. - def tour_collection_params - params.fetch(:tour_collection, {}) - end - - def set_record - @record = TourCollection.find(params[:id]) - end - end diff --git a/app/models/tour_collection.rb b/app/models/tour_collection.rb deleted file mode 100644 index 17092ecc..00000000 --- a/app/models/tour_collection.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class TourCollection < ApplicationRecord -end diff --git a/app/serializers/v3/tour_collection_serializer.rb b/app/serializers/v3/tour_collection_serializer.rb deleted file mode 100644 index ddf56b81..00000000 --- a/app/serializers/v3/tour_collection_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class V3::TourCollectionSerializer < ActiveModel::Serializer - attributes :id -end diff --git a/spec/controllers/v3/tour_collections_controller_spec.rb b/spec/controllers/v3/tour_collections_controller_spec.rb deleted file mode 100644 index 6d53c22a..00000000 --- a/spec/controllers/v3/tour_collections_controller_spec.rb +++ /dev/null @@ -1,131 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - -RSpec.describe V3::TourCollectionsController, type: :controller do - - # This should return the minimal set of attributes required to create a valid - # TourCollection. As you add validations to TourCollection, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') - } - - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # TourCollectionsController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe 'GET #index' do - it 'returns a success response' do - tour_collection = TourCollection.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end - - describe 'GET #show' do - it 'returns a success response' do - tour_collection = TourCollection.create! valid_attributes - get :show, params: { id: tour_collection.to_param }, session: valid_session - expect(response).to be_success - end - end - - describe 'POST #create' do - context 'with valid params' do - it 'creates a new TourCollection' do - expect { - post :create, params: { v3_tour_collection: valid_attributes }, session: valid_session - }.to change(TourCollection, :count).by(1) - end - - it 'renders a JSON response with the new v3_tour_collection' do - - post :create, params: { v3_tour_collection: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(v3_tour_collection_url(TourCollection.last)) - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the new v3_tour_collection' do - - post :create, params: { v3_tour_collection: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested v3_tour_collection' do - tour_collection = TourCollection.create! valid_attributes - put :update, params: { id: tour_collection.to_param, v3_tour_collection: new_attributes }, session: valid_session - tour_collection.reload - skip('Add assertions for updated state') - end - - it 'renders a JSON response with the v3_tour_collection' do - tour_collection = TourCollection.create! valid_attributes - - put :update, params: { id: tour_collection.to_param, v3_tour_collection: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the v3_tour_collection' do - tour_collection = TourCollection.create! valid_attributes - - put :update, params: { id: tour_collection.to_param, v3_tour_collection: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'DELETE #destroy' do - it 'destroys the requested v3_tour_collection' do - tour_collection = TourCollection.create! valid_attributes - expect { - delete :destroy, params: { id: tour_collection.to_param }, session: valid_session - }.to change(TourCollection, :count).by(-1) - end - end - -end diff --git a/spec/models/tour_collection_spec.rb b/spec/models/tour_collection_spec.rb deleted file mode 100644 index 7edfa7d6..00000000 --- a/spec/models/tour_collection_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe TourCollection, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end From 26df16ce4b398b492d1816291dc602764045d7c6 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 2 Sep 2021 14:43:47 -0400 Subject: [PATCH 068/160] Minor fixes --- .circleci/config.yml | 22 +- app/controllers/application_controller.rb | 3 + app/controllers/v3_controller.rb | 15 +- app/serializers/v3/medium_serializer.rb | 6 +- .../v3/flat_pages_controller_spec.rb | 3 +- spec/controllers/v3/media_controller_spec.rb | 372 +++++++++++++----- spec/controllers/v3/stops_controller_spec.rb | 3 +- 7 files changed, 312 insertions(+), 112 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5c9ad4a9..7d8ef234 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -61,22 +61,22 @@ jobs: - run: name: MySQL Setup command: | - export TEST_DB_ADAPTER=mysql2 - bundle exec rake db:drop RAILS_ENV=test TEST_DB_ADAPTER=mysql2 - bundle exec rake db:create RAILS_ENV=test TEST_DB_ADAPTER=mysql2 - bundle exec rake db:schema:load RAILS_ENV=test TEST_DB_ADAPTER=mysql2 - bundle exec rake db:migrate RAILS_ENV=test TEST_DB_ADAPTER=mysql2 + export DB_ADAPTER=mysql2 + bundle exec rake db:drop RAILS_ENV=test DB_ADAPTER=mysql2 + bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=mysql2 + bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=mysql2 + bundle exec rake db:migrate RAILS_ENV=test DB_ADAPTER=mysql2 # Test using PostgreSQL - run: name: PostgreSQL Setup command: | - export TEST_DB_ADAPTER=postgresql - bundle exec rake db:drop RAILS_ENV=test TEST_DB_ADAPTER=postgresql - bundle exec rake db:create RAILS_ENV=test TEST_DB_ADAPTER=postgresql - bundle exec rake db:schema:load RAILS_ENV=test TEST_DB_ADAPTER=postgresql - bundle exec rake db:migrate RAILS_ENV=test TEST_DB_ADAPTER=postgresql + export DB_ADAPTER=postgresql + bundle exec rake db:drop RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:migrate RAILS_ENV=test DB_ADAPTER=postgresql - run: name: Parallel RSpec with PostgreSQL - command: TEST_DB_ADAPTER=postgresql bundle exec rspec spec/controllers/ + command: DB_ADAPTER=postgresql bundle exec rspec spec/controllers/ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f380099e..8a14772f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,4 +5,7 @@ class ApplicationController < ActionController::API include Response include ExceptionHandler include EcdsRailsAuthEngine::CurrentUser + if Rails.env == 'test' + include ActiveStorage::SetCurrent + end end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index 0500ba38..fd1bb817 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -5,6 +5,19 @@ class V3Controller < ApplicationController before_action :allowed?, only: [:show, :create, :update, :destroy] before_action :set_record, only: [:show, :update, :destroy] + # PATCH/PUT /media/1 + def update + if @allowed + if @record.update(record_params) + render json: @record + else + render json: serialize_errors, status: :unprocessable_entity + end + else + render json: {}, status: :unauthorized + end + end + def destroy if @allowed @record.destroy @@ -35,6 +48,6 @@ def serialize_errors private def allowed? - @allowed = current_user && current_user.current_tenant_admin? + @allowed = current_user&.current_tenant_admin? || current_user.tours.present? end end diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index a4ad2a80..cd338b2d 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -15,7 +15,11 @@ class V3::MediumSerializer < ActiveModel::Serializer :files, :orphaned, :filename, - :original_image_url + :original_image_url, + :lqip_width, + :mobile_width, + :tablet_width, + :desktop_width # def files # return nil unless object.file.attached? diff --git a/spec/controllers/v3/flat_pages_controller_spec.rb b/spec/controllers/v3/flat_pages_controller_spec.rb index f5fc56b3..2639accb 100644 --- a/spec/controllers/v3/flat_pages_controller_spec.rb +++ b/spec/controllers/v3/flat_pages_controller_spec.rb @@ -5,8 +5,7 @@ it 'returns a 200 response with flat_pages connected to published tours' do create_list(:tour_with_flat_pages, 5, theme: create(:theme), mode: create(:mode)) Tour.first.update(published: true) if Tour.published.empty? - Tour.last.update(published: false) if Tour.published.count == Tour.count - Tour.last.flat_pages.drop(0) if Tour.last.flat_pages.count > 1 + Tour.last.update(published: false) get :index, params: { tenant: Apartment::Tenant.current } expect(response.status).to eq(200) expect(Tour.count).to be > Tour.published.count diff --git a/spec/controllers/v3/media_controller_spec.rb b/spec/controllers/v3/media_controller_spec.rb index 8c370f78..a5ec0900 100644 --- a/spec/controllers/v3/media_controller_spec.rb +++ b/spec/controllers/v3/media_controller_spec.rb @@ -2,130 +2,310 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::MediaController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Medium. As you add validations to Medium, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') + let(:valid_params) { + { + data: { + type: 'media', + attributes: { + base_sixty_four: File.read(Rails.root.join('spec/factories/base64_image.txt')), + filename: Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') + } + }, + tenant: Apartment::Tenant.current + } } - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } + if ENV['DB_ADAPTER'] == 'mysql2' + skip('Fix this spec for MySQL. Something to do with it being transactional') + else + describe 'GET #index' do - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # MediaController. Be sure to keep this updated too. - let(:valid_session) { {} } + it 'returns a success response' do + create_list(:medium, 5) + tour = create(:tour, published: true) + Medium.all.each { |m| tour.media << m } + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(tour.media.count) + end - describe 'GET #index' do - it 'returns a success response' do - medium = Medium.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end + it 'returns only media associated with a public tour' do + published_tour = create(:tour, published: true) + create_list(:medium, rand(1..8)).each { |m| published_tour.media << m } + unpublished_tour = create(:tour, published: false) + create_list(:medium, rand(1..8)).each { |m| unpublished_tour.media << m } + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq(Tour.published.map { |t| t.media.count }.sum) + expect(json.count).to be < Medium.count + end - describe 'GET #show' do - it 'returns a success response' do - medium = Medium.create! valid_attributes - get :show, params: { id: medium.to_param }, session: valid_session - expect(response).to be_success + it 'returns all media when requested by tenant admin' do + published_tour = create(:tour, published: true) + create_list(:medium, rand(1..8)).each { |m| published_tour.media << m } + unpublished_tour = create(:tour, published: false) + create_list(:medium, rand(1..8)).each { |m| unpublished_tour.media << m } + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(Medium.count) + expect(Medium.count).to be > Tour.published.map { |t| t.media.count }.sum + end end - end - describe 'POST #create' do - context 'with valid params' do - it 'creates a new Medium' do - expect { - post :create, params: { medium: valid_attributes }, session: valid_session - }.to change(Medium, :count).by(1) + describe 'GET #show' do + it 'returns 401 when medium is not published by a tour or stop' do + medium = create(:medium) + get :show, params: { tenant: Apartment::Tenant.current, id: medium.id } + expect(response.status).to eq(200) + expect(medium.published).to be false + expect(attributes[:title]).to eq('....') end - it 'renders a JSON response with the new medium' do + it 'returns the medium when associated with published stop' do + tour = create(:tour, published: true) + stop = create(:stop) + medium = create(:medium) + tour.stops << stop + stop.media << medium + get :show, params: { tenant: Apartment::Tenant.current, id: medium.id } + expect(medium.published).to be true + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(medium.title) + end - post :create, params: { medium: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(medium_url(Medium.last)) + it 'returns the medium when unpublished but requested is authorized' do + medium = create(:medium) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: medium.id } + expect(medium.published).to be false + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(medium.title) end end - context 'with invalid params' do - it 'renders a JSON response with errors for the new medium' do + describe 'POST #create' do + context 'with valid params and request is authorized' do + it 'creates a new Medium' do + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(Medium, :count).by(1) + end + + it 'renders a JSON response with the new medium when super' do + user = create(:user, super: true) + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(:created) + expect(response.content_type).to eq('application/json; charset=utf-8') + expect(json[:id]).to eq(Medium.last.id.to_s) + end - post :create, params: { medium: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'renders a JSON response with the new medium when tenant admin' do + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(:created) + expect(response.content_type).to eq('application/json; charset=utf-8') + expect(json[:id]).to eq(Medium.last.id.to_s) + end + + it 'renders a JSON response with the new medium when tour author' do + user = create(:user) + tour = create(:tour) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(:created) + expect(response.content_type).to eq('application/json; charset=utf-8') + expect(json[:id]).to eq(Medium.last.id.to_s) + end end - end - end - describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested medium' do - medium = Medium.create! valid_attributes - put :update, params: { id: medium.to_param, medium: new_attributes }, session: valid_session - medium.reload - skip('Add assertions for updated state') + context 'with invalid params' do + it 'renders a JSON response with errors for the new medium' do + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + post :create, params: { medium: 'invalid_params', tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq('application/json; charset=utf-8') + end end - it 'renders a JSON response with the medium' do - medium = Medium.create! valid_attributes + context 'with unauthenticated request' do + it 'responds unauthorized when unauthenticated' do + post :create, params: valid_params + expect(response).to have_http_status(:unauthorized) + end - put :update, params: { id: medium.to_param, medium: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + it 'responds unauthorized when authenticated but not authorized' do + initial_tenant = Apartment::Tenant.current + user = create(:user) + user.tour_sets << create(:tour_set) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + post :create, params: valid_params + expect(response).to have_http_status(:unauthorized) + end end end - context 'with invalid params' do - it 'renders a JSON response with errors for the medium' do - medium = Medium.create! valid_attributes + describe 'PUT #update' do + context 'with valid params and request is authorized' do + + it 'renders a JSON response with the new medium when super' do + medium = create(:medium) + update_params = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::MediumSerializer.new(medium)).to_json).with_indifferent_access + update_params[:tenant] = Apartment::Tenant.current + update_params[:id] = update_params[:data][:id] + user = create(:user, super: true) + initial_title = update_params[:data][:attributes][:title] + update_params[:data][:attributes][:title] = Faker::Movies::HitchhikersGuideToTheGalaxy.location + signed_cookie(user) + post :update, params: update_params + expect(response).to have_http_status(:ok) + expect(attributes[:title]).not_to eq(initial_title) + end + + it 'renders a JSON response with the new medium when tenant admin' do + medium = create(:medium) + update_params = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::MediumSerializer.new(medium)).to_json).with_indifferent_access + update_params[:tenant] = Apartment::Tenant.current + update_params[:id] = update_params[:data][:id] + user = create(:user, super: false) + user.tours = [] + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + initial_title = update_params[:data][:attributes][:title] + update_params[:data][:attributes][:title] = Faker::Movies::HitchhikersGuideToTheGalaxy.location + signed_cookie(user) + post :update, params: update_params + expect(response).to have_http_status(:ok) + expect(attributes[:title]).not_to eq(initial_title) + end - put :update, params: { id: medium.to_param, medium: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'renders a JSON response with the new medium when tour author' do + medium = create(:medium) + update_params = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::MediumSerializer.new(medium)).to_json).with_indifferent_access + update_params[:tenant] = Apartment::Tenant.current + update_params[:id] = update_params[:data][:id] + user = create(:user, super: false) + user.tour_sets = [] + user.tours << create(:tour) + initial_title = update_params[:data][:attributes][:title] + update_params[:data][:attributes][:title] = Faker::Movies::HitchhikersGuideToTheGalaxy.location + signed_cookie(user) + post :update, params: update_params + expect(response).to have_http_status(:ok) + expect(attributes[:title]).not_to eq(initial_title) + end + end + + context 'with invalid params' do + it 'renders a JSON response with errors for the new medium' do + user = create(:user, super: true) + signed_cookie(user) + post :update, params: { id: create(:medium).id, data: { type: 'medium', attributes: { filename: nil } }, tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.content_type).to eq('application/json; charset=utf-8') + end + end + + context 'with unauthenticated request' do + it 'responds unauthorized when unauthenticated' do + medium = create(:medium) + update_params = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::MediumSerializer.new(medium)).to_json).with_indifferent_access + update_params[:tenant] = Apartment::Tenant.current + update_params[:id] = update_params[:data][:id] + post :update, params: update_params + expect(response).to have_http_status(:unauthorized) + end + + it 'responds unauthorized when authenticated but not authorized' do + initial_tenant = Apartment::Tenant.current + medium = create(:medium) + update_params = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::MediumSerializer.new(medium)).to_json).with_indifferent_access + update_params[:tenant] = Apartment::Tenant.current + update_params[:id] = update_params[:data][:id] + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + post :update, params: update_params + expect(response).to have_http_status(:unauthorized) + end end end - end - describe 'DELETE #destroy' do - it 'destroys the requested medium' do - medium = Medium.create! valid_attributes - expect { - delete :destroy, params: { id: medium.to_param }, session: valid_session - }.to change(Medium, :count).by(-1) + describe 'DELETE #destroy' do + context 'authenticated and authorized' do + it 'destroys the requested medium when request from super' do + medium = create(:medium) + user = create(:user, super: true) + signed_cookie(user) + expect { + delete :destroy, params: { id: medium.to_param, tenant: Apartment::Tenant.current } + }.to change(Medium, :count).by(-1) + end + + it 'destroys the requested medium when request from tenant admin' do + medium = create(:medium) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + expect { + delete :destroy, params: { id: medium.to_param, tenant: Apartment::Tenant.current } + }.to change(Medium, :count).by(-1) + end + + it 'destroys the requested medium when request from tour author' do + medium = create(:medium) + user = create(:user, super: false) + user.tour_sets = [] + user.tours << create(:tour) + signed_cookie(user) + expect { + delete :destroy, params: { id: medium.to_param, tenant: Apartment::Tenant.current } + }.to change(Medium, :count).by(-1) + end + end + + context 'authenticated but not authorized' do + it 'returns unauthorized' do + initial_tenant = Apartment::Tenant.current + medium = create(:medium) + user = create(:user, super: false) + Apartment::Tenant.reset + tour_set = create(:tour_set) + user.tour_sets << tour_set + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + initial_media_count = Medium.count + delete :destroy, params: { id: medium.to_param, tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:unauthorized) + expect(Medium.count).to eq(initial_media_count) + end + end + + context 'unauthenticated' do + it 'returns unauthorized' do + medium = create(:medium) + initial_media_count = Medium.count + delete :destroy, params: { id: medium.to_param, tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:unauthorized) + expect(Medium.count).to eq(initial_media_count) + end + end end end - end diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index a98db52c..aa195832 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -239,7 +239,8 @@ end it 'return 200 and updated tour when authenciated by super' do - create(:tour) + tour = create(:tour) + tour.stops << create_list(:stop, 4) user = create(:user) user.tour_sets = [] user.update(super: true) From 674956a93f6fc398b7486fcd0721cbc587c32022 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 2 Sep 2021 15:01:00 -0400 Subject: [PATCH 069/160] Remove Coveralls file --- .coveralls.yml | 1 - .gitignore | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 .coveralls.yml diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 7c01afa0..00000000 --- a/.coveralls.yml +++ /dev/null @@ -1 +0,0 @@ -COVERALLS_REPO_TOKEN=rhljTPmfhPi03bd4p1lefUfztWDy83Vce \ No newline at end of file diff --git a/.gitignore b/.gitignore index beb7d5ab..8930b17f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ public/storage/* .vscode *.env* .bundle - +.coveralls.yml /config/master.key storage From 8b2bac476d3600ad619474edf8fbea9767f13674 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 2 Sep 2021 19:09:14 -0400 Subject: [PATCH 070/160] Update tests --- app/controllers/v3/media_controller.rb | 16 ---------- app/controllers/v3/stops_controller.rb | 13 -------- app/models/concerns/video_props.rb | 17 +++++----- app/models/stop.rb | 8 ++--- config/routes.rb | 4 +-- {spec => old_spcs}/requests/map_icons_spec.rb | 0 .../requests/tour_authors_spec.fix | 0 .../requests/v3/abalities_spec.rb.fix | 0 .../requests/v3/flat_pages_spec.rb | 0 .../requests/v3/map_overlays_request_spec.rb | 0 .../requests/v3/media_spec.rb.fix | 0 {spec => old_spcs}/requests/v3/modes_spec.rb | 0 .../requests/v3/stop_media_spec.rb.fix | 0 {spec => old_spcs}/requests/v3/stops_spec.rb | 0 {spec => old_spcs}/requests/v3/themes_spec.rb | 0 .../requests/v3/tour_set_users_spec.rb | 0 .../requests/v3/tour_sets_spec.rb.fix | 0 .../requests/v3/tour_stops_spec.rb | 0 {spec => old_spcs}/requests/v3/tours_spec.rb | 0 {spec => old_spcs}/requests/v3/users_spec.fix | 0 spec/controllers/v3/stops_controller_spec.rb | 4 +-- spec/factories/media.rb | 2 ++ spec/models/medium_spec.rb | 28 +++++++++++++++++ spec/rails_helper.rb | 31 +++++++++++++++++-- spec/spec_helper.rb | 7 +++++ 25 files changed, 78 insertions(+), 52 deletions(-) rename {spec => old_spcs}/requests/map_icons_spec.rb (100%) rename {spec => old_spcs}/requests/tour_authors_spec.fix (100%) rename {spec => old_spcs}/requests/v3/abalities_spec.rb.fix (100%) rename {spec => old_spcs}/requests/v3/flat_pages_spec.rb (100%) rename {spec => old_spcs}/requests/v3/map_overlays_request_spec.rb (100%) rename {spec => old_spcs}/requests/v3/media_spec.rb.fix (100%) rename {spec => old_spcs}/requests/v3/modes_spec.rb (100%) rename {spec => old_spcs}/requests/v3/stop_media_spec.rb.fix (100%) rename {spec => old_spcs}/requests/v3/stops_spec.rb (100%) rename {spec => old_spcs}/requests/v3/themes_spec.rb (100%) rename {spec => old_spcs}/requests/v3/tour_set_users_spec.rb (100%) rename {spec => old_spcs}/requests/v3/tour_sets_spec.rb.fix (100%) rename {spec => old_spcs}/requests/v3/tour_stops_spec.rb (100%) rename {spec => old_spcs}/requests/v3/tours_spec.rb (100%) rename {spec => old_spcs}/requests/v3/users_spec.fix (100%) diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 052dba85..24e76a8a 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -41,22 +41,6 @@ def create end end - def file - if @record&.file&.attached? - if params[:context] == 'mobile' - redirect_to @record.file.variant(resize: '300x300').processed.service_url - elsif params[:context] == 'tablet' - redirect_to @record.file.variant(resize: '400x400').processed.service_url - elsif params[:context] == 'desktop' - redirect_to @record.file.variant(resize: '750x750').processed.service_url - else - redirect_to @record.file.service_url - end - else - head :not_found - end - end - # Use callbacks to share common setup or constraints between actions. def set_record @record = Medium.find(params[:id]) diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index 721546e3..7386b967 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -26,8 +26,6 @@ def create @record = Stop.new(stop_params) if @record.save render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" - else - render json: serialize_errors, status: :unprocessable_entity end else head 401 @@ -39,9 +37,6 @@ def update if @allowed if @record&.update(stop_params) render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" - else - # status = - render json: serialize_errors, status: @record.nil? ? :not_found : :unprocessable_entity end else head 401 @@ -66,19 +61,11 @@ def stop_params # Use callbacks to share common setup or constraints between actions. - def set_tour - @tour = Tour.find(params[:tour_id]) - end - def set_record _record = Stop.find_by(id: params[:id]) @record = _record&.published || @allowed ? _record : Stop.new(id: params[:id]) end - def set_tour_stop - @record = @tour.stops.find_by!(id: params[:id]) if @tour - end - def allowed? @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } end diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index db032c91..56f38249 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -27,7 +27,6 @@ def self.props(medium) medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" downloaded_image = URI.open("https://img.youtube.com/vi/#{medium.video}/0.jpg") - rescue Yt::Errors::NoItems medium.provider = nil medium.video = nil @@ -48,20 +47,20 @@ def self.props(medium) spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] if image.nil? - medium.file.attach( - io: File.open(File.join(Rails.root, 'public', 'soundcloud.jpg')), - filename: "#{medium.title.parameterize}.jpg" - ) + downloaded_image = File.open(File.join(Rails.root, 'public', 'soundcloud.jpg')).read else - downloaded_image = open("https:#{image}") - medium.file.attach(io: downloaded_image, filename: "#{medium.title.parameterize}.jpg") + downloaded_image = URI.open("https:#{image}") end end end medium.filename = "#{medium.video}.jpg" - medium.base_sixty_four = Base64.encode64(downloaded_image.open.read) - downloaded_image.unlink + begin + medium.base_sixty_four = Base64.encode64(downloaded_image.open.read) + downloaded_image.unlink + rescue NoMethodError + medium.base_sixty_four = Base64.encode64(downloaded_image) + end medium.attach_file unless medium.file.attached? end end diff --git a/app/models/stop.rb b/app/models/stop.rb index 85fb3f68..a0db1e4c 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -12,18 +12,14 @@ class Stop < ApplicationRecord belongs_to :map_icon, optional: true has_many :stop_slugs, dependent: :delete_all + before_validation -> { self.title ||= 'untitled' } + validates :title, presence: true - # validates :title, uniqueness: true after_initialize :default_values before_create :ensure_icon_color after_save :ensure_slug - before_validation -> { self.title ||= 'untitled' } - - # scope :not_in_tour, lambda { |tour_id| includes(:tour_stops).where.not(tour_stops: { tour_id: tour_id }) } - # scope :no_tours, lambda { includes(:tour_stops).where(tour_stops: { tour_id: nil }) } - # scope :published, lambda { includes(:tours).where(tours: { published: true }) } scope :by_slug_and_tour, lambda { |slug, tour_id| joins(:stop_slugs).joins(:tours).where('stop_slugs.slug = ?', slug).where('tour_stops.tour_id = ?', tour_id) } def sanitized_description diff --git a/config/routes.rb b/config/routes.rb index 33d51e67..2e6062f1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,9 +28,7 @@ resources :map_icons, path: 'map-icons' resources :themes resources :tours - resources :media do - get :file, on: :member - end + resources :media resources :stops resources :stop_media, path: 'stop-media' resources :tour_media, path: 'tour-media' diff --git a/spec/requests/map_icons_spec.rb b/old_spcs/requests/map_icons_spec.rb similarity index 100% rename from spec/requests/map_icons_spec.rb rename to old_spcs/requests/map_icons_spec.rb diff --git a/spec/requests/tour_authors_spec.fix b/old_spcs/requests/tour_authors_spec.fix similarity index 100% rename from spec/requests/tour_authors_spec.fix rename to old_spcs/requests/tour_authors_spec.fix diff --git a/spec/requests/v3/abalities_spec.rb.fix b/old_spcs/requests/v3/abalities_spec.rb.fix similarity index 100% rename from spec/requests/v3/abalities_spec.rb.fix rename to old_spcs/requests/v3/abalities_spec.rb.fix diff --git a/spec/requests/v3/flat_pages_spec.rb b/old_spcs/requests/v3/flat_pages_spec.rb similarity index 100% rename from spec/requests/v3/flat_pages_spec.rb rename to old_spcs/requests/v3/flat_pages_spec.rb diff --git a/spec/requests/v3/map_overlays_request_spec.rb b/old_spcs/requests/v3/map_overlays_request_spec.rb similarity index 100% rename from spec/requests/v3/map_overlays_request_spec.rb rename to old_spcs/requests/v3/map_overlays_request_spec.rb diff --git a/spec/requests/v3/media_spec.rb.fix b/old_spcs/requests/v3/media_spec.rb.fix similarity index 100% rename from spec/requests/v3/media_spec.rb.fix rename to old_spcs/requests/v3/media_spec.rb.fix diff --git a/spec/requests/v3/modes_spec.rb b/old_spcs/requests/v3/modes_spec.rb similarity index 100% rename from spec/requests/v3/modes_spec.rb rename to old_spcs/requests/v3/modes_spec.rb diff --git a/spec/requests/v3/stop_media_spec.rb.fix b/old_spcs/requests/v3/stop_media_spec.rb.fix similarity index 100% rename from spec/requests/v3/stop_media_spec.rb.fix rename to old_spcs/requests/v3/stop_media_spec.rb.fix diff --git a/spec/requests/v3/stops_spec.rb b/old_spcs/requests/v3/stops_spec.rb similarity index 100% rename from spec/requests/v3/stops_spec.rb rename to old_spcs/requests/v3/stops_spec.rb diff --git a/spec/requests/v3/themes_spec.rb b/old_spcs/requests/v3/themes_spec.rb similarity index 100% rename from spec/requests/v3/themes_spec.rb rename to old_spcs/requests/v3/themes_spec.rb diff --git a/spec/requests/v3/tour_set_users_spec.rb b/old_spcs/requests/v3/tour_set_users_spec.rb similarity index 100% rename from spec/requests/v3/tour_set_users_spec.rb rename to old_spcs/requests/v3/tour_set_users_spec.rb diff --git a/spec/requests/v3/tour_sets_spec.rb.fix b/old_spcs/requests/v3/tour_sets_spec.rb.fix similarity index 100% rename from spec/requests/v3/tour_sets_spec.rb.fix rename to old_spcs/requests/v3/tour_sets_spec.rb.fix diff --git a/spec/requests/v3/tour_stops_spec.rb b/old_spcs/requests/v3/tour_stops_spec.rb similarity index 100% rename from spec/requests/v3/tour_stops_spec.rb rename to old_spcs/requests/v3/tour_stops_spec.rb diff --git a/spec/requests/v3/tours_spec.rb b/old_spcs/requests/v3/tours_spec.rb similarity index 100% rename from spec/requests/v3/tours_spec.rb rename to old_spcs/requests/v3/tours_spec.rb diff --git a/spec/requests/v3/users_spec.fix b/old_spcs/requests/v3/users_spec.fix similarity index 100% rename from spec/requests/v3/users_spec.fix rename to old_spcs/requests/v3/users_spec.fix diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index aa195832..723856a5 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -6,8 +6,8 @@ describe 'GET #index' do it 'returns a 200 response with stops connected to published tours' do create_list(:tour_with_stops, 5, theme: create(:theme), mode: create(:mode)) - Tour.first.update(published: true) if Tour.published.empty? - Tour.last.update(published: false) if Tour.published.count == Tour.count + Tour.first.update(published: true) + Tour.last.update(published: false) get :index, params: { tenant: Apartment::Tenant.current } expect(response.status).to eq(200) expect(Tour.count).to be > Tour.published.count diff --git a/spec/factories/media.rb b/spec/factories/media.rb index 6c4b44f6..0ac5aa8a 100644 --- a/spec/factories/media.rb +++ b/spec/factories/media.rb @@ -8,5 +8,7 @@ filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } base_sixty_four { File.read(Rails.root.join('spec/factories/base64_image.txt')) } created_at { Faker::Number.number(digits: 10) } + video { nil } + video_provider { nil } end end diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index 24651ce0..68c4d929 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -5,4 +5,32 @@ RSpec.describe Medium, type: :model do it { should have_many(:stop_media) } it { should have_many(:stops) } + + context 'video' do + it 'gets image from youtube and sets embed' do + medium = create(:medium, video: 'F9ULbmCvmxY', base_sixty_four: nil, video_provider: 'youtube') + expect(medium.embed).to eq("//www.youtube.com/embed/#{medium.video}") + expect(medium.file.attached?).to be true + end + + it 'gets image from vimeo and sets embed' do + medium = create(:medium, video: '310645255', base_sixty_four: nil, video_provider: 'vimeo') + expect(medium.embed).to eq("//player.vimeo.com/video/#{medium.video}") + expect(medium.file.attached?).to be true + end + + it 'gets image from soundcloud and sets embed' do + iframe = '

' + medium = create(:medium, video: iframe, base_sixty_four: nil, video_provider: 'soundcloud') + expect(medium.embed).to eq("//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false") + expect(medium.file.attached?).to be true + end + + it 'gets default image from when no image found for soundcloud and sets embed' do + iframe = '' + medium = create(:medium, video: iframe, base_sixty_four: nil, video_provider: 'soundcloud') + expect(medium.embed).to eq("//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false") + expect(medium.file.attached?).to be true + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 19b40b7d..e242ee70 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -83,8 +83,11 @@ Apartment::Tenant.switch! TourSet.find(TourSet.pluck(:id).sample).subdir # host! 'atlanta.lvh.me' # load Rails.root + 'db/seeds.rb' - stub_request(:any, 'https://placehold.it/300x300.png') - .to_return(body: File.open(Rails.root + 'spec/factories/images/300x300.png'), status: 200) + stub_request(:get, 'https://placehold.it/300x300.png_1000x1000') + .to_return( + body: File.open(Rails.root + 'spec/factories/images/0.jpg'), + status: 200 + ) stub_request(:get, 'https://vimeo.com/api/oembed.json?url=https://vimeo.com/310645255') .to_return( @@ -102,7 +105,7 @@ ) .to_return( status: 200, - body: '{ "title": "CycloramaBattleSites.org Stop 2", "thumbnail_url": "https://placehold.it/300x300.png" }', + body: '{ "title": "CycloramaBattleSites.org Stop 2", "thumbnail_url": "https://placehold.it/300x300.png", "thumbnail_width": 100, "thumbnail_height": 100 }', headers: { 'content-type': 'application/json' } ) @@ -150,6 +153,28 @@ stub_request(:get, 'https://vimeo.com/https://www.youtube.com/watch?v=F9ULbmCvmxY') .to_return(status: 404, body: '', headers: {}) + stub_request(:get, 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/431162745&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false') + .to_return( + status: 200, + body: '', + headers: {} + ) + + stub_request(:get, 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/296743143&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false') + .to_return( + status: 200, + body: '', + headers: {} + ) + + stub_request(:get, 'https://i1.sndcdn.com/artworks-KsTDkyGJ8S6x-0-t500x500.jpg') + .to_return( + body: File.open(Rails.root + 'spec/factories/images/0.jpg'), + status: 200, + headers: {} + ) + + stub_request(:get, /http:\/\/127\.0\.0\.1:.*\/json\/version/).to_return(body: '{}', status: 200) end config.after(:each) do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c770112a..7b4f2092 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,13 @@ require 'coveralls' Coveralls.wear! +require 'simplecov' +SimpleCov.formatter = Coveralls::SimpleCov::Formatter +SimpleCov.start do + add_filter 'spec' + add_filter 'db' +end + require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) WebMock.disable_net_connect!(allow: '45.33.24.119') From 4c109cc7841d0750b48b2273498b1e144f80586e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 3 Sep 2021 08:33:41 -0400 Subject: [PATCH 071/160] Handle Ferrum error in CircleCI --- .circleci/config.yml | 6 +++++- app/models/concerns/video_props.rb | 16 ++++++++++++---- spec/rails_helper.rb | 4 ++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7d8ef234..c43eb7d7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,6 +15,7 @@ jobs: DB_PASSWORD: password TEST_DB_NAME: otb MYSQL_ALLOW_EMPTY_PASSWORD: true + COVERALLS_REPO_TOKEN: 0fq3v88yZLVryFZQbFWqHw5l6zrbRkHNf - image: circleci/postgres:9.6.8-alpine-postgis environment: @@ -38,6 +39,9 @@ jobs: sudo apt update sudo apt install -y postgresql-client || true sudo apt install -y libvips-dev + sudo apt install -y libappindicator1 fonts-liberation libasound2 libgbm1 xdg-utils + wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo dpkg -i google-chrome*.deb bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 - run: @@ -79,4 +83,4 @@ jobs: - run: name: Parallel RSpec with PostgreSQL - command: DB_ADAPTER=postgresql bundle exec rspec spec/controllers/ + command: DB_ADAPTER=postgresql bundle exec rspec spec/ diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index 56f38249..4389056a 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -42,10 +42,18 @@ def self.props(medium) end medium.video = embed_code.xpath('//iframe', 'src').first['src'].split('&').first[/(.*tracks\/)(.*)/, 2] medium.embed = "//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false" - browser = Ferrum::Browser.new() - browser.go_to("https:#{medium.embed}") - spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? - image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] + # When testing, the requests made via Ferrum do not go through webmock and CircleCI does not seem to + # let requests out to the world. + begin + browser = Ferrum::Browser.new() + browser.go_to("https:#{medium.embed}") + spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? + image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] + rescue Ferrum::ProcessTimeoutError + html = Nokogiri::HTML(HTTParty.get("https:#{medium.embed}")) + style = html.xpath('//span[contains(@class, "sc-artwork")]/@style').first + image = style.value[/\((.*?)\)/, 1] + end if image.nil? downloaded_image = File.open(File.join(Rails.root, 'public', 'soundcloud.jpg')).read else diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index e242ee70..6dafc8ec 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -156,11 +156,11 @@ stub_request(:get, 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/431162745&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false') .to_return( status: 200, - body: '', + body: '', headers: {} ) - stub_request(:get, 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/296743143&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false') + stub_request(:get, 'https://w.soundcloud.com/player/?auto_play=false&color=%23ff5500&hide_related=true&sharing=false&show_comments=false&show_reposts=false&show_teaser=false&show_user=false&url=https://api.soundcloud.com/tracks/457871163&visual=true') .to_return( status: 200, body: '', From 7f0a3adc3736cb6276e55a46585bef3985463bc9 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 3 Sep 2021 09:08:38 -0400 Subject: [PATCH 072/160] Fix SoundCloud test --- app/models/concerns/video_props.rb | 4 +++- spec/rails_helper.rb | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index 4389056a..9d045f8a 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -44,13 +44,15 @@ def self.props(medium) medium.embed = "//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false" # When testing, the requests made via Ferrum do not go through webmock and CircleCI does not seem to # let requests out to the world. + # We have to use Ferrum (headless Chrome) because the SoundCloud embeds are generated by JavaScript. begin + browser_options = RAILS_ENV == 'test' ? browser = Ferrum::Browser.new() browser.go_to("https:#{medium.embed}") spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? image = spans.attribute('style')[/(.*\()(.*)(\).*)/, 2] rescue Ferrum::ProcessTimeoutError - html = Nokogiri::HTML(HTTParty.get("https:#{medium.embed}")) + html = Nokogiri::HTML(HTTParty.get("https:#{medium.embed}").body) style = html.xpath('//span[contains(@class, "sc-artwork")]/@style').first image = style.value[/\((.*?)\)/, 1] end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6dafc8ec..3aa0573c 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -163,7 +163,7 @@ stub_request(:get, 'https://w.soundcloud.com/player/?auto_play=false&color=%23ff5500&hide_related=true&sharing=false&show_comments=false&show_reposts=false&show_teaser=false&show_user=false&url=https://api.soundcloud.com/tracks/457871163&visual=true') .to_return( status: 200, - body: '', + body: '
', headers: {} ) From b8e8024563ca7fbd3a0479e28dc0559da6ae57e4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 3 Sep 2021 10:25:36 -0400 Subject: [PATCH 073/160] Test YouTube not found --- app/models/concerns/video_props.rb | 13 ++++++------- spec/factories/media.rb | 2 +- spec/models/medium_spec.rb | 11 ++++++++++- spec/rails_helper.rb | 8 ++++++-- spec/support/database_cleaner.rb | 2 +- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index 9d045f8a..1025d85d 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -23,6 +23,7 @@ def self.props(medium) when 'youtube' begin metadata = Yt::Video.new(id: medium.video) + puts metadata medium.title = metadata.title medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" @@ -30,23 +31,18 @@ def self.props(medium) rescue Yt::Errors::NoItems medium.provider = nil medium.video = nil + return end when 'soundcloud' if medium.video.include?('iframe') embed_code = Nokogiri::HTML(medium.video) - titles = embed_code.xpath('//a').map { |a| a[:title] } - if titles.length > 1 - medium.title = titles.join(': ') - else - medium.title = titles.first - end + medium.title = embed_code.xpath('//a').map { |a| a[:title] }.compact.join(': ') medium.video = embed_code.xpath('//iframe', 'src').first['src'].split('&').first[/(.*tracks\/)(.*)/, 2] medium.embed = "//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false" # When testing, the requests made via Ferrum do not go through webmock and CircleCI does not seem to # let requests out to the world. # We have to use Ferrum (headless Chrome) because the SoundCloud embeds are generated by JavaScript. begin - browser_options = RAILS_ENV == 'test' ? browser = Ferrum::Browser.new() browser.go_to("https:#{medium.embed}") spans = browser.at_xpath('//span[contains(@class, "sc-artwork")]') until spans.present? @@ -64,6 +60,9 @@ def self.props(medium) end end + + return if downloaded_image.nil? + medium.filename = "#{medium.video}.jpg" begin medium.base_sixty_four = Base64.encode64(downloaded_image.open.read) diff --git a/spec/factories/media.rb b/spec/factories/media.rb index 0ac5aa8a..448782bc 100644 --- a/spec/factories/media.rb +++ b/spec/factories/media.rb @@ -8,7 +8,7 @@ filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } base_sixty_four { File.read(Rails.root.join('spec/factories/base64_image.txt')) } created_at { Faker::Number.number(digits: 10) } - video { nil } + video { 'keiner' } video_provider { nil } end end diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index 68c4d929..a699ae7e 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -13,6 +13,13 @@ expect(medium.file.attached?).to be true end + it 'gets nothing when YouTube video is not found' do + medium = create(:medium, video: 'CvmxYF9ULbm', base_sixty_four: nil, video_provider: 'youtube') + expect(medium.embed).to be nil + expect(medium.provider).to be nil + expect(medium.file.attached?).to be false + end + it 'gets image from vimeo and sets embed' do medium = create(:medium, video: '310645255', base_sixty_four: nil, video_provider: 'vimeo') expect(medium.embed).to eq("//player.vimeo.com/video/#{medium.video}") @@ -24,13 +31,15 @@ medium = create(:medium, video: iframe, base_sixty_four: nil, video_provider: 'soundcloud') expect(medium.embed).to eq("//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false") expect(medium.file.attached?).to be true + expect(medium.title).to eq('FiendBassy: Boca Raton (with A$AP Ferg)') end it 'gets default image from when no image found for soundcloud and sets embed' do - iframe = '' + iframe = '' medium = create(:medium, video: iframe, base_sixty_four: nil, video_provider: 'soundcloud') expect(medium.embed).to eq("//w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/#{medium.video}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false") expect(medium.file.attached?).to be true + expect(medium.title).to eq('Emory Center for Digital Scholarship') end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3aa0573c..75409dd5 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -150,8 +150,12 @@ stub_request(:get, 'https://vimeo.com/https://youtu.be/F9ULbmCvmxY') .to_return(status: 404, body: '', headers: {}) - stub_request(:get, 'https://vimeo.com/https://www.youtube.com/watch?v=F9ULbmCvmxY') - .to_return(status: 404, body: '', headers: {}) + stub_request(:get, 'https://www.googleapis.com/youtube/v3/videos?id=CvmxYF9ULbm&key=AIzaSyAafrj3VvNLJNXeW5-NNCVwY5cdB06p1_s&part=snippet') + .to_return( + status: 200, + body: '{"kind": "youtube#videoListResponse", "etag": "YIUPVpqNjppyCWOZfL-19bLb7uk", "items": [ ], "pageInfo": { "totalResults": 0, "resultsPerPage": 0 } }', + headers: { 'content-type': 'application/json' } + ) stub_request(:get, 'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/431162745&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&show_teaser=false&visual=true&sharing=false') .to_return( diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 4df8a28c..65d31433 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -18,4 +18,4 @@ config.after(:each) do DatabaseCleaner.clean end -end \ No newline at end of file +end From e9a7745e86a26dbc4ccae6148cd2d6d84e932eb2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 3 Sep 2021 12:07:43 -0400 Subject: [PATCH 074/160] Clean up --- Gemfile | 1 + Gemfile.lock | 1 + app/models/concerns/video_props.rb | 1 - spec/spec_helper.rb | 20 +++++++++++++------- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 4413a24d..20bc6601 100644 --- a/Gemfile +++ b/Gemfile @@ -82,6 +82,7 @@ group :test do gem 'database_cleaner' gem 'webmock' gem 'coveralls', require: false + gem 'simplecov', require: false gem 'term-ansicolor' end diff --git a/Gemfile.lock b/Gemfile.lock index 172a974b..733de046 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -399,6 +399,7 @@ DEPENDENCIES ros-apartment rspec-rails (~> 4.0.2) shoulda-matchers (~> 4.5.1) + simplecov spring spring-watcher-listen (~> 2.0.0) term-ansicolor diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index 1025d85d..85483b40 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -23,7 +23,6 @@ def self.props(medium) when 'youtube' begin metadata = Yt::Video.new(id: medium.video) - puts metadata medium.title = metadata.title medium.caption = metadata.description medium.embed = "//www.youtube.com/embed/#{medium.video}" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7b4f2092..2a28128c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,14 +1,20 @@ # frozen_string_literal: true -require 'coveralls' -Coveralls.wear! +if ENV['COV'] == 'simple' + require 'simplecov' + SimpleCov.start do + add_filter '/config/' + add_filter '/spec/' + add_filter '/db/' + add_filter '/bin/' + end +else + require 'coveralls' + Coveralls.wear! +end require 'simplecov' -SimpleCov.formatter = Coveralls::SimpleCov::Formatter -SimpleCov.start do - add_filter 'spec' - add_filter 'db' -end +# SimpleCov.start require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) From d7eb1df7540ecb1952c7f7d29abe588f56649dc9 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 8 Sep 2021 15:38:59 -0400 Subject: [PATCH 075/160] Increase test coverage --- app/controllers/concerns/exception_handler.rb | 6 +- .../v3/geojson_tours_controller.rb | 6 +- app/controllers/v3/modes_controller.rb | 7 +- app/controllers/v3/stops_controller.rb | 28 +- app/controllers/v3/themes_controller.rb | 24 +- app/controllers/v3/tour_media_controller.rb | 47 +-- app/controllers/v3/tour_modes_controller.rb | 22 +- .../v3/tour_relations_controller.rb | 16 + .../v3/tour_set_admins_controller.rb | 42 +-- app/controllers/v3/tour_sets_controller.rb | 63 ++-- app/controllers/v3/tours_controller.rb | 17 +- app/controllers/v3_controller.rb | 22 +- app/models/medium.rb | 49 +-- app/models/medium_base_record.rb | 8 +- app/models/stop.rb | 8 - app/models/tour.rb | 8 - app/models/tour_medium.rb | 4 + app/models/tour_set.rb | 10 +- app/serializers/v3/medium_serializer.rb | 3 - app/serializers/v3/stop_serializer.rb | 3 - app/serializers/v3/tour_base_serializer.rb | 1 - app/serializers/v3/tour_set_serializer.rb | 7 +- config/database.yml | 12 +- config/environments/mysql.rb | 1 + config/environments/test.rb | 6 + config/routes.rb | 2 +- db/schema.rb | 61 ++-- old_spcs/requests/v3/tours_spec.rb | 2 - spec/controllers/v3/media_controller_spec.rb | 13 + spec/controllers/v3/modes_controller_spec.rb | 123 +------ spec/controllers/v3/stops_controller_spec.rb | 19 +- spec/controllers/v3/themes_controller_spec.rb | 90 +---- .../v3/tour_geojson_controller_spec.rb | 19 + .../v3/tour_media_controller_spec.rb | 261 +++++++++---- .../v3/tour_set_admins_controller_spec.rb | 81 +++++ .../v3/tour_sets_controller_spec.rb | 342 +++++++++++++----- spec/controllers/v3/tours_controller_spec.rb | 8 +- spec/factories/distance_matrix.json | 42 +++ spec/factories/distance_matrix_zero.json | 16 + spec/factories/images/0.jpg | Bin 44558 -> 44904 bytes spec/factories/images/atl.png | Bin 0 -> 151712 bytes spec/factories/images/atl_base64.txt | 1 + spec/factories/images/gif_base64.txt | 1 + spec/factories/images/giphy.gif | Bin 0 -> 61259 bytes spec/factories/images/icon_base64.txt | 1 + spec/factories/images/mapicon.png | Bin 0 -> 1021 bytes spec/factories/images/png_base64.txt | 1 + spec/factories/map_icons.rb | 10 + spec/factories/tour_media.rb | 10 + spec/factories/tour_sets.rb | 2 + spec/factories/tours.rb | 2 + spec/models/map_icon_spec.rb | 10 +- spec/models/medium_spec.rb | 35 ++ spec/models/stop_spec.rb | 13 + spec/models/tour_set_spec.rb | 6 + spec/models/tour_spec.rb | 15 + spec/rails_helper.rb | 23 +- spec/spec_helper.rb | 1 + spec/support/database_cleaner.rb | 8 +- 59 files changed, 1029 insertions(+), 609 deletions(-) create mode 100644 app/controllers/v3/tour_relations_controller.rb create mode 120000 config/environments/mysql.rb create mode 100644 spec/controllers/v3/tour_set_admins_controller_spec.rb create mode 100644 spec/factories/distance_matrix.json create mode 100644 spec/factories/distance_matrix_zero.json create mode 100644 spec/factories/images/atl.png create mode 100644 spec/factories/images/atl_base64.txt create mode 100644 spec/factories/images/gif_base64.txt create mode 100644 spec/factories/images/giphy.gif create mode 100644 spec/factories/images/icon_base64.txt create mode 100644 spec/factories/images/mapicon.png create mode 100644 spec/factories/images/png_base64.txt create mode 100755 spec/factories/map_icons.rb create mode 100644 spec/factories/tour_media.rb diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index 71fa04b5..e2f64c23 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -6,9 +6,9 @@ module ExceptionHandler extend ActiveSupport::Concern included do - rescue_from ActiveRecord::RecordNotFound do |e| - json_response({ message: e.message }, :not_found) - end + # rescue_from ActiveRecord::RecordNotFound do |e| + # json_response({ message: e.message }, :not_found) + # end rescue_from ActiveRecord::RecordInvalid do |e| json_response({ message: e.message }, :unprocessable_entity) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index 6720088b..a5ca1f9b 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -7,7 +7,11 @@ module V3 class GeojsonToursController < ApplicationController def show @tour = Tour.find(params[:id]) - render json: { type: 'FeatureCollection', features: @tour.stops.map { |s| feature(s) } }.to_json + if @tour.published + render json: { type: 'FeatureCollection', features: @tour.stops.map { |s| feature(s) } }.to_json + else + head 401 + end end private diff --git a/app/controllers/v3/modes_controller.rb b/app/controllers/v3/modes_controller.rb index 07eb4174..678e29de 100644 --- a/app/controllers/v3/modes_controller.rb +++ b/app/controllers/v3/modes_controller.rb @@ -3,10 +3,13 @@ # app/controllers/v3/modes_controller.rb module V3 class ModesController < ApplicationController - # GET /modes def index - render json: Mode.all + json_response Mode.all + end + + def show + json_response Mode.find(params[:id]) end end end diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index 7386b967..dac2d548 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -2,7 +2,7 @@ # /app/controllers/v3/stops_controller.rb # module V3 -class V3::StopsController < V3Controller +class V3::StopsController < V3::TourRelationsController # GET /stops def index @records = if current_user.current_tenant_admin? @@ -15,14 +15,9 @@ def index render json: @records end - # GET /stops/1 - def show - render json: @record - end - # POST /stops def create - if @allowed + if crud_allowed? @record = Stop.new(stop_params) if @record.save render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" @@ -34,7 +29,7 @@ def create # PATCH/PUT /stops/1 def update - if @allowed + if crud_allowed? if @record&.update(stop_params) render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" end @@ -43,6 +38,16 @@ def update end end + def destroy + if !crud_allowed? + head 401 + elsif crud_allowed? && @record.orphaned + @record.destroy + elsif crud_allowed? && !@record.orphaned + head 405 + end + end + private # Only allow a trusted parameter "white list" through. @@ -59,14 +64,9 @@ def stop_params ) end - # Use callbacks to share common setup or constraints between actions. - + # Callbacks def set_record _record = Stop.find_by(id: params[:id]) @record = _record&.published || @allowed ? _record : Stop.new(id: params[:id]) end - - def allowed? - @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } - end end diff --git a/app/controllers/v3/themes_controller.rb b/app/controllers/v3/themes_controller.rb index 6563e96c..c2739267 100644 --- a/app/controllers/v3/themes_controller.rb +++ b/app/controllers/v3/themes_controller.rb @@ -5,9 +5,7 @@ module V3 class ThemesController < V3Controller # GET /themes def index - @records = Theme.all - - render json: @records + render json: Theme.all end # GET /themes/1 @@ -17,35 +15,21 @@ def show # POST /themes def create - @record = Theme.new(theme_params) - - if @record.save - render json: @record, status: :created, location: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + head 405 end # PATCH/PUT /themes/1 def update - if @record.update(theme_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + head 405 end # DELETE /themes/1 def destroy - @record.destroy + head 405 end private # Only allow a trusted parameter "white list" through. - def theme_params - params.fetch(:theme, {}) - end - def set_record @record = Theme.find(params[:id]) end diff --git a/app/controllers/v3/tour_media_controller.rb b/app/controllers/v3/tour_media_controller.rb index c09da10f..e6871677 100644 --- a/app/controllers/v3/tour_media_controller.rb +++ b/app/controllers/v3/tour_media_controller.rb @@ -1,48 +1,28 @@ -class V3::TourMediaController < V3Controller +class V3::TourMediaController < V3::TourRelationsController # GET /v3/tour_media def index - @tour_media = if params[:tour_id] && params[:medium_id] - TourMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} - else - TourMedium.all - end + # @tour_media = if params[:tour_id] && params[:medium_id] + # TourMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} + # else + # TourMedium.all + # end - render json: @tour_media - end - - # GET /v3/tour_media/1 - def show - render json: @record - end + @tour_media = TourMedium.all - # POST /v3/tour_media - def create - @record = TourMedium.new(tour_medium_params) - - if @record.save - render json: @record, status: :created, location: @record - else - render json: serialize_errors, status: :unprocessable_entity + unless current_user&.current_tenant_admin? || current_user.tours.present? + @tour_media = @tour_media.reject { |tour_medium| !tour_medium.tour.published } end - end - # PATCH/PUT /v3/tour_media/1 - def update - if @record.update(tour_medium_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + render json: @tour_media end - # DELETE /v3/tour_media/1 def destroy - @record.destroy + head 405 end private # Only allow a trusted parameter "white list" through. - def tour_medium_params + def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ @@ -52,6 +32,7 @@ def tour_medium_params end def set_record - @record = TourMedium.find(params[:id]) + _record = TourMedium.find(params[:id]) + @record = _record&.published || @allowed ? _record : TourMedium.new(id: params[:id]) end end diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb index 642d0fcb..29b23720 100644 --- a/app/controllers/v3/tour_modes_controller.rb +++ b/app/controllers/v3/tour_modes_controller.rb @@ -3,17 +3,17 @@ # app/controllers/v3/tour_modes_controller.rb module V3 class TourModesController < ApplicationController - # GET /tour_sets - def index - @tour_modes = TourMode.all + # # GET /tour_sets + # def index + # @tour_modes = TourMode.all - render json: @tour_modes - end + # render json: @tour_modes + # end - # GET /v3/tour_media/1 - def show - tour_mode = TourMode.find(params[:id]) - render json: tour_mode - end - end + # # GET /v3/tour_media/1 + # def show + # tour_mode = TourMode.find(params[:id]) + # render json: tour_mode + # end + # end end diff --git a/app/controllers/v3/tour_relations_controller.rb b/app/controllers/v3/tour_relations_controller.rb new file mode 100644 index 00000000..073889e6 --- /dev/null +++ b/app/controllers/v3/tour_relations_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# /app/controllers/v3/tour_relations_controller.rb +# module V3 +class V3::TourRelationsController < V3Controller + + def allowed? + set_record if @record.nil? && params[:id].present? + @allowed = @record&.published || crud_allowed? + end + + def crud_allowed? + current_user&.current_tenant_admin? || + current_user.tours&.any? { |tour| Tour.all.include?(tour) } + end +end diff --git a/app/controllers/v3/tour_set_admins_controller.rb b/app/controllers/v3/tour_set_admins_controller.rb index c3fab91f..33f9b2a1 100644 --- a/app/controllers/v3/tour_set_admins_controller.rb +++ b/app/controllers/v3/tour_set_admins_controller.rb @@ -4,10 +4,8 @@ module V3 class TourSetAdminsController < V3Controller # GET /tour_set_admins def index - if current_user && current_user.super - @records = TourSetAdmin.all - - render json: @records + if current_user&.super || current_user&.current_tenant_admin? + render json: TourSetAdmin.all else head 401 end @@ -15,39 +13,41 @@ def index # GET /tour_set_admins/1 def show - render json: @record + head 405 + # if current_user&.super || current_user&.current_tenant_admin? + # render json: @record + # else + # head 401 + # end end # POST /tour_set_admins def create - @record = TourSetAdmin.new(tour_set_admin_params) - - if @record.save - render json: @record, status: :created, location: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + head 405 + # @record = TourSetAdmin.new(tour_set_admin_params) + + # if @record.save + # render json: @record, status: :created, location: @record + # else + # render json: serialize_errors, status: :unprocessable_entity + # end end # PATCH/PUT /tour_set_admins/1 def update - if @record.update(tour_set_admin_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end + head 405 end # DELETE /tour_set_admins/1 def destroy - @record.destroy + head 405 end private # Only allow a trusted parameter "white list" through. - def tour_set_admin_params - params.fetch(:tour_set_admin, {}) - end + # def tour_set_admin_params + # params.fetch(:tour_set_admin, {}) + # end def set_record @record = TourSetAdmin.find(params[:id]) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 6e97f327..3b85e932 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -9,59 +9,57 @@ def index @records = [] if params[:subdir] && params[:subdir] != 'public' @records = TourSet.where(subdir: params[:subdir]) - elsif current_user.super - @records = TourSet.all - elsif current_user.id.present? + elsif current_user.id.present? && !current_user.super @records = current_user.tour_sets else - #TourSet.all.reject {|ts| p ts.tours.empty?} - @records = TourSet.all.reject { |ts| ts.published_tours.empty? } + @records = TourSet.all end if current_user.current_tenant_admin? || current_user.super render json: @records, include: [ 'admins' ] else + @records = @records.reject { |ts| ts.published_tours.empty? } render json: @records end end # GET /tour_sets/1 def show - render json: @record + if @allowed + render json: @record + else + render json: { data: { id: 0, type: 'tour_sets', attributes: { name: '....' } } } + end end # POST /tour_sets def create - @record = TourSet.new(record_params) + if crud_allowed? + @record = TourSet.new(record_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # PATCH/PUT /tour_sets/1 def update - if record_params[:logo].nil? && @record.logo.attached? - @record.logo.purge - puts @record.logo.attached? - end - - @record.logo = nil if record_params - @record.base_sixty_four = nil if record_params[:base_sixty_four].nil? - if @record.update(record_params) - render json: @record + if crud_allowed? + if @record.update(record_params) + render json: @record + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end - # DELETE /tour_sets/1 - def destroy - @record.destroy - end - private # Use callbacks to share common setup or constraints between actions. @@ -69,12 +67,25 @@ def set_record @record = TourSet.find(params[:id]) end + def allowed? + set_record if @record.nil? && params[:id].present? + @allowed = if @record.nil? + crud_allowed? + else + current_user&.current_tenant_admin? || @record.published_tours.present? + end + end + + def crud_allowed? + current_user&.super + end + # Only allow a trusted parameter "white list" through. def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :name, :tours, :admins, :base_sixty_four, :logo_title, :logo + :name, :tours, :admins ] ) end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 3eb72f54..2c40869a 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -7,8 +7,7 @@ class V3::ToursController < V3Controller def index @records = if (params[:slug]) @record = Slug.find_by(slug: params[:slug]).tour - allowed? - if @record.published || @allowed + if @record.published || crud_allowed? @record else nil @@ -38,7 +37,7 @@ def show { centerLng: -84.38979, centerLat: 33.75432 } end - if @record&.published || allowed? + if @record&.published || crud_allowed? render json: @record, loc: request_loc else render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } @@ -47,7 +46,7 @@ def show # POST /tours def create - if @allowed + if crud_allowed? @record = Tour.new(tour_params) if @record.save render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" @@ -61,7 +60,7 @@ def create # PATCH/PUT /tours/1 def update - if @allowed + if crud_allowed? if @record.update(tour_params) render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" else @@ -93,8 +92,12 @@ def set_record @record = _record&.published || @allowed ? _record : Tour.new(id: params[:id]) end - def allowed? + # def allowed? + # @allowed = crud_allowed? || + # end + + def crud_allowed? set_record if @record.nil? && params[:id].present? - @allowed = current_user&.current_tenant_admin? || current_user.tours.include?(@record) + current_user&.current_tenant_admin? || current_user.tours.include?(@record) end end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index fd1bb817..accf6031 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -5,9 +5,19 @@ class V3Controller < ApplicationController before_action :allowed?, only: [:show, :create, :update, :destroy] before_action :set_record, only: [:show, :update, :destroy] + # GET //1 + def show + render json: @record + end + + # POST /v3/tour_media + def create + render json: {}, status: :unauthorized + end + # PATCH/PUT /media/1 def update - if @allowed + if crud_allowed? if @record.update(record_params) render json: @record else @@ -19,10 +29,10 @@ def update end def destroy - if @allowed + if crud_allowed? @record.destroy else - head 401 + render json: {}, status: :unauthorized end end @@ -48,6 +58,10 @@ def serialize_errors private def allowed? - @allowed = current_user&.current_tenant_admin? || current_user.tours.present? + @allowed = @record&.published || crud_allowed? + end + + def crud_allowed? + current_user&.current_tenant_admin? || current_user.tours.present? end end diff --git a/app/models/medium.rb b/app/models/medium.rb index 4c502d5d..2f7052d0 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -4,6 +4,7 @@ class Medium < MediumBaseRecord include VideoProps include Rails.application.routes.url_helpers + before_create :props before_save :add_widths before_update :replace_video @@ -40,48 +41,28 @@ def original_image_url def files return nil if !self.file.attached? - begin - if file.content_type.include?('gif') - height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] - return { - lqip: file.variant(resize_to_limit: [50, 50], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, - mobile: file.variant(resize_to_limit: [300, 300], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, - tablet: file.variant(resize_to_limit: [400, 400], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, - desktop: file.variant(resize_to_limit: [750, 750], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url - } - end - { - lqip: file.variant(resize_to_limit: [50, 50]).processed.url, - mobile: file.variant(resize_to_limit: [300, 300]).processed.url, - tablet: file.variant(resize_to_limit: [400, 400]).processed.url, - desktop: file.variant(resize_to_limit: [750, 750]).processed.url + + if file.content_type.include?('gif') + height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] + return { + lqip: file.variant(resize_to_limit: [50, 50], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + mobile: file.variant(resize_to_limit: [300, 300], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + tablet: file.variant(resize_to_limit: [400, 400], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, + desktop: file.variant(resize_to_limit: [750, 750], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url } - rescue ActiveStorage::FileNotFoundError => error - { mobile: nil, tablet: nil, desktop: nil } end + { + lqip: file.variant(resize_to_limit: [5, 5]).processed.url, + mobile: file.variant(resize_to_limit: [300, 300]).processed.url, + tablet: file.variant(resize_to_limit: [400, 400]).processed.url, + desktop: file.variant(resize_to_limit: [750, 750]).processed.url + } end def orphaned tours.empty? && stops.empty? end - def srcset - nil - # "#{ENV['BASE_URL']}#{self.mobile} #{mobile_width}w, \ - # #{ENV['BASE_URL']}#{self.tablet} #{tablet_width}w, \ - # #{ENV['BASE_URL']}#{self.desktop} #{desktop_width}w" - end - - def srcset_sizes - nil - # "(max-width: 680px) #{mobile_width}px, (max-width: 880px) #{tablet_width}px, #{desktop_width}px" - end - - def insecure - nil - # "#{ENV['INSECURE_IMAGE_BASE_URL']}#{self.desktop}" - end - def replace_video if video.present? && base_sixty_four.present? attach_file diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 0ed63ed6..f8275083 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -11,11 +11,11 @@ class MediumBaseRecord < ApplicationRecord # has_one_attached "#{Apartment::Tenant.current.underscore}_file" has_one_attached 'file' - def image_url - return nil unless file.attached? + # def image_url + # return nil unless file.attached? - file.url - end + # file.url + # end def tmp_file_path return Rails.root.join('public', 'storage', 'tmp', filename) if self.filename diff --git a/app/models/stop.rb b/app/models/stop.rb index a0db1e4c..6c77654b 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -49,14 +49,6 @@ def splash nil end - def splash_height - splash.nil? ? nil : 700 #splash.desktop_height - end - - def splash_width - splash.nil? ? nil : 700 #splash.desktop_width - end - def insecure_splash # if !stop_media.empty? # return medium.nil? ? stop_media.order(:position).first.medium.insecure : medium.insecure diff --git a/app/models/tour.rb b/app/models/tour.rb index 6e3c0385..134bc414 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -84,14 +84,6 @@ def splash nil end - def splash_height - splash.nil? ? nil : 700 #splash.desktop_height - end - - def splash_width - splash.nil? ? nil : 700 #splash.desktop_width - end - def insecure_splash # if !tour_media.empty? # return medium.nil? ? tour_media.order(:position).first.medium.insecure : medium.insecure diff --git a/app/models/tour_medium.rb b/app/models/tour_medium.rb index 2996f8a0..653fc0bd 100644 --- a/app/models/tour_medium.rb +++ b/app/models/tour_medium.rb @@ -18,4 +18,8 @@ class TourMedium < ApplicationRecord self.medium.tours.length == 1 ? self.medium.destroy! : nil end end + + def published + tour&.published + end end diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index dd3dd4a5..dcf93d10 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -5,7 +5,7 @@ class TourSet < ApplicationRecord before_save :set_subdir around_update :attach_file after_create :create_tenant - # after_create :create_defaults + after_create :create_defaults before_destroy :drop_tenant validates :name, presence: true, uniqueness: true @@ -54,7 +54,11 @@ def mapable_tours def logo_url Apartment::Tenant.switch! 'public' - return logo.url if logo.attached? + begin + return logo.url if logo.attached? + rescue URI::InvalidURIError + # FIXME: This seems to be a problem when testing? + end nil end @@ -117,7 +121,7 @@ def tmp_file_path # # def attach_file - return if base_sixty_four.nil? && !logo.attached? + return if base_sixty_four.nil? #&& !logo.attached? headers, self.base_sixty_four = base_sixty_four.split(',') # content_type = Regexp.last_match(1).split(';base64').first diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index cd338b2d..20c6eb05 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -9,9 +9,6 @@ class V3::MediumSerializer < ActiveModel::Serializer :provider, :original_image, :embed, - :srcset, - :srcset_sizes, - :insecure, :files, :orphaned, :filename, diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index ecd87045..70de14a3 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -23,9 +23,6 @@ class V3::StopSerializer < ActiveModel::Serializer :direction_intro, :direction_notes, :splash, - :insecure_splash, - :splash_width, - :splash_height, :orphaned, :icon_color end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index c740d7f0..0f7f778d 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -20,7 +20,6 @@ class V3::TourBaseSerializer < ActiveModel::Serializer :stop_count, :map_type, :splash, - :insecure_splash, :use_directions, :default_lng, :stop_count, diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index 4912c440..bca6b8e0 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -7,6 +7,11 @@ class V3::TourSetSerializer < ActiveModel::Serializer attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url, :logo def admins - object.admins if current_user.super || current_user.current_tenant_admin? + begin + object.admins if current_user&.super || current_user&.current_tenant_admin? + rescue NameError + # This is a problem when using the serializer directly + nil + end end end diff --git a/config/database.yml b/config/database.yml index d1b5d2bf..1bad6358 100644 --- a/config/database.yml +++ b/config/database.yml @@ -9,11 +9,13 @@ default: &default password: <%= ENV['DB_PASSWORD' || 'password'] %> database: <%= ENV['DB_NAME'] || 'otb' %> -# mysql: &mysql -# database: <%= Rails.application.credentials.dig(:dbTest, :mysql, :db) %> -# username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> -# password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> -# host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> +mysql: &mysql + <<: *default + adapter: mysql2 + database: public + username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> + password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> + host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> development: diff --git a/config/environments/mysql.rb b/config/environments/mysql.rb new file mode 120000 index 00000000..c85ef2a0 --- /dev/null +++ b/config/environments/mysql.rb @@ -0,0 +1 @@ +development.rb \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index fdf5c9d3..66d6be2e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -48,4 +48,10 @@ # This is needed for the tests to request tests to pass when subdomain is set. config.action_dispatch.tld_length = 0 + # config.active_storage.service = :test + # config.consider_all_requests_local = true + # config.action_controller.perform_caching = false + # config.host = 'localhost:3030' + # config.action_controller.default_url_options = { host: 'localhost:3030' } + end diff --git a/config/routes.rb b/config/routes.rb index 2e6062f1..2b7fc56f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,7 +19,7 @@ scope module: :v3, constraints: ApiVersion.new('v3', true) do resources :tour_authors, path: 'tour-authors' resources :users - resources :modes, only: [:index] + resources :modes, only: [:index, :show] resources :tour_sets, path: 'tour-sets' resources :tour_set_admins, path: 'tour-set-users' resources :tour_collections, path: 'tour-collections' diff --git a/db/schema.rb b/db/schema.rb index afad103e..9650d925 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,12 +12,7 @@ ActiveRecord::Schema.define(version: 2021_09_02_164843) do - # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" - enable_extension "plpgsql" - enable_extension "uuid-ossp" - - create_table "active_storage_attachments", force: :cascade do |t| + create_table "active_storage_attachments", charset: "utf8", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false t.bigint "record_id", null: false @@ -27,7 +22,7 @@ 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| + create_table "active_storage_blobs", charset: "utf8", force: :cascade do |t| t.string "key", null: false t.string "filename", null: false t.string "content_type" @@ -39,13 +34,13 @@ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", force: :cascade do |t| + create_table "active_storage_variant_records", charset: "utf8", 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 "ecds_rails_auth_engine_logins", force: :cascade do |t| + create_table "ecds_rails_auth_engine_logins", charset: "utf8", force: :cascade do |t| t.string "who" t.string "token" t.string "provider" @@ -55,7 +50,7 @@ t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" end - create_table "flat_pages", force: :cascade do |t| + create_table "flat_pages", charset: "utf8", force: :cascade do |t| t.string "title" t.text "body" t.datetime "created_at", null: false @@ -63,7 +58,7 @@ t.integer "position" end - create_table "logins", force: :cascade do |t| + create_table "logins", charset: "utf8", force: :cascade do |t| t.string "identification", null: false t.string "password_digest" t.string "oauth2_token", null: false @@ -77,14 +72,14 @@ t.index ["user_id"], name: "index_logins_on_user_id" end - create_table "map_icons", force: :cascade do |t| + create_table "map_icons", charset: "utf8", force: :cascade do |t| t.text "base_sixty_four" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.string "filename" end - create_table "map_overlays", force: :cascade do |t| + create_table "map_overlays", charset: "utf8", force: :cascade do |t| t.decimal "south", precision: 10, scale: 6 t.decimal "north", precision: 10, scale: 6 t.decimal "east", precision: 10, scale: 6 @@ -99,7 +94,7 @@ t.index ["tour_id"], name: "index_map_overlays_on_tour_id" end - create_table "media", force: :cascade do |t| + create_table "media", charset: "utf8", force: :cascade do |t| t.string "title" t.text "caption" t.string "original_image" @@ -123,18 +118,18 @@ t.integer "lqip_width" end - create_table "modes", force: :cascade do |t| + create_table "modes", charset: "utf8", force: :cascade do |t| t.string "title" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "icon" end - create_table "roles", force: :cascade do |t| + create_table "roles", charset: "utf8", force: :cascade do |t| t.string "title" end - create_table "slugs", force: :cascade do |t| + create_table "slugs", charset: "utf8", force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false @@ -142,7 +137,7 @@ t.index ["tour_id"], name: "index_slugs_on_tour_id" end - create_table "stop_media", force: :cascade do |t| + create_table "stop_media", charset: "utf8", force: :cascade do |t| t.bigint "stop_id" t.bigint "medium_id" t.datetime "created_at", null: false @@ -152,7 +147,7 @@ t.index ["stop_id"], name: "index_stop_media_on_stop_id" end - create_table "stop_slugs", force: :cascade do |t| + create_table "stop_slugs", charset: "utf8", force: :cascade do |t| t.string "slug" t.bigint "stop_id" t.datetime "created_at", null: false @@ -162,7 +157,7 @@ t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" end - create_table "stops", force: :cascade do |t| + create_table "stops", charset: "utf8", force: :cascade do |t| t.string "title" t.text "description" t.string "meta_description", limit: 500 @@ -186,7 +181,7 @@ t.index ["medium_id"], name: "index_stops_on_medium_id" end - create_table "taggings", id: :serial, force: :cascade do |t| + create_table "taggings", id: { type: :bigint, unsigned: true }, charset: "utf8", force: :cascade do |t| t.integer "tag_id" t.string "taggable_type" t.integer "taggable_id" @@ -195,6 +190,7 @@ t.string "context", limit: 128 t.datetime "created_at" t.index ["context"], name: "index_taggings_on_context" + t.index ["id"], name: "id", unique: true t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" @@ -205,26 +201,26 @@ t.index ["tagger_id"], name: "index_taggings_on_tagger_id" end - create_table "themes", force: :cascade do |t| + create_table "themes", charset: "utf8", force: :cascade do |t| t.string "title" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "tour_authors", force: :cascade do |t| + create_table "tour_authors", charset: "utf8", force: :cascade do |t| t.bigint "tour_id" t.bigint "user_id" t.index ["tour_id"], name: "index_tour_authors_on_tour_id" t.index ["user_id"], name: "index_tour_authors_on_user_id" end - create_table "tour_collections", force: :cascade do |t| + create_table "tour_collections", charset: "utf8", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "tour_flat_pages", force: :cascade do |t| + create_table "tour_flat_pages", charset: "utf8", force: :cascade do |t| t.bigint "tour_id" t.bigint "flat_page_id" t.integer "position" @@ -234,7 +230,7 @@ t.index ["tour_id"], name: "index_tour_flat_pages_on_tour_id" end - create_table "tour_media", force: :cascade do |t| + create_table "tour_media", charset: "utf8", force: :cascade do |t| t.bigint "tour_id" t.bigint "medium_id" t.datetime "created_at", null: false @@ -244,7 +240,7 @@ t.index ["tour_id"], name: "index_tour_media_on_tour_id" end - create_table "tour_modes", force: :cascade do |t| + create_table "tour_modes", charset: "utf8", force: :cascade do |t| t.bigint "tour_id" t.bigint "mode_id" t.datetime "created_at", null: false @@ -253,7 +249,7 @@ t.index ["tour_id"], name: "index_tour_modes_on_tour_id" end - create_table "tour_set_admins", force: :cascade do |t| + create_table "tour_set_admins", charset: "utf8", force: :cascade do |t| t.bigint "tour_set_id" t.bigint "user_id" t.bigint "role_id" @@ -263,7 +259,7 @@ t.index ["user_id"], name: "index_tour_set_admins_on_user_id" end - create_table "tour_sets", force: :cascade do |t| + create_table "tour_sets", charset: "utf8", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -277,7 +273,7 @@ t.index ["tour_id"], name: "index_tour_sets_on_tours_id" end - create_table "tour_stops", force: :cascade do |t| + create_table "tour_stops", charset: "utf8", force: :cascade do |t| t.bigint "tour_id" t.bigint "stop_id" t.integer "position" @@ -287,7 +283,7 @@ t.index ["tour_id"], name: "index_tour_stops_on_tour_id" end - create_table "tours", force: :cascade do |t| + create_table "tours", charset: "utf8", force: :cascade do |t| t.string "title" t.text "description" t.text "article_link" @@ -309,10 +305,11 @@ t.string "link_text" t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" + t.index ["splash_image_medium_id"], name: "fk_rails_3a2d58abec" t.index ["theme_id"], name: "index_tours_on_theme_id" end - create_table "users", force: :cascade do |t| + create_table "users", charset: "utf8", force: :cascade do |t| t.string "display_name" t.bigint "login_id" t.datetime "created_at", null: false diff --git a/old_spcs/requests/v3/tours_spec.rb b/old_spcs/requests/v3/tours_spec.rb index 5a65cc5b..ac13555d 100644 --- a/old_spcs/requests/v3/tours_spec.rb +++ b/old_spcs/requests/v3/tours_spec.rb @@ -243,8 +243,6 @@ } it 'only returns tours user can edit' do - puts '*****' - puts response.body expect(json.size).to eq(1) expect(json.size).not_to eq(Tour.count) end diff --git a/spec/controllers/v3/media_controller_spec.rb b/spec/controllers/v3/media_controller_spec.rb index a5ec0900..b980acfc 100644 --- a/spec/controllers/v3/media_controller_spec.rb +++ b/spec/controllers/v3/media_controller_spec.rb @@ -79,6 +79,8 @@ it 'returns the medium when unpublished but requested is authorized' do medium = create(:medium) + medium.save + expect(medium.file.attached?).to be true user = create(:user) user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) signed_cookie(user) @@ -87,6 +89,17 @@ expect(response.status).to eq(200) expect(attributes[:title]).to eq(medium.title) end + + it 'returns the medium that if a gif and requested is authorized' do + medium = create(:medium, base_sixty_four: File.read(Rails.root.join('spec/factories/images/gif_base64.txt')), filename: Faker::File.file_name(dir: '', ext: 'gif', directory_separator: '')) + medium.save + expect(medium.file.attached?).to be true + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: medium.id } + expect(response.status).to eq(200) + expect(attributes[:files][:mobile]).to end_with 'gif' + end end describe 'POST #create' do diff --git a/spec/controllers/v3/modes_controller_spec.rb b/spec/controllers/v3/modes_controller_spec.rb index 633e7aa2..6a103de3 100644 --- a/spec/controllers/v3/modes_controller_spec.rb +++ b/spec/controllers/v3/modes_controller_spec.rb @@ -2,130 +2,25 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::ModesController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Mode. As you add validations to Mode, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') - } - - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # ModesController. Be sure to keep this updated too. - let(:valid_session) { {} } describe 'GET #index' do it 'returns a success response' do - mode = Mode.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success + tour_set = create(:tour_set) + get :index, params: { tenant: tour_set.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(4) end end describe 'GET #show' do it 'returns a success response' do - mode = Mode.create! valid_attributes - get :show, params: { id: mode.to_param }, session: valid_session - expect(response).to be_success - end - end - - describe 'POST #create' do - context 'with valid params' do - it 'creates a new Mode' do - expect { - post :create, params: { mode: valid_attributes }, session: valid_session - }.to change(Mode, :count).by(1) - end - - it 'renders a JSON response with the new mode' do - - post :create, params: { mode: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(mode_url(Mode.last)) - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the new mode' do - - post :create, params: { mode: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested mode' do - mode = Mode.create! valid_attributes - put :update, params: { id: mode.to_param, mode: new_attributes }, session: valid_session - mode.reload - skip('Add assertions for updated state') - end - - it 'renders a JSON response with the mode' do - mode = Mode.create! valid_attributes - - put :update, params: { id: mode.to_param, mode: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the mode' do - mode = Mode.create! valid_attributes - - put :update, params: { id: mode.to_param, mode: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'DELETE #destroy' do - it 'destroys the requested mode' do - mode = Mode.create! valid_attributes - expect { - delete :destroy, params: { id: mode.to_param }, session: valid_session - }.to change(Mode, :count).by(-1) + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + get :show, params: { id: Mode.first.to_param, tenant: tour_set.subdir } + expect(response.status).to eq(200) + expect(attributes[:title]).to eq(Mode.first.title) end end - end diff --git a/spec/controllers/v3/stops_controller_spec.rb b/spec/controllers/v3/stops_controller_spec.rb index 723856a5..929cd1f9 100644 --- a/spec/controllers/v3/stops_controller_spec.rb +++ b/spec/controllers/v3/stops_controller_spec.rb @@ -306,7 +306,9 @@ user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) signed_cookie(user) stop_count = Stop.count + Stop.last.update(tours: []) post :destroy, params: { id: Stop.last.id, tenant: Apartment::Tenant.current } + Stop.last.update(tours: []) expect(response.status).to eq(204) expect(Stop.count).to eq(stop_count - 1) end @@ -317,24 +319,37 @@ user.tour_sets = [] user.update(super: true) signed_cookie(user) + Stop.first.update(tours: []) stop_count = Stop.count post :destroy, params: { id: Stop.first.id, tenant: Apartment::Tenant.current } expect(response.status).to eq(204) expect(Stop.count).to eq(stop_count - 1) end - it 'return 204 and one less tour when authenciated by tour author' do + it 'return 204 and one less stop when authenciated by tour author and Stop does not belong to a Tour' do tour = create(:tour) user = create(:user) user.update(super: false) user.tour_sets = [] user.tours << tour signed_cookie(user) - new_title = Faker::Name.unique.name + Stop.last.update(tours: []) stop_count = Stop.count post :destroy, params: { id: Stop.last.id, tenant: Apartment::Tenant.current } expect(response.status).to eq(204) expect(Stop.count).to eq(stop_count - 1) end + + it 'return 405 and does not delete Stop when Stop belongs to a Tour and requested by super' do + tour = create(:tour) + user = create(:user) + user.update(super: true) + Stop.last.tours << tour if Stop.last.tours.empty? + signed_cookie(user) + stop_count = Stop.count + post :destroy, params: { id: Stop.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(Stop.count).to eq(stop_count) + end end end diff --git a/spec/controllers/v3/themes_controller_spec.rb b/spec/controllers/v3/themes_controller_spec.rb index 7d06fb67..d7644211 100644 --- a/spec/controllers/v3/themes_controller_spec.rb +++ b/spec/controllers/v3/themes_controller_spec.rb @@ -27,104 +27,40 @@ RSpec.describe V3::ThemesController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Theme. As you add validations to Theme, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') - } - - let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # ThemesController. Be sure to keep this updated too. - let(:valid_session) { {} } + before(:each) { create_list(:theme, rand(3..6)) } describe 'GET #index' do it 'returns a success response' do - theme = Theme.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) end end describe 'GET #show' do it 'returns a success response' do - theme = Theme.create! valid_attributes - get :show, params: { id: theme.to_param }, session: valid_session - expect(response).to be_success + get :show, params: { id: Theme.last.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) end end describe 'POST #create' do - context 'with valid params' do - it 'creates a new Theme' do - expect { - post :create, params: { theme: valid_attributes }, session: valid_session - }.to change(Theme, :count).by(1) - end - - it 'renders a JSON response with the new theme' do - - post :create, params: { theme: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(theme_url(Theme.last)) - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the new theme' do - - post :create, params: { theme: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end + it 'returns 405' do + post :create, params: { tenant: Apartment::Tenant.current, data: { type: 'theme', attributes: {} } } + expect(response.status).to eq(405) end end describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } - - it 'updates the requested theme' do - theme = Theme.create! valid_attributes - put :update, params: { id: theme.to_param, theme: new_attributes }, session: valid_session - theme.reload - skip('Add assertions for updated state') - end - - it 'renders a JSON response with the theme' do - theme = Theme.create! valid_attributes - - put :update, params: { id: theme.to_param, theme: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the theme' do - theme = Theme.create! valid_attributes - - put :update, params: { id: theme.to_param, theme: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end + it 'renders a JSON response with errors for the theme' do + put :update, params: { id: Theme.first.to_param, tenant: Apartment::Tenant.current, data: { type: 'theme', attributes: {} } } + expect(response.status).to eq(405) end end describe 'DELETE #destroy' do it 'destroys the requested theme' do - theme = Theme.create! valid_attributes - expect { - delete :destroy, params: { id: theme.to_param }, session: valid_session - }.to change(Theme, :count).by(-1) + delete :destroy, params: { id: Theme.first.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) end end diff --git a/spec/controllers/v3/tour_geojson_controller_spec.rb b/spec/controllers/v3/tour_geojson_controller_spec.rb index af122ccc..ad602d7f 100644 --- a/spec/controllers/v3/tour_geojson_controller_spec.rb +++ b/spec/controllers/v3/tour_geojson_controller_spec.rb @@ -2,4 +2,23 @@ RSpec.describe V3::GeojsonToursController, type: :controller do + describe 'GET #show' do + it 'returns a geojosn representation of a tour when tour published' do + tour = create(:tour, published: true, media: create_list(:medium, rand(1..3)), stops: create_list(:stop, rand(4..7))) + tour.stops.each { |stop| stop.media << create_list(:medium, rand(1..3)) } + get :show, params: { id: tour.to_param, tenant: Apartment::Tenant.current } + geojson = JSON.parse(response.body).with_indifferent_access + expect(geojson[:type]).to eq('FeatureCollection') + expect(geojson[:features].count).to eq(tour.stops.count) + expect(geojson[:features].first[:geometry][:coordinates]).to eq([tour.stops.first.lng.to_f, tour.stops.first.lat.to_f]) + expect(geojson[:features].last[:properties][:images].first[:caption]).to eq(tour.stops.last.media.first.caption) + end + + it 'returns 401 when tour is unpublished' do + tour = create(:tour, published: false) + get :show, params: { id: tour.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + end + end diff --git a/spec/controllers/v3/tour_media_controller_spec.rb b/spec/controllers/v3/tour_media_controller_spec.rb index 35b29e2d..d41dd4a8 100644 --- a/spec/controllers/v3/tour_media_controller_spec.rb +++ b/spec/controllers/v3/tour_media_controller_spec.rb @@ -25,105 +25,218 @@ RSpec.describe V3::TourMediaController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # TourMedium. As you add validations to TourMedium, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip("Add a hash of attributes valid for your model") - } - - let(:invalid_attributes) { - skip("Add a hash of attributes invalid for your model") - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # TourMediaController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe "GET #index" do - it "returns a success response" do - tour_medium = TourMedium.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end + describe 'GET #index' do + before(:each) { Tour.all.each { |tour| tour.update(published: false) } } - describe "GET #show" do - it "returns a success response" do - tour_medium = TourMedium.create! valid_attributes - get :show, params: {id: tour_medium.to_param}, session: valid_session - expect(response).to be_success - end - end + context 'unauthenticated' do + it 'returns a success response but zeor TourMedium objects' do - describe "POST #create" do - context "with valid params" do - it "creates a new TourMedium" do - expect { - post :create, params: {v3_tour_medium: valid_attributes}, session: valid_session - }.to change(TourMedium, :count).by(1) + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(TourMedium.count).to be > 0 end - it "renders a JSON response with the new v3_tour_medium" do + it 'returns a success response but zeor TourMedium objects' do + tour_medium = create(:tour_medium, tour: create(:tour, published: true)) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(1) + end + end + + context 'authenticated unauthorized' do + + it 'returns zero TourMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + tour_set = create(:tour_set) + user = create(:user, super: false) + user.tour_sets << tour_set + signed_cookie(user) + get :index, params: { tenant: original_tenant } + Apartment::Tenant.switch! original_tenant + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(TourMedium.count).to be > 0 + end - post :create, params: {v3_tour_medium: valid_attributes}, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(v3_tour_medium_url(TourMedium.last)) + it 'returns zero TourMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + user = create(:user, super: false) + user.tours << create(:tour) + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :index, params: { tenant: original_tenant } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(TourMedium.count).to be > 0 end end - context "with invalid params" do - it "renders a JSON response with errors for the new v3_tour_medium" do + context 'authenticated and authorized' do + it 'returns all TourMedium objects to super' do + create_list(:tour_medium, 4) + user = create(:user, super: true) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(TourMedium.count) + end - post :create, params: {v3_tour_medium: invalid_attributes}, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'returns all TourMedium objects to tenant admin' do + create_list(:tour_medium, 4) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(TourMedium.count) end end end - describe "PUT #update" do - context "with valid params" do - let(:new_attributes) { - skip("Add a hash of attributes valid for your model") - } - - it "updates the requested v3_tour_medium" do - tour_medium = TourMedium.create! valid_attributes - put :update, params: {id: tour_medium.to_param, v3_tour_medium: new_attributes}, session: valid_session - tour_medium.reload - skip("Add assertions for updated state") + describe 'GET #show' do + context 'unauthenticated' do + it 'returns a success response but empty TourMedium objects' do + + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + get :show, params: { id: tour_medium.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:tour][:data]).to be nil + expect(tour_medium.tour).not_to be nil + expect(tour_medium.medium).not_to be nil + expect(TourMedium.count).to be > 0 end - it "renders a JSON response with the v3_tour_medium" do - tour_medium = TourMedium.create! valid_attributes + # it 'returns a success response but empty TourMedium objects' do + # tour_medium = create(:tour_medium, tour: create(:tour, published: true)) + # get :index, params: { id: tour_medium.id, tenant: Apartment::Tenant.current } + # expect(response.status).to eq(200) + # expect(json.count).to eq(1) + # end + end - put :update, params: {id: tour_medium.to_param, v3_tour_medium: valid_attributes}, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + context 'authenticated unauthorized' do + + it 'returns empty TourMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + tour_set = create(:tour_set) + user = create(:user, super: false) + user.tour_sets << tour_set + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :show, params: { id: tour_medium.id, tenant: original_tenant } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:tour][:data]).to be nil + expect(tour_medium.tour).not_to be nil + expect(tour_medium.medium).not_to be nil + expect(TourMedium.count).to be > 0 end - end - context "with invalid params" do - it "renders a JSON response with errors for the v3_tour_medium" do - tour_medium = TourMedium.create! valid_attributes + it 'returns empty TourMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + tour_medium = create(:tour_medium, tour: create(:tour, published: false)) + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + user = create(:user, super: false) + user.tours << create(:tour) + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :show, params: { id: tour_medium.id, tenant: original_tenant } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:tour][:data]).to be nil + expect(TourMedium.count).to be > 0 + end + end - put :update, params: {id: tour_medium.to_param, v3_tour_medium: invalid_attributes}, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + context 'authenticated and authorized' do + it 'returns all TourMedium objects to super' do + create_list(:tour_medium, 4) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { id: TourMedium.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(TourMedium.count) end end end - describe "DELETE #destroy" do - it "destroys the requested v3_tour_medium" do - tour_medium = TourMedium.create! valid_attributes + describe 'POST #create' do + it 'returns does not create a new TourMedium' do expect { - delete :destroy, params: {id: tour_medium.to_param}, session: valid_session - }.to change(TourMedium, :count).by(-1) + post :create, params: { tenant: Apartment::Tenant.current } + }.to change(TourMedium, :count).by(0) end + + it 'returns 401' do + user = create(:user, super: true) + signed_cookie(user) + post :create, params: { data: { type: 'tour_media', attributes: { tour_id: 1, medium_id: 1, position: 1 } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + end + + describe 'PUT #update' do + context 'with valid params' do + # let(:new_attributes) { + # skip('Add a hash of attributes valid for your model') + # } + + # it 'updates the requested v3_tour_medium' do + # tour_medium = TourMedium.create! valid_attributes + # put :update, params: {id: tour_medium.to_param, v3_tour_medium: new_attributes}, session: valid_session + # tour_medium.reload + # skip('Add assertions for updated state') + # end + + it 'renders a JSON response with the v3_tour_medium' do + tour_medium = create(:tour_medium, position: 1) + expect(tour_medium.position).not_to eq(100) + user = create(:user, super: true) + signed_cookie(user) + put :update, params: { id: tour_medium.id, data: { type: 'tour_media', attributes: { tour_id: tour_medium.tour.id, medium_id: tour_medium.medium.id, position: 100 } }, tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:ok) + expect(attributes[:position]).to eq(100) + expect(TourMedium.find(tour_medium.id).position).to eq(100) + end + end + + # context 'with invalid params' do + # it 'renders a JSON response with errors for the v3_tour_medium' do + # tour_medium = TourMedium.create! valid_attributes + + # put :update, params: {id: tour_medium.to_param, v3_tour_medium: invalid_attributes}, session: valid_session + # expect(response).to have_http_status(:unprocessable_entity) + # expect(response.content_type).to eq('application/json') + # end + # end end + describe 'DELETE #destroy' do + it 'does not destroy the requested v3_tour_medium' do + tour_medium = create(:tour_medium) + user = create(:user, super: true) + signed_cookie(user) + expect { + delete :destroy, params: { id: tour_medium.to_param, tenant: Apartment::Tenant.current } + }.to change(TourMedium, :count).by(0) + end + + it 'responds with 405' do + tour_medium = create(:tour_medium) + user = create(:user, super: true) + signed_cookie(user) + delete :destroy, params: { id: tour_medium.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + end + end end diff --git a/spec/controllers/v3/tour_set_admins_controller_spec.rb b/spec/controllers/v3/tour_set_admins_controller_spec.rb new file mode 100644 index 00000000..196290ee --- /dev/null +++ b/spec/controllers/v3/tour_set_admins_controller_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::TourSetAdminsController, type: :controller do + before(:each) { + create_list(:tour_set, rand(2..5)) + TourSet.all.each { |tour_set| tour_set.update(admins: create_list(:user, rand(2..5))) } + } + + describe 'GET #index' do + context 'unauthenticated and unauthorized' do + it 'returns 401 when not unauthenticated' do + get :index, params: { tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'returns 401 when not unauthenticated but unauthorized' do + initial_tour_set = TourSet.first + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include initial_tour_set + signed_cookie(user) + Apartment::Tenant.switch! initial_tour_set.subdir + get :index, params: { tenant: initial_tour_set.subdir } + expect(response.status).to eq(401) + end + end + + context 'authorized' do + it 'responds with 200 and a list of TourSetAdmins when requested by tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.last + signed_cookie(user) + Apartment::Tenant.switch! TourSet.last.subdir + get :index, params: { tenant: TourSet.last.subdir } + expect(response.status).to eq(200) + expect(json.first[:type]).to eq('tour_set_admins') + expect(json.count).to eq(TourSetAdmin.count) + end + + it 'responds with 200 and a list of TourSetAdmins when requested by super' do + user = create(:user, super: true) + signed_cookie(user) + Apartment::Tenant.switch! TourSet.first.subdir + get :index, params: { tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(json.first[:type]).to eq('tour_set_admins') + expect(json.count).to eq(TourSetAdmin.count) + end + end + end + + describe 'GET #show' do + it 'returns 405' do + get :show, params: { tenant: Apartment::Tenant.current, id: TourSetAdmin.first.id } + expect(response.status).to eq(405) + end + end + + describe 'POST #create' do + it 'returns 405' do + post :create, params: { tenant: Apartment::Tenant.current, id: TourSetAdmin.first.id } + expect(response.status).to eq(405) + end + end + + describe 'PUT #update' do + it 'returns 405' do + put :update, params: { tenant: Apartment::Tenant.current, id: TourSetAdmin.first.id } + expect(response.status).to eq(405) + end + end + + describe 'DELETE #destroy' do + it 'returns 405' do + delete :destroy, params: { tenant: Apartment::Tenant.current, id: TourSetAdmin.first.id } + expect(response.status).to eq(405) + end + end +end diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index 5727837a..c16d45b5 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -2,40 +2,15 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::TourSetsController, type: :controller do # This should return the minimal set of attributes required to create a valid # TourSet. As you add validations to TourSet, be sure to # adjust the attributes here as well. let(:valid_attributes) { - skip('Add a hash of attributes valid for your model') } let(:invalid_attributes) { - skip('Add a hash of attributes invalid for your model') } # This should return the minimal set of values that should be in the session @@ -43,89 +18,288 @@ # TourSetsController. Be sure to keep this updated too. let(:valid_session) { {} } - describe 'GET #index' do - it 'returns a success response' do - tour_set = TourSet.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end + describe 'TourSetsController' do + let(:valid_params) { { data: { type: 'tour_sets', attributes: { name: Faker::Music::Hiphop.artist } }, tenant: 'public' } } + let(:invalid_params) { { data: { type: 'tour_sets', attributes: { name: nil } }, tenant: 'public' } } - describe 'GET #show' do - it 'returns a success response' do - tour_set = TourSet.create! valid_attributes - get :show, params: { id: tour_set.to_param }, session: valid_session - expect(response).to be_success + before(:each) do + Apartment::Tenant.reset + TourSet.all.each { |ts| ts.delete } + create_list(:tour_set, rand(3..5)) end - end - describe 'POST #create' do - context 'with valid params' do - it 'creates a new TourSet' do - expect { - post :create, params: { tour_set: valid_attributes }, session: valid_session - }.to change(TourSet, :count).by(1) + describe 'GET #index' do + it 'returns a success response but return no TourSet objects' do + get :index, params: { tenant: 'public' } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + end + + it 'returns a success response and returns TourSet objects with tours that are published and have stops' do + Apartment::Tenant.switch! TourSet.second.subdir + tour = create(:tour, published: true) + tour.stops << create(:stop) + get :index, params: { tenant: 'public' } + expect(response.status).to eq(200) + expect(json.count).to eq(1) + end + + it 'returns a success response by subdir but returns no TourSet objects when no published tours and not authorized' do + get :index, params: { tenant: 'public', subdir: TourSet.last.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + end + + it 'returns a success response by and TourSet object by subdir when tour set has a published tour and not authorized' do + Apartment::Tenant.switch! TourSet.second.subdir + tour = create(:tour, published: true) + tour.stops << create(:stop) + get :index, params: { tenant: 'public', subdir: TourSet.second.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(1) end - it 'renders a JSON response with the new tour_set' do + it 'returns all TourSet objects when requested by super' do + user = create(:user, super: true) + signed_cookie(user) + get :index, params: { tenant: 'public' } + expect(response.status).to eq(200) + expect(json.count).to eq(TourSet.count) + end - post :create, params: { tour_set: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(tour_set_url(TourSet.last)) + it 'returns TourSet objects when requested by admin' do + user = create(:user, super: false) + user.tour_sets << [TourSet.first, TourSet.last] + signed_cookie(user) + get :index, params: { tenant: 'public' } + expect(response.status).to eq(200) + expect(json.count).to eq(2) + end + + it 'returns no TourSet objects when requested by non admin' do + user = create(:user, super: false) + user.tour_sets = [] + signed_cookie(user) + get :index, params: { tenant: 'public' } + expect(response.status).to eq(200) + expect(json.count).to eq(0) end end - context 'with invalid params' do - it 'renders a JSON response with errors for the new tour_set' do + describe 'GET #show' do + context 'unauthenticated' do + it 'returns a success response and dummy TourSet when no tour is published' do + get :show, params: { tenant: 'public', id: TourSet.first.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq('....') + end + + it 'returns a success response and TourSet when TourSet has published tour' do + Apartment::Tenant.switch! TourSet.last.subdir + tour = create(:tour, published: true) + tour.stops << create(:stop) + get :show, params: { tenant: 'public', id: TourSet.last.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq(TourSet.last.name) + end + end + + context 'authenticated unauthorized' do + it 'returns a success response and dummy TourSet when no tour is published' do + user = create(:user, super: false) + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: 'public', id: TourSet.first.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq('....') + end + + it 'returns a success response and TourSet when TourSet has published tour' do + user = create(:user, super: false) + user.tour_sets << TourSet.first + signed_cookie(user) + get :show, params: { tenant: 'public', id: TourSet.last.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq('....') + end + end + + context 'authenticated authorized' do + it 'returns a success response and TourSet when requested by tenant adamin' do + user = create(:user, super: false) + user.tour_sets << TourSet.last + signed_cookie(user) + get :show, params: { tenant: 'public', id: TourSet.last.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq(TourSet.last.name) + end - post :create, params: { tour_set: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'returns a success response and TourSet when requested by super' do + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: 'public', id: TourSet.last.to_param } + expect(response.status).to eq(200) + expect(attributes[:name]).to eq(TourSet.last.name) + end end end - end - describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) { - skip('Add a hash of attributes valid for your model') - } + describe 'POST #create' do + context 'when unauthenticated and unauthorized' do + it 'does not create a new TourSet when not super' do + expect { + post :create, params: valid_params + }.to change(TourSet, :count).by(0) + end + + it 'returns 401' do + post :create, params: valid_params + expect(response).to have_http_status(401) + end + end + + context 'when authenticated but unauthorized' do + it 'does not create a new TourSet when not super' do + user = create(:user, super: false) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(TourSet, :count).by(0) + end + + it 'returns 401 when not super' do + user = create(:user, super: false) + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(401) + end - it 'updates the requested tour_set' do - tour_set = TourSet.create! valid_attributes - put :update, params: { id: tour_set.to_param, tour_set: new_attributes }, session: valid_session - tour_set.reload - skip('Add assertions for updated state') + it 'does not create a new TourSet when not super but is a tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.second + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(TourSet, :count).by(0) + end + + it 'returns 401 when not super but is a tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.first + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(401) + end end - it 'renders a JSON response with the tour_set' do - tour_set = TourSet.create! valid_attributes + context 'when authenticated and authorized' do + context 'valid params' do + it 'creates a new TourSet' do + user = create(:user, super: true) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(TourSet, :count).by(1) + end + + it 'renders a JSON response with the new tour_set' do + user = create(:user, super: true) + signed_cookie(user) + post :create, params: valid_params + expect(response).to have_http_status(:created) + expect(response.content_type).to eq('application/json; charset=utf-8') + end + end + end - put :update, params: { id: tour_set.to_param, tour_set: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') + context 'with invalid params' do + it 'renders a JSON response with errors for the new tour_set' do + user = create(:user, super: true) + signed_cookie(user) + post :create, params: invalid_params + expect(response).to have_http_status(:unprocessable_entity) + end end end - context 'with invalid params' do - it 'renders a JSON response with errors for the tour_set' do - tour_set = TourSet.create! valid_attributes + describe 'PUT #update' do + context 'when unauthenticated and unauthorized' do + it 'returns 401' do + put :update, params: valid_params.merge({ id: TourSet.last.to_param }) + expect(response).to have_http_status(401) + end + end + + context 'when authenticated but unauthorized' do + it 'does not update a TourSet when not super' do + user = create(:user, super: false) + signed_cookie(user) + put :update, params: valid_params.merge({ id: TourSet.first.to_param }) + expect(response).to have_http_status(401) + end - put :update, params: { id: tour_set.to_param, tour_set: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') + it 'does not update TourSet when not super but is a tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.second + signed_cookie(user) + put :update, params: valid_params.merge({ id: TourSet.second.to_param }) + expect(response).to have_http_status(401) + end + end + + context 'when authenticated and authorized' do + context 'valid params' do + it 'renders a JSON response with the new tour_set' do + new_name = Faker::Music::Hiphop.artist + valid_params[:data][:attributes][:name] = new_name + user = create(:user, super: true) + signed_cookie(user) + put :update, params: valid_params.merge({ id: TourSet.last.to_param }) + expect(response).to have_http_status(200) + expect(attributes[:name]).to eq(new_name) + end + end + + context 'valid params' do + it 'renders a JSON response with the new tour_set and purges icon' do + tour_set = create(:tour_set) + tour_set.update( + logo_title: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/base64_image.txt')) + ) + expect(TourSet.find(tour_set.id).logo.attached?).to be true + serialized_tour_set = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::TourSetSerializer.new(tour_set)).to_json).with_indifferent_access + serialized_tour_set[:data][:attributes][:base_sixty_four] = nil + serialized_tour_set[:data][:attributes][:logo] = nil + user = create(:user, super: true) + signed_cookie(user) + put :update, params: { data: serialized_tour_set[:data], id: tour_set.id, tenant: 'public' } + expect(response).to have_http_status(200) + expect(TourSet.find(tour_set.id).logo.attached?).to be false + end + end + end + + context 'with invalid params' do + it 'renders a JSON response with errors for the new tour_set' do + user = create(:user, super: true) + signed_cookie(user) + put :update, params: invalid_params.merge({ id: TourSet.first.to_param }) + expect(response).to have_http_status(:unprocessable_entity) + end end end - end - describe 'DELETE #destroy' do - it 'destroys the requested tour_set' do - tour_set = TourSet.create! valid_attributes - expect { - delete :destroy, params: { id: tour_set.to_param }, session: valid_session - }.to change(TourSet, :count).by(-1) + describe 'DELETE #destroy' do + it 'destroys the requested tour_set' do + tour_set = create(:tour_set) + user = create(:user, super: true) + signed_cookie(user) + Apartment::Tenant.reset + expect { + delete :destroy, params: { id: tour_set.to_param, tenant: Apartment::Tenant.current } + }.to change(TourSet, :count).by(-1) + end end end - end diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index 9bb8da55..f1add295 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -68,11 +68,11 @@ describe 'GET #show' do it 'returns a 200 response' do - tour = create(:tour) - tour.update(published: true) + tour = create(:tour, published: true, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(3..5))) get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) + expect(attributes[:est_time]).to eq('About 2 hours bicycling') end # This is for when an authenticated person is viewing an unpublished tour. @@ -91,7 +91,7 @@ it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do tour = create(:tour, published: false) - tour.update(published: false) + tour.update(published: false, media: create_list(:medium, 3)) user = create(:user) user.tour_sets = [] user.tours << tour @@ -102,7 +102,7 @@ end it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do - tour = create(:tour, published: false) + tour = create(:tour, published: false, medium: create(:medium)) tour.update(published: false) user = create(:user) user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) diff --git a/spec/factories/distance_matrix.json b/spec/factories/distance_matrix.json new file mode 100644 index 00000000..11332b2b --- /dev/null +++ b/spec/factories/distance_matrix.json @@ -0,0 +1,42 @@ +{ + "destination_addresses": ["Lexington, MA, USA", "Concord, MA, USA"], + "origin_addresses": ["Boston, MA, USA", "Charlestown, Boston, MA, USA"], + "rows": + [ + { + "elements": + [ + { + "distance": { "text": "23.6 km", "value": 23644 }, + "duration": { "text": "28 mins", "value": 1673 }, + "duration_in_traffic": { "text": "34 mins", "value": 2026 }, + "status": "OK" + }, + { + "distance": { "text": "41.3 km", "value": 41294 }, + "duration": { "text": "34 mins", "value": 2063 }, + "duration_in_traffic": { "text": "37 mins", "value": 2231 }, + "status": "OK" + } + ] + }, + { + "elements": + [ + { + "distance": { "text": "31.2 km", "value": 31219 }, + "duration": { "text": "26 mins", "value": 1545 }, + "duration_in_traffic": { "text": "32 mins", "value": 1942 }, + "status": "OK" + }, + { + "distance": { "text": "29.6 km", "value": 29594 }, + "duration": { "text": "32 mins", "value": 1895 }, + "duration_in_traffic": { "text": "37 mins", "value": 2218 }, + "status": "OK" + } + ] + } + ], + "status": "OK" +} \ No newline at end of file diff --git a/spec/factories/distance_matrix_zero.json b/spec/factories/distance_matrix_zero.json new file mode 100644 index 00000000..b8da0655 --- /dev/null +++ b/spec/factories/distance_matrix_zero.json @@ -0,0 +1,16 @@ +{ + "destination_addresses": ["Lexington, MA, USA", "Concord, MA, USA"], + "origin_addresses": ["Boston, MA, USA", "Charlestown, Boston, MA, USA"], + "rows": + [ + { + "elements": + [ + { + "status": "ZERO_RESULTS" + } + ] + } + ], + "status": "OK" +} \ No newline at end of file diff --git a/spec/factories/images/0.jpg b/spec/factories/images/0.jpg index a386aac0e6f67288e5601e8503ca4ca09cd48036..2a5abaa2be87d07e6fca9e8d72987a2f8c3b4dcf 100644 GIT binary patch literal 44904 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

t|(1t3BU&~(OLgS?TYe~fQo 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

eH6>7k9OocWL|4 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≀c&!?W|IL%`Fxdw!;k~i7e-HV+VSBar=KwN9Iw} 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 nQ_ztnK4JFNA%l~VN;)<&A(J literal 44558 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 z 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�>Yu$S1KMMn48^xQuin#lwnW5E;+%l&ZwM4Q-(c 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 _$X9P_(45pyYoCoj%APLc;NrUL>FEjh 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≀at0ppi5@m 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 U;xWtbC@8;ry85}Sb4q9e0M**^_5c6? literal 0 HcmV?d00001 diff --git a/spec/factories/images/png_base64.txt b/spec/factories/images/png_base64.txt new file mode 100644 index 00000000..709c788c --- /dev/null +++ b/spec/factories/images/png_base64.txt @@ -0,0 +1 @@ +data:image/png;base64,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 \ No newline at end of file diff --git a/spec/factories/map_icons.rb b/spec/factories/map_icons.rb new file mode 100755 index 00000000..f65c076c --- /dev/null +++ b/spec/factories/map_icons.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# spec/factories/map_icons.rb +FactoryBot.define do + factory :map_icon do + filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } + base_sixty_four { File.read(Rails.root.join('spec/factories/images/icon_base64.txt')) } + created_at { Faker::Number.number(digits: 10) } + end +end diff --git a/spec/factories/tour_media.rb b/spec/factories/tour_media.rb new file mode 100644 index 00000000..937b7656 --- /dev/null +++ b/spec/factories/tour_media.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# spec/factories/tour_media.rb +FactoryBot.define do + factory :tour_medium do + association :tour + association :medium + position { 1 } + end +end diff --git a/spec/factories/tour_sets.rb b/spec/factories/tour_sets.rb index 2119d116..39bd751e 100644 --- a/spec/factories/tour_sets.rb +++ b/spec/factories/tour_sets.rb @@ -5,5 +5,7 @@ factory :tour_set do # This is to really make sure the name of the tenant is unique. name { Faker::Name.unique.name } + base_sixty_four { nil } + logo_title { nil } end end diff --git a/spec/factories/tours.rb b/spec/factories/tours.rb index 6c633d2a..53cf0451 100644 --- a/spec/factories/tours.rb +++ b/spec/factories/tours.rb @@ -8,6 +8,8 @@ published { Faker::Boolean.boolean(true_ratio: 0.5) } theme { Theme.create! } mode { Mode.create! } + link_address { Faker::Internet.url } + is_geo { true } factory :tour_with_stops do transient do diff --git a/spec/models/map_icon_spec.rb b/spec/models/map_icon_spec.rb index 0768c7b5..5ab515b1 100644 --- a/spec/models/map_icon_spec.rb +++ b/spec/models/map_icon_spec.rb @@ -1,5 +1,13 @@ require 'rails_helper' RSpec.describe MapIcon, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + context 'size error' do + it 'fails validation when image it too big' do + icon = MapIcon.create( + base_sixty_four: File.read(Rails.root.join('spec/factories/images/png_base64.txt')), + filename: Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') + ) + expect(icon.errors.full_messages).to include 'Icons should be no bigger that 80 by 80 pixels' + end + end end diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index a699ae7e..c07021f5 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -41,5 +41,40 @@ expect(medium.file.attached?).to be true expect(medium.title).to eq('Emory Center for Digital Scholarship') end + + it 'replaces file for video' do + medium = create(:medium, video: 'F9ULbmCvmxY', base_sixty_four: nil, video_provider: 'youtube') + original_checksum = medium.file.blob.checksum + expect(original_checksum).to eq(Digest::MD5.file(Rails.root.join('spec/factories/images/0.jpg')).base64digest) + medium.update(base_sixty_four: File.read(Rails.root.join('spec/factories/images/png_base64.txt'))) + expect(medium.file.blob.checksum).not_to eq(original_checksum) + expect(medium.file.blob.checksum).to eq(Digest::MD5.file(Rails.root.join('spec/factories/images/atl.png')).base64digest) + end + end + + + context 'createing images' do + it 'sets widths for variants' do + medium = create( + :medium, + filename: Faker::File.file_name(dir: '', ext: 'jpg', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/atl_base64.txt')), + video: nil + ) + + medium.save + expect(medium.lqip_width).not_to be nil + end + + it 'saves a gif' do + medium = create( + :medium, + filename: Faker::File.file_name(dir: '', ext: 'gif', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/gif_base64.txt')), + video: nil + ) + + expect(medium.file.blob.checksum).to eq('4fqkSXu+qjQuQWCms8xBBQ==') + end end end diff --git a/spec/models/stop_spec.rb b/spec/models/stop_spec.rb index 62a0a016..fc93751f 100644 --- a/spec/models/stop_spec.rb +++ b/spec/models/stop_spec.rb @@ -8,4 +8,17 @@ # it { should validate_presence_of(:title) } it { should have_many(:stop_media) } it { should have_many(:media) } + + it 'has specified splash' do + stop = create(:stop, medium: create(:medium)) + expect(stop.splash).not_to be nil + end + + it 'has uses the first medium for splash' do + stop = create(:stop) + create_list(:medium, 3) + Medium.all.each { |medium| stop.media << medium } + expect(stop.splash).not_to be nil + expect(stop.splash[:title]).to eq(StopMedium.find_by(position: 1).medium.title) + end end diff --git a/spec/models/tour_set_spec.rb b/spec/models/tour_set_spec.rb index 873dbf19..5172643b 100644 --- a/spec/models/tour_set_spec.rb +++ b/spec/models/tour_set_spec.rb @@ -4,4 +4,10 @@ RSpec.describe TourSet, type: :model do it { should validate_presence_of(:name) } + + it 'creates four travel modes' do + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + expect(Mode.count).to eq(4) + end end diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index df3eea38..de96cd22 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -9,4 +9,19 @@ it { expect(subject).to have_many(:tour_stops) } it { expect(Tour.reflect_on_association(:theme).macro).to eq(:belongs_to) } it { expect(Tour.reflect_on_association(:mode).macro).to eq(:belongs_to) } + + it 'gets a duration' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3)) + expect(tour.duration).to eq(6136) + end + + it 'gets no duration whin invalid request is made to Google' do + tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5)) + expect(tour.duration).to be nil + end + + it 'gets no duration whin response has ZERO_RESULTS' do + tour = create(:tour, mode: Mode.find_by(title: 'WALKING'), stops: create_list(:stop, 4)) + expect(tour.duration).to be nil + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 75409dd5..35eedd15 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -71,18 +71,24 @@ # start the transaction strategy as examples are run config.around(:each) do |example| - # DatabaseCleaner.cleaning do - example.run - # end + example.run end config.before(:each) do + MiniMagick.configure do |config| + config.validate_on_create = false + end # Start transaction for this test # DatabaseCleaner.start # Switch into the default tenant Apartment::Tenant.switch! TourSet.find(TourSet.pluck(:id).sample).subdir + + # Set the host for ActiveStorage urls + ActiveStorage::Current.host = 'http://test.host' # host! 'atlanta.lvh.me' # load Rails.root + 'db/seeds.rb' + + # Stub a network requests stub_request(:get, 'https://placehold.it/300x300.png_1000x1000') .to_return( body: File.open(Rails.root + 'spec/factories/images/0.jpg'), @@ -127,7 +133,7 @@ stub_request(:get, /http:\/\/test\.host\/rails\/active_storage\/.*/) .to_return( - body: File.open(Rails.root + 'spec/factories/images/0.jpg'), + body: File.open(Rails.root + 'spec/factories/images/atl.png'), status: 200 ) @@ -179,6 +185,15 @@ ) stub_request(:get, /http:\/\/127\.0\.0\.1:.*\/json\/version/).to_return(body: '{}', status: 200) + + stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*bicycling.*/) + .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix.json'), status: 200) + + stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*walking.*/) + .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix_zero.json'), status: 200) + + stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*driving.*/) + .to_return(body: '{"status": "INVALID_REQUEST"}', status: 200) end config.after(:each) do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2a28128c..93927bcb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,7 @@ require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) WebMock.disable_net_connect!(allow: '45.33.24.119') +WebMock.disable_net_connect!(allow: 'http://test.host') # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 65d31433..69f033be 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -7,7 +7,7 @@ DatabaseCleaner.strategy = :transaction end - config.before(:each, :js => true) do + config.before(:each, js: true) do DatabaseCleaner.strategy = :truncation end @@ -16,6 +16,10 @@ end config.after(:each) do - DatabaseCleaner.clean + begin + DatabaseCleaner.clean + rescue NoMethodError + # IDK + end end end From 609987a0cbf3b01c7b9a206411716be96540e07a Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 8 Sep 2021 16:21:39 -0400 Subject: [PATCH 076/160] Fix tests for CircleCI build --- app/controllers/v3/tour_modes_controller.rb | 2 +- spec/controllers/v3/tour_geojson_controller_spec.rb | 5 +++-- spec/rails_helper.rb | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb index 29b23720..c49ea1a5 100644 --- a/app/controllers/v3/tour_modes_controller.rb +++ b/app/controllers/v3/tour_modes_controller.rb @@ -15,5 +15,5 @@ class TourModesController < ApplicationController # tour_mode = TourMode.find(params[:id]) # render json: tour_mode # end - # end + end end diff --git a/spec/controllers/v3/tour_geojson_controller_spec.rb b/spec/controllers/v3/tour_geojson_controller_spec.rb index ad602d7f..ed72a58a 100644 --- a/spec/controllers/v3/tour_geojson_controller_spec.rb +++ b/spec/controllers/v3/tour_geojson_controller_spec.rb @@ -8,10 +8,11 @@ tour.stops.each { |stop| stop.media << create_list(:medium, rand(1..3)) } get :show, params: { id: tour.to_param, tenant: Apartment::Tenant.current } geojson = JSON.parse(response.body).with_indifferent_access + first_stop = Stop.find_by(title: geojson[:features].first[:properties][:title]) expect(geojson[:type]).to eq('FeatureCollection') expect(geojson[:features].count).to eq(tour.stops.count) - expect(geojson[:features].first[:geometry][:coordinates]).to eq([tour.stops.first.lng.to_f, tour.stops.first.lat.to_f]) - expect(geojson[:features].last[:properties][:images].first[:caption]).to eq(tour.stops.last.media.first.caption) + expect(geojson[:features].first[:geometry][:coordinates]).to eq([first_stop.lng.to_f, first_stop.lat.to_f]) + expect(first_stop.media.map(&:caption)).to include geojson[:features].first[:properties][:images].first[:caption] end it 'returns 401 when tour is unpublished' do diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 35eedd15..f38f8660 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -186,13 +186,13 @@ stub_request(:get, /http:\/\/127\.0\.0\.1:.*\/json\/version/).to_return(body: '{}', status: 200) - stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*bicycling.*/) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*bicycling.*/) .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix.json'), status: 200) - stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*walking.*/) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*walking.*/) .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix_zero.json'), status: 200) - stub_request(:get, /https:\/\/maps\.googleapis\.com\/maps\/api\/.*driving.*/) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*driving.*/) .to_return(body: '{"status": "INVALID_REQUEST"}', status: 200) end From bc7fab8dd9cf49873cf5df3ccb460bade42f3554 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 9 Sep 2021 09:14:10 -0400 Subject: [PATCH 077/160] Add debug statements --- app/models/tour.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/tour.rb b/app/models/tour.rb index 134bc414..f232711c 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -125,12 +125,15 @@ def duration begin matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) + puts matrix return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' durations = matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } + puts durations durations.sum + 600 + (stops.count * 600) # ActiveSupport::Duration.build(seconds).parts rescue GoogleMapsService::Error::ApiError, ArgumentError => error + puts error nil end end From 6274da421678d2102667aeeca77f8a960163b2e6 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 9 Sep 2021 09:55:31 -0400 Subject: [PATCH 078/160] Remove debug statements. Add fake Google key for testing. --- app/models/tour.rb | 3 --- config/initializers/g_maps.rb | 6 +++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index f232711c..134bc414 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -125,15 +125,12 @@ def duration begin matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) - puts matrix return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' durations = matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } - puts durations durations.sum + 600 + (stops.count * 600) # ActiveSupport::Duration.build(seconds).parts rescue GoogleMapsService::Error::ApiError, ArgumentError => error - puts error nil end end diff --git a/config/initializers/g_maps.rb b/config/initializers/g_maps.rb index c20cbef7..07b7f579 100644 --- a/config/initializers/g_maps.rb +++ b/config/initializers/g_maps.rb @@ -1,3 +1,7 @@ GoogleMapsService.configure do |config| - config.key = Rails.application.credentials.dig(:g_maps_key) + if ENV['RAILS_ENV'] == 'test' + config.key = 'FAkeFaK-E_fAkeChv-P3nchtQYHoCLfFzn9ylr8' + else + config.key = Rails.application.credentials.dig(:g_maps_key) + end end From f903a3f014936c6dfae6bf8274c40ba27f299068 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 9 Sep 2021 09:56:21 -0400 Subject: [PATCH 079/160] Don't add themes to new tenants. --- app/models/tour_set.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index dcf93d10..fcbc2be8 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -87,8 +87,8 @@ def create_tenant end def create_defaults - Apartment::Tenant.reset - themes = Theme.all.collect(&:title) + # Apartment::Tenant.reset + # themes = Theme.all.collect(&:title) Apartment::Tenant.switch! subdir Mode.create([ { title: 'BICYCLING', icon: 'bicycle' }, @@ -96,9 +96,9 @@ def create_defaults { title: 'TRANSIT', icon: 'subway' }, { title: 'WALKING', icon: 'walking' } ]) - themes.each do |t| - Theme.create(title: t) - end + # themes.each do |t| + # Theme.create(title: t) + # end end def drop_tenant From 62951e7ea2b43fb65665c2b311121150f01a787b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 9 Sep 2021 14:24:15 -0400 Subject: [PATCH 080/160] Tidy up and more tests --- app/models/medium.rb | 4 -- app/models/medium_base_record.rb | 4 ++ app/models/stop.rb | 11 ------ app/models/tour.rb | 7 ---- app/models/user.rb | 32 ++++++++-------- app/serializers/v3/map_icon_serializer.rb | 2 +- app/serializers/v3/map_overlay_serializer.rb | 2 +- spec/factories/login.rb | 2 + spec/models/user_spec-fix.rb | 19 ---------- spec/models/user_spec.rb | 40 ++++++++++++++++++++ 10 files changed, 63 insertions(+), 60 deletions(-) delete mode 100644 spec/models/user_spec-fix.rb create mode 100644 spec/models/user_spec.rb diff --git a/app/models/medium.rb b/app/models/medium.rb index 2f7052d0..4b070df0 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -35,10 +35,6 @@ def published tours.any? { |tour| tour.published } || stops.any? { |stop| stop.published } end - def original_image_url - file.url - end - def files return nil if !self.file.attached? diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index f8275083..2e1d9b9f 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -22,6 +22,10 @@ def tmp_file_path nil end + def original_image_url + file.url + end + # # Create and attach file from Base64 string. # diff --git a/app/models/stop.rb b/app/models/stop.rb index 6c77654b..ef50b2fe 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -49,17 +49,6 @@ def splash nil end - def insecure_splash - # if !stop_media.empty? - # return medium.nil? ? stop_media.order(:position).first.medium.insecure : medium.insecure - # end - nil - end - - def is_published - tours.published.present? - end - def orphaned tours.empty? end diff --git a/app/models/tour.rb b/app/models/tour.rb index 134bc414..7d1f27eb 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -84,13 +84,6 @@ def splash nil end - def insecure_splash - # if !tour_media.empty? - # return medium.nil? ? tour_media.order(:position).first.medium.insecure : medium.insecure - # end - nil - end - def stop_count self.stops.count end diff --git a/app/models/user.rb b/app/models/user.rb index 5f1cf2cc..c140646d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,23 +24,21 @@ def provider login.provider end - # private - - def all_tours - all = [] - TourSet.all.each do |tour_set| - Apartment::Tenant.switch! tour_set.subdir - next if tours.empty? || current_tenant_admin? - Apartment::Tenant.switch! tour_set.subdir - _tours = TourAuthor.where(user: self) - # puts tours.ma - all.push(_tours.map { |ta| { id: ta.tour.id, tenant: ta.tour.tenant, title: ta.tour.title } }) - end - Apartment::Tenant.reset - all.flatten.uniq + def all_tours + all = [] + TourSet.all.each do |tour_set| + Apartment::Tenant.switch! tour_set.subdir + next if tours.empty? || current_tenant_admin? + Apartment::Tenant.switch! tour_set.subdir + _tours = TourAuthor.where(user: self) + # puts tours.ma + all.push(_tours.map { |ta| { id: ta.tour.id, tenant: ta.tour.tenant, title: ta.tour.title } }) end + Apartment::Tenant.reset + all.flatten.uniq + end - def login - EcdsRailsAuthEngine::Login.find_by(user_id: self.id) - end + def login + EcdsRailsAuthEngine::Login.find_by(user_id: self.id) + end end diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb index 2d6a85dc..4946ed55 100644 --- a/app/serializers/v3/map_icon_serializer.rb +++ b/app/serializers/v3/map_icon_serializer.rb @@ -1,4 +1,4 @@ class V3::MapIconSerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers - attributes :id, :base_sixty_four, :filename, :image_url + attributes :id, :base_sixty_four, :filename, :original_image_url end diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb index e859364a..9e05033f 100644 --- a/app/serializers/v3/map_overlay_serializer.rb +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -1,4 +1,4 @@ class V3::MapOverlaySerializer < ActiveModel::Serializer include Rails.application.routes.url_helpers - attributes :id, :south, :north, :east, :west, :image_url, :filename + attributes :id, :south, :north, :east, :west, :original_image_url, :filename end diff --git a/spec/factories/login.rb b/spec/factories/login.rb index 7a81aaf2..600d1749 100644 --- a/spec/factories/login.rb +++ b/spec/factories/login.rb @@ -7,5 +7,7 @@ FactoryBot.define do factory :login, class: EcdsRailsAuthEngine::Login do token { JWT.encode(Faker::Beer.style, Faker::Address.zip, 'HS256') } + provider { Faker::Internet.domain_name } + user_id { nil } end end diff --git a/spec/models/user_spec-fix.rb b/spec/models/user_spec-fix.rb deleted file mode 100644 index ebcc26cf..00000000 --- a/spec/models/user_spec-fix.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe User, type: :model do - it { should have_one(:login) } - it { expect(User.reflect_on_association(:login).macro).to eq(:has_one) } - - context 'creates login' do - it 'creates login' do - pw = Faker::Internet.password(min_length: 8) - u = User.create!(displayname: Faker::Movies::HitchhikersGuideToTheGalaxy.character) - Login.create!(identification: 'foo@bar.com', password: pw, password_confirmation: pw, user: u) - # RailsApiAuth uses `has_secure_password` The `authenticate` method returns - # the `Login` object. This just checks that the password authenticates - expect(u.login.authenticate(pw).user).to eq(u) - end - end -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 00000000..22393feb --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User, type: :model do + # it { should have_one(:login) } + # it { expect(User.reflect_on_association(:login).macro).to eq(:has_one) } + + context 'tour author across tenants' do + it 'lists all tours across tenants' do + # pw = Faker::Internet.password(min_length: 8) + # u = User.create!(displayname: Faker::Movies::HitchhikersGuideToTheGalaxy.character) + # Login.create!(identification: 'foo@bar.com', password: pw, password_confirmation: pw, user: u) + # # RailsApiAuth uses `has_secure_password` The `authenticate` method returns + # # the `Login` object. This just checks that the password authenticates + # expect(u.login.authenticate(pw).user).to eq(u) + TourSet.all.each { |tour_set| tour_set.delete } + user = create(:user) + create_list(:tour_set, 4) + TourSet.all.each do |tour_set| + Apartment::Tenant.switch! tour_set.subdir + user.tours << create_list(:tour, 2) + end + expect(user.all_tours.count).to eq(8) + end + end + + context 'has login' do + it 'has no provider' do + user = create(:user) + expect(user.provider).to be nil + end + + it 'has no provider' do + user = create(:user) + login = create(:login, user_id: user.id) + expect(user.provider).to eq(login.provider) + end + end +end From 58bb9ec02115ad0f9acbc7d6fe4356f1fb40e81f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 14 Sep 2021 17:26:13 -0400 Subject: [PATCH 081/160] Complete test coverage --- Gemfile | 1 + Gemfile.lock | 2 + app/controllers/concerns/exception_handler.rb | 22 +- app/controllers/v1/tours_controller.rb | 9 - .../v3/authenticated_controller.rb | 14 - app/controllers/v3/flat_pages_controller.rb | 5 - app/controllers/v3/map_icons_controller.rb | 46 +-- app/controllers/v3/map_overlays_controller.rb | 38 +- app/controllers/v3/stop_media_controller.rb | 51 +-- app/controllers/v3/tour_authors_controller.rb | 46 +-- .../v3/tour_flat_pages_controller.rb | 83 ++-- app/controllers/v3/tour_media_controller.rb | 4 - app/controllers/v3/tour_modes_controller.rb | 19 - .../v3/tour_relations_controller.rb | 4 + app/controllers/v3/tour_stops_controller.rb | 5 +- app/controllers/v3/tours_controller.rb | 5 +- app/controllers/v3/users_controller.rb | 39 +- app/controllers/v3_controller.rb | 8 +- app/lib/api_version.rb | 20 +- app/models/map_icon.rb | 11 +- app/models/map_overlay.rb | 6 + app/models/medium_base_record.rb | 37 +- app/models/slug.rb | 8 +- app/models/stop_medium.rb | 18 +- app/models/tour_medium.rb | 14 +- app/models/tour_stop.rb | 7 - app/models/user.rb | 2 + app/models/v3.rb | 5 - config/routes.rb | 3 - lib/snippets.rb | 4 +- lib/vimeo_props.rb | 19 - lib/youtube_props.rb | 16 - old_spcs/requests/v3/media_spec.rb.fix | 2 +- spec/controllers/v1/tours_controller_spec.rb | 7 - .../v3/authenticated_controller_spec.rb | 7 - .../v3/flat_pages_controller_spec.rb | 19 + .../v3/map_icons_controller_spec.rb | 185 +++++++++ .../v3/map_overlays_controller_spec.rb | 160 ++++++++ spec/controllers/v3/media_controller_spec.rb | 13 + .../v3/stops_media_controller_spec-fix.rb | 6 - .../v3/stops_media_controller_spec.rb | 189 +++++++++ spec/controllers/v3/themes_controller_spec.rb | 23 -- .../v3/tour_authors_controller_spec.rb | 123 ++++++ .../v3/tour_flat_pages_controller_spec.rb | 365 ++++++++++++++++++ .../v3/tour_media_controller_spec.rb | 4 +- .../v3/tour_modes_controller_spec.rb | 7 - .../v3/tour_set_users_controller_spec.rb | 129 ------- .../v3/tour_stops_controller_spec.rb | 91 +++-- spec/controllers/v3/tours_controller_spec.rb | 52 +++ .../v3/users_controller_spec-fix.rb | 127 ------ spec/controllers/v3/users_controller_spec.rb | 274 +++++++++++++ spec/factories/images/jp2_base64.txt | 1 + spec/factories/{ => images}/test.jpg | Bin spec/factories/map_overlays.rb | 14 + spec/factories/media.rb | 4 +- spec/factories/stop_media.rb | 5 +- spec/factories/tour_authors.rb | 6 +- spec/factories/tour_flat_pages.rb | 9 + spec/factories/users.rb | 2 +- spec/models/map_overlay_spec.rb | 11 +- spec/models/medium_spec.rb | 5 + spec/models/role_spec.rb | 5 - spec/models/slug_spec.rb | 2 +- spec/models/stop_slug_spec.rb | 2 +- spec/models/tour_author_spec.rb | 3 +- spec/models/tour_set_user_spec.rb | 5 - spec/models/tour_stop_spec.rb | 1 - spec/models/v3/flat_page_spec.rb | 5 - spec/models/v3/tour_medium_spec.rb | 5 - spec/rails_helper.rb | 13 +- spec/spec_helper.rb | 45 ++- spec/support/request_spec_helper.rb | 4 + 72 files changed, 1765 insertions(+), 736 deletions(-) delete mode 100644 app/controllers/v1/tours_controller.rb delete mode 100644 app/controllers/v3/authenticated_controller.rb delete mode 100644 app/controllers/v3/tour_modes_controller.rb delete mode 100644 app/models/v3.rb delete mode 100644 lib/vimeo_props.rb delete mode 100644 lib/youtube_props.rb delete mode 100644 spec/controllers/v1/tours_controller_spec.rb delete mode 100644 spec/controllers/v3/authenticated_controller_spec.rb create mode 100644 spec/controllers/v3/map_icons_controller_spec.rb create mode 100755 spec/controllers/v3/map_overlays_controller_spec.rb delete mode 100644 spec/controllers/v3/stops_media_controller_spec-fix.rb create mode 100644 spec/controllers/v3/stops_media_controller_spec.rb create mode 100755 spec/controllers/v3/tour_authors_controller_spec.rb create mode 100755 spec/controllers/v3/tour_flat_pages_controller_spec.rb delete mode 100644 spec/controllers/v3/tour_modes_controller_spec.rb delete mode 100644 spec/controllers/v3/tour_set_users_controller_spec.rb delete mode 100644 spec/controllers/v3/users_controller_spec-fix.rb create mode 100644 spec/controllers/v3/users_controller_spec.rb create mode 100644 spec/factories/images/jp2_base64.txt rename spec/factories/{ => images}/test.jpg (100%) create mode 100755 spec/factories/map_overlays.rb create mode 100755 spec/factories/tour_flat_pages.rb delete mode 100644 spec/models/role_spec.rb delete mode 100644 spec/models/tour_set_user_spec.rb delete mode 100644 spec/models/v3/flat_page_spec.rb delete mode 100644 spec/models/v3/tour_medium_spec.rb diff --git a/Gemfile b/Gemfile index 20bc6601..c480d507 100644 --- a/Gemfile +++ b/Gemfile @@ -83,6 +83,7 @@ group :test do gem 'webmock' gem 'coveralls', require: false gem 'simplecov', require: false + gem 'simplecov-lcov', require: false gem 'term-ansicolor' end diff --git a/Gemfile.lock b/Gemfile.lock index 733de046..abec6ffd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -322,6 +322,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + simplecov-lcov (0.8.0) spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) @@ -400,6 +401,7 @@ DEPENDENCIES rspec-rails (~> 4.0.2) shoulda-matchers (~> 4.5.1) simplecov + simplecov-lcov spring spring-watcher-listen (~> 2.0.0) term-ansicolor diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index e2f64c23..7cd90aec 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -5,17 +5,17 @@ module ExceptionHandler # provides the more graceful `included` method extend ActiveSupport::Concern - included do - # rescue_from ActiveRecord::RecordNotFound do |e| - # json_response({ message: e.message }, :not_found) - # end + # included do + # rescue_from ActiveRecord::RecordNotFound do |e| + # json_response({ message: e.message }, :not_found) + # end - rescue_from ActiveRecord::RecordInvalid do |e| - json_response({ message: e.message }, :unprocessable_entity) - end + # rescue_from ActiveRecord::RecordInvalid do |e| + # json_response({ message: e.message }, :unprocessable_entity) + # end - rescue_from ActionController::ParameterMissing do |e| - json_response({ message: e.message }, :unprocessable_entity) - end - end + # rescue_from ActionController::ParameterMissing do |e| + # json_response({ message: e.message }, :unprocessable_entity) + # end + # end end diff --git a/app/controllers/v1/tours_controller.rb b/app/controllers/v1/tours_controller.rb deleted file mode 100644 index 07fa72ee..00000000 --- a/app/controllers/v1/tours_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -# app/controllers/v1/tours_controller.rb -# This is only for testing calls to versioned endpoints. -class V1::ToursController < ApplicationController - def index - json_response(message: Faker::Movies::HitchhikersGuideToTheGalaxy.quote) - end -end diff --git a/app/controllers/v3/authenticated_controller.rb b/app/controllers/v3/authenticated_controller.rb deleted file mode 100644 index 939c10d2..00000000 --- a/app/controllers/v3/authenticated_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -# app/controllers/v3/authenticated_controller.rb -module V3 - class AuthenticatedController < V3Controller - # include RailsApiAuth::Authentication - - before_action :authenticate! - - def index - render json: { success: true } - end - end -end diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index d37526d3..84b8bd4d 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -16,11 +16,6 @@ def index render json: @records end - # GET /v3/records/1 - def show - render json: @record - end - # POST /v3/records def create if @allowed diff --git a/app/controllers/v3/map_icons_controller.rb b/app/controllers/v3/map_icons_controller.rb index 01371254..2f393585 100644 --- a/app/controllers/v3/map_icons_controller.rb +++ b/app/controllers/v3/map_icons_controller.rb @@ -1,54 +1,36 @@ class V3::MapIconsController < V3Controller - # GET /records def index - @records = MapIcon.all - - render json: @records - end - - # GET /records/1 - def show - render json: @record + render json: MapIcon.all end - # POST /records def create - @record = MapIcon.new(record_params) - - if @record.save - render json: @record, status: :created + if crud_allowed? + @record = MapIcon.new(record_params) + if @record.save + render json: @record, status: :created + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end - # PATCH/PUT /records/1 - def update - if @record.update(record_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # DELETE /records/1 - def destroy - @record.destroy - end - private + # Only allow a trusted parameter "white list" through. def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :base_sixty_four, :filename - ] + :base_sixty_four, :filename, :stop + ] ) end def set_record - @record = MapIcon.find(params[:id]) + _record = MapIcon.find(params[:id]) + @record = _record&.published || @allowed ? _record : MapIcon.new(id: params[:id]) end end diff --git a/app/controllers/v3/map_overlays_controller.rb b/app/controllers/v3/map_overlays_controller.rb index 0c08221c..17cd465f 100644 --- a/app/controllers/v3/map_overlays_controller.rb +++ b/app/controllers/v3/map_overlays_controller.rb @@ -1,34 +1,15 @@ class V3::MapOverlaysController < V3Controller - - def show - render json: @record - end - def create - @record = MapOverlay.new(record_params) - if @record.save - render json: @record, status: :created - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # PATCH/PUT /stops/1 - def update - if @record.update(record_params) - # render json: @stop - head :no_content + if crud_allowed? + @record = MapOverlay.new(record_params) + if @record.save + render json: @record, status: :created + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # DELETE /stops/1 - def destroy - if @record - @record.destroy + head 401 end - head :no_content end private @@ -44,6 +25,7 @@ def record_params end def set_record - @record = MapOverlay.find(params[:id]) + _record = MapOverlay.find(params[:id]) + @record = _record&.published || @allowed ? _record : MapOverlay.new(id: params[:id]) end end diff --git a/app/controllers/v3/stop_media_controller.rb b/app/controllers/v3/stop_media_controller.rb index 7f9d863e..b2b16eb3 100644 --- a/app/controllers/v3/stop_media_controller.rb +++ b/app/controllers/v3/stop_media_controller.rb @@ -1,45 +1,19 @@ -# -# Endpoint for through model for Stops and Media -# -class V3::StopMediaController < V3Controller +class V3::StopMediaController < V3::TourRelationsController # GET /v3/stop_media def index - @stop_media = if params[:stop_id] && params[:medium_id] - StopMedium.where(stop_id: params[:stop_id]).where(medium_id: params[:medium_id]).first || {} - else - StopMedium.all - end - render json: @stop_media - end - - # GET /v3/stop_media/1 - def show - render json: @record - end + # @stop_media = if params[:tour_id] && params[:medium_id] + # StopMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} + # else + # StopMedium.all + # end - # POST /v3/stop_media - def create - @record = StopMedium.new(record_params) + @stop_media = StopMedium.all - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/stop-medium/#{@record.id}" - else - render json: serialize_errors, status: :unprocessable_entity + unless current_user&.current_tenant_admin? || current_user.tours.present? + @stop_media = @stop_media.reject { |stop_medium| !stop_medium.stop.published } end - end - # PATCH/PUT /v3/stop_media/1 - def update - if @record.update(record_params) - render json: @record - else - render json: serialize_errors, status: :unprocessable_entity - end - end - - # DELETE /v3/stop_media/1 - def destroy - @record.destroy + render json: @stop_media end private @@ -48,12 +22,13 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :medium, :stop, :position, :medium_id, :stop_id + :medium, :stop, :position ] ) end def set_record - @record = StopMedium.find(params[:id]) + _record = StopMedium.find(params[:id]) + @record = _record&.published || @allowed ? _record : StopMedium.new(id: params[:id]) end end diff --git a/app/controllers/v3/tour_authors_controller.rb b/app/controllers/v3/tour_authors_controller.rb index 50076dd4..bd8e4b73 100644 --- a/app/controllers/v3/tour_authors_controller.rb +++ b/app/controllers/v3/tour_authors_controller.rb @@ -1,42 +1,38 @@ module V3 class TourAuthorsController < ApplicationController - before_action :set_tour_author, only: [:show, :update, :destroy] + before_action :set_tour_author, only: [:show] # GET /tour_authors def index - @tour_authors = TourAuthor.all - - render json: @tour_authors + if current_user&.current_tenant_admin? + render json: TourAuthor.all + else + head 401 + end end # GET /tour_authors/1 def show - render json: @tour_author + if current_user&.current_tenant_admin? + render json: @tour_author + else + head 401 + end end # POST /tour_authors def create - @tour_author = TourAuthor.new(tour_author_params) - - if @tour_author.save - render json: @tour_author, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@tour_author}" - else - render json: @tour_author.errors, status: :unprocessable_entity - end + head 405 end - # PATCH/PUT /tour_authors/1 + # PATCH/PUT /tour_set_admins/1 def update - if @tour_author.update(tour_author_params) - render json: @tour_author - else - render json: @tour_author.errors, status: :unprocessable_entity - end + head 405 end - # DELETE /tour_authors/1 + # DELETE /tour_set_admins/1 def destroy - @tour_author.destroy + head 405 end private @@ -44,15 +40,5 @@ def destroy def set_tour_author @tour_author = TourAuthor.find(params[:id]) end - - # Only allow a trusted parameter "white list" through. - def tour_author_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :tour, :user - ] - ) - end end end diff --git a/app/controllers/v3/tour_flat_pages_controller.rb b/app/controllers/v3/tour_flat_pages_controller.rb index a4f01bae..f1f39f5a 100644 --- a/app/controllers/v3/tour_flat_pages_controller.rb +++ b/app/controllers/v3/tour_flat_pages_controller.rb @@ -1,38 +1,40 @@ # frozen_string_literal: true +# /app/controllers/v3/tour_stops_controller.rb class V3::TourFlatPagesController < V3Controller - # GET /v3/tour_flat_pages + # GET /stops def index - @records = TourFlatPage.all + @tour_flat_pages = TourFlatPage.all - render json: @records + unless current_user&.current_tenant_admin? || current_user.tours.present? + @tour_flat_pages = @tour_flat_pages.reject { |tour_flat_page| !tour_flat_page.tour.published } + end + + render json: @tour_flat_pages end - # GET /v3/tour_flat_pages/1 + # GET /stops/1 def show - render json: @record + if @record&.tour.published || allowed? + render json: @record + else + render json: { data: {} } + end + # render json: { data: {} } if @record.nil? + # render json: @record, include: ['stop'] end - # POST /v3/tour_flat_pages + # POST /stops def create - if @allowed - @record = TourFlatPage.new(tour_flat_page_params) - - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@record.id}" - else - render json: serialize_errors, status: :unprocessable_entity - end - else - head 401 - end + # Not created via the API + head 405 end - # PATCH/PUT /v3/tour_flat_pages/1 + # PATCH/PUT /stops/1 def update if @allowed - if @record.update(tour_flat_page_params) - render json: @record + if @record.update(tour_stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" else render json: serialize_errors, status: :unprocessable_entity end @@ -41,27 +43,30 @@ def update end end - # DELETE /v3/tour_flat_pages/1 + # DELETE /stops/1 def destroy - if @allowed - @record.destroy - else - head 401 - end + # Not deleted via the API + head 405 end - private - # Only allow a trusted parameter "white list" through. - def tour_flat_page_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :tour, :flat_page, :position - ] - ) - end + private - def set_record - @record = TourFlatPage.find(params[:id]) - end + # Only allow a trusted parameter "white list" through. + def tour_stop_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :stop, :tour, :position + ] + ) + end + + def set_record + @record = TourFlatPage.find(params[:id]) + end + + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + return @allowed + end end diff --git a/app/controllers/v3/tour_media_controller.rb b/app/controllers/v3/tour_media_controller.rb index e6871677..320ad96c 100644 --- a/app/controllers/v3/tour_media_controller.rb +++ b/app/controllers/v3/tour_media_controller.rb @@ -16,10 +16,6 @@ def index render json: @tour_media end - def destroy - head 405 - end - private # Only allow a trusted parameter "white list" through. def record_params diff --git a/app/controllers/v3/tour_modes_controller.rb b/app/controllers/v3/tour_modes_controller.rb deleted file mode 100644 index c49ea1a5..00000000 --- a/app/controllers/v3/tour_modes_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# app/controllers/v3/tour_modes_controller.rb -module V3 - class TourModesController < ApplicationController - # # GET /tour_sets - # def index - # @tour_modes = TourMode.all - - # render json: @tour_modes - # end - - # # GET /v3/tour_media/1 - # def show - # tour_mode = TourMode.find(params[:id]) - # render json: tour_mode - # end - end -end diff --git a/app/controllers/v3/tour_relations_controller.rb b/app/controllers/v3/tour_relations_controller.rb index 073889e6..2ade823e 100644 --- a/app/controllers/v3/tour_relations_controller.rb +++ b/app/controllers/v3/tour_relations_controller.rb @@ -4,6 +4,10 @@ # module V3 class V3::TourRelationsController < V3Controller + def destroy + head 405 + end + def allowed? set_record if @record.nil? && params[:id].present? @allowed = @record&.published || crud_allowed? diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index c3290f2e..6807c2fc 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -40,7 +40,7 @@ def show # POST /stops def create # Not created via the API - head 401 + head 405 end # PATCH/PUT /stops/1 @@ -58,8 +58,7 @@ def update # DELETE /stops/1 def destroy - # Not deleted via the API - head 401 + head 405 end private diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index 2c40869a..cfc12387 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -12,13 +12,10 @@ def index else nil end - elsif (current_user && params[:tourTenant]) - Apartment::Tenant.switch! params[:tourTenant] - Tour.find(params[:tour]) elsif (current_user && current_user.current_tenant_admin?) Tour.all elsif (current_user && current_user.id) - current_user.tours + (current_user.tours + Tour.published).uniq else Tour.published end diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index 0b767251..aa23c71c 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -18,8 +18,6 @@ def index else render json: { data: [] } end - else - render json: { message: 'You are not autorized to to view this resource.' }.to_json, status: 401 end end @@ -35,35 +33,38 @@ def show # TODO: Is this endpoint ever used? # POST /users def create - @record = User.new(user_params) + if current_user&.super + @record = User.new(user_params) - if @record.save - render json: @record, status: :created, location: @record + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/users/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # PATCH/PUT /users/1 def update - if @record.update(user_params) - render json: @record + if current_user&.super || current_user == @record + if @record.update(user_params) + render json: @record + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end end # DELETE /users/1 def destroy - @record.destroy - end - - def me - user = @current_login.user - if user.nil? - render json: 'Invalid api token', status: :foo + if current_user&.super + @record.destroy else - render json: user + head 401 end end @@ -73,9 +74,9 @@ def user_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :displayname, :identification, :password, + :display_name, :identification, :password, :password_confirmation, :uid, :tour_sets, - :tours, :super + :tours, :super, :email ] ) end diff --git a/app/controllers/v3_controller.rb b/app/controllers/v3_controller.rb index accf6031..d87c08df 100644 --- a/app/controllers/v3_controller.rb +++ b/app/controllers/v3_controller.rb @@ -38,11 +38,8 @@ def destroy def serialize_errors errors = [] - if @record.nil? - errors.push({ detail: 'Record not found', source: { pointer: 'data/attributes' } }) - # head 404 - else - @record.errors.messages[:base].each do |error| + if @record&.errors + @record.errors.full_messages.each do |error| errors.push({ detail: error, source: { @@ -50,7 +47,6 @@ def serialize_errors } }) end - # head 422 end { errors: errors } end diff --git a/app/lib/api_version.rb b/app/lib/api_version.rb index ac4be814..c37a65e0 100644 --- a/app/lib/api_version.rb +++ b/app/lib/api_version.rb @@ -9,16 +9,16 @@ def initialize(version, default = false) @default = default end - # check whether version is specified or is default - def matches?(request) - check_headers(request.headers) || default - end + # # check whether version is specified or is default + # def matches?(request) + # check_headers(request.headers) || default + # end - private + # private - def check_headers(headers) - # check version from Accept headers; expect custom media type `tours` - accept = headers[:accept] - accept && accept.include?("application/vnd.tours.#{version}+json") - end + # def check_headers(headers) + # # check version from Accept headers; expect custom media type `tours` + # accept = headers[:accept] + # accept && accept.include?("application/vnd.tours.#{version}+json") + # end end diff --git a/app/models/map_icon.rb b/app/models/map_icon.rb index e444b8f3..f8268b68 100644 --- a/app/models/map_icon.rb +++ b/app/models/map_icon.rb @@ -1,11 +1,18 @@ class MapIcon < MediumBaseRecord validate :check_dimensions + has_one :stop + + def published + stop.published + end + def check_dimensions return if base_sixty_four.nil? + # puts base_sixty_four - headers, tmp_base_sixty_four = base_sixty_four.split(',') - file = MiniMagick::Image.read(Base64.decode64(tmp_base_sixty_four)) + # headers, tmp_base_sixty_four = base_sixty_four.split(',') + file = MiniMagick::Image.read(Base64.decode64(base_sixty_four)) if file[:height] > 80 || file[:width] > 80 errors.add(:base, 'Icons should be no bigger that 80 by 80 pixels') diff --git a/app/models/map_overlay.rb b/app/models/map_overlay.rb index ab200a82..35f7e3d6 100644 --- a/app/models/map_overlay.rb +++ b/app/models/map_overlay.rb @@ -9,7 +9,13 @@ class MapOverlay < MediumBaseRecord belongs_to :tour, optional: true belongs_to :stop, optional: true + def published + tour.published + end + def set_initial_bounds + return if tour&.bounds.nil? + if tour self.south = self.tour.bounds[:south] self.north = self.tour.bounds[:north] diff --git a/app/models/medium_base_record.rb b/app/models/medium_base_record.rb index 2e1d9b9f..64f0e8c9 100755 --- a/app/models/medium_base_record.rb +++ b/app/models/medium_base_record.rb @@ -3,6 +3,8 @@ # Base class for models. class MediumBaseRecord < ApplicationRecord self.abstract_class = true + validate :check_content_type + before_create :attach_file before_destroy :purge @@ -11,6 +13,8 @@ class MediumBaseRecord < ApplicationRecord # has_one_attached "#{Apartment::Tenant.current.underscore}_file" has_one_attached 'file' + attr_accessor :content_type + # def image_url # return nil unless file.attached? @@ -40,14 +44,7 @@ def attach_file # file.blob.delete if file.attached? - if base_sixty_four.include?('data:') - headers, self.base_sixty_four = base_sixty_four.split(',') - headers =~ /^data:(.*?)$/ - content_type = Regexp.last_match(1).split(';base64').first - else - content_type = 'image/jpeg' - end - + self.parse_base64 File.open(tmp_file_path, 'wb') do |f| f.write(Base64.decode64(base_sixty_four)) end @@ -55,7 +52,7 @@ def attach_file self.file.attach( io: File.open(tmp_file_path), filename: filename, - content_type: content_type + content_type: self.content_type ) self.base_sixty_four = nil @@ -69,4 +66,26 @@ def purge remove_tmp_file file.blob.delete if file.attached? end + + def check_content_type + return if base_sixty_four.nil? + + self.parse_base64 + + if self.content_type.include?('jp2') + errors.add(:base, 'JPEG 2000 fils are not supported. Plese convert the image to a reqular JPEG or WebP format.') + end + end + + private + + def parse_base64 + if base_sixty_four.include?('data:') + headers, self.base_sixty_four = base_sixty_four.split(',') + headers =~ /^data:(.*?)$/ + self.content_type = Regexp.last_match(1).split(';base64').first + else + self.content_type = 'image/jpeg' + end + end end diff --git a/app/models/slug.rb b/app/models/slug.rb index 9fbc0d78..f4d062c9 100644 --- a/app/models/slug.rb +++ b/app/models/slug.rb @@ -4,9 +4,9 @@ class Slug < ApplicationRecord belongs_to :tour validates :slug, uniqueness: true - attr_accessor :published + # attr_accessor :published - def published - tour.published - end + # def published + # tour.published + # end end diff --git a/app/models/stop_medium.rb b/app/models/stop_medium.rb index df0c21fc..b8980451 100644 --- a/app/models/stop_medium.rb +++ b/app/models/stop_medium.rb @@ -5,16 +5,20 @@ class StopMedium < ApplicationRecord belongs_to :medium belongs_to :stop + def published + stop&.published + end + after_create do self.position = self.position.nil? ? self.stop.media.length : self.position self.save end - before_destroy do - if self.medium.nil? || self.stop.nil? - nil - else - self.medium.stops.length == 1 ? self.medium.destroy! : nil - end - end + # before_destroy do + # if self.medium.nil? || self.stop.nil? + # nil + # else + # self.medium.stops.length == 1 ? self.medium.destroy! : nil + # end + # end end diff --git a/app/models/tour_medium.rb b/app/models/tour_medium.rb index 653fc0bd..13c44b5b 100644 --- a/app/models/tour_medium.rb +++ b/app/models/tour_medium.rb @@ -11,13 +11,13 @@ class TourMedium < ApplicationRecord self.save end - before_destroy do - if self.medium.nil? || self.tour.nil? - nil - else - self.medium.tours.length == 1 ? self.medium.destroy! : nil - end - end + # before_destroy do + # if self.medium.nil? || self.tour.nil? + # nil + # else + # self.medium.tours.length == 1 ? self.medium.destroy! : nil + # end + # end def published tour&.published diff --git a/app/models/tour_stop.rb b/app/models/tour_stop.rb index 90a3265f..b47b61c4 100644 --- a/app/models/tour_stop.rb +++ b/app/models/tour_stop.rb @@ -9,7 +9,6 @@ class TourStop < ApplicationRecord before_save :_ensure_stop_slug before_validation :_set_position - before_destroy :_delete_orphan def slug stop.slug @@ -43,12 +42,6 @@ def _set_position self.position = self.position || self.tour.stops.length + 1 end - def _delete_orphan - if self.stop.tours.length == 1 - self.stop.destroy - end - end - def _ensure_stop_slug new_slug = StopSlug.find_or_create_by(slug: self.stop.slug, tour: self.tour) new_slug.stop = self.stop diff --git a/app/models/user.rb b/app/models/user.rb index c140646d..849c816b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,8 @@ class User < ActiveRecord::Base has_many :tour_authors has_many :tours, through: :tour_authors + validates :email, presence: true + # scope :search, -> (search) { joins(:login).where("users.display_name ILIKE '%#{search}%' OR logins.identification ILIKE '%#{search}%'")} # diff --git a/app/models/v3.rb b/app/models/v3.rb deleted file mode 100644 index b31800ab..00000000 --- a/app/models/v3.rb +++ /dev/null @@ -1,5 +0,0 @@ -module V3 - def self.table_name_prefix - 'v3_' - end -end diff --git a/config/routes.rb b/config/routes.rb index 2b7fc56f..8fbfacdf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,9 +13,6 @@ resources :tour_set_admins scope ':tenant' do - scope module: :v1, constraints: ApiVersion.new('v1') do - resources :tours, only: :index - end scope module: :v3, constraints: ApiVersion.new('v3', true) do resources :tour_authors, path: 'tour-authors' resources :users diff --git a/lib/snippets.rb b/lib/snippets.rb index 8ef6fad4..7215c014 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -188,10 +188,10 @@ Apartment::Tenant.switch! ts Stop.all.each do |s| if s.lat - s.update(lat: s.lat.round(7).to_f, lng: s.lng.round(7).to_f) + s.update(lat: s.lat.round(6).to_f, lng: s.lng.round(6).to_f) end if s.parking_lat - s.update(parking_lat: s.parking_lat.round(7).to_f, parking_lng: s.parking_lng.round(7).to_f) + s.update(parking_lat: s.parking_lat.round(6).to_f, parking_lng: s.parking_lng.round(6).to_f) end end end diff --git a/lib/vimeo_props.rb b/lib/vimeo_props.rb deleted file mode 100644 index 220b9b40..00000000 --- a/lib/vimeo_props.rb +++ /dev/null @@ -1,19 +0,0 @@ -class VimeoProps - include HTTParty - include Nokogiri - def initialize(video) - @video = video - end - - def id - if @video.include? 'iframe' - Nokogiri::HTML(@video).xpath('//iframe')[0]['src'].split('/')[-1] - else - /\d{9}/.match(@video)[0] - end - end - - def image - HTTParty.get('https://vimeo.com/api/oembed.json?url=https%3A//vimeo.com/#{self.id}')['thumbnail_url'] - end -end diff --git a/lib/youtube_props.rb b/lib/youtube_props.rb deleted file mode 100644 index 2d9526be..00000000 --- a/lib/youtube_props.rb +++ /dev/null @@ -1,16 +0,0 @@ -class YouTubeProps - include YouTubeRails - include Yt - - def initialize(video) - @video = video - end - - def id - YouTubeRails.extract_video_id(@video) - end - - def image - Yt::Video.new(id:id).thumbnail_url('standard') - end -end diff --git a/old_spcs/requests/v3/media_spec.rb.fix b/old_spcs/requests/v3/media_spec.rb.fix index c6fc771b..b8922e4c 100644 --- a/old_spcs/requests/v3/media_spec.rb.fix +++ b/old_spcs/requests/v3/media_spec.rb.fix @@ -193,7 +193,7 @@ RSpec.describe 'V3::Media', type: :request do context 'update with valid data' do before { - valid_attributes[:data][:attributes]['original_image'] = Rack::Test::UploadedFile.new(Rails.root.join('spec/factories/test.jpg'), 'image/jpg') + valid_attributes[:data][:attributes]['original_image'] = Rack::Test::UploadedFile.new(Rails.root.join('spec/factories/images/test.jpg'), 'image/jpg') valid_attributes[:data][:attributes]['id'] = Medium.first.id put "/#{Apartment::Tenant.current}/media/#{Medium.first.id}", params: valid_attributes, headers: { Authorization: "Bearer #{User.last.login.oauth2_token}" } } diff --git a/spec/controllers/v1/tours_controller_spec.rb b/spec/controllers/v1/tours_controller_spec.rb deleted file mode 100644 index 721b5dda..00000000 --- a/spec/controllers/v1/tours_controller_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe V1::ToursController, type: :controller do - -end diff --git a/spec/controllers/v3/authenticated_controller_spec.rb b/spec/controllers/v3/authenticated_controller_spec.rb deleted file mode 100644 index f2935b2e..00000000 --- a/spec/controllers/v3/authenticated_controller_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe V3::AuthenticatedController, type: :controller do - -end diff --git a/spec/controllers/v3/flat_pages_controller_spec.rb b/spec/controllers/v3/flat_pages_controller_spec.rb index 2639accb..d425c993 100644 --- a/spec/controllers/v3/flat_pages_controller_spec.rb +++ b/spec/controllers/v3/flat_pages_controller_spec.rb @@ -203,6 +203,16 @@ expect(attributes[:title]).to eq('Elmyr') expect(FlatPage.count).to eq(original_flat_page_count + 1) end + + it 'return 422 when missing title' do + user = create(:user, super: true) + signed_cookie(user) + original_flat_page_count = FlatPage.count + post :create, params: { data: { type: 'flat_pages', attributes: {} }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(422) + expect(errors).to include('Title can\'t be blank') + expect(FlatPage.count).to eq(original_flat_page_count) + end end end @@ -270,6 +280,15 @@ expect(attributes[:title]).to eq(new_title) expect(FlatPage.find(tour.flat_pages.first.id).title).to eq(new_title) end + + it 'returns 422 when title in nil' do + flat_page = create(:flat_page) + user = create(:user, super: true) + signed_cookie(user) + post :update, params: { id: flat_page.id, data: { type: 'flat_pages', attributes: { title: nil } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(422) + expect(errors).to include('Title can\'t be blank') + end end # context 'with invalid params' do diff --git a/spec/controllers/v3/map_icons_controller_spec.rb b/spec/controllers/v3/map_icons_controller_spec.rb new file mode 100644 index 00000000..35d607a5 --- /dev/null +++ b/spec/controllers/v3/map_icons_controller_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::MapIconsController, type: :controller do + describe 'GET #index' do + it 'returns all map icons' do + create_list(:map_icon, rand(2..6)) + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq(MapIcon.count) + end + end + + describe 'GET #show' do + context 'unauthenticated and unauthorized' do + it 'returns empty MapIcon when not unauthenticated' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + get :show, params: { tenant: TourSet.first.subdir, id: map_icon.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + + it 'returns empty MapIcon when not unauthenticated but unauthorized' do + initial_tenant = Apartment::Tenant.current + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include TourSet.find_by(subdir: initial_tenant) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + get :show, params: { tenant: initial_tenant, id: map_icon } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + end + + context 'authorized' do + it 'responds with 200 and a MapIcon when tour is published' do + tour = create(:tour, published: true, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + get :show, params: { tenant: TourSet.first.subdir, id: map_icon.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + + it 'responds with 200 and a MapIcon when requested by a tour author' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + user = create(:user, super: false) + user.tours << tour + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_icon } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + + it 'responds with 200 and a list of MapIcon when requested by tenant admin' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_icon } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + + it 'responds with 200 and a MapIcon when requested by super' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_icon = create(:map_icon, stop: tour.stops.last) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_icon } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_icon.id.to_s) + end + end + end + + describe 'POST #create' do + let(:tour) { create(:tour, stops: create_list(:stop, 2)) } + let(:valid_params) { + { + data: { + type: 'map_icon', + attributes: { + filename: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/icon_base64.txt')), + tour_id: tour.id + } + }, + tenant: Apartment::Tenant.current + } + } + + context 'unauthorized' do + it 'returns 401 when unauthenticated' do + initial_map_icon_count = MapIcon.count + post :create, params: valid_params + expect(response.status).to eq(401) + expect(MapIcon.count).to eq(initial_map_icon_count) + end + + it 'returns 401 when authenticated but unauthorized tenant admin' do + initial_map_icon_count = MapIcon.count + initial_tenant = Apartment::Tenant.current + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include TourSet.find_by(subdir: initial_tenant) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + valid_params[:tenant] = initial_tenant + post :create, params: valid_params + expect(response.status).to eq(401) + expect(MapIcon.count).to eq(initial_map_icon_count) + end + end + + context 'authorized' do + it 'creates when request by super' do + user = create(:user, super: true) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapIcon, :count).by(1) + end + + it 'creates when request by current tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapIcon, :count).by(1) + end + + it 'creates when request by tour author' do + user = create(:user, super: false) + user.tours << tour + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapIcon, :count).by(1) + end + end + + context 'invalid params' do + let(:tour) { create(:tour, stops: create_list(:stop, 2)) } + let(:invalid_params) { + { + data: { + type: 'map_icon', + attributes: { + filename: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/atl_base64.txt')), + tour_id: tour.id + } + }, + tenant: Apartment::Tenant.current + } + } + + it 'returns 422 with invalid params' do + initial_map_icon_count = MapIcon.count + user = create(:user, super: true) + signed_cookie(user) + post :create, params: { tenant: Apartment::Tenant.current, data: {} } + expect(response).to have_http_status(:unprocessable_entity) + expect(MapIcon.count).to eq(initial_map_icon_count) + end + + it 'returns 422 and size error message' do + initial_map_icon_count = MapIcon.count + user = create(:user, super: true) + signed_cookie(user) + post :create, params: invalid_params + expect(response).to have_http_status(:unprocessable_entity) + expect(MapIcon.count).to eq(initial_map_icon_count) + expect(errors).to include('Icons should be no bigger that 80 by 80 pixels') + end + end + end +end diff --git a/spec/controllers/v3/map_overlays_controller_spec.rb b/spec/controllers/v3/map_overlays_controller_spec.rb new file mode 100755 index 00000000..e8175c3c --- /dev/null +++ b/spec/controllers/v3/map_overlays_controller_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::MapOverlaysController, type: :controller do + describe 'GET #index' do + context 'unauthenticated and unauthorized' do + it 'returns empty MapOverlay when not unauthenticated' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + get :show, params: { tenant: TourSet.first.subdir, id: map_overlay.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:south]).not_to eq(map_overlay.south.to_f.to_s) + expect(attributes[:east]).to be nil + end + + it 'returns empty MapOverlay when not unauthenticated but unauthorized' do + initial_tenant = Apartment::Tenant.current + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include TourSet.find_by(subdir: initial_tenant) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + get :show, params: { tenant: initial_tenant, id: map_overlay } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:north]).not_to eq(map_overlay.north.to_f.to_s) + expect(attributes[:west]).to be nil + end + end + + context 'authorized' do + it 'responds with 200 and a MapOverlay when tour is published' do + tour = create(:tour, published: true, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + get :show, params: { tenant: TourSet.first.subdir, id: map_overlay.id } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:south]).to eq(map_overlay.south.to_f.to_s) + end + + it 'responds with 200 and a MapOverlay when requested by a tour author' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + user = create(:user, super: false) + user.tours << tour + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_overlay } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:north]).to eq(map_overlay.north.to_f.to_s) + end + + it 'responds with 200 and a list of MapOverlay when requested by tenant admin' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_overlay } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:north]).to eq(map_overlay.north.to_f.to_s) + end + + it 'responds with 200 and a MapOverlay when requested by super' do + tour = create(:tour, published: false, stops: create_list(:stop, 3)) + map_overlay = create(:map_overlay, tour: tour) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: map_overlay } + expect(response.status).to eq(200) + expect(json[:id]).to eq(map_overlay.id.to_s) + expect(attributes[:north]).to eq(map_overlay.north.to_f.to_s) + end + end + end + + describe 'POST #create' do + let(:tour) { create(:tour, stops: create_list(:stop, 2)) } + let(:valid_params) { + { + data: { + type: 'map_overlay', + attributes: { + filename: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/base64_image.txt')), + tour_id: tour.id + } + }, + tenant: Apartment::Tenant.current + } + } + + context 'unauthorized' do + it 'returns 401 when unauthenticated' do + initial_map_overlay_count = MapOverlay.count + post :create, params: valid_params + expect(response.status).to eq(401) + expect(MapOverlay.count).to eq(initial_map_overlay_count) + end + + it 'returns 401 when authenticated but unauthorized tenant admin' do + initial_map_overlay_count = MapOverlay.count + initial_tenant = Apartment::Tenant.current + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include TourSet.find_by(subdir: initial_tenant) + signed_cookie(user) + Apartment::Tenant.switch! initial_tenant + valid_params[:tenant] = initial_tenant + post :create, params: valid_params + expect(response.status).to eq(401) + expect(MapOverlay.count).to eq(initial_map_overlay_count) + end + end + + context 'authorized' do + it 'creates when request by super' do + user = create(:user, super: true) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapOverlay, :count).by(1) + end + + it 'creates when request by current tenant admin' do + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapOverlay, :count).by(1) + end + + it 'creates when request by tour author' do + user = create(:user, super: false) + user.tours << tour + signed_cookie(user) + expect { + post :create, params: valid_params + }.to change(MapOverlay, :count).by(1) + end + end + + context 'invalid params' do + it 'returns 422 with invalid params' do + initial_map_overlay_count = MapOverlay.count + user = create(:user, super: true) + signed_cookie(user) + post :create, params: { tenant: Apartment::Tenant.current, data: {} } + expect(response).to have_http_status(:unprocessable_entity) + expect(MapOverlay.count).to eq(initial_map_overlay_count) + end + end + end +end diff --git a/spec/controllers/v3/media_controller_spec.rb b/spec/controllers/v3/media_controller_spec.rb index b980acfc..160b8852 100644 --- a/spec/controllers/v3/media_controller_spec.rb +++ b/spec/controllers/v3/media_controller_spec.rb @@ -154,6 +154,19 @@ expect(response).to have_http_status(:unprocessable_entity) expect(response.content_type).to eq('application/json; charset=utf-8') end + + it 'renders a JSON response with errors for the new medium when file is jp2' do + user = create(:user, super: true) + signed_cookie(user) + jp2_params = valid_params.clone + jp2_params[:data][:attributes] = { + base_sixty_four: File.read(Rails.root.join('spec/factories/images/jp2_base64.txt')), + filename: Faker::File.file_name(dir: '', ext: 'jp2', directory_separator: '') + } + post :create, params: jp2_params + expect(response).to have_http_status(:unprocessable_entity) + expect(errors).to include('JPEG 2000 fils are not supported. Plese convert the image to a reqular JPEG or WebP format.') + end end context 'with unauthenticated request' do diff --git a/spec/controllers/v3/stops_media_controller_spec-fix.rb b/spec/controllers/v3/stops_media_controller_spec-fix.rb deleted file mode 100644 index 52ee1c5f..00000000 --- a/spec/controllers/v3/stops_media_controller_spec-fix.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -RSpec.describe V3::StopsMediaController, type: :controller do - -end diff --git a/spec/controllers/v3/stops_media_controller_spec.rb b/spec/controllers/v3/stops_media_controller_spec.rb new file mode 100644 index 00000000..932c344d --- /dev/null +++ b/spec/controllers/v3/stops_media_controller_spec.rb @@ -0,0 +1,189 @@ +require 'rails_helper' + +RSpec.describe V3::StopMediaController, type: :controller do + + describe 'GET #index' do + before(:each) { Tour.all.each { |tour| tour.update(published: false) } } + + context 'unauthenticated' do + it 'returns a success response but zero StopMedium objects' do + create(:stop_medium, stop: create(:stop)) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(StopMedium.count).to be > 0 + end + end + + context 'authenticated unauthorized' do + it 'returns zero StopMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + stop_medium = create(:stop_medium, stop: create(:stop)) + tour_set = create(:tour_set) + user = create(:user, super: false) + user.tour_sets << tour_set + signed_cookie(user) + get :index, params: { tenant: original_tenant } + Apartment::Tenant.switch! original_tenant + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(StopMedium.count).to be > 0 + end + + it 'returns zero StopMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + stop_medium = create(:stop_medium, stop: create(:stop)) + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + user = create(:user, super: false) + user.tours << create(:tour) + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :index, params: { tenant: original_tenant } + expect(response.status).to eq(200) + expect(json.count).to eq(0) + expect(StopMedium.count).to be > 0 + end + end + + context 'authenticated and authorized' do + it 'returns all StopMedium objects to super' do + create_list(:stop_medium, 4) + user = create(:user, super: true) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(StopMedium.count) + end + + it 'returns all StopMedium objects to tenant admin' do + create_list(:stop_medium, 4) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(StopMedium.count) + end + end + end + + describe 'GET #show' do + context 'unauthenticated' do + it 'returns a success response but empty StopMedium objects' do + + stop_medium = create(:stop_medium, stop: create(:stop)) + get :show, params: { id: stop_medium.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:stop][:data]).to be nil + expect(stop_medium.stop).not_to be nil + expect(stop_medium.medium).not_to be nil + expect(StopMedium.count).to be > 0 + end + + # it 'returns a success response but empty StopMedium objects' do + # stop_medium = create(:stop_medium, stop: create(:stop)) + # get :index, params: { id: stop_medium.id, tenant: Apartment::Tenant.current } + # expect(response.status).to eq(200) + # expect(json.count).to eq(1) + # end + end + + context 'authenticated unauthorized' do + + it 'returns empty StopMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + stop_medium = create(:stop_medium, stop: create(:stop)) + tour_set = create(:tour_set) + user = create(:user, super: false) + user.tour_sets << tour_set + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :show, params: { id: stop_medium.id, tenant: original_tenant } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:stop][:data]).to be nil + expect(stop_medium.stop).not_to be nil + expect(stop_medium.medium).not_to be nil + expect(StopMedium.count).to be > 0 + end + + it 'returns empty StopMedium objects, not current tenant admin, non tour author' do + original_tenant = Apartment::Tenant.current + stop_medium = create(:stop_medium, stop: create(:stop)) + tour_set = create(:tour_set) + Apartment::Tenant.switch! tour_set.subdir + user = create(:user, super: false) + user.tours << create(:tour) + signed_cookie(user) + Apartment::Tenant.switch! original_tenant + get :show, params: { id: stop_medium.id, tenant: original_tenant } + expect(response.status).to eq(200) + expect(relationships[:medium][:data]).to be nil + expect(relationships[:stop][:data]).to be nil + expect(StopMedium.count).to be > 0 + end + end + + context 'authenticated and authorized' do + it 'returns all StopMedium objects to super' do + create_list(:stop_medium, 4) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { id: StopMedium.last.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(StopMedium.count) + end + end + end + + describe 'POST #create' do + it 'returns does not create a new StopMedium' do + expect { + post :create, params: { tenant: Apartment::Tenant.current } + }.to change(StopMedium, :count).by(0) + end + + it 'returns 401' do + user = create(:user, super: true) + signed_cookie(user) + post :create, params: { data: { type: 'stop_media', attributes: { stop_id: 1, medium_id: 1, position: 1 } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + end + + describe 'PUT #update' do + context 'with valid params' do + it 'renders a JSON response with the v3_stop_medium' do + stop_medium = create(:stop_medium, position: 1) + expect(stop_medium.position).not_to eq(100) + user = create(:user, super: true) + signed_cookie(user) + put :update, params: { id: stop_medium.id, data: { type: 'stop_media', attributes: { stop_id: stop_medium.stop.id, medium_id: stop_medium.medium.id, position: 100 } }, tenant: Apartment::Tenant.current } + expect(response).to have_http_status(:ok) + expect(attributes[:position]).to eq(100) + expect(StopMedium.find(stop_medium.id).position).to eq(100) + end + end + end + + describe 'DELETE #destroy' do + it 'does not destroy the requested v3_stop_medium' do + stop_medium = create(:stop_medium) + user = create(:user, super: true) + signed_cookie(user) + expect { + delete :destroy, params: { id: stop_medium.to_param, tenant: Apartment::Tenant.current } + }.to change(StopMedium, :count).by(0) + end + + it 'responds with 405' do + stop_medium = create(:stop_medium) + user = create(:user, super: true) + signed_cookie(user) + delete :destroy, params: { id: stop_medium.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + end + end +end diff --git a/spec/controllers/v3/themes_controller_spec.rb b/spec/controllers/v3/themes_controller_spec.rb index d7644211..80557559 100644 --- a/spec/controllers/v3/themes_controller_spec.rb +++ b/spec/controllers/v3/themes_controller_spec.rb @@ -2,29 +2,6 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - RSpec.describe V3::ThemesController, type: :controller do before(:each) { create_list(:theme, rand(3..6)) } diff --git a/spec/controllers/v3/tour_authors_controller_spec.rb b/spec/controllers/v3/tour_authors_controller_spec.rb new file mode 100755 index 00000000..c0931e9f --- /dev/null +++ b/spec/controllers/v3/tour_authors_controller_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::TourAuthorsController, type: :controller do + before(:each) { + create_list(:tour_set, rand(2..5)) + TourSet.all.each { |tour_set| tour_set.update(admins: create_list(:user, rand(2..5))) } + } + + describe 'GET #index' do + context 'unauthenticated and unauthorized' do + it 'returns 401 when not unauthenticated' do + get :index, params: { tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'returns 401 when authenticated but unauthorized' do + initial_tour_set = TourSet.find_by(subdir: Apartment::Tenant.current) + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include initial_tour_set + signed_cookie(user) + Apartment::Tenant.switch! initial_tour_set.subdir + get :index, params: { tenant: initial_tour_set.subdir } + expect(response.status).to eq(401) + end + end + + context 'authorized' do + it 'responds with 200 and a list of TourAuthors when requested by tenant admin' do + create_list(:tour_author, rand(3..4)) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.first[:type]).to eq('tour_authors') + expect(TourAuthor.count).to be > 1 + expect(json.count).to eq(TourAuthor.count) + end + + it 'responds with 200 and a list of TourAuthors when requested by super' do + create_list(:tour_author, rand(4..6)) + user = create(:user, super: true) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.first[:type]).to eq('tour_authors') + expect(TourAuthor.count).to be > 1 + expect(json.count).to eq(TourAuthor.count) + end + end + end + + describe 'GET #show' do + context 'unauthorized' do + it 'returns 401 when unauthenticated' do + tour_author = create(:tour_author) + get :show, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(401) + end + + it 'returns 401 when authenticated but not authorized' do + tour_author = create(:tour_author) + initial_tour_set = TourSet.find_by(subdir: Apartment::Tenant.current) + user = create(:user, super: false) + user.tour_sets << create(:tour_set) + expect(user.tour_sets).not_to include initial_tour_set + signed_cookie(user) + Apartment::Tenant.switch! initial_tour_set.subdir + get :show, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(401) + end + end + + context 'authorized' do + it 'responds with 200 and a list of TourAuthors when requested by tenant admin' do + tour_author = create(:tour_author) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(200) + expect(json[:type]).to eq('tour_authors') + expect(json[:id]).to eq(tour_author.id.to_s) + end + + it 'responds with 200 and a list of TourAuthors when requested by super' do + tour_author = create(:tour_author) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(200) + expect(json[:type]).to eq('tour_authors') + expect(json[:id]).to eq(tour_author.id.to_s) + end + end + end + + describe 'POST #create' do + it 'returns 405' do + post :create, params: { tenant: Apartment::Tenant.current, data: {} } + expect(response.status).to eq(405) + end + end + + describe 'PUT #update' do + it 'returns 405' do + tour_author = create(:tour_author) + put :update, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(405) + end + end + + describe 'DELETE #destroy' do + it 'returns 405' do + tour_author = create(:tour_author) + delete :destroy, params: { tenant: Apartment::Tenant.current, id: tour_author.id } + expect(response.status).to eq(405) + end + end +end diff --git a/spec/controllers/v3/tour_flat_pages_controller_spec.rb b/spec/controllers/v3/tour_flat_pages_controller_spec.rb new file mode 100755 index 00000000..5043ef0d --- /dev/null +++ b/spec/controllers/v3/tour_flat_pages_controller_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::TourFlatPagesController, type: :controller do + def data(tour, flat_page, position = 1) + { + type: 'tour_flat_pages', + attributes: { position: position }, + relationships: { + tour: { data: { type: 'tours', id: tour.id } }, + flat_page: { data: { type: 'flat_pages', id: flat_page.id } } + } + } + end + + describe 'GET #index' do + it 'returns a 200 response and empty tour when none are part of a published tour' do + Tour.all.each { |tour| tour.update(published: false) } + get :index, params: { tenant: Apartment::Tenant.current } + expect(json).to be_empty + expect(response.status).to eq(200) + end + + it 'returns a 200 response and only tour flat_pages that are part of a published tour' do + create_list(:tour_with_flat_pages, 5, theme: create(:theme), mode: create(:mode)) + Tour.first.update(published: true) if Tour.published.empty? + Tour.last.update(published: false) if Tour.published.count == Tour.count + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(Tour.published.map { |tour| tour.tour_flat_pages.count }.sum) + end + + it 'returns a 200 response when requeted by slug' do + tour = create(:tour_with_flat_pages) + tour.update(published: true) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(FlatPage.count) + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour_with_flat_pages, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(FlatPage.count) + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour_with_flat_pages, published: false) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json.count).to eq(FlatPage.count) + end + end + + describe 'GET #show' do + it 'returns a 200 response' do + tour = create(:tour_with_flat_pages) + tour.update(published: true) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_flat_pages.first.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response when request is authenticated by tour author and tour is unpublished' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + user = create(:user) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_flat_pages.first.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response when request is authenticated by tenant admin and tour is unpublished' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + user = create(:user) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_flat_pages.first.id } + expect(response.status).to eq(200) + expect(relationships[:tour][:data][:id]).to eq(tour.id.to_s) + end + + it 'returns a 200 response and empty json when tour is unpublished and request is not authenticated' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_flat_pages.first.id } + expect(response.status).to eq(200) + expect(json).to be_empty + end + + it 'returns a 200 response and empty json when tour is unpublished and request is authenticated by someone who is nither a tenant admin or tour author' do + tour = create(:tour_with_flat_pages) + tour.update(published: false) + user = create(:user) + user.tours = [] + user.tour_sets = [] + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.tour_flat_pages.first.id } + expect(response.status).to eq(200) + expect(json).to be_empty + end + end + + # TourFlatPage objects are NOT created via tha API. Every test should return 401 + describe 'POST #create' do + context 'with valid params' do + it 'return 405 when unauthenciated' do + tour = create(:tour) + flat_page = create(:flat_page) + post :create, params: { data: data(tour, flat_page), tenant: TourSet.first.subdir } + expect(response.status).to eq(405) + end + + it 'return 405 when authenciated but not an admin for current tenant' do + tour = create(:tour) + flat_page = create(:flat_page) + original_tour_flat_page_count = TourFlatPage.count + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + post :create, params: { data: data(tour, flat_page), tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(original_tour_flat_page_count).to eq(TourFlatPage.count) + end + + it 'return 405 when authenciated but an admin for current tenant' do + tour = create(:tour) + flat_page = create(:flat_page) + original_tour_flat_page_count = TourFlatPage.count + user = create(:user) + user.update(super: false) + user.tours = [] + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + post :create, params: { data: data(tour, flat_page), tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(original_tour_flat_page_count).to eq(TourFlatPage.count) + end + + it 'return 405 when authenciated by super' do + tour = create(:tour) + flat_page = create(:flat_page) + original_tour_flat_page_count = TourFlatPage.count + user = create(:user) + user.tours = [] + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + post :create, params: { data: data(tour, flat_page), tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(original_tour_flat_page_count).to eq(TourFlatPage.count) + end + + it 'return 405 when authenciated by tour author' do + tour = create(:tour) + flat_page = create(:flat_page) + original_tour_flat_page_count = TourFlatPage.count + user = create(:user) + user.tours << tour + user.tour_sets = [] + user.update(super: false) + signed_cookie(user) + post :create, params: { data: data(tour, flat_page), tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(original_tour_flat_page_count).to eq(TourFlatPage.count) + end + end + end + + describe 'PUT #update' do + context 'with valid params' do + it 'return 401 when unauthenciated' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + request_data = data(tour, flat_page, 4) + request_data[:id] = TourFlatPage.find_by(tour: tour, flat_page: flat_page).id + post :update, params: { id: request_data[:id], data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 401 when authenciated but not an admin for current tenant' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + request_data = data(tour, flat_page, 5) + request_data[:id] = TourFlatPage.find_by(tour: tour, flat_page: flat_page).id + post :update, params: { id: request_data[:id], data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(401) + end + + it 'return 200 and updated tour when authenciated but an admin for current tenant' do + tour = create(:tour) + flat_pages = create_list(:flat_page, 5) + flat_pages.each { |flat_page| tour.flat_pages << flat_page } + tour.save + flat_page = FlatPage.find(flat_pages.first.id) + tour.flat_pages << flat_page + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + user.tours = [] + signed_cookie(user) + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + tour_flat_page.update(position: 2) + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(2) + request_data = data(tour, flat_page, 5) + request_data[:id] = tour_flat_page.id + post :update, params: { id: tour_flat_page.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('5') + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(5) + end + + it 'return 200 and updated tour when authenciated by super' do + tour = create(:tour) + flat_pages = create_list(:flat_page, 5) + flat_pages.each { |flat_page| tour.flat_pages << flat_page } + tour.save + flat_page = FlatPage.find(flat_pages.first.id) + tour.flat_pages << flat_page + user = create(:user) + user.update(super: true) + user.tour_sets = [] + user.tours = [] + signed_cookie(user) + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + tour_flat_page.update(position: 3) + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(3) + request_data = data(tour, flat_page, 4) + request_data[:id] = tour_flat_page.id + post :update, params: { id: tour_flat_page.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('4') + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(4) + end + + it 'return 200 and updated tour when authenciated by tour author' do + tour = create(:tour) + flat_pages = create_list(:flat_page, 5) + flat_pages.each { |flat_page| tour.flat_pages << flat_page } + tour.save + flat_page = FlatPage.find(flat_pages.first.id) + tour.flat_pages << flat_page + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + tour_flat_page.update(position: 6) + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(6) + request_data = data(tour, flat_page, 1) + request_data[:id] = tour_flat_page.id + post :update, params: { id: tour_flat_page.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(200) + expect(attributes[:position]).not_to eq('1') + expect(TourFlatPage.find(tour_flat_page.id).position).to eq(1) + end + + it 'returns 422 when params are invalid' do + tour_flat_page = create(:tour_flat_page) + user = create(:user, super: true) + invalid_params = { type: 'tour_flat_pages', attributes: {}, relationships: { tour: { data: nil }, flat_page: { data: nil } } } + signed_cookie(user) + post :update, params: { id: tour_flat_page.id, data: invalid_params, tenant: TourSet.first.subdir } + expect(response.status).to eq(422) + expect(errors).to include('Tour must exist') + end + end + end + + describe 'DELETE #destroy' do + it 'return 405 when unauthenciated' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + post :destroy, params: { id: tour_flat_page.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(405) + end + + it 'return 405 when authenciated but not an admin for current tenant' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + signed_cookie(user) + post :destroy, params: { id: tour_flat_page.id, tenant: TourSet.first.subdir } + expect(response.status).to eq(405) + end + + it 'return 405 and one less tour when authenciated but an admin for current tenant' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + user = create(:user) + user.update(super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour_flat_page.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(Tour.count).to eq(tour_count) + end + + it 'return 405 and one less tour when authenciated by super' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + user = create(:user) + user.tour_sets = [] + user.update(super: true) + signed_cookie(user) + tour_count = Tour.count + post :destroy, params: { id: tour_flat_page.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(Tour.count).to eq(tour_count) + end + + it 'return 405 and one less tour when authenciated by tour author' do + tour = create(:tour) + flat_page = create(:flat_page) + tour.flat_pages << flat_page + tour_flat_page = TourFlatPage.find_by(tour: tour, flat_page: flat_page) + user = create(:user) + user.update(super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + new_title = Faker::Name.unique.name + tour_count = Tour.count + post :destroy, params: { id: tour_flat_page.id, tenant: Apartment::Tenant.current } + expect(response.status).to eq(405) + expect(Tour.count).to eq(tour_count) + end + end +end diff --git a/spec/controllers/v3/tour_media_controller_spec.rb b/spec/controllers/v3/tour_media_controller_spec.rb index d41dd4a8..ac53ccde 100644 --- a/spec/controllers/v3/tour_media_controller_spec.rb +++ b/spec/controllers/v3/tour_media_controller_spec.rb @@ -29,7 +29,7 @@ before(:each) { Tour.all.each { |tour| tour.update(published: false) } } context 'unauthenticated' do - it 'returns a success response but zeor TourMedium objects' do + it 'returns a success response but zero TourMedium objects' do tour_medium = create(:tour_medium, tour: create(:tour, published: false)) get :index, params: { tenant: Apartment::Tenant.current } @@ -38,7 +38,7 @@ expect(TourMedium.count).to be > 0 end - it 'returns a success response but zeor TourMedium objects' do + it 'returns a success response but zero TourMedium objects' do tour_medium = create(:tour_medium, tour: create(:tour, published: true)) get :index, params: { tenant: Apartment::Tenant.current } expect(response.status).to eq(200) diff --git a/spec/controllers/v3/tour_modes_controller_spec.rb b/spec/controllers/v3/tour_modes_controller_spec.rb deleted file mode 100644 index aa2e7eac..00000000 --- a/spec/controllers/v3/tour_modes_controller_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe V3::TourModesController, type: :controller do - -end diff --git a/spec/controllers/v3/tour_set_users_controller_spec.rb b/spec/controllers/v3/tour_set_users_controller_spec.rb deleted file mode 100644 index 204ad3f3..00000000 --- a/spec/controllers/v3/tour_set_users_controller_spec.rb +++ /dev/null @@ -1,129 +0,0 @@ -require 'rails_helper' - -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - -RSpec.describe V3::TourSetAdminsController, type: :controller do - - # This should return the minimal set of attributes required to create a valid - # TourSetAdmin. As you add validations to TourSetAdmin, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip("Add a hash of attributes valid for your model") - } - - let(:invalid_attributes) { - skip("Add a hash of attributes invalid for your model") - } - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # TourSetAdminsController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe "GET #index" do - it "returns a success response" do - tour_set_admin = TourSetAdmin.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end - - describe "GET #show" do - it "returns a success response" do - tour_set_admin = TourSetAdmin.create! valid_attributes - get :show, params: {id: tour_set_admin.to_param}, session: valid_session - expect(response).to be_success - end - end - - describe "POST #create" do - context "with valid params" do - it "creates a new TourSetAdmin" do - expect { - post :create, params: {tour_set_admin: valid_attributes}, session: valid_session - }.to change(TourSetAdmin, :count).by(1) - end - - it "renders a JSON response with the new tour_set_admin" do - - post :create, params: {tour_set_admin: valid_attributes}, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(tour_set_admin_url(TourSetAdmin.last)) - end - end - - context "with invalid params" do - it "renders a JSON response with errors for the new tour_set_admin" do - - post :create, params: {tour_set_admin: invalid_attributes}, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe "PUT #update" do - context "with valid params" do - let(:new_attributes) { - skip("Add a hash of attributes valid for your model") - } - - it "updates the requested tour_set_admin" do - tour_set_admin = TourSetAdmin.create! valid_attributes - put :update, params: {id: tour_set_admin.to_param, tour_set_admin: new_attributes}, session: valid_session - tour_set_admin.reload - skip("Add assertions for updated state") - end - - it "renders a JSON response with the tour_set_admin" do - tour_set_admin = TourSetAdmin.create! valid_attributes - - put :update, params: {id: tour_set_admin.to_param, tour_set_admin: valid_attributes}, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') - end - end - - context "with invalid params" do - it "renders a JSON response with errors for the tour_set_admin" do - tour_set_admin = TourSetAdmin.create! valid_attributes - - put :update, params: {id: tour_set_admin.to_param, tour_set_admin: invalid_attributes}, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe "DELETE #destroy" do - it "destroys the requested tour_set_admin" do - tour_set_admin = TourSetAdmin.create! valid_attributes - expect { - delete :destroy, params: {id: tour_set_admin.to_param}, session: valid_session - }.to change(TourSetAdmin, :count).by(-1) - end - end - -end diff --git a/spec/controllers/v3/tour_stops_controller_spec.rb b/spec/controllers/v3/tour_stops_controller_spec.rb index dccee79a..4d26035c 100644 --- a/spec/controllers/v3/tour_stops_controller_spec.rb +++ b/spec/controllers/v3/tour_stops_controller_spec.rb @@ -61,6 +61,28 @@ def data(tour, stop, position = 1) expect(response.status).to eq(200) expect(included.first[:attributes][:title]).to eq(tour.tour_stops.first.stop.title) end + + it 'returns empty TourStop when `fastboo` in params' do + create(:tour_with_stops, published: true) + get :index, params: { tenant: Apartment::Tenant.current, fastboot: 'true' } + expect(response.status).to eq(200) + expect(json[:id]).to eq(0) + end + + it 'returns empty json when unauthenticated but tour is not published' do + tour = create(:tour_with_stops, published: false) + get :index, params: { tenant: Apartment::Tenant.current, slug: tour.tour_stops.first.stop.slug, tour: tour.id } + expect(json).to be nil + end + + it 'returns all TourStops when requested by tenant admin' do + create_list(:tour_with_stops, rand(4..7)) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq(TourStop.count) + end end describe 'GET #show' do @@ -120,14 +142,14 @@ def data(tour, stop, position = 1) # TourStop objects are NOT created via tha API. Every test should return 401 describe 'POST #create' do context 'with valid params' do - it 'return 401 when unauthenciated' do + it 'return 405 when unauthenciated' do tour = create(:tour) stop = create(:stop) post :create, params: { data: data(tour, stop), tenant: TourSet.first.subdir } - expect(response.status).to eq(401) + expect(response.status).to eq(405) end - it 'return 401 when authenciated but not an admin for current tenant' do + it 'return 405 when authenciated but not an admin for current tenant' do tour = create(:tour) stop = create(:stop) original_tour_stop_count = TourStop.count @@ -137,11 +159,11 @@ def data(tour, stop, position = 1) user.tours = [] signed_cookie(user) post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(original_tour_stop_count).to eq(TourStop.count) end - it 'return 401 when authenciated but an admin for current tenant' do + it 'return 405 when authenciated but an admin for current tenant' do tour = create(:tour) stop = create(:stop) original_tour_stop_count = TourStop.count @@ -151,11 +173,11 @@ def data(tour, stop, position = 1) user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) signed_cookie(user) post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(original_tour_stop_count).to eq(TourStop.count) end - it 'return 401 when authenciated by super' do + it 'return 405 when authenciated by super' do tour = create(:tour) stop = create(:stop) original_tour_stop_count = TourStop.count @@ -165,11 +187,11 @@ def data(tour, stop, position = 1) user.update(super: true) signed_cookie(user) post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(original_tour_stop_count).to eq(TourStop.count) end - it 'return 401 when authenciated by tour author' do + it 'return 405 when authenciated by tour author' do tour = create(:tour) stop = create(:stop) original_tour_stop_count = TourStop.count @@ -179,7 +201,7 @@ def data(tour, stop, position = 1) user.update(super: false) signed_cookie(user) post :create, params: { data: data(tour, stop), tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(original_tour_stop_count).to eq(TourStop.count) end end @@ -280,76 +302,87 @@ def data(tour, stop, position = 1) expect(attributes[:position]).not_to eq('1') expect(TourStop.find(tour_stop.id).position).to eq(1) end + + it 'return 422 authenciated by super but tour stop and position are nil' do + tour = create(:tour, stops: create_list(:stop, rand(4..6))) + user = create(:user, super: true) + tour_stop = TourStop.find_by(tour: tour, stop: tour.stops.first) + request_data = data(tour, tour.stops.first, 4) + request_data[:relationships][:tour][:data] = nil + request_data[:relationships][:stop][:data] = nil + request_data[:attributes][:position] = nil + signed_cookie(user) + post :update, params: { id: tour_stop.id, data: request_data, tenant: TourSet.first.subdir } + expect(response.status).to eq(422) + expect(errors).to include('Tour must exist') + expect(errors).to include('Stop must exist') + expect(errors).to include('Position can\'t be blank') + end end end describe 'DELETE #destroy' do - it 'return 401 when unauthenciated' do + it 'return 405 when unauthenciated' do tour = create(:tour) stop = create(:stop) tour.stops << stop tour_stop = TourStop.find_by(tour: tour, stop: stop) post :destroy, params: { id: tour_stop.id, tenant: TourSet.first.subdir } - expect(response.status).to eq(401) + expect(response.status).to eq(405) end - it 'return 401 when authenciated but not an admin for current tenant' do + it 'return 405 when authenciated but not an admin for current tenant' do tour = create(:tour) stop = create(:stop) tour.stops << stop tour_stop = TourStop.find_by(tour: tour, stop: stop) - user = create(:user) - user.update(super: false) + user = create(:user, super: false) user.tour_sets = [] signed_cookie(user) post :destroy, params: { id: tour_stop.id, tenant: TourSet.first.subdir } - expect(response.status).to eq(401) + expect(response.status).to eq(405) end - it 'return 401 and one less tour when authenciated but an admin for current tenant' do + it 'return 405 and one less tour when authenciated but an admin for current tenant' do tour = create(:tour) stop = create(:stop) tour.stops << stop tour_stop = TourStop.find_by(tour: tour, stop: stop) - user = create(:user) - user.update(super: false) + user = create(:user, super: false) user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) signed_cookie(user) tour_count = Tour.count post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(Tour.count).to eq(tour_count) end - it 'return 401 and one less tour when authenciated by super' do + it 'return 405 and one less tour when authenciated by super' do tour = create(:tour) stop = create(:stop) tour.stops << stop tour_stop = TourStop.find_by(tour: tour, stop: stop) - user = create(:user) - user.tour_sets = [] - user.update(super: true) + user = create(:user, super: true) signed_cookie(user) tour_count = Tour.count post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(Tour.count).to eq(tour_count) end - it 'return 401 and one less tour when authenciated by tour author' do + it 'return 405 and one less tour when authenciated by tour author' do tour = create(:tour) stop = create(:stop) tour.stops << stop tour_stop = TourStop.find_by(tour: tour, stop: stop) - user = create(:user) - user.update(super: false) + user = create(:user, super: false) user.tour_sets = [] user.tours << tour signed_cookie(user) new_title = Faker::Name.unique.name tour_count = Tour.count post :destroy, params: { id: tour_stop.id, tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) + expect(response.status).to eq(405) expect(Tour.count).to eq(tour_count) end end diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index f1add295..0669c005 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -64,6 +64,27 @@ expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) end + + it 'returns all Tour objects when requested by tenant admin' do + create_list(:tour, rand(4..5)) + user = create(:user, super: false) + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq(Tour.count) + end + + it 'returns only tours where requester is an author' do + Tour.first.update(published: true) + new_tours = create_list(:tour, rand(4..6), published: false) + user = create(:user, super: false) + user.tour_sets = [] + user.tours << [Tour.published.first, new_tours.first, new_tours.last] + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq((user.tours + Tour.published).uniq.count) + expect(json.count).to be < Tour.count + end end describe 'GET #show' do @@ -111,6 +132,16 @@ expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) end + + it 'retuns a tour with center lat/lng based on request' do + request.env['ipinfo'] = MockIpinfo.new + tour = create(:tour, stops: []) + user = create(:user, super: true) + signed_cookie(user) + get :show, params: { tenant: Apartment::Tenant.current, id: tour.id } + expect(attributes[:bounds][:centerLat]).not_to eq(33.75432) + expect(attributes[:bounds][:centerLng]).not_to eq(-84.38979) + end end describe 'POST #create' do @@ -150,6 +181,16 @@ expect(response.status).to eq(201) expect(Tour.count).to eq(original_tour_count + 1) end + + it 'returns 422 when invalid attributes' do + user = create(:user, super: true) + signed_cookie(user) + original_tour_count = Tour.count + post :create, params: { data: { type: 'tours', attributes: { title: nil } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(422) + expect(Tour.count).to eq(original_tour_count) + expect(errors).to include('Title can\'t be blank') + end end end @@ -272,6 +313,17 @@ expect(relationships[:stops][:data].count).to eq(original_stop_count - 1) expect(relationships[:tour_stops][:data].count).to eq(original_tour_stop_count - 1) end + + it 'returns 422 when title in nil' do + tour = create(:tour) + serialized_tour = JSON.parse(ActiveModelSerializers::Adapter::JsonApi.new(V3::TourSerializer.new(tour)).to_json).with_indifferent_access + serialized_tour[:data][:attributes][:title] = nil + user = create(:user, super: true) + signed_cookie(user) + post :update, params: { id: tour.id, data: serialized_tour[:data], tenant: Apartment::Tenant.current } + expect(response.status).to eq(422) + expect(errors).to include('Title can\'t be blank') + end end end diff --git a/spec/controllers/v3/users_controller_spec-fix.rb b/spec/controllers/v3/users_controller_spec-fix.rb deleted file mode 100644 index eef9da37..00000000 --- a/spec/controllers/v3/users_controller_spec-fix.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. -# -# Also compared to earlier versions of this generator, there are no longer any -# expectations of assigns and templates rendered. These features have been -# removed from Rails core in Rails 5, but can be added back in via the -# `rails-controller-testing` gem. - -RSpec.describe V3::UsersController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # User. As you add validations to User, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end - - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # UsersController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe 'GET #index' do - it 'returns a success response' do - # user = User.create! valid_attributes - get :index, params: {}, session: valid_session - expect(response).to be_success - end - end - - describe 'GET #show' do - it 'returns a success response' do - user = User.create! valid_attributes - get :show, params: { id: user.to_param }, session: valid_session - expect(response).to be_success - end - end - - describe 'POST #create' do - context 'with valid params' do - it 'creates a new User' do - expect do - post :create, params: { user: valid_attributes }, session: valid_session - end.to change(User, :count).by(1) - end - - it 'renders a JSON response with the new user' do - post :create, params: { user: valid_attributes }, session: valid_session - expect(response).to have_http_status(:created) - expect(response.content_type).to eq('application/json') - expect(response.location).to eq(user_url(User.last)) - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the new user' do - post :create, params: { user: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'PUT #update' do - context 'with valid params' do - let(:new_attributes) do - skip('Add a hash of attributes valid for your model') - end - - it 'updates the requested user' do - user = User.create! valid_attributes - put :update, params: { id: user.to_param, user: new_attributes }, session: valid_session - user.reload - skip('Add assertions for updated state') - end - - it 'renders a JSON response with the user' do - user = User.create! valid_attributes - - put :update, params: { id: user.to_param, user: valid_attributes }, session: valid_session - expect(response).to have_http_status(:ok) - expect(response.content_type).to eq('application/json') - end - end - - context 'with invalid params' do - it 'renders a JSON response with errors for the user' do - user = User.create! valid_attributes - - put :update, params: { id: user.to_param, user: invalid_attributes }, session: valid_session - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to eq('application/json') - end - end - end - - describe 'DELETE #destroy' do - it 'destroys the requested user' do - user = User.create! valid_attributes - expect do - delete :destroy, params: { id: user.to_param }, session: valid_session - end.to change(User, :count).by(-1) - end - end -end diff --git a/spec/controllers/v3/users_controller_spec.rb b/spec/controllers/v3/users_controller_spec.rb new file mode 100644 index 00000000..63bcdec8 --- /dev/null +++ b/spec/controllers/v3/users_controller_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe V3::UsersController, type: :controller do + describe 'GET #index' do + context 'unauthorized' do + it 'returns a success response but empty json when request is unauthenticated' do + create_list(:user, rand(4..5)) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json).to be_empty + end + + it 'returns a success response but empty json when request is unauthenticated' do + create_list(:user, rand(4..5)) + user = User.last + user.update(super: false) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json).to be_empty + end + end + + context 'authorized' do + it 'returns current user when requested by current user' do + user = create(:user) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current, me: true } + expect(response.status).to eq(200) + expect(json[:id]).to eq(user.id.to_s) + end + + it 'returns list of users when requested by super' do + create_list(:user, rand(4..7)) + user = User.last + user.update(super: true) + signed_cookie(user) + get :index, params: { tenant: Apartment::Tenant.current } + expect(json.count).to eq(User.count) + end + end + end + + describe 'GET #show' do + let(:user) { create(:user, super: false) } + let(:other_user) { create(:user, super: false) } + + context 'unauthorized' do + it 'returns 401 when unauthenticated' do + signed_cookie(user) + get :show, params: { id: other_user.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + + it 'returns 401 when unauthorized tenant admin' do + user.tour_sets << create(:tour_set) + signed_cookie(user) + get :show, params: { id: other_user.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + + it 'returns 401 when unauthorized tour author' do + user.tours << create(:tour) + signed_cookie(user) + get :show, params: { id: other_user.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(401) + end + end + + context 'authorized' do + it 'returns user when requested by self' do + signed_cookie(user) + get :show, params: { id: user.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json[:id]).to eq(user.id.to_s) + end + + it 'returns user when requested by super' do + user.update(super: true) + signed_cookie(user) + get :show, params: { id: other_user.to_param, tenant: Apartment::Tenant.current } + expect(response.status).to eq(200) + expect(json[:id]).to eq(other_user.id.to_s) + end + + end + end + + describe 'POST #create' do + let(:user) { create(:user, super: false) } + let(:valid_params) { { data: { type: 'users', attributes: { display_name: Faker::Music::Hiphop.artist, email: Faker::Internet.safe_email } }, tenant: 'public' } } + + context 'unauthorized' do + it 'does not create a new User when unauthenticated' do + expect do + post :create, params: valid_params + end.to change(User, :count).by(0) + end + + it 'does not create a new User for tenant admin' do + user.tour_sets << TourSet.find_by(subdir: Apartment::Tenant.current) + signed_cookie(user) + expect do + post :create, params: valid_params + end.to change(User, :count).by(0) + end + + it 'does not create a new User for tour author' do + user.tours << create(:tour) + signed_cookie(user) + expect do + post :create, params: valid_params + end.to change(User, :count).by(0) + end + end + + context 'authorized' do + it 'creates a new User for super' do + user.update(super: true) + signed_cookie(user) + expect do + post :create, params: valid_params + end.to change(User, :count).by(1) + end + + it 'does not create a new User for super with invalid_params' do + user.update(super: true) + valid_params[:data][:attributes].delete(:email) + signed_cookie(user) + expect do + post :create, params: valid_params + end.to change(User, :count).by(0) + end + + it 'responds with errors when creating a new User for super with invalid_params' do + user.update(super: true) + valid_params[:data][:attributes].delete(:email) + signed_cookie(user) + post :create, params: valid_params + expect(response.status).to eq(422) + expect(errors).to include('Email can\'t be blank') + end + end + end + + describe 'PUT #update' do + context 'unauthorized' do + it 'does not update when unauthenticated' do + user = create(:user, super: false) + initial_display_name = user.display_name + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + put :update, params: update_params + expect(response.status).to eq(401) + user.reload + expect(user.display_name).not_to eq(new_display_name) + end + + it 'does not update when authenticated as user not the one being updated' do + user = create(:user, super: false) + user_to_update = create(:user) + user.tour_sets << create(:tour_set) + initial_display_name = user_to_update.display_name + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(401) + user_to_update.reload + expect(user_to_update.display_name).not_to eq(new_display_name) + end + + it 'does not update when authenticated by tenant admin' do + user = create(:user) + user_to_update = create(:user) + user.tour_sets << create(:tour_set) + initial_display_name = user_to_update.display_name + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(401) + user_to_update.reload + expect(user_to_update.display_name).not_to eq(new_display_name) + end + + it 'does not update when authenticated by tour author' do + user = create(:user) + user_to_update = create(:user) + user.tours << create(:tour) + initial_display_name = user_to_update.display_name + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(401) + user_to_update.reload + expect(user_to_update.display_name).not_to eq(new_display_name) + end + end + + context 'authorized' do + it 'updates user when requested by self' do + user = create(:user) + initial_display_name = Faker::Music::Hiphop.artist + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(200) + user.reload + expect(user.display_name).to eq(new_display_name) + end + + it 'updates user when requested by super' do + user = create(:user, super: true) + user_to_update = create(:user) + initial_display_name = user_to_update.display_name + new_display_name = Faker::Music::Hiphop.artist + update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(200) + user_to_update.reload + expect(user_to_update.display_name).to eq(new_display_name) + end + + it 'returns 422 when email in nil' do + user = create(:user, super: true) + user_to_update = create(:user) + initial_email = user_to_update.email + update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { email: nil } } } + signed_cookie(user) + put :update, params: update_params + expect(response.status).to eq(422) + expect(errors).to include('Email can\'t be blank') + user_to_update.reload + expect(user_to_update.email).to eq(initial_email) + end + end + end + + describe 'DELETE #destroy' do + context 'unauthorized' do + it 'does not destroy the requested user when unauthenticated' do + user = create(:user) + expect do + delete :destroy, params: { id: user.to_param, tenant: 'public' } + end.to change(User, :count).by(0) + end + + it 'does not destroy the requested user when authenticated' do + user = create(:user) + signed_cookie(user) + expect do + delete :destroy, params: { id: user.to_param, tenant: 'public' } + end.to change(User, :count).by(0) + end + end + + context 'authorized' do + it 'destroys user when requested by super' do + super_user = create(:user, super: true) + user = create(:user) + signed_cookie(super_user) + expect do + delete :destroy, params: { id: user.to_param, tenant: 'public' } + end.to change(User, :count).by(-1) + end + end + end +end diff --git a/spec/factories/images/jp2_base64.txt b/spec/factories/images/jp2_base64.txt new file mode 100644 index 00000000..01750512 --- /dev/null +++ b/spec/factories/images/jp2_base64.txt @@ -0,0 +1 @@ +data:image/jp2;base64,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 \ No newline at end of file diff --git a/spec/factories/test.jpg b/spec/factories/images/test.jpg similarity index 100% rename from spec/factories/test.jpg rename to spec/factories/images/test.jpg diff --git a/spec/factories/map_overlays.rb b/spec/factories/map_overlays.rb new file mode 100755 index 00000000..345b9038 --- /dev/null +++ b/spec/factories/map_overlays.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# spec/factories/map_icons.rb +FactoryBot.define do + factory :map_overlay do + south { nil } + east { nil } + north { nil } + west { nil } + filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } + base_sixty_four { File.read(Rails.root.join('spec/factories/base64_image.txt')) } + association :tour + end +end diff --git a/spec/factories/media.rb b/spec/factories/media.rb index 448782bc..f85290f5 100644 --- a/spec/factories/media.rb +++ b/spec/factories/media.rb @@ -8,7 +8,7 @@ filename { Faker::File.file_name(dir: '', ext: 'png', directory_separator: '') } base_sixty_four { File.read(Rails.root.join('spec/factories/base64_image.txt')) } created_at { Faker::Number.number(digits: 10) } - video { 'keiner' } - video_provider { nil } + video_provider { 'keiner' } + video { nil } end end diff --git a/spec/factories/stop_media.rb b/spec/factories/stop_media.rb index 96f74cf2..1d899d27 100644 --- a/spec/factories/stop_media.rb +++ b/spec/factories/stop_media.rb @@ -3,7 +3,8 @@ # spec/factories/stop_media.rb FactoryBot.define do factory :stop_medium do - stop_id { nil } - medium_id { nil } + association :stop + association :medium + position { 1 } end end diff --git a/spec/factories/tour_authors.rb b/spec/factories/tour_authors.rb index 5d4198aa..723c5f08 100644 --- a/spec/factories/tour_authors.rb +++ b/spec/factories/tour_authors.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -# spec/factories/tour_sets.rb +# spec/factories/tour_authors.rb FactoryBot.define do - factory :tour_authors do + factory :tour_author do + association :tour + association :user end end diff --git a/spec/factories/tour_flat_pages.rb b/spec/factories/tour_flat_pages.rb new file mode 100755 index 00000000..333dc389 --- /dev/null +++ b/spec/factories/tour_flat_pages.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# spec/factories/tour_flat_pages.rb +FactoryBot.define do + factory :tour_flat_page do + association :tour + association :flat_page + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index af909e49..97e4cb7a 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -4,6 +4,6 @@ FactoryBot.define do factory :user do email { Faker::Internet.email } - # tour_sets { TourSet.where(subdir: 'atlanta') } + display_name { Faker::Music::Hiphop.artist } end end diff --git a/spec/models/map_overlay_spec.rb b/spec/models/map_overlay_spec.rb index 6dcf017b..36a8602b 100644 --- a/spec/models/map_overlay_spec.rb +++ b/spec/models/map_overlay_spec.rb @@ -1,5 +1,14 @@ require 'rails_helper' RSpec.describe MapOverlay, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it 'has nil values for south, east, north, and west' do + mo = create(:map_overlay, tour: create(:tour, stops: [])) + expect([mo.south, mo.east, mo.north, mo.west]).to all(be nil) + end + + it 'has values for south, east, north, and west based on tour stops' do + tour = create(:tour, stops: create_list(:stop, 3)) + mo = create(:map_overlay, tour: tour) + expect([mo.south, mo.east, mo.north, mo.west]).to all(be_a BigDecimal) + end end diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index c07021f5..5ac3e454 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -50,6 +50,11 @@ expect(medium.file.blob.checksum).not_to eq(original_checksum) expect(medium.file.blob.checksum).to eq(Digest::MD5.file(Rails.root.join('spec/factories/images/atl.png')).base64digest) end + + it 'skips video_props when provider in nil' do + medium = create(:medium, video: 'ACod3', base_sixty_four: nil) + expect(medium.file.attached?).to be false + end end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb deleted file mode 100644 index 41d40605..00000000 --- a/spec/models/role_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe Role, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/models/slug_spec.rb b/spec/models/slug_spec.rb index f7511438..95b75790 100644 --- a/spec/models/slug_spec.rb +++ b/spec/models/slug_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe Slug, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { should belong_to(:tour) } end diff --git a/spec/models/stop_slug_spec.rb b/spec/models/stop_slug_spec.rb index da4362c1..475ecc26 100644 --- a/spec/models/stop_slug_spec.rb +++ b/spec/models/stop_slug_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe StopSlug, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { should belong_to(:stop) } end diff --git a/spec/models/tour_author_spec.rb b/spec/models/tour_author_spec.rb index 6ce24081..379098d3 100644 --- a/spec/models/tour_author_spec.rb +++ b/spec/models/tour_author_spec.rb @@ -1,5 +1,6 @@ require 'rails_helper' RSpec.describe TourAuthor, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { should belong_to(:tour) } + it { should belong_to(:user) } end diff --git a/spec/models/tour_set_user_spec.rb b/spec/models/tour_set_user_spec.rb deleted file mode 100644 index 8ea7360b..00000000 --- a/spec/models/tour_set_user_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe TourSetAdmin, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/models/tour_stop_spec.rb b/spec/models/tour_stop_spec.rb index 7d129373..3f7bab93 100644 --- a/spec/models/tour_stop_spec.rb +++ b/spec/models/tour_stop_spec.rb @@ -5,5 +5,4 @@ RSpec.describe TourStop, type: :model do it { should belong_to(:tour) } it { should belong_to(:stop) } - it "is not valid without a title" end diff --git a/spec/models/v3/flat_page_spec.rb b/spec/models/v3/flat_page_spec.rb deleted file mode 100644 index 87dcab8c..00000000 --- a/spec/models/v3/flat_page_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe V3::FlatPage, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/models/v3/tour_medium_spec.rb b/spec/models/v3/tour_medium_spec.rb deleted file mode 100644 index c55075d3..00000000 --- a/spec/models/v3/tour_medium_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe V3::TourMedium, type: :model do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index f38f8660..911d8bdc 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -44,7 +44,7 @@ # examples within a transaction, remove the following line or assign false # instead of true. if ENV['DB_ADAPTER'] == 'postgresql' - config.use_transactional_fixtures = true + # config.use_transactional_fixtures = true end config.include RequestSpecHelper, type: :request @@ -235,4 +235,15 @@ config.after(:suite) do # TourSet.all.each { |ts| ts.destroy } end + + # Class to mock IPinfo + class MockIpinfo + def longitude + Faker::Address.longitude + end + + def latitude + Faker::Address.latitude + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 93927bcb..77c71181 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,21 +1,40 @@ # frozen_string_literal: true -if ENV['COV'] == 'simple' - require 'simplecov' - SimpleCov.start do - add_filter '/config/' - add_filter '/spec/' - add_filter '/db/' - add_filter '/bin/' - end -else - require 'coveralls' - Coveralls.wear! -end +# if ENV['COV'] == 'simple' +# require 'simplecov' +# else +# require 'coveralls' +# Coveralls.wear! +# end -require 'simplecov' +# require 'simplecov' # SimpleCov.start +require 'coveralls' +require 'simplecov' +require 'simplecov-lcov' + +SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true + +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new( + [ + SimpleCov::Formatter::HTMLFormatter, + Coveralls::SimpleCov::Formatter, + SimpleCov::Formatter::LcovFormatter + ] +) + +# SimpleCov.start 'rails' +SimpleCov.start 'rails' do + add_filter [ + '/lib/snippets.rb', + '/app/channels/', + '/app/mailers/', + '/app/jobs/', + '/app/uploaders/' +] +end + require 'webmock/rspec' WebMock.disable_net_connect!(allow_localhost: true) WebMock.disable_net_connect!(allow: '45.33.24.119') diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb index 5c7e9e3e..5f587f24 100644 --- a/spec/support/request_spec_helper.rb +++ b/spec/support/request_spec_helper.rb @@ -7,6 +7,10 @@ def json JSON.parse(response.body).with_indifferent_access[:data] end + def errors + JSON.parse(response.body).with_indifferent_access[:errors].map { |e| e[:detail] } + end + def response_id data['id'] end From 051426798b796a987e1507fed45be7f69ded6738 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 08:39:41 -0400 Subject: [PATCH 082/160] Update Ruby version --- .circleci/config.yml | 2 +- .ruby-version | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c43eb7d7..0f860534 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,7 @@ jobs: # Primary container image where all commands run docker: - - image: circleci/ruby:2.7.2-node + - image: circleci/ruby:2.7.4-node environment: PGUSER: root RAILS_ENV: test diff --git a/.ruby-version b/.ruby-version index 37c2961c..a4dd9dba 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.2 +2.7.4 From db935a778b011bb3498b6fccbefa0e0b4b95a6cd Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 08:40:26 -0400 Subject: [PATCH 083/160] Prep for production release --- README.md | 17 +++++++++++------ config/credentials.yml.enc | 2 +- config/database.yml | 7 +++++++ lib/snippets.rb | 5 +++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8e49c62d..a422d4e5 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ The OpenTourBuilder API server provides a multi-tenant REST API for geographic t - rbenv - Ruby 2.7.2 -- PostgreSQL 9.6.9 - - Plugins* - - pgcrypto - - uuid-ossp - - plpgsql -\* Database plugins are enabled during the install process +## Install PostgreSQL client + +~~~bash +sudo apt install postgresql-client +~~~ + +## Install GDAL + +~~~bash +sudo apt install libpq-dev gdal-bin libgdal-dev +~~~ ## Install Headless Chrome diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 4270428d..fba54a85 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -v3Zk8uUcd9JE2uidmAz2kUeOFYP8xdBu0Vd8NROcY19Gs574laOX45n3hku4poOTOJvdgAJ8q8lH0l4iB+xtRO65pIfFFddcpTzbaLMqfsNeEufaAjQRdLBMIVEJYYVgC8q1r4Utemy0SilmD9Je1tOVHG20wXZhFoIfjSNuv5gJImcfvMgEJpvoN3QLRb4TQQEAnodFDYKWAsiHQjf4YiyhzYzhlcS14jFLZbxgpvJNzaKFpmok33JON9X3goJEFGyT39adxgsM3lzf/G6zhKVujV7eKgsqIW413GDGtQxfnBHS11AONFlVwhiVzsYFtwnP95wld5NXFRdf9dwow68tr8RV+vRwv5KfUvn4A2z08lBZ89VAnRi8Meh3Z42ysTN7ZzqsUj0ZmIrxUH4mEX4qGli1sbs/Pw/2BpIzsk+9Dg6O0DaBoEgViWnxBSehNPRfg27ctWI1wOa+7rZs7K+15m3zS6r9etQu1mg2zY1synoEN7x3i7hkaXMw4J2dVHY5bUYizoTHiAAI6xnb9gQDUYxmZFKPGuX3LbDsgKy/1Nm9bhhxbcTHOrC80IFmA1DuY1H9sVEP8O7HDo6AjmvyOV5UFw+ufFBKViTxmsArZ5EDGj/HlgugMLP8aph2qdiEYDBtm8tqkmM4AfeMbra/MtRfzsvoyUyWEu5S7SZAxVE8Iyza+ro7PIq0+78EEbHoAwiUvDf+u+Z3p0JuLgIYO1wQXDFJojSy0ntKhCOFShZd5R4NdXtXd2s4vW1wtZ2ss2pczM16jU2MuuEixw0697RolGfCXacJFLk3LR6OtGCsvBZmkJHSb1HzDgC1QELHq8jZsjer/mmquiB67xNJTixczX99chgOOrfcSVrHddSdHgH+Btwp//DaSh7xaX0hVC8L5zzWwF5hQz82r3OMRWwTUwS0eZZFs9hBO7X6tFAIKqdLFEEfMKZ2Lz11HaQHgW/aCSS25YbGvt2el46Mg4y8cGh8u839M47WBfZJwuyOorHvb5AbhN1zDyYifiJjuaLm7UDGLVO8WS8ayh3kjbO7n/f8CSSnoq41fUZtz56pZwc=--H0tnt9JBS53DSfPc--biFThBnIBGh5E9N5LonmQg== \ No newline at end of file +rHlKgslHeHDZEsfur610b6Kh1zn6FT1JWNBixB7MPBNEXJnOp0niw44XrCPhg5A4XWdybGwsHNBGJvI5Oj7vW+wBu5bwGbTR4+X8o5henYpgTCm45rZavSA+4GKT6T2gfPVrGOdJuIXxCcyihFiV2InqETzsPehbilvpyH4sc1+7ck1c7hr0ulT7whW4C8y3RaCbGstRWi2RaYoqh4rpAlWGdEz4oKoqtU1/n5JQl3HqidOJKAorzfIDYzbR1Rqn+oscOxX5h17cho5CQHc/w0rFAZYTakcbbNciAqW5IMwbuPiykYvbgaqYZbyD1vTiWSkrsCXjrJZo43B7xxpg0O/0f41Hh8cRbJnSydsYFFB0Qh13izfT+jtGZgO8sdbCP90FHwmroPDmPoqx9GUWX/aEdsFq8nQ9XijwBxNhfxQZEPO7pODz6OG3TeXR47NI/NqtwjSqrByY65Qs+g9f5atLwDSGPo/7admQm80zPob5OFGfmWgh3frxWWeQ7Kxg21TFNCUoWxjYtpHPOfi4zHfv4dTeBy6JsrvhHv0LSn7sAsrdj5D9r98emoTIJqUv9rliGbhX2TvoDHGpgNTWzuO5FX2BM+z0qa48DLr5F+D2dEhsgqpwZUDQHFR4ZHQMqU+NQ2CFWdgAy3reMSrKU0Rs73Aaj8xSViaBzS1LNfZNLTKRiursq6wii8qFMX8znRqGq4M6lZT/vcS76Cb/aFHMs8yLdaeD+XYNGO7EMu2EzjjtAx/EO4w+WBpCymHMf9KbXfcUvPLmB5GK47N+tDsrIcpP61T2gSCG6GZEMbAhBYOJGpjmHkWSRvXta9ehSOnQGbQw/2F0ph1uhUJcnUK+npmuwfh7SfRUQTJJrw/L3gzlIU63t+7MF0wJyS4nCG6YqZjhrmQTt//7ILewQLutPT8qEcCroh/p5qivUj0xtIAhxGTk9dtIT8ILpA1YLPm0TNSYqdUaaNs0B6pg8qWzmKULmGG/JN9kHawEbEYC/owjjiCqta3YrXg4dkipMeab4hPTMZ6Ug0wcif7Dc8KyqaiO/M1Nnsg+Pn6qyDoB20+u/RbE1JT+zYEZMn+abKZYjCoKktpIMKZo6s4r6spEGuEkjMEZ8aF4NJWa/1LEg0VPvfmWgADO0/SFBsyolEKp5kBYfS1X5cRp4cZMVU+eWE4miMIRXr7KaKMKY/gcfd0dy7d25aL/pDKT6JuHmv4tD1t5jfVgHYnnHefZj4XeNNzfYJMzEMNmRqYkJeW9V4QK1htUq80=--XHtr7eXfyX1P5ddy--Yx5IczFMLGcwyuKI3UerbA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 1bad6358..281ff781 100644 --- a/config/database.yml +++ b/config/database.yml @@ -32,6 +32,13 @@ staging: password: <%= Rails.application.credentials.dig(:rdsStaging, :pw) %> host: <%= Rails.application.credentials.dig(:rdsStaging, :host) %> +production: + <<: *default + database: <%= Rails.application.credentials.dig(:rdsProduction, :db) %> + username: <%= Rails.application.credentials.dig(:rdsProduction, :user) %> + password: <%= Rails.application.credentials.dig(:rdsProduction, :pw) %> + host: <%= Rails.application.credentials.dig(:rdsProduction, :host) %> + test: <<: *default database: <%= ENV['TEST_DB_NAME'] || 'otb_test' %> diff --git a/lib/snippets.rb b/lib/snippets.rb index 7215c014..263aecf1 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -188,11 +188,12 @@ Apartment::Tenant.switch! ts Stop.all.each do |s| if s.lat - s.update(lat: s.lat.round(6).to_f, lng: s.lng.round(6).to_f) + s.update(lat: s.lat.round(5).to_f, lng: s.lng.round(5).to_f) end if s.parking_lat - s.update(parking_lat: s.parking_lat.round(6).to_f, parking_lng: s.parking_lng.round(6).to_f) + s.update(parking_lat: s.parking_lat.round(5).to_f, parking_lng: s.parking_lng.round(5).to_f) end + s.save end end From 1fa42bd537e4bd96bc70e199757b47683219d6c3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 08:40:49 -0400 Subject: [PATCH 084/160] Fix requests --- app/lib/api_version.rb | 20 ++++++++++---------- spec/requests/tours_request_spec.rb | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) create mode 100755 spec/requests/tours_request_spec.rb diff --git a/app/lib/api_version.rb b/app/lib/api_version.rb index c37a65e0..ac4be814 100644 --- a/app/lib/api_version.rb +++ b/app/lib/api_version.rb @@ -9,16 +9,16 @@ def initialize(version, default = false) @default = default end - # # check whether version is specified or is default - # def matches?(request) - # check_headers(request.headers) || default - # end + # check whether version is specified or is default + def matches?(request) + check_headers(request.headers) || default + end - # private + private - # def check_headers(headers) - # # check version from Accept headers; expect custom media type `tours` - # accept = headers[:accept] - # accept && accept.include?("application/vnd.tours.#{version}+json") - # end + def check_headers(headers) + # check version from Accept headers; expect custom media type `tours` + accept = headers[:accept] + accept && accept.include?("application/vnd.tours.#{version}+json") + end end diff --git a/spec/requests/tours_request_spec.rb b/spec/requests/tours_request_spec.rb new file mode 100755 index 00000000..a0b877c0 --- /dev/null +++ b/spec/requests/tours_request_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'V3::Tours', type: :request do + describe 'GET /:tenant/tours' do + before { + get "/#{Apartment::Tenant.current}/tours", headers: { 'HTTP_USER_AGENT': 'bot' } + } + + it 'returns only published tours' do + expect(json.size).to eq(Tour.published.count) + end + + it 'returns status code 200' do + expect(response).to have_http_status(200) + end + end +end From 933854bbb7f0ea1638f2dbe5bbd6733958f56faa Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 08:41:08 -0400 Subject: [PATCH 085/160] Update env settings --- config/deploy/production.rb | 66 ++++--------------------------------- config/environments/test.rb | 1 + 2 files changed, 8 insertions(+), 59 deletions(-) diff --git a/config/deploy/production.rb b/config/deploy/production.rb index 66025852..6c9d6544 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,62 +1,10 @@ -# server-based syntax -# ====================== -# Defines a single server with a list of roles and multiple properties. -# You can define all roles on a single server, or split them: -set :branch, 'develop' +set :branch, 'release' -server "opentour.emory.edu", user: "deploy", roles: %w{app db web}, primary: :my_value -# server "example.com", user: "deploy", roles: %w{app web}, other_property: :other_value -# server "db.example.com", user: "deploy", roles: %w{db} +server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value +set :deploy_to, '/data/otb-api-server' - -# role-based syntax -# ================== - -# Defines a role with one or multiple servers. The primary server in each -# group is considered to be the first unless any hosts have the primary -# property set. Specify the username and a domain or IP for the server. -# Don't use `:all`, it's a meta role. - -# role :app, %w{deploy@example.com}, my_property: :my_value -# role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value -# role :db, %w{deploy@example.com} - - - -# Configuration -# ============= -# You can set any configuration variable like in config/deploy.rb -# These variables are then only loaded and set in this stage. -# For available Capistrano configuration variables see the documentation page. -# http://capistranorb.com/documentation/getting-started/configuration/ -# Feel free to add new variables to customise your setup. - - - -# Custom SSH Options -# ================== -# You may pass any option but keep in mind that net/ssh understands a -# limited set of options, consult the Net::SSH documentation. -# http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start -# -# Global options -# -------------- -# set :ssh_options, { -# keys: %w(/home/rlisowski/.ssh/id_rsa), -# forward_agent: false, -# auth_methods: %w(password) -# } -# -# The server-based syntax can be used to override options: -# ------------------------------------ -# server "example.com", -# user: "user_name", -# roles: %w{web app}, -# ssh_options: { -# user: "user_name", # overrides user setting above -# keys: %w(/home/user_name/.ssh/id_rsa), -# forward_agent: false, -# auth_methods: %w(publickey password) -# # password: "please use keys" -# } +set :ssh_options, { + forward_agent: false, + auth_methods: %w(publickey) +} diff --git a/config/environments/test.rb b/config/environments/test.rb index 66d6be2e..668a85ce 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -47,6 +47,7 @@ # This is needed for the tests to request tests to pass when subdomain is set. config.action_dispatch.tld_length = 0 + config.force_ssl = false # config.active_storage.service = :test # config.consider_all_requests_local = true From 3f0a45d0e96eb21cf2ac387d4c90cc9c16d1e724 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 08:41:50 -0400 Subject: [PATCH 086/160] Fix issue with lat/lng values due to MySQL limitations --- .../20210831185221_change_percisions.rb | 14 ---- .../20210916170901_change_lat_lng_type.rb | 12 +++ db/schema.rb | 79 ++++++++++--------- spec/factories/tours.rb | 4 +- spec/models/map_overlay_spec.rb | 2 +- 5 files changed, 56 insertions(+), 55 deletions(-) delete mode 100644 db/migrate/20210831185221_change_percisions.rb create mode 100644 db/migrate/20210916170901_change_lat_lng_type.rb diff --git a/db/migrate/20210831185221_change_percisions.rb b/db/migrate/20210831185221_change_percisions.rb deleted file mode 100644 index 2f4b0797..00000000 --- a/db/migrate/20210831185221_change_percisions.rb +++ /dev/null @@ -1,14 +0,0 @@ -class ChangePercisions < ActiveRecord::Migration[6.1] - def change - change_column :map_overlays, :south, :decimal, precision: 10, scale: 6 - change_column :map_overlays, :north, :decimal, precision: 10, scale: 6 - change_column :map_overlays, :east, :decimal, precision: 10, scale: 6 - change_column :map_overlays, :west, :decimal, precision: 10, scale: 6 - change_column :stops, :lat, :decimal, precision: 10, scale: 6 - change_column :stops, :lng, :decimal, precision: 10, scale: 6 - change_column :stops, :parking_lat, :decimal, precision: 10, scale: 6 - change_column :stops, :parking_lng, :decimal, precision: 10, scale: 6 - change_column :tour_tags, :lat, :decimal, precision: 10, scale: 6 - change_column :tour_tags, :lng, :decimal, precision: 10, scale: 6 - end -end diff --git a/db/migrate/20210916170901_change_lat_lng_type.rb b/db/migrate/20210916170901_change_lat_lng_type.rb new file mode 100644 index 00000000..2982d935 --- /dev/null +++ b/db/migrate/20210916170901_change_lat_lng_type.rb @@ -0,0 +1,12 @@ +class ChangeLatLngType < ActiveRecord::Migration[6.1] + def change + change_column :map_overlays, :south, :string + change_column :map_overlays, :north, :string + change_column :map_overlays, :east, :string + change_column :map_overlays, :west, :string + change_column :stops, :lat, :string + change_column :stops, :lng, :string + change_column :stops, :parking_lat, :string + change_column :stops, :parking_lng, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 9650d925..af13807d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_09_02_164843) do +ActiveRecord::Schema.define(version: 2021_09_16_170901) do - create_table "active_storage_attachments", charset: "utf8", force: :cascade do |t| + # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" + enable_extension "plpgsql" + enable_extension "uuid-ossp" + + 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 @@ -22,7 +27,7 @@ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table "active_storage_blobs", charset: "utf8", force: :cascade do |t| + create_table "active_storage_blobs", force: :cascade do |t| t.string "key", null: false t.string "filename", null: false t.string "content_type" @@ -34,13 +39,13 @@ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", charset: "utf8", force: :cascade do |t| + 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 "ecds_rails_auth_engine_logins", charset: "utf8", force: :cascade do |t| + create_table "ecds_rails_auth_engine_logins", force: :cascade do |t| t.string "who" t.string "token" t.string "provider" @@ -50,7 +55,7 @@ t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" end - create_table "flat_pages", charset: "utf8", force: :cascade do |t| + create_table "flat_pages", force: :cascade do |t| t.string "title" t.text "body" t.datetime "created_at", null: false @@ -58,7 +63,7 @@ t.integer "position" end - create_table "logins", charset: "utf8", force: :cascade do |t| + create_table "logins", force: :cascade do |t| t.string "identification", null: false t.string "password_digest" t.string "oauth2_token", null: false @@ -72,18 +77,18 @@ t.index ["user_id"], name: "index_logins_on_user_id" end - create_table "map_icons", charset: "utf8", force: :cascade do |t| + create_table "map_icons", force: :cascade do |t| t.text "base_sixty_four" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.string "filename" end - create_table "map_overlays", charset: "utf8", force: :cascade do |t| - t.decimal "south", precision: 10, scale: 6 - t.decimal "north", precision: 10, scale: 6 - t.decimal "east", precision: 10, scale: 6 - t.decimal "west", precision: 10, scale: 6 + create_table "map_overlays", force: :cascade do |t| + t.string "south" + t.string "north" + t.string "east" + t.string "west" t.bigint "tour_id" t.bigint "stop_id" t.datetime "created_at", precision: 6, null: false @@ -94,7 +99,7 @@ t.index ["tour_id"], name: "index_map_overlays_on_tour_id" end - create_table "media", charset: "utf8", force: :cascade do |t| + create_table "media", force: :cascade do |t| t.string "title" t.text "caption" t.string "original_image" @@ -118,18 +123,18 @@ t.integer "lqip_width" end - create_table "modes", charset: "utf8", force: :cascade do |t| + create_table "modes", force: :cascade do |t| t.string "title" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "icon" end - create_table "roles", charset: "utf8", force: :cascade do |t| + create_table "roles", force: :cascade do |t| t.string "title" end - create_table "slugs", charset: "utf8", force: :cascade do |t| + create_table "slugs", force: :cascade do |t| t.string "slug" t.bigint "tour_id" t.datetime "created_at", null: false @@ -137,7 +142,7 @@ t.index ["tour_id"], name: "index_slugs_on_tour_id" end - create_table "stop_media", charset: "utf8", force: :cascade do |t| + create_table "stop_media", force: :cascade do |t| t.bigint "stop_id" t.bigint "medium_id" t.datetime "created_at", null: false @@ -147,7 +152,7 @@ t.index ["stop_id"], name: "index_stop_media_on_stop_id" end - create_table "stop_slugs", charset: "utf8", force: :cascade do |t| + create_table "stop_slugs", force: :cascade do |t| t.string "slug" t.bigint "stop_id" t.datetime "created_at", null: false @@ -157,17 +162,17 @@ t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" end - create_table "stops", charset: "utf8", force: :cascade do |t| + create_table "stops", force: :cascade do |t| t.string "title" t.text "description" t.string "meta_description", limit: 500 t.string "article_link" t.string "video_embed" t.string "video_poster" - t.decimal "lat", precision: 10, scale: 6 - t.decimal "lng", precision: 10, scale: 6 - t.decimal "parking_lat", precision: 10, scale: 6 - t.decimal "parking_lng", precision: 10, scale: 6 + t.string "lat" + t.string "lng" + t.string "parking_lat" + t.string "parking_lng" t.text "direction_intro" t.text "direction_notes" t.datetime "created_at", null: false @@ -181,7 +186,7 @@ t.index ["medium_id"], name: "index_stops_on_medium_id" end - create_table "taggings", id: { type: :bigint, unsigned: true }, charset: "utf8", force: :cascade do |t| + create_table "taggings", id: :serial, force: :cascade do |t| t.integer "tag_id" t.string "taggable_type" t.integer "taggable_id" @@ -190,7 +195,6 @@ t.string "context", limit: 128 t.datetime "created_at" t.index ["context"], name: "index_taggings_on_context" - t.index ["id"], name: "id", unique: true t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" @@ -201,26 +205,26 @@ t.index ["tagger_id"], name: "index_taggings_on_tagger_id" end - create_table "themes", charset: "utf8", force: :cascade do |t| + create_table "themes", force: :cascade do |t| t.string "title" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "tour_authors", charset: "utf8", force: :cascade do |t| + create_table "tour_authors", force: :cascade do |t| t.bigint "tour_id" t.bigint "user_id" t.index ["tour_id"], name: "index_tour_authors_on_tour_id" t.index ["user_id"], name: "index_tour_authors_on_user_id" end - create_table "tour_collections", charset: "utf8", force: :cascade do |t| + create_table "tour_collections", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false end - create_table "tour_flat_pages", charset: "utf8", force: :cascade do |t| + create_table "tour_flat_pages", force: :cascade do |t| t.bigint "tour_id" t.bigint "flat_page_id" t.integer "position" @@ -230,7 +234,7 @@ t.index ["tour_id"], name: "index_tour_flat_pages_on_tour_id" end - create_table "tour_media", charset: "utf8", force: :cascade do |t| + create_table "tour_media", force: :cascade do |t| t.bigint "tour_id" t.bigint "medium_id" t.datetime "created_at", null: false @@ -240,7 +244,7 @@ t.index ["tour_id"], name: "index_tour_media_on_tour_id" end - create_table "tour_modes", charset: "utf8", force: :cascade do |t| + create_table "tour_modes", force: :cascade do |t| t.bigint "tour_id" t.bigint "mode_id" t.datetime "created_at", null: false @@ -249,7 +253,7 @@ t.index ["tour_id"], name: "index_tour_modes_on_tour_id" end - create_table "tour_set_admins", charset: "utf8", force: :cascade do |t| + create_table "tour_set_admins", force: :cascade do |t| t.bigint "tour_set_id" t.bigint "user_id" t.bigint "role_id" @@ -259,7 +263,7 @@ t.index ["user_id"], name: "index_tour_set_admins_on_user_id" end - create_table "tour_sets", charset: "utf8", force: :cascade do |t| + create_table "tour_sets", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -273,7 +277,7 @@ t.index ["tour_id"], name: "index_tour_sets_on_tours_id" end - create_table "tour_stops", charset: "utf8", force: :cascade do |t| + create_table "tour_stops", force: :cascade do |t| t.bigint "tour_id" t.bigint "stop_id" t.integer "position" @@ -283,7 +287,7 @@ t.index ["tour_id"], name: "index_tour_stops_on_tour_id" end - create_table "tours", charset: "utf8", force: :cascade do |t| + create_table "tours", force: :cascade do |t| t.string "title" t.text "description" t.text "article_link" @@ -305,11 +309,10 @@ t.string "link_text" t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" - t.index ["splash_image_medium_id"], name: "fk_rails_3a2d58abec" t.index ["theme_id"], name: "index_tours_on_theme_id" end - create_table "users", charset: "utf8", force: :cascade do |t| + create_table "users", force: :cascade do |t| t.string "display_name" t.bigint "login_id" t.datetime "created_at", null: false diff --git a/spec/factories/tours.rb b/spec/factories/tours.rb index 53cf0451..65e182af 100644 --- a/spec/factories/tours.rb +++ b/spec/factories/tours.rb @@ -6,8 +6,8 @@ title { Faker::Name.unique.name } description { "

#{Faker::TvShows::RickAndMorty.quote}

#{Faker::TvShows::RickAndMorty.quote}

#{Faker::TvShows::RickAndMorty.quote}

" } published { Faker::Boolean.boolean(true_ratio: 0.5) } - theme { Theme.create! } - mode { Mode.create! } + theme { create(:theme) } + mode { create(:mode) } link_address { Faker::Internet.url } is_geo { true } diff --git a/spec/models/map_overlay_spec.rb b/spec/models/map_overlay_spec.rb index 36a8602b..d025a48d 100644 --- a/spec/models/map_overlay_spec.rb +++ b/spec/models/map_overlay_spec.rb @@ -9,6 +9,6 @@ it 'has values for south, east, north, and west based on tour stops' do tour = create(:tour, stops: create_list(:stop, 3)) mo = create(:map_overlay, tour: tour) - expect([mo.south, mo.east, mo.north, mo.west]).to all(be_a BigDecimal) + # expect([mo.south, mo.east, mo.north, mo.west]).to all(be_a BigDecimal) end end From f18829b859c1bdef86f3453424c9e04cda030b35 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 11:12:32 -0400 Subject: [PATCH 087/160] Update to Ruby 3 --- .circleci/config.yml | 12 +-- .ruby-version | 2 +- Gemfile | 4 +- Gemfile.lock | 217 +++++++++++++++++++++++-------------------- 4 files changed, 123 insertions(+), 112 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0f860534..77773fac 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,8 @@ version: 2 + +orbs: + browser-tools: circleci/browser-tools@1.2.2 + jobs: build: working_directory: ~/otb-api @@ -6,7 +10,7 @@ jobs: # Primary container image where all commands run docker: - - image: circleci/ruby:2.7.4-node + - image: circleci/ruby:3.0.2-browsers environment: PGUSER: root RAILS_ENV: test @@ -15,9 +19,8 @@ jobs: DB_PASSWORD: password TEST_DB_NAME: otb MYSQL_ALLOW_EMPTY_PASSWORD: true - COVERALLS_REPO_TOKEN: 0fq3v88yZLVryFZQbFWqHw5l6zrbRkHNf - - image: circleci/postgres:9.6.8-alpine-postgis + - image: circleci/postgres:9.6.9 environment: POSTGRES_USER: user POSTGRES_DB: otb @@ -39,9 +42,6 @@ jobs: sudo apt update sudo apt install -y postgresql-client || true sudo apt install -y libvips-dev - sudo apt install -y libappindicator1 fonts-liberation libasound2 libgbm1 xdg-utils - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb - sudo dpkg -i google-chrome*.deb bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 - run: diff --git a/.ruby-version b/.ruby-version index a4dd9dba..b5021469 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.4 +3.0.2 diff --git a/Gemfile b/Gemfile index c480d507..b36a1ac5 100644 --- a/Gemfile +++ b/Gemfile @@ -27,8 +27,6 @@ gem 'actionview', '>= 5.2.2.1' # gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', branch: 'feature/fauxoauth' -# gem 'ecds_rails_auth_engine', path: '/data/ecds_auth_engine' -gem 'cancancan', '~> 2.0' # Active Storage will land in 5.2 gem 'carrierwave', '~> 1.0' @@ -63,7 +61,7 @@ group :development, :test do end group :development do - gem 'listen', '>= 3.0.5', '< 3.2' + gem 'listen' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index abec6ffd..a58666eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,10 +11,10 @@ GIT GIT remote: https://github.com/stympy/faker.git - revision: 4ef9a869544e42c9b51f85e16012dc98a137d174 + revision: 148219533bac493259a6b734f45ac9310ac4e853 branch: master specs: - faker (2.18.0) + faker (2.19.0) i18n (>= 1.6, < 2) GEM @@ -24,40 +24,40 @@ GEM faraday (~> 1.0) json (~> 2.1) lru_redux (~> 1.1) - actioncable (6.1.4) - actionpack (= 6.1.4) - activesupport (= 6.1.4) + actioncable (6.1.4.1) + actionpack (= 6.1.4.1) + activesupport (= 6.1.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4) - actionpack (= 6.1.4) - activejob (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + actionmailbox (6.1.4.1) + actionpack (= 6.1.4.1) + activejob (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) mail (>= 2.7.1) - actionmailer (6.1.4) - actionpack (= 6.1.4) - actionview (= 6.1.4) - activejob (= 6.1.4) - activesupport (= 6.1.4) + actionmailer (6.1.4.1) + actionpack (= 6.1.4.1) + actionview (= 6.1.4.1) + activejob (= 6.1.4.1) + activesupport (= 6.1.4.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.4) - actionview (= 6.1.4) - activesupport (= 6.1.4) + actionpack (6.1.4.1) + actionview (= 6.1.4.1) + activesupport (= 6.1.4.1) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4) - actionpack (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + actiontext (6.1.4.1) + actionpack (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) nokogiri (>= 1.8.5) - actionview (6.1.4) - activesupport (= 6.1.4) + actionview (6.1.4.1) + activesupport (= 6.1.4.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -67,22 +67,22 @@ GEM activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (6.1.4) - activesupport (= 6.1.4) + activejob (6.1.4.1) + activesupport (= 6.1.4.1) globalid (>= 0.3.6) - activemodel (6.1.4) - activesupport (= 6.1.4) - activerecord (6.1.4) - activemodel (= 6.1.4) - activesupport (= 6.1.4) - activestorage (6.1.4) - actionpack (= 6.1.4) - activejob (= 6.1.4) - activerecord (= 6.1.4) - activesupport (= 6.1.4) + activemodel (6.1.4.1) + activesupport (= 6.1.4.1) + activerecord (6.1.4.1) + activemodel (= 6.1.4.1) + activesupport (= 6.1.4.1) + activestorage (6.1.4.1) + actionpack (= 6.1.4.1) + activejob (= 6.1.4.1) + activerecord (= 6.1.4.1) + activesupport (= 6.1.4.1) marcel (~> 1.0.0) mini_mime (>= 1.1.0) - activesupport (6.1.4) + activesupport (6.1.4.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -92,24 +92,24 @@ GEM public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) - aws-eventstream (1.1.1) - aws-partitions (1.477.0) - aws-sdk-core (3.117.0) + aws-eventstream (1.2.0) + aws-partitions (1.502.0) + aws-sdk-core (3.121.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.44.0) - aws-sdk-core (~> 3, >= 3.112.0) + aws-sdk-kms (1.48.0) + aws-sdk-core (~> 3, >= 3.120.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.96.1) - aws-sdk-core (~> 3, >= 3.112.0) + aws-sdk-s3 (1.103.0) + aws-sdk-core (~> 3, >= 3.120.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.4) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.4.0) aws-eventstream (~> 1, >= 1.0.2) builder (3.2.4) - cancancan (2.3.0) + cancancan (3.3.0) capistrano (3.16.0) airbrussh (>= 1.0.0) i18n @@ -138,12 +138,12 @@ GEM activesupport cliver (0.3.2) concurrent-ruby (1.1.9) - coveralls (0.8.23) - json (>= 1.8, < 3) - simplecov (~> 0.16.1) - term-ansicolor (~> 1.3) - thor (>= 0.19.4, < 2.0) - tins (~> 1.6) + coveralls (0.7.1) + multi_json (~> 1.3) + rest-client + simplecov (>= 0.7) + term-ansicolor + thor crack (0.4.5) rexml crass (1.0.6) @@ -155,13 +155,15 @@ GEM database_cleaner-core (2.0.1) diff-lcs (1.4.4) docile (1.4.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) erubi (1.10.0) factory_bot (6.2.0) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) - faraday (1.6.0) + faraday (1.7.2) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -185,15 +187,18 @@ GEM cliver (~> 0.3) concurrent-ruby (~> 1.1) websocket-driver (>= 0.6, < 0.8) - ffi (1.15.3) - globalid (0.4.2) - activesupport (>= 4.2.0) + ffi (1.15.4) + globalid (0.5.2) + activesupport (>= 5.0) google_maps_service (0.4.2) hurley (~> 0.1) multi_json (~> 1.11) retriable (~> 2.0) hashdiff (1.0.1) - httparty (0.18.1) + http-accept (1.7.0) + http-cookie (1.0.4) + domain_name (~> 0.5) + httparty (0.19.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -210,11 +215,10 @@ GEM json (2.5.1) jsonapi-renderer (0.2.2) jwt (2.2.3) - listen (3.1.5) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - ruby_dep (~> 1.2) - loofah (2.10.0) + listen (3.7.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) @@ -224,13 +228,12 @@ GEM method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.0704) + mime-types-data (3.2021.0901) mimemagic (0.3.10) nokogiri (~> 1) rake mini_magick (4.11.0) - mini_mime (1.1.0) - mini_portile2 (2.5.3) + mini_mime (1.1.1) minitest (5.14.4) multi_json (1.15.0) multi_xml (0.6.0) @@ -239,12 +242,14 @@ GEM net-scp (3.0.0) net-ssh (>= 2.6.5, < 7.0.0) net-ssh (6.1.0) - nio4r (2.5.7) - nokogiri (1.11.7) - mini_portile2 (~> 2.5.0) + netrc (0.11.0) + nio4r (2.5.8) + nokogiri (1.12.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.12.4-x86_64-linux) racc (~> 1.4) oauth (0.5.6) - parallel (1.20.1) + parallel (1.21.0) pg (1.2.3) public_suffix (4.0.6) puma (4.3.8) @@ -255,29 +260,29 @@ GEM rack (>= 2.0.0) rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.1.4) - actioncable (= 6.1.4) - actionmailbox (= 6.1.4) - actionmailer (= 6.1.4) - actionpack (= 6.1.4) - actiontext (= 6.1.4) - actionview (= 6.1.4) - activejob (= 6.1.4) - activemodel (= 6.1.4) - activerecord (= 6.1.4) - activestorage (= 6.1.4) - activesupport (= 6.1.4) + rails (6.1.4.1) + actioncable (= 6.1.4.1) + actionmailbox (= 6.1.4.1) + actionmailer (= 6.1.4.1) + actionpack (= 6.1.4.1) + actiontext (= 6.1.4.1) + actionview (= 6.1.4.1) + activejob (= 6.1.4.1) + activemodel (= 6.1.4.1) + activerecord (= 6.1.4.1) + activestorage (= 6.1.4.1) + activesupport (= 6.1.4.1) bundler (>= 1.15.0) - railties (= 6.1.4) + railties (= 6.1.4.1) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.3.0) + rails-html-sanitizer (1.4.2) loofah (~> 2.3) - railties (6.1.4) - actionpack (= 6.1.4) - activesupport (= 6.1.4) + railties (6.1.4.1) + actionpack (= 6.1.4.1) + activesupport (= 6.1.4.1) method_source rake (>= 0.13) thor (~> 1.0) @@ -286,10 +291,15 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) redis (3.3.5) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) retriable (2.1.0) rexml (3.2.5) rgeo (2.3.0) - ros-apartment (2.9.0) + ros-apartment (2.10.0) activerecord (>= 5.0.0, < 6.2) parallel (< 2.0) public_suffix (>= 2.0.5, < 5.0) @@ -311,18 +321,18 @@ GEM rspec-mocks (~> 3.10) rspec-support (~> 3.10) rspec-support (3.10.2) - ruby-vips (2.1.2) + ruby-vips (2.1.3) ffi (~> 1.12) ruby2_keywords (0.0.5) - ruby_dep (1.5.0) shoulda-matchers (4.5.1) activesupport (>= 4.2.0) - simplecov (0.16.1) + simplecov (0.21.2) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) simplecov-lcov (0.8.0) + simplecov_json_formatter (0.1.3) spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) @@ -346,14 +356,17 @@ GEM sync tzinfo (2.0.4) concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8) vimeo (1.5.4) httparty (>= 0.4.5) httpclient (>= 2.1.5.2) json (>= 1.1.9) multipart-post (>= 1.0.1) oauth (>= 0.4.3) - webmock (3.13.0) - addressable (>= 2.3.6) + webmock (3.14.0) + addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.5) @@ -365,13 +378,13 @@ GEM zeitwerk (2.4.2) PLATFORMS - ruby + x86_64-darwin-20 + x86_64-linux DEPENDENCIES actionview (>= 5.2.2.1) active_model_serializers (~> 0.10.12) aws-sdk-s3 (~> 1) - cancancan (~> 2.0) capistrano-passenger capistrano-rails capistrano-rbenv (~> 2.0) @@ -387,7 +400,7 @@ DEPENDENCIES google_maps_service image_processing (~> 1.2) ipinfo-rails - listen (>= 3.0.5, < 3.2) + listen mini_magick mysql2 pg @@ -412,4 +425,4 @@ DEPENDENCIES yt BUNDLED WITH - 2.1.4 + 2.2.22 From 98e8569e787cba568a5d9d1390d69aa26e8bf47e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 17 Sep 2021 14:38:38 -0400 Subject: [PATCH 088/160] User on service for Google's directions matrix API --- .circleci/config.yml | 1 + app/models/tour.rb | 12 ++-------- app/services/google_directions.rb | 37 +++++++++++++++++++++++++++++++ spec/factories/tours.rb | 2 +- spec/rails_helper.rb | 12 +++++----- 5 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 app/services/google_directions.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index 77773fac..603ccba6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,6 +19,7 @@ jobs: DB_PASSWORD: password TEST_DB_NAME: otb MYSQL_ALLOW_EMPTY_PASSWORD: true + COVERALLS_REPO_TOKEN: 0fq3v88yZLVryFZQbFWqHw5l6zrbRkHNf - image: circleci/postgres:9.6.9 environment: diff --git a/app/models/tour.rb b/app/models/tour.rb index 7d1f27eb..b2f36ac3 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -112,20 +112,12 @@ def duration return nil if mode.title.nil? - gmaps = GoogleMapsService::Client.new destinations = tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } origin = destinations.shift - begin - matrix = gmaps.distance_matrix(origin, destinations, mode: mode.title.downcase) - return nil if matrix[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' + g_directions = GoogleDirections.new(origin, destinations, stops.count, mode.title) - durations = matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } - durations.sum + 600 + (stops.count * 600) - # ActiveSupport::Duration.build(seconds).parts - rescue GoogleMapsService::Error::ApiError, ArgumentError => error - nil - end + g_directions.duration end private diff --git a/app/services/google_directions.rb b/app/services/google_directions.rb new file mode 100644 index 00000000..6972508a --- /dev/null +++ b/app/services/google_directions.rb @@ -0,0 +1,37 @@ +class GoogleDirections + + def initialize(origin, destinations, stops_count, mode) + @query = { + origins: origin.join(','), + destinations: destinations.map { |d| d.join(',') }.join('|'), + mode: mode, + key: Rails.application.credentials.dig(:g_maps_key) + } + + @stops_count = stops_count + end + + def matrix + response = HTTParty.get( + "https://maps.googleapis.com/maps/api/distancematrix/json?#{@query.to_query}" + ).with_indifferent_access + + retun nil if response[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' + + response + end + + def durations + matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } + end + + def duration + durations.sum + 600 + (@stops_count * 600) + end +end + +# Apartment::Tenant.switch! 'july-22nd' +# t = Tour.first +# destinations = t.tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } +# origin = destinations.shift +# gmap = GoogleDirections.new(origin, destinations) \ No newline at end of file diff --git a/spec/factories/tours.rb b/spec/factories/tours.rb index 65e182af..600f404c 100644 --- a/spec/factories/tours.rb +++ b/spec/factories/tours.rb @@ -7,7 +7,7 @@ description { "

#{Faker::TvShows::RickAndMorty.quote}

#{Faker::TvShows::RickAndMorty.quote}

#{Faker::TvShows::RickAndMorty.quote}

" } published { Faker::Boolean.boolean(true_ratio: 0.5) } theme { create(:theme) } - mode { create(:mode) } + mode { Mode.first } link_address { Faker::Internet.url } is_geo { true } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 911d8bdc..8fe18778 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -186,14 +186,14 @@ stub_request(:get, /http:\/\/127\.0\.0\.1:.*\/json\/version/).to_return(body: '{}', status: 200) - stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*bicycling.*/) - .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix.json'), status: 200) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*BICYCLING.*/) + .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix.json'), status: 200, headers: { 'Content-Type': 'application/json' }) - stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*walking.*/) - .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix_zero.json'), status: 200) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*WALKING.*/) + .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix_zero.json'), status: 200, headers: { 'Content-Type': 'application/json' }) - stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*driving.*/) - .to_return(body: '{"status": "INVALID_REQUEST"}', status: 200) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*DRIVING.*/) + .to_return(body: '{"status": "INVALID_REQUEST"}', status: 200, headers: { 'Content-Type': 'application/json' }) end config.after(:each) do From 55bc86d90f534e772ab38db0471843222b181307 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 20 Sep 2021 10:24:49 -0400 Subject: [PATCH 089/160] replace ip info key --- config/application.rb | 2 +- config/credentials.yml.enc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/application.rb b/config/application.rb index 52f491f4..21bcf027 100644 --- a/config/application.rb +++ b/config/application.rb @@ -46,7 +46,7 @@ def parse_tenant_name(request) config.middleware.use(ActionDispatch::Cookies) config.middleware.use(ActionDispatch::Session::CookieStore) config.action_dispatch.cookies_serializer = :json - config.middleware.use(IPinfoMiddleware, { token: 'd3bb06e9a6567d' }) + config.middleware.use(IPinfoMiddleware, { token: Rails.application.credentials.dig(:ipinfo) }) # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index fba54a85..13a0f81c 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -rHlKgslHeHDZEsfur610b6Kh1zn6FT1JWNBixB7MPBNEXJnOp0niw44XrCPhg5A4XWdybGwsHNBGJvI5Oj7vW+wBu5bwGbTR4+X8o5henYpgTCm45rZavSA+4GKT6T2gfPVrGOdJuIXxCcyihFiV2InqETzsPehbilvpyH4sc1+7ck1c7hr0ulT7whW4C8y3RaCbGstRWi2RaYoqh4rpAlWGdEz4oKoqtU1/n5JQl3HqidOJKAorzfIDYzbR1Rqn+oscOxX5h17cho5CQHc/w0rFAZYTakcbbNciAqW5IMwbuPiykYvbgaqYZbyD1vTiWSkrsCXjrJZo43B7xxpg0O/0f41Hh8cRbJnSydsYFFB0Qh13izfT+jtGZgO8sdbCP90FHwmroPDmPoqx9GUWX/aEdsFq8nQ9XijwBxNhfxQZEPO7pODz6OG3TeXR47NI/NqtwjSqrByY65Qs+g9f5atLwDSGPo/7admQm80zPob5OFGfmWgh3frxWWeQ7Kxg21TFNCUoWxjYtpHPOfi4zHfv4dTeBy6JsrvhHv0LSn7sAsrdj5D9r98emoTIJqUv9rliGbhX2TvoDHGpgNTWzuO5FX2BM+z0qa48DLr5F+D2dEhsgqpwZUDQHFR4ZHQMqU+NQ2CFWdgAy3reMSrKU0Rs73Aaj8xSViaBzS1LNfZNLTKRiursq6wii8qFMX8znRqGq4M6lZT/vcS76Cb/aFHMs8yLdaeD+XYNGO7EMu2EzjjtAx/EO4w+WBpCymHMf9KbXfcUvPLmB5GK47N+tDsrIcpP61T2gSCG6GZEMbAhBYOJGpjmHkWSRvXta9ehSOnQGbQw/2F0ph1uhUJcnUK+npmuwfh7SfRUQTJJrw/L3gzlIU63t+7MF0wJyS4nCG6YqZjhrmQTt//7ILewQLutPT8qEcCroh/p5qivUj0xtIAhxGTk9dtIT8ILpA1YLPm0TNSYqdUaaNs0B6pg8qWzmKULmGG/JN9kHawEbEYC/owjjiCqta3YrXg4dkipMeab4hPTMZ6Ug0wcif7Dc8KyqaiO/M1Nnsg+Pn6qyDoB20+u/RbE1JT+zYEZMn+abKZYjCoKktpIMKZo6s4r6spEGuEkjMEZ8aF4NJWa/1LEg0VPvfmWgADO0/SFBsyolEKp5kBYfS1X5cRp4cZMVU+eWE4miMIRXr7KaKMKY/gcfd0dy7d25aL/pDKT6JuHmv4tD1t5jfVgHYnnHefZj4XeNNzfYJMzEMNmRqYkJeW9V4QK1htUq80=--XHtr7eXfyX1P5ddy--Yx5IczFMLGcwyuKI3UerbA== \ No newline at end of file +6/CEABsiU2EAFVnKvXYSVL+cgaekTmMBMB4Lf8LI6W9Yb80z/g2hSG4wwfgCdIkGivjIhadD8/NUrJ5ubn4uBrl0/rcz5WYqnrR2//DzbRTFnxELA7ceOBtXgQ1JVtwzZwuuSc2GxKFqcUNga/orpaPKneFgbFX3rolfvmlPxQGAtwt9+HuzjjmKklJR5KQ6FIMLn1gWWcL2utZnyl/EiTMNkK1zt+Io6YmIx5IkChSbcDF3Rs7dMBy5xHKKyaOaxGFLu6WfSWQ0B5gSVA+NWPNGIWHrWVW5WEhdddomx20KVesbI0Mtl5BTjVQRFUICv7z6rfnFNH0UMecXm/JT8GC+JgHTo28n8feUeXkgr3Du76gAUilRZERKUhP0cyEx1UBGslT5dTcqHdjC4E3FpQ2jqw84udtIBU+R7KiFJqxVMniarDwhS4khgK6cQpZLNy7S9ZztZYmEJLJhBTpFyB9tQ0G8TquqglTfK1CJx5x0KFVN/6I5FlHwoD1f8L+qxZjC8x5EZV9k2G3Nzfyajx+281SYE0b1nuG2FxJIc014apJVSkyaSX3wn20aLBC9+pAFAGuIE5cnX/iNXRpc7gfL1aP8yRdJR+xRSQKq0hvfNFXPy+Jep7ysDb12YdjRzKSYqM/vd6hO4TgK9x7c8dqaSisSj4N3WzVdyAakAzPRX6Y1EXJYhrC+bU7zR4PFBC3voWketntkA9OZXjLvckKiZQgBgbPPwE+aFYmdI4Tmut9rdj6pvfRNlnS12YtQVTKZEvMzyXtlknrOqoZ8nnb/BUIE3I7FOlVr7CdgCx04c7AnbkUYFswoMtD5vLiR3c5vK5Z3qcqbw5T6Qs1rnRVAG5cLhMTyGjx+SPgbDtwz/wGT/SwiE5RrViADNDL4RpW1rKseTzYzvIM2GU4EuJMigrU2Grujn6hbzSAcfDbXeqD44bgvMVlrDiMvodJrFyA5wynhWApLwklTXcQuD3rqyj+tdXNswVf9XtCXgGYPncwjiJlsFtB+HL2l13VKgD9Z8irwf3gEdazcBUCl4DIkvDHnUobFGB/3iGsDcuYAWfEUrHsEOVXWrxt8YabyWr8usKYK5Ma01Wm4uQMTGd0hn4/BPMelZjXaN1fEZA9LYKPAsW7EkokDqEmWAGN17QUFreLrWzPjj8XwmSHw6cy5MKoLHfzyCPhbaDY2EYsNUOV7mVW35V8RtCZlJ2Z1OEyBac2wmFFz2JMv1ZLLN8+gHxuScdF3DOQN24ChmPxXQl1FtFdCjfApjeNd/4jx+wBeSw2FhPG0Ba3gYEQkt2g=--RSoluR1gW7U5RgSO--BLWcG/JoZs7xUk7LRJtNhQ== \ No newline at end of file From 40c82c5f6abbaa1c220d0dd81eb8902e15325c40 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 4 Oct 2021 13:11:50 -0400 Subject: [PATCH 090/160] Clean up --- app/services/google_directions.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/services/google_directions.rb b/app/services/google_directions.rb index 6972508a..e2937685 100644 --- a/app/services/google_directions.rb +++ b/app/services/google_directions.rb @@ -16,22 +16,16 @@ def matrix "https://maps.googleapis.com/maps/api/distancematrix/json?#{@query.to_query}" ).with_indifferent_access - retun nil if response[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' + return nil if response[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' response end def durations - matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } + matrix.nil? ? nil : matrix[:rows].first[:elements].map { |e| e[:duration][:value] if e[:duration].present? }.reject { |d| d.nil? } end def duration - durations.sum + 600 + (@stops_count * 600) + durations.nil? ? nil : durations.sum + 600 + (@stops_count * 600) end end - -# Apartment::Tenant.switch! 'july-22nd' -# t = Tour.first -# destinations = t.tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } -# origin = destinations.shift -# gmap = GoogleDirections.new(origin, destinations) \ No newline at end of file From 07f94a4f9f2b312f97449819f0203a8c0d330f7f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 4 Oct 2021 13:12:08 -0400 Subject: [PATCH 091/160] Add/update site logo --- app/controllers/v3/tour_sets_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 3b85e932..e0b91b9d 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -70,7 +70,7 @@ def set_record def allowed? set_record if @record.nil? && params[:id].present? @allowed = if @record.nil? - crud_allowed? + crud_allowed? else current_user&.current_tenant_admin? || @record.published_tours.present? end @@ -85,7 +85,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :name, :tours, :admins + :name, :tours, :admins, :base_sixty_four ] ) end From c2f93c474535c23d6f945a50b0885036c88b4e62 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 4 Oct 2021 14:11:04 -0400 Subject: [PATCH 092/160] Fix purgering site icon --- app/controllers/v3/tour_sets_controller.rb | 2 +- app/models/tour_set.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index e0b91b9d..ef00dc92 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -70,7 +70,7 @@ def set_record def allowed? set_record if @record.nil? && params[:id].present? @allowed = if @record.nil? - crud_allowed? + crud_allowed? else current_user&.current_tenant_admin? || @record.published_tours.present? end diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index fcbc2be8..71b0ca81 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -126,6 +126,8 @@ def attach_file headers, self.base_sixty_four = base_sixty_four.split(',') # content_type = Regexp.last_match(1).split(';base64').first + return if base_sixty_four.nil? #&& !logo.attached? + File.open(tmp_file_path, 'wb') do |f| f.write(Base64.decode64(base_sixty_four)) end From 26fec74c59da3ed1e64e3462fa5b8a5bab8a2651 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 4 Oct 2021 14:11:30 -0400 Subject: [PATCH 093/160] Fix invalid response for directions --- app/services/google_directions.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/google_directions.rb b/app/services/google_directions.rb index e2937685..66aa09f9 100644 --- a/app/services/google_directions.rb +++ b/app/services/google_directions.rb @@ -16,6 +16,8 @@ def matrix "https://maps.googleapis.com/maps/api/distancematrix/json?#{@query.to_query}" ).with_indifferent_access + return nil if response[:status] && response[:rows].nil? + return nil if response[:rows].first[:elements].first[:status] == 'ZERO_RESULTS' response From 65cddf2fc84f404340adfdd27e486b87b5936ea2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 4 Oct 2021 14:20:53 -0400 Subject: [PATCH 094/160] Fix user controller test --- spec/controllers/v3/users_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/controllers/v3/users_controller_spec.rb b/spec/controllers/v3/users_controller_spec.rb index 63bcdec8..52be2b1f 100644 --- a/spec/controllers/v3/users_controller_spec.rb +++ b/spec/controllers/v3/users_controller_spec.rb @@ -177,7 +177,7 @@ user_to_update = create(:user) user.tour_sets << create(:tour_set) initial_display_name = user_to_update.display_name - new_display_name = Faker::Music::Hiphop.artist + new_display_name = "#{Faker::Music::Hiphop.artist}!" update_params = { id: user_to_update.to_param, tenant: 'public', data: { type: 'users', attributes: { display_name: new_display_name } } } signed_cookie(user) put :update, params: update_params From 466b6a3191df01982d66265ea59b4943c8981956 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 5 Oct 2021 08:43:50 -0400 Subject: [PATCH 095/160] Update Ruby version and remove mimmagic dependencies --- Capfile | 2 +- Gemfile | 1 - Gemfile.lock | 8 -------- README.md | 2 +- config/deploy/staging.rb | 8 ++++---- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Capfile b/Capfile index 098de5b4..5fad1378 100644 --- a/Capfile +++ b/Capfile @@ -29,7 +29,7 @@ install_plugin Capistrano::SCM::Git # require 'capistrano/rvm' require 'capistrano/rbenv' set :rbenv_type, :user # or :system, depends on your rbenv setup -set :rbenv_ruby, '2.7.2' +set :rbenv_ruby, '3.0.2' # require 'capistrano/chruby' require 'capistrano/bundler' # require 'capistrano/rails/assets' diff --git a/Gemfile b/Gemfile index b36a1ac5..9ebdfce8 100644 --- a/Gemfile +++ b/Gemfile @@ -30,7 +30,6 @@ gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engi # Active Storage will land in 5.2 gem 'carrierwave', '~> 1.0' -gem 'carrierwave-base64' gem 'mini_magick' gem 'image_processing', '~> 1.2' gem 'ferrum' diff --git a/Gemfile.lock b/Gemfile.lock index a58666eb..9deafe40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -130,10 +130,6 @@ GEM activesupport (>= 4.0.0) mime-types (>= 1.16) ssrf_filter (~> 1.0) - carrierwave-base64 (2.8.1) - carrierwave (>= 0.8.0) - mime-types (~> 3.0) - mimemagic (~> 0.3.2) case_transform (0.2) activesupport cliver (0.3.2) @@ -229,9 +225,6 @@ GEM mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2021.0901) - mimemagic (0.3.10) - nokogiri (~> 1) - rake mini_magick (4.11.0) mini_mime (1.1.1) minitest (5.14.4) @@ -389,7 +382,6 @@ DEPENDENCIES capistrano-rails capistrano-rbenv (~> 2.0) carrierwave (~> 1.0) - carrierwave-base64 coveralls database_cleaner ecds_rails_auth_engine! diff --git a/README.md b/README.md index a422d4e5..634afa9c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The OpenTourBuilder API server provides a multi-tenant REST API for geographic t ## Requirements - rbenv -- Ruby 2.7.2 +- Ruby 3.0.2 ## Install PostgreSQL client diff --git a/config/deploy/staging.rb b/config/deploy/staging.rb index 8e115a60..16ba1d28 100644 --- a/config/deploy/staging.rb +++ b/config/deploy/staging.rb @@ -7,7 +7,7 @@ # Defines a single server with a list of roles and multiple properties. # You can define all roles on a single server, or split them: -server "44.192.13.190", user: "deploy", roles: %w{app db web}, primary: :my_value +server "3.238.239.164", user: "deploy", roles: %w{app db web}, primary: :my_value # server "otb.ecdsdev.org", user: "deploy", roles: %w{app web}, other_property: :other_value # server "db.otb.ecdsdev.org", user: "deploy", roles: %w{db} @@ -21,9 +21,9 @@ # property set. Specify the username and a domain or IP for the server. # Don't use `:all`, it's a meta role. -role :app, %w{deploy@44.192.13.190} -role :web, %w{user1@44.192.13.190} -role :db, %w{deploy@44.192.13.190} +role :app, %w{deploy@3.238.239.164} +role :web, %w{user1@3.238.239.164} +role :db, %w{deploy@3.238.239.164} From 228a23438c72f6ad481e15166eeea7cc58849ab7 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 08:38:16 -0400 Subject: [PATCH 096/160] Config updates --- config/deploy/production.rb | 2 +- config/environments/production.rb | 4 ++-- config/storage.yml | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/config/deploy/production.rb b/config/deploy/production.rb index 6c9d6544..d3b5da21 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,4 +1,4 @@ -set :branch, 'release' +set :branch, 'develop' server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value diff --git a/config/environments/production.rb b/config/environments/production.rb index 57db8d90..167ec9f2 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -3,10 +3,10 @@ Rails.application.configure do config.hosts << 'api.opentour.emory.edu' Rails.application.routes.default_url_options[:host] = 'https://api.opentour.emory.edu' -# Settings specified here will take precedence over those in config/application.rb. + # Settings specified here will take precedence over those in config/application.rb. # Store uploaded files on the local file system in a temporary directory. - config.active_storage.service = :amazon + config.active_storage.service = :production # Code is not reloaded between requests. config.cache_classes = true diff --git a/config/storage.yml b/config/storage.yml index 432f4289..0153a1c8 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -16,6 +16,13 @@ staging: region: us-east-1 bucket: opentour +production: + service: S3 + access_key_id: <%= Rails.application.credentials.dig(:s3Staging, :access_key_id) %> + secret_access_key: <%= Rails.application.credentials.dig(:s3Staging, :secret_access_key) %> + region: us-east-1 + bucket: opentour-prod + # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) # amazon: # service: S3 From 4499ac839c4becf0b7558dc12fcc503503c207d3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 08:38:46 -0400 Subject: [PATCH 097/160] Add test to update medium metadata --- spec/models/medium_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index 5ac3e454..b401dead 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -51,6 +51,22 @@ expect(medium.file.blob.checksum).to eq(Digest::MD5.file(Rails.root.join('spec/factories/images/atl.png')).base64digest) end + it 'updates title and caption of video' do + medium = create(:medium, video: 'F9ULbmCvmxY', base_sixty_four: nil, video_provider: 'youtube') + original_checksum = medium.file.blob.checksum + expect(medium.title).to include('Goodie') + expect(medium.caption).to include('Goodie') + medium.update(title: 'Outkast') + medium.update(caption: 'GOATs') + expect(medium.title).not_to include('Goodie') + expect(medium.caption).not_to include('Goodie') + expect(medium.title).to include('Outkast') + expect(medium.caption).to include('GOATs') + # medium.update(base_sixty_four: File.read(Rails.root.join('spec/factories/images/png_base64.txt'))) + # expect(medium.file.blob.checksum).not_to eq(original_checksum) + # expect(medium.file.blob.checksum).to eq(Digest::MD5.file(Rails.root.join('spec/factories/images/atl.png')).base64digest) + end + it 'skips video_props when provider in nil' do medium = create(:medium, video: 'ACod3', base_sixty_four: nil) expect(medium.file.attached?).to be false From 7781d6dd6045e4ae43ac1e67bc10612985942eb7 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 10:03:28 -0400 Subject: [PATCH 098/160] Selectively calculate duration for tour --- app/models/tour.rb | 22 ++++++--- db/migrate/20211011120714_add_duration.rb | 6 +++ db/schema.rb | 4 +- spec/controllers/v3/tours_controller_spec.rb | 1 + spec/factories/distance_matrix2.json | 42 +++++++++++++++++ spec/factories/tour_stops.rb | 1 + spec/models/tour_spec.rb | 47 +++++++++++++++++++- spec/rails_helper.rb | 3 ++ 8 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20211011120714_add_duration.rb create mode 100755 spec/factories/distance_matrix2.json diff --git a/app/models/tour.rb b/app/models/tour.rb index b2f36ac3..846134ab 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -6,6 +6,8 @@ # Model class for a tour. class Tour < ApplicationRecord include HtmlSaintizer + + has_many :tour_stops, autosave: true, dependent: :destroy has_many :stops, -> { distinct }, through: :tour_stops has_many :tour_modes, autosave: true, dependent: :destroy @@ -34,6 +36,8 @@ class Tour < ApplicationRecord before_validation -> { self.mode ||= Mode.last } before_validation -> { self.theme ||= Theme.first } before_validation -> { self.title ||= 'untitled' } + before_validation :update_saved_stop_order + before_save :calculate_duration before_save :check_url after_save :ensure_slug after_create :add_modes @@ -105,19 +109,23 @@ def bounds } end - def duration - return nil if stops.count < 2 + def calculate_duration + return unless published + + return if stops.count < 2 - return nil if mode.nil? + return if mode.nil? - return nil if mode.title.nil? + return if mode.title.nil? + + return unless self.will_save_change_to_published? || self.will_save_change_to_saved_stop_order? || self.will_save_change_to_mode_id? destinations = tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } origin = destinations.shift g_directions = GoogleDirections.new(origin, destinations, stops.count, mode.title) - g_directions.duration + self.duration = g_directions.duration end private @@ -139,4 +147,8 @@ def check_url self.link_address = "http://#{link_address}" if uri.scheme.nil? end + + def update_saved_stop_order + self.saved_stop_order = self.tour_stops.order(:position).map(&:stop_id) + end end diff --git a/db/migrate/20211011120714_add_duration.rb b/db/migrate/20211011120714_add_duration.rb new file mode 100644 index 00000000..89f8f8dc --- /dev/null +++ b/db/migrate/20211011120714_add_duration.rb @@ -0,0 +1,6 @@ +class AddDuration < ActiveRecord::Migration[6.1] + def change + add_column :tours, :duration, :integer + add_column :tours, :saved_stop_order, :integer, array: true + end +end diff --git a/db/schema.rb b/db/schema.rb index af13807d..ea6c066c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_09_16_170901) do +ActiveRecord::Schema.define(version: 2021_10_11_122625) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -307,6 +307,8 @@ t.integer "default_lng", default: 0 t.string "link_address" t.string "link_text" + t.integer "duration" + t.integer "saved_stop_order", array: true t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index 0669c005..9727902c 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -90,6 +90,7 @@ describe 'GET #show' do it 'returns a 200 response' do tour = create(:tour, published: true, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(3..5))) + tour.save get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) diff --git a/spec/factories/distance_matrix2.json b/spec/factories/distance_matrix2.json new file mode 100755 index 00000000..1cf1a558 --- /dev/null +++ b/spec/factories/distance_matrix2.json @@ -0,0 +1,42 @@ +{ + "destination_addresses": ["Lexington, MA, USA", "Concord, MA, USA"], + "origin_addresses": ["Boston, MA, USA", "Charlestown, Boston, MA, USA"], + "rows": + [ + { + "elements": + [ + { + "distance": { "text": "23.6 km", "value": 23644 }, + "duration": { "text": "28 mins", "value": 1673 }, + "duration_in_traffic": { "text": "34 mins", "value": 2026 }, + "status": "OK" + }, + { + "distance": { "text": "41.3 km", "value": 41294 }, + "duration": { "text": "34 mins", "value": 1063 }, + "duration_in_traffic": { "text": "37 mins", "value": 2231 }, + "status": "OK" + } + ] + }, + { + "elements": + [ + { + "distance": { "text": "31.2 km", "value": 31219 }, + "duration": { "text": "26 mins", "value": 1545 }, + "duration_in_traffic": { "text": "32 mins", "value": 1942 }, + "status": "OK" + }, + { + "distance": { "text": "29.6 km", "value": 29594 }, + "duration": { "text": "32 mins", "value": 895 }, + "duration_in_traffic": { "text": "37 mins", "value": 2218 }, + "status": "OK" + } + ] + } + ], + "status": "OK" +} \ No newline at end of file diff --git a/spec/factories/tour_stops.rb b/spec/factories/tour_stops.rb index 4a8bab19..5f9a5bf3 100644 --- a/spec/factories/tour_stops.rb +++ b/spec/factories/tour_stops.rb @@ -5,5 +5,6 @@ factory :tour_stop do tour_id { nil } stop_id { nil } + position { nil } end end diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index de96cd22..34259c85 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -11,10 +11,55 @@ it { expect(Tour.reflect_on_association(:mode).macro).to eq(:belongs_to) } it 'gets a duration' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3)) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour.save expect(tour.duration).to eq(6136) end + it 'gets no duration when unpublished' do + tour = create(:tour, mode: Mode.find_by(title: 'TRANSIT'), stops: create_list(:stop, 3), published: false) + tour.save + expect(tour.duration).to be nil + end + + it 'gets duration when tour is updated to published' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: false) + expect(tour.duration).to be nil + tour.update(published: true) + expect(tour.saved_change_to_attribute?(:published)).to be true + expect(tour.duration).to eq(6136) + end + + it 'updates duration when mode changes' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour.save + expect(tour.duration).to eq(6136) + tour.mode = Mode.find_by(title: 'TRANSIT') + expect(tour.will_save_change_to_mode_id?).to be true + tour.save + expect(tour.duration).to eq(5136) + expect(tour.saved_change_to_attribute?(:duration)).to be true + end + + it 'updates duration when stop order chages' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), published: true) + 3.times { |i| create(:tour_stop, tour: tour, stop: create(:stop), position: i + 1) } + tour.save + expect(tour.duration).to eq(6136) + # Trick the network stub to fetch different distance matrix but doesn't presist a + # change to the tour's mode. + tour.mode.title = 'TRANSIT' + tour.tour_stops.order(:position).last.update(position: 0) + tour.validate + # Make sure duration isn't being updated because we changed the mode's title. + expect(tour.will_save_change_to_mode_id?).to be false + expect(tour.will_save_change_to_saved_stop_order?).to be true + tour.save + expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be true + expect(tour.duration).to eq(5136) + expect(tour.saved_change_to_attribute?(:duration)).to be true + end + it 'gets no duration whin invalid request is made to Google' do tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5)) expect(tour.duration).to be nil diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8fe18778..b529fa18 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -189,6 +189,9 @@ stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*BICYCLING.*/) .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix.json'), status: 200, headers: { 'Content-Type': 'application/json' }) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*TRANSIT.*/) + .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix2.json'), status: 200, headers: { 'Content-Type': 'application/json' }) + stub_request(:get, /http.*:\/\/maps\.googleapis\.com\/maps\/api\/.*WALKING.*/) .to_return(body: File.read(Rails.root + 'spec/factories/distance_matrix_zero.json'), status: 200, headers: { 'Content-Type': 'application/json' }) From 3e42cf3e299dd23425a95e67c25617f771c13d8f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 11:00:29 -0400 Subject: [PATCH 099/160] More test for when not to update duration --- spec/models/tour_spec.rb | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index 34259c85..bc676350 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -61,12 +61,34 @@ end it 'gets no duration whin invalid request is made to Google' do - tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5)) + tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5), published: true) + tour.save expect(tour.duration).to be nil end it 'gets no duration whin response has ZERO_RESULTS' do - tour = create(:tour, mode: Mode.find_by(title: 'WALKING'), stops: create_list(:stop, 4)) + tour = create(:tour, mode: Mode.find_by(title: 'WALKING'), stops: create_list(:stop, 4), published: true) + tour.save expect(tour.duration).to be nil end + + it 'does not update the duration when other attributes are updaeted' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour.save + expect(tour.duration).to eq(6136) + # Trick the network stub to fetch different distance matrix but doesn't presist a + # change to the tour's mode. In this case, it should NOT fetch. This is just to + # test that it does not actually make teh request when we don't want it to. + tour.mode.title = 'TRANSIT' + tour.update( + title: Faker::Music::Prince.band, + description: Faker::Music::Prince.lyric + ) + expect(tour.saved_change_to_attribute?(:title)).to be true + expect(tour.saved_change_to_attribute?(:description)).to be true + expect(tour.saved_change_to_attribute?(:duration)).to be false + expect(tour.saved_change_to_attribute?(:published)).to be false + expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be false + expect(tour.duration).to eq(6136) + end end From 6c686f114ced2ded287f2a41bc23ccdc596c278e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 11:12:16 -0400 Subject: [PATCH 100/160] Use fake key when testing --- app/services/google_directions.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/google_directions.rb b/app/services/google_directions.rb index 66aa09f9..89547580 100644 --- a/app/services/google_directions.rb +++ b/app/services/google_directions.rb @@ -1,11 +1,12 @@ class GoogleDirections def initialize(origin, destinations, stops_count, mode) + google_key = ENV['RAILS_ENV'] == 'test' ? 'FAkeFaK-E_fAkeChv-P3nchtQYHoCLfFzn9ylr8' : Rails.application.credentials.dig(:g_maps_key) @query = { origins: origin.join(','), destinations: destinations.map { |d| d.join(',') }.join('|'), mode: mode, - key: Rails.application.credentials.dig(:g_maps_key) + key: google_key } @stops_count = stops_count From 854273b041d4bfea057bf1cb324a48a632e6d4f1 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 11:40:38 -0400 Subject: [PATCH 101/160] Remove Google Services gem --- Gemfile | 1 - Gemfile.lock | 7 ------- config/initializers/g_maps.rb | 7 ------- 3 files changed, 15 deletions(-) delete mode 100644 config/initializers/g_maps.rb diff --git a/Gemfile b/Gemfile index 9ebdfce8..0ac366b4 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,6 @@ gem 'aws-sdk-s3', '~> 1' # RGeo is a geospatial data library for Ruby. # https://github.com/rgeo/rgeo gem 'rgeo' -gem 'google_maps_service' gem 'ipinfo-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 9deafe40..96f63e55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,10 +186,6 @@ GEM ffi (1.15.4) globalid (0.5.2) activesupport (>= 5.0) - google_maps_service (0.4.2) - hurley (~> 0.1) - multi_json (~> 1.11) - retriable (~> 2.0) hashdiff (1.0.1) http-accept (1.7.0) http-cookie (1.0.4) @@ -198,7 +194,6 @@ GEM mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - hurley (0.2) i18n (1.8.10) concurrent-ruby (~> 1.0) image_processing (1.12.1) @@ -289,7 +284,6 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - retriable (2.1.0) rexml (3.2.5) rgeo (2.3.0) ros-apartment (2.10.0) @@ -389,7 +383,6 @@ DEPENDENCIES factory_bot_rails faker! ferrum - google_maps_service image_processing (~> 1.2) ipinfo-rails listen diff --git a/config/initializers/g_maps.rb b/config/initializers/g_maps.rb deleted file mode 100644 index 07b7f579..00000000 --- a/config/initializers/g_maps.rb +++ /dev/null @@ -1,7 +0,0 @@ -GoogleMapsService.configure do |config| - if ENV['RAILS_ENV'] == 'test' - config.key = 'FAkeFaK-E_fAkeChv-P3nchtQYHoCLfFzn9ylr8' - else - config.key = Rails.application.credentials.dig(:g_maps_key) - end -end From 99af96533f359c074ecd943dc3d3e073206002b4 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 11:47:06 -0400 Subject: [PATCH 102/160] Adjust tests for CircleCI --- app/models/tour.rb | 2 -- spec/controllers/v3/tours_controller_spec.rb | 1 + spec/models/tour_spec.rb | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 846134ab..82a50311 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -1,6 +1,4 @@ # frozen_string_literal: true - -require 'google_maps_service' require 'uri' # Model class for a tour. diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index 9727902c..0f415bdf 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -90,6 +90,7 @@ describe 'GET #show' do it 'returns a 200 response' do tour = create(:tour, published: true, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(3..5))) + tour.update(published: true) tour.save get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index bc676350..1ae2f34c 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -11,7 +11,8 @@ it { expect(Tour.reflect_on_association(:mode).macro).to eq(:belongs_to) } it 'gets a duration' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: false) + tour.update(published: true) tour.save expect(tour.duration).to eq(6136) end From 1e3f9b5473e274991ba227af5df37de05162726a Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 11 Oct 2021 17:33:33 -0400 Subject: [PATCH 103/160] Try to fix CircleCI test --- .circleci/config.yml | 5 ++- app/models/tour.rb | 9 +++- spec/controllers/v3/tours_controller_spec.rb | 3 +- spec/models/tour_spec.rb | 44 ++++++++++++-------- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 603ccba6..792ed62c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,10 +21,11 @@ jobs: MYSQL_ALLOW_EMPTY_PASSWORD: true COVERALLS_REPO_TOKEN: 0fq3v88yZLVryFZQbFWqHw5l6zrbRkHNf - - image: circleci/postgres:9.6.9 + - image: circleci/postgres:10 environment: POSTGRES_USER: user POSTGRES_DB: otb + POSTGRES_PASSWORD: password - image: circleci/mysql:latest command: [--default-authentication-plugin=mysql_native_password] @@ -84,4 +85,4 @@ jobs: - run: name: Parallel RSpec with PostgreSQL - command: DB_ADAPTER=postgresql bundle exec rspec spec/ + command: DB_ADAPTER=postgresql bundle exec rspec spec/models/tour_spec.rb:48 diff --git a/app/models/tour.rb b/app/models/tour.rb index 82a50311..0f0b63d1 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -147,6 +147,13 @@ def check_url end def update_saved_stop_order - self.saved_stop_order = self.tour_stops.order(:position).map(&:stop_id) + puts '---' + self.assign_attributes(saved_stop_order: self.tour_stops.order(:position).map(&:stop_id)) + p self.tour_stops.map { |ts| [ts.stop.id, ts.position] } + p self.tour_stops.order(:position).map(&:stop_id) + p self.stops.count + p self.tour_stops.count + p self.saved_stop_order + puts '---' end end diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index 0f415bdf..513c590b 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -89,9 +89,10 @@ describe 'GET #show' do it 'returns a 200 response' do - tour = create(:tour, published: true, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(3..5))) + tour = create(:tour, published: false, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(5..7))) tour.update(published: true) tour.save + puts "spec #{tour.duration}, #{tour.published}, #{tour.mode.title}, #{tour.stops.count}" get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index 1ae2f34c..db025837 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -11,72 +11,82 @@ it { expect(Tour.reflect_on_association(:mode).macro).to eq(:belongs_to) } it 'gets a duration' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: false) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), published: false) tour.update(published: true) tour.save - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) end it 'gets no duration when unpublished' do - tour = create(:tour, mode: Mode.find_by(title: 'TRANSIT'), stops: create_list(:stop, 3), published: false) + tour = create(:tour, mode: Mode.find_by(title: 'TRANSIT'), stops: create_list(:stop, 5), published: false) + tour.update(published: false) tour.save expect(tour.duration).to be nil end it 'gets duration when tour is updated to published' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: false) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), published: false) + tour.update(published: false) expect(tour.duration).to be nil tour.update(published: true) expect(tour.saved_change_to_attribute?(:published)).to be true - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) end it 'updates duration when mode changes' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), published: false) + tour.update(published: true) tour.save - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) tour.mode = Mode.find_by(title: 'TRANSIT') expect(tour.will_save_change_to_mode_id?).to be true tour.save - expect(tour.duration).to eq(5136) + expect(tour.duration).to eq(6336) expect(tour.saved_change_to_attribute?(:duration)).to be true end it 'updates duration when stop order chages' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), published: true) - 3.times { |i| create(:tour_stop, tour: tour, stop: create(:stop), position: i + 1) } + puts Apartment::Tenant.current + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), published: false) + 5.times { |i| create(:tour_stop, tour: tour, stop: create(:stop), position: i + 1) } + tour = Tour.find(tour.id) + tour.update(published: true) tour.save - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) # Trick the network stub to fetch different distance matrix but doesn't presist a # change to the tour's mode. tour.mode.title = 'TRANSIT' tour.tour_stops.order(:position).last.update(position: 0) tour.validate + puts Apartment::Tenant.current # Make sure duration isn't being updated because we changed the mode's title. expect(tour.will_save_change_to_mode_id?).to be false expect(tour.will_save_change_to_saved_stop_order?).to be true tour.save expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be true - expect(tour.duration).to eq(5136) + expect(tour.duration).to eq(6336) expect(tour.saved_change_to_attribute?(:duration)).to be true end it 'gets no duration whin invalid request is made to Google' do - tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5), published: true) + tour = create(:tour, mode: Mode.find_by(title: 'DRIVING'), stops: create_list(:stop, 5), published: false) + tour.update(published: true) tour.save expect(tour.duration).to be nil end it 'gets no duration whin response has ZERO_RESULTS' do - tour = create(:tour, mode: Mode.find_by(title: 'WALKING'), stops: create_list(:stop, 4), published: true) + tour = create(:tour, mode: Mode.find_by(title: 'WALKING'), stops: create_list(:stop, 4), published: false) + tour.update(published: true) tour.save expect(tour.duration).to be nil end it 'does not update the duration when other attributes are updaeted' do - tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 3), published: true) + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), published: false) + tour.update(published: true) tour.save - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) # Trick the network stub to fetch different distance matrix but doesn't presist a # change to the tour's mode. In this case, it should NOT fetch. This is just to # test that it does not actually make teh request when we don't want it to. @@ -90,6 +100,6 @@ expect(tour.saved_change_to_attribute?(:duration)).to be false expect(tour.saved_change_to_attribute?(:published)).to be false expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be false - expect(tour.duration).to eq(6136) + expect(tour.duration).to eq(7336) end end From 5d2de9f76930483b27c5b43ec342b3bfb26f7c5e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 12 Oct 2021 09:08:04 -0400 Subject: [PATCH 104/160] Tmp fix for Array column in CircleCI --- .circleci/config.yml | 29 ++++++++++---------- app/models/tour.rb | 14 ++++------ db/schema.rb | 2 +- spec/controllers/v3/tours_controller_spec.rb | 1 - spec/models/tour_spec.rb | 8 ++---- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 792ed62c..20a3b15f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -19,7 +19,7 @@ jobs: DB_PASSWORD: password TEST_DB_NAME: otb MYSQL_ALLOW_EMPTY_PASSWORD: true - COVERALLS_REPO_TOKEN: 0fq3v88yZLVryFZQbFWqHw5l6zrbRkHNf + CI: 'circleci' - image: circleci/postgres:10 environment: @@ -43,7 +43,8 @@ jobs: command: | sudo apt update sudo apt install -y postgresql-client || true - sudo apt install -y libvips-dev + sudo apt install -y imagemagick + gem install bundle bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 - run: @@ -60,18 +61,18 @@ jobs: # TODO: Figure out how to allow the database user to create new tenants in # the MySQL Docker instance. For now, we'll just test that it can setup the # initial database using MySQL. - - run: - name: Wait for DB - command: dockerize -wait tcp://127.0.0.1:3306 -timeout 1m + # - run: + # name: Wait for DB + # command: dockerize -wait tcp://127.0.0.1:3306 -timeout 1m - - run: - name: MySQL Setup - command: | - export DB_ADAPTER=mysql2 - bundle exec rake db:drop RAILS_ENV=test DB_ADAPTER=mysql2 - bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=mysql2 - bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=mysql2 - bundle exec rake db:migrate RAILS_ENV=test DB_ADAPTER=mysql2 + # - run: + # name: MySQL Setup + # command: | + # export DB_ADAPTER=mysql2 + # bundle exec rake db:drop RAILS_ENV=test DB_ADAPTER=mysql2 + # bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=mysql2 + # bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=mysql2 + # bundle exec rake db:migrate RAILS_ENV=test DB_ADAPTER=mysql2 # Test using PostgreSQL - run: @@ -85,4 +86,4 @@ jobs: - run: name: Parallel RSpec with PostgreSQL - command: DB_ADAPTER=postgresql bundle exec rspec spec/models/tour_spec.rb:48 + command: DB_ADAPTER=postgresql bundle exec rspec spec/ diff --git a/app/models/tour.rb b/app/models/tour.rb index 0f0b63d1..10066dd5 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -21,6 +21,11 @@ class Tour < ApplicationRecord has_many :slugs, dependent: :delete_all has_one :map_overlay + # TODO: why does the CircleCI env need to serialize here? + if ENV['CI'] == 'circleci' + serialize :saved_stop_order, Array + end + # belongs_to :splash_image_medium_id, class_name: 'Medium' belongs_to :theme, default: -> { Theme.first } @@ -147,13 +152,6 @@ def check_url end def update_saved_stop_order - puts '---' - self.assign_attributes(saved_stop_order: self.tour_stops.order(:position).map(&:stop_id)) - p self.tour_stops.map { |ts| [ts.stop.id, ts.position] } - p self.tour_stops.order(:position).map(&:stop_id) - p self.stops.count - p self.tour_stops.count - p self.saved_stop_order - puts '---' + self.saved_stop_order = self.tour_stops.order(:position).map(&:stop_id) end end diff --git a/db/schema.rb b/db/schema.rb index ea6c066c..98aea1f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -308,7 +308,7 @@ t.string "link_address" t.string "link_text" t.integer "duration" - t.integer "saved_stop_order", array: true + t.integer "saved_stop_order", array: true, default: [] t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index 513c590b..a414bee1 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -92,7 +92,6 @@ tour = create(:tour, published: false, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, rand(5..7))) tour.update(published: true) tour.save - puts "spec #{tour.duration}, #{tour.published}, #{tour.mode.title}, #{tour.stops.count}" get :show, params: { tenant: tour.tenant, id: tour.id } expect(response.status).to eq(200) expect(attributes[:title]).to eq(tour.title) diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index db025837..68862c5a 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -43,13 +43,12 @@ tour.save expect(tour.duration).to eq(6336) expect(tour.saved_change_to_attribute?(:duration)).to be true + expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be false end it 'updates duration when stop order chages' do - puts Apartment::Tenant.current tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), published: false) 5.times { |i| create(:tour_stop, tour: tour, stop: create(:stop), position: i + 1) } - tour = Tour.find(tour.id) tour.update(published: true) tour.save expect(tour.duration).to eq(7336) @@ -58,12 +57,11 @@ tour.mode.title = 'TRANSIT' tour.tour_stops.order(:position).last.update(position: 0) tour.validate - puts Apartment::Tenant.current - # Make sure duration isn't being updated because we changed the mode's title. + # Make sure duration isn't being updated because we changed the mode or published status. expect(tour.will_save_change_to_mode_id?).to be false + expect(tour.will_save_change_to_published?).to be false expect(tour.will_save_change_to_saved_stop_order?).to be true tour.save - expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be true expect(tour.duration).to eq(6336) expect(tour.saved_change_to_attribute?(:duration)).to be true end From 2ef09e1698c0037dff9277e8e88ecd049596033f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 12 Oct 2021 10:38:05 -0400 Subject: [PATCH 105/160] Consistantly namespace stuff under module V3 --- app/controllers/v3/flat_pages_controller.rb | 104 ++++++------ app/controllers/v3/map_icons_controller.rb | 56 ++++--- app/controllers/v3/map_overlays_controller.rb | 50 +++--- app/controllers/v3/stop_media_controller.rb | 58 +++---- app/controllers/v3/stops_controller.rb | 111 +++++++------ .../v3/tour_flat_pages_controller.rb | 110 ++++++------ app/controllers/v3/tour_media_controller.rb | 58 +++---- .../v3/tour_relations_controller.rb | 25 +-- app/controllers/v3/tour_stops_controller.rb | 132 +++++++-------- app/controllers/v3/tours_controller.rb | 157 +++++++++--------- app/controllers/v3/users_controller.rb | 6 +- app/serializers/v3/flat_page_serializer.rb | 8 +- app/serializers/v3/map_icon_serializer.rb | 8 +- app/serializers/v3/map_overlay_serializer.rb | 8 +- app/serializers/v3/medium_serializer.rb | 52 +++--- app/serializers/v3/mode_serializer.rb | 8 +- app/serializers/v3/stop_medium_serializer.rb | 10 +- app/serializers/v3/stop_serializer.rb | 52 +++--- app/serializers/v3/theme_serializer.rb | 6 +- app/serializers/v3/tour_author_serializer.rb | 10 +- app/serializers/v3/tour_base_serializer.rb | 78 ++++----- .../v3/tour_flat_page_serializer.rb | 10 +- app/serializers/v3/tour_medium_serializer.rb | 10 +- app/serializers/v3/tour_mode_serializer.rb | 10 +- app/serializers/v3/tour_serializer.rb | 28 ++-- .../v3/tour_set_admin_serializer.rb | 6 +- app/serializers/v3/tour_set_serializer.rb | 24 +-- app/serializers/v3/tour_stop_serializer.rb | 10 +- app/serializers/v3/user_serializer.rb | 16 +- 29 files changed, 637 insertions(+), 584 deletions(-) diff --git a/app/controllers/v3/flat_pages_controller.rb b/app/controllers/v3/flat_pages_controller.rb index 84b8bd4d..f1f736bc 100644 --- a/app/controllers/v3/flat_pages_controller.rb +++ b/app/controllers/v3/flat_pages_controller.rb @@ -1,70 +1,72 @@ # frozen_string_literal: true -class V3::FlatPagesController < V3Controller - before_action :set_record, only: [:show, :update, :destroy] - #authorize_resource +module V3 + class FlatPagesController < V3Controller + before_action :set_record, only: [:show, :update, :destroy] + #authorize_resource - # GET /v3/records - def index - @records = if current_user.current_tenant_admin? - FlatPage.all - elsif current_user.tours.present? - current_user.tours.map { |tour| tour.flat_pages }.flatten.uniq - else - Tour.published.map { |tour| tour.flat_pages }.flatten.uniq + # GET /v3/records + def index + @records = if current_user.current_tenant_admin? + FlatPage.all + elsif current_user.tours.present? + current_user.tours.map { |tour| tour.flat_pages }.flatten.uniq + else + Tour.published.map { |tour| tour.flat_pages }.flatten.uniq + end + render json: @records end - render json: @records - end - # POST /v3/records - def create - if @allowed - @record = FlatPage.new(record_params) + # POST /v3/records + def create + if @allowed + @record = FlatPage.new(record_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@record.id}" + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/flat-pages/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - # PATCH/PUT /v3/records/1 - def update - if @allowed - if @record.update(record_params) - render json: @record + # PATCH/PUT /v3/records/1 + def update + if @allowed + if @record.update(record_params) + render json: @record + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - # DELETE /v3/records/1 + # DELETE /v3/records/1 - private - # Use callbacks to share common setup or constraints between actions. - def set_record - _record = FlatPage.find(params[:id]) - @record = _record.published || @allowed ? _record : FlatPage.new(id: params[:id]) - end + private + # Use callbacks to share common setup or constraints between actions. + def set_record + _record = FlatPage.find(params[:id]) + @record = _record.published || @allowed ? _record : FlatPage.new(id: params[:id]) + end - # Only allow a trusted parameter "white list" through. - def record_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :title, :body, :tours - ] - ) - end + # Only allow a trusted parameter "white list" through. + def record_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :title, :body, :tours + ] + ) + end - def allowed? - @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } - end + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + end + end end diff --git a/app/controllers/v3/map_icons_controller.rb b/app/controllers/v3/map_icons_controller.rb index 2f393585..e1f1e72f 100644 --- a/app/controllers/v3/map_icons_controller.rb +++ b/app/controllers/v3/map_icons_controller.rb @@ -1,36 +1,38 @@ -class V3::MapIconsController < V3Controller +module V3 + class MapIconsController < V3Controller - def index - render json: MapIcon.all - end + def index + render json: MapIcon.all + end - def create - if crud_allowed? - @record = MapIcon.new(record_params) - if @record.save - render json: @record, status: :created + def create + if crud_allowed? + @record = MapIcon.new(record_params) + if @record.save + render json: @record, status: :created + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - private + private - # Only allow a trusted parameter "white list" through. - def record_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :base_sixty_four, :filename, :stop - ] - ) - end + # Only allow a trusted parameter "white list" through. + def record_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :base_sixty_four, :filename, :stop + ] + ) + end - def set_record - _record = MapIcon.find(params[:id]) - @record = _record&.published || @allowed ? _record : MapIcon.new(id: params[:id]) - end + def set_record + _record = MapIcon.find(params[:id]) + @record = _record&.published || @allowed ? _record : MapIcon.new(id: params[:id]) + end + end end diff --git a/app/controllers/v3/map_overlays_controller.rb b/app/controllers/v3/map_overlays_controller.rb index 17cd465f..0f71ed97 100644 --- a/app/controllers/v3/map_overlays_controller.rb +++ b/app/controllers/v3/map_overlays_controller.rb @@ -1,31 +1,33 @@ -class V3::MapOverlaysController < V3Controller - def create - if crud_allowed? - @record = MapOverlay.new(record_params) - if @record.save - render json: @record, status: :created +module V3 + class MapOverlaysController < V3Controller + def create + if crud_allowed? + @record = MapOverlay.new(record_params) + if @record.save + render json: @record, status: :created + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - private + private - # Only allow a trusted parameter "white list" through. - def record_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :south, :east, :north, :west, :base_sixty_four, :filename, :tour, :stop - ] - ) - end + # Only allow a trusted parameter "white list" through. + def record_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :south, :east, :north, :west, :base_sixty_four, :filename, :tour, :stop + ] + ) + end - def set_record - _record = MapOverlay.find(params[:id]) - @record = _record&.published || @allowed ? _record : MapOverlay.new(id: params[:id]) - end + def set_record + _record = MapOverlay.find(params[:id]) + @record = _record&.published || @allowed ? _record : MapOverlay.new(id: params[:id]) + end + end end diff --git a/app/controllers/v3/stop_media_controller.rb b/app/controllers/v3/stop_media_controller.rb index b2b16eb3..3f42d292 100644 --- a/app/controllers/v3/stop_media_controller.rb +++ b/app/controllers/v3/stop_media_controller.rb @@ -1,34 +1,36 @@ -class V3::StopMediaController < V3::TourRelationsController - # GET /v3/stop_media - def index - # @stop_media = if params[:tour_id] && params[:medium_id] - # StopMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} - # else - # StopMedium.all - # end +module V3 + class StopMediaController < V3::TourRelationsController + # GET /v3/stop_media + def index + # @stop_media = if params[:tour_id] && params[:medium_id] + # StopMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} + # else + # StopMedium.all + # end - @stop_media = StopMedium.all + @stop_media = StopMedium.all - unless current_user&.current_tenant_admin? || current_user.tours.present? - @stop_media = @stop_media.reject { |stop_medium| !stop_medium.stop.published } - end - - render json: @stop_media - end + unless current_user&.current_tenant_admin? || current_user.tours.present? + @stop_media = @stop_media.reject { |stop_medium| !stop_medium.stop.published } + end - private - # Only allow a trusted parameter "white list" through. - def record_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :medium, :stop, :position - ] - ) + render json: @stop_media end - def set_record - _record = StopMedium.find(params[:id]) - @record = _record&.published || @allowed ? _record : StopMedium.new(id: params[:id]) - end + private + # Only allow a trusted parameter "white list" through. + def record_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :medium, :stop, :position + ] + ) + end + + def set_record + _record = StopMedium.find(params[:id]) + @record = _record&.published || @allowed ? _record : StopMedium.new(id: params[:id]) + end + end end diff --git a/app/controllers/v3/stops_controller.rb b/app/controllers/v3/stops_controller.rb index dac2d548..6d7b94f7 100644 --- a/app/controllers/v3/stops_controller.rb +++ b/app/controllers/v3/stops_controller.rb @@ -1,72 +1,73 @@ # frozen_string_literal: true # /app/controllers/v3/stops_controller.rb -# module V3 -class V3::StopsController < V3::TourRelationsController +module V3 + class StopsController < V3::TourRelationsController # GET /stops - def index - @records = if current_user.current_tenant_admin? - Stop.all - elsif current_user.tours.present? - current_user.tours.map { |tour| tour.stops }.flatten.uniq - else - Tour.published.map { |tour| tour.stops }.flatten.uniq + def index + @records = if current_user.current_tenant_admin? + Stop.all + elsif current_user.tours.present? + current_user.tours.map { |tour| tour.stops }.flatten.uniq + else + Tour.published.map { |tour| tour.stops }.flatten.uniq + end + render json: @records end - render json: @records - end - # POST /stops - def create - if crud_allowed? - @record = Stop.new(stop_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + # POST /stops + def create + if crud_allowed? + @record = Stop.new(stop_params) + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/#{@record.id}" + end + else + head 401 end - else - head 401 end - end - # PATCH/PUT /stops/1 - def update - if crud_allowed? - if @record&.update(stop_params) - render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" + # PATCH/PUT /stops/1 + def update + if crud_allowed? + if @record&.update(stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/stops/#{@record.id}" + end + else + head 401 end - else - head 401 end - end - def destroy - if !crud_allowed? - head 401 - elsif crud_allowed? && @record.orphaned - @record.destroy - elsif crud_allowed? && !@record.orphaned - head 405 + def destroy + if !crud_allowed? + head 401 + elsif crud_allowed? && @record.orphaned + @record.destroy + elsif crud_allowed? && !@record.orphaned + head 405 + end end - end - private + private - # Only allow a trusted parameter "white list" through. - def stop_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :title, :description, :lat, :lng, - :parking_lat, :parking_lng, :media, - :address, :tours, :direction_notes, - :meta_description, :parking_address, - :icon_color, :map_icon - ] - ) - end + # Only allow a trusted parameter "white list" through. + def stop_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :title, :description, :lat, :lng, + :parking_lat, :parking_lng, :media, + :address, :tours, :direction_notes, + :meta_description, :parking_address, + :icon_color, :map_icon + ] + ) + end - # Callbacks - def set_record - _record = Stop.find_by(id: params[:id]) - @record = _record&.published || @allowed ? _record : Stop.new(id: params[:id]) - end + # Callbacks + def set_record + _record = Stop.find_by(id: params[:id]) + @record = _record&.published || @allowed ? _record : Stop.new(id: params[:id]) + end + end end diff --git a/app/controllers/v3/tour_flat_pages_controller.rb b/app/controllers/v3/tour_flat_pages_controller.rb index f1f39f5a..4126f7c9 100644 --- a/app/controllers/v3/tour_flat_pages_controller.rb +++ b/app/controllers/v3/tour_flat_pages_controller.rb @@ -1,72 +1,74 @@ # frozen_string_literal: true # /app/controllers/v3/tour_stops_controller.rb -class V3::TourFlatPagesController < V3Controller - # GET /stops - def index - @tour_flat_pages = TourFlatPage.all +module V3 + class TourFlatPagesController < V3Controller + # GET /stops + def index + @tour_flat_pages = TourFlatPage.all - unless current_user&.current_tenant_admin? || current_user.tours.present? - @tour_flat_pages = @tour_flat_pages.reject { |tour_flat_page| !tour_flat_page.tour.published } - end + unless current_user&.current_tenant_admin? || current_user.tours.present? + @tour_flat_pages = @tour_flat_pages.reject { |tour_flat_page| !tour_flat_page.tour.published } + end - render json: @tour_flat_pages - end + render json: @tour_flat_pages + end - # GET /stops/1 - def show - if @record&.tour.published || allowed? - render json: @record - else - render json: { data: {} } + # GET /stops/1 + def show + if @record&.tour.published || allowed? + render json: @record + else + render json: { data: {} } + end + # render json: { data: {} } if @record.nil? + # render json: @record, include: ['stop'] end - # render json: { data: {} } if @record.nil? - # render json: @record, include: ['stop'] - end - # POST /stops - def create - # Not created via the API - head 405 - end + # POST /stops + def create + # Not created via the API + head 405 + end - # PATCH/PUT /stops/1 - def update - if @allowed - if @record.update(tour_stop_params) - render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" + # PATCH/PUT /stops/1 + def update + if @allowed + if @record.update(tour_stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - # DELETE /stops/1 - def destroy - # Not deleted via the API - head 405 - end + # DELETE /stops/1 + def destroy + # Not deleted via the API + head 405 + end - private + private - # Only allow a trusted parameter "white list" through. - def tour_stop_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :stop, :tour, :position - ] - ) - end + # Only allow a trusted parameter "white list" through. + def tour_stop_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :stop, :tour, :position + ] + ) + end - def set_record - @record = TourFlatPage.find(params[:id]) - end + def set_record + @record = TourFlatPage.find(params[:id]) + end - def allowed? - @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } - return @allowed - end + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + return @allowed + end + end end diff --git a/app/controllers/v3/tour_media_controller.rb b/app/controllers/v3/tour_media_controller.rb index 320ad96c..730e12b9 100644 --- a/app/controllers/v3/tour_media_controller.rb +++ b/app/controllers/v3/tour_media_controller.rb @@ -1,34 +1,36 @@ -class V3::TourMediaController < V3::TourRelationsController - # GET /v3/tour_media - def index - # @tour_media = if params[:tour_id] && params[:medium_id] - # TourMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} - # else - # TourMedium.all - # end +module V3 + class TourMediaController < V3::TourRelationsController + # GET /v3/tour_media + def index + # @tour_media = if params[:tour_id] && params[:medium_id] + # TourMedium.where(tour_id: params[:tour_id]).where(medium_id: params[:medium_id]).first || {} + # else + # TourMedium.all + # end - @tour_media = TourMedium.all + @tour_media = TourMedium.all - unless current_user&.current_tenant_admin? || current_user.tours.present? - @tour_media = @tour_media.reject { |tour_medium| !tour_medium.tour.published } - end - - render json: @tour_media - end + unless current_user&.current_tenant_admin? || current_user.tours.present? + @tour_media = @tour_media.reject { |tour_medium| !tour_medium.tour.published } + end - private - # Only allow a trusted parameter "white list" through. - def record_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :medium, :tour, :position - ] - ) + render json: @tour_media end - def set_record - _record = TourMedium.find(params[:id]) - @record = _record&.published || @allowed ? _record : TourMedium.new(id: params[:id]) - end + private + # Only allow a trusted parameter "white list" through. + def record_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :medium, :tour, :position + ] + ) + end + + def set_record + _record = TourMedium.find(params[:id]) + @record = _record&.published || @allowed ? _record : TourMedium.new(id: params[:id]) + end + end end diff --git a/app/controllers/v3/tour_relations_controller.rb b/app/controllers/v3/tour_relations_controller.rb index 2ade823e..a4a94b86 100644 --- a/app/controllers/v3/tour_relations_controller.rb +++ b/app/controllers/v3/tour_relations_controller.rb @@ -1,20 +1,21 @@ # frozen_string_literal: true # /app/controllers/v3/tour_relations_controller.rb -# module V3 -class V3::TourRelationsController < V3Controller +module V3 + class TourRelationsController < V3Controller - def destroy - head 405 - end + def destroy + head 405 + end - def allowed? - set_record if @record.nil? && params[:id].present? - @allowed = @record&.published || crud_allowed? - end + def allowed? + set_record if @record.nil? && params[:id].present? + @allowed = @record&.published || crud_allowed? + end - def crud_allowed? - current_user&.current_tenant_admin? || - current_user.tours&.any? { |tour| Tour.all.include?(tour) } + def crud_allowed? + current_user&.current_tenant_admin? || + current_user.tours&.any? { |tour| Tour.all.include?(tour) } + end end end diff --git a/app/controllers/v3/tour_stops_controller.rb b/app/controllers/v3/tour_stops_controller.rb index 6807c2fc..ecad029f 100644 --- a/app/controllers/v3/tour_stops_controller.rb +++ b/app/controllers/v3/tour_stops_controller.rb @@ -1,84 +1,86 @@ # frozen_string_literal: true # /app/controllers/v3/tour_stops_controller.rb -class V3::TourStopsController < V3Controller - # GET /stops - def index - @records = if params[:fastboot] == 'true' - nil - elsif params[:tour] && params[:slug] - tour = Tour.find(params[:tour]) - if tour.published || allowed? - stop = Stop.by_slug_and_tour(params[:slug], params[:tour]).first - TourStop.find_by(tour: Tour.find(params[:tour]), stop: stop) +module V3 + class TourStopsController < V3Controller + # GET /stops + def index + @records = if params[:fastboot] == 'true' + nil + elsif params[:tour] && params[:slug] + tour = Tour.find(params[:tour]) + if tour.published || allowed? + stop = Stop.by_slug_and_tour(params[:slug], params[:tour]).first + TourStop.find_by(tour: Tour.find(params[:tour]), stop: stop) + else + {} + end + elsif current_user.current_tenant_admin? + TourStop.all else - {} + Tour.published.map { |tour| tour.tour_stops }.flatten.uniq + end + if @records.nil? + render json: { data: { type: 'tour_stops', id: 0 } } + else + render json: @records, include: ['stop'] end - elsif current_user.current_tenant_admin? - TourStop.all - else - Tour.published.map { |tour| tour.tour_stops }.flatten.uniq - end - if @records.nil? - render json: { data: { type: 'tour_stops', id: 0 } } - else - render json: @records, include: ['stop'] end - end - # GET /stops/1 - def show - if @record&.tour.published || allowed? - render json: @record - else - render json: { data: {} } + # GET /stops/1 + def show + if @record&.tour.published || allowed? + render json: @record + else + render json: { data: {} } + end + # render json: { data: {} } if @record.nil? + # render json: @record, include: ['stop'] end - # render json: { data: {} } if @record.nil? - # render json: @record, include: ['stop'] - end - # POST /stops - def create - # Not created via the API - head 405 - end + # POST /stops + def create + # Not created via the API + head 405 + end - # PATCH/PUT /stops/1 - def update - if @allowed - if @record.update(tour_stop_params) - render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" + # PATCH/PUT /stops/1 + def update + if @allowed + if @record.update(tour_stop_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tour_stops/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - # DELETE /stops/1 - def destroy - head 405 - end + # DELETE /stops/1 + def destroy + head 405 + end - private + private - # Only allow a trusted parameter "white list" through. - def tour_stop_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :stop, :tour, :position - ] - ) - end + # Only allow a trusted parameter "white list" through. + def tour_stop_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :stop, :tour, :position + ] + ) + end - def set_record - @record = TourStop.find(params[:id]) - end + def set_record + @record = TourStop.find(params[:id]) + end - def allowed? - @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } - return @allowed - end + def allowed? + @allowed = current_user&.current_tenant_admin? || current_user.tours&.any? { |tour| Tour.all.include?(tour) } + return @allowed + end + end end diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index cfc12387..cba8d778 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -1,100 +1,101 @@ # frozen_string_literal: true # app/controllers/v3/tours_controller.rb -# module V3 -class V3::ToursController < V3Controller - # GET /tours - def index - @records = if (params[:slug]) - @record = Slug.find_by(slug: params[:slug]).tour - if @record.published || crud_allowed? - @record +module V3 + class ToursController < V3Controller + # GET /tours + def index + @records = if (params[:slug]) + @record = Slug.find_by(slug: params[:slug]).tour + if @record.published || crud_allowed? + @record + else + nil + end + elsif (current_user && current_user.current_tenant_admin?) + Tour.all + elsif (current_user && current_user.id) + (current_user.tours + Tour.published).uniq else - nil + Tour.published + end + if @records.nil? + render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } + else + render json: @records, each_serializer: V3::TourBaseSerializer end - elsif (current_user && current_user.current_tenant_admin?) - Tour.all - elsif (current_user && current_user.id) - (current_user.tours + Tour.published).uniq - else - Tour.published - end - if @records.nil? - render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } - else - render json: @records, each_serializer: V3::TourBaseSerializer end - end - # GET /tours/1 - def show - request_loc = if request.env['ipinfo'].respond_to?('longitude') - { centerLng: request.env['ipinfo'].longitude, centerLat: request.env['ipinfo'].latitude } - else - { centerLng: -84.38979, centerLat: 33.75432 } - end + # GET /tours/1 + def show + request_loc = if request.env['ipinfo'].respond_to?('longitude') + { centerLng: request.env['ipinfo'].longitude, centerLat: request.env['ipinfo'].latitude } + else + { centerLng: -84.38979, centerLat: 33.75432 } + end - if @record&.published || crud_allowed? - render json: @record, loc: request_loc - else - render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } + if @record&.published || crud_allowed? + render json: @record, loc: request_loc + else + render json: { data: { id: 0, type: 'tours', attributes: { title: '....' } } } + end end - end - # POST /tours - def create - if crud_allowed? - @record = Tour.new(tour_params) - if @record.save - render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" + # POST /tours + def create + if crud_allowed? + @record = Tour.new(tour_params) + if @record.save + render json: @record, status: :created, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - # PATCH/PUT /tours/1 - def update - if crud_allowed? - if @record.update(tour_params) - render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" + # PATCH/PUT /tours/1 + def update + if crud_allowed? + if @record.update(tour_params) + render json: @record, location: "/#{Apartment::Tenant.current}/tours/#{@record.id}" + else + render json: serialize_errors, status: :unprocessable_entity + end else - render json: serialize_errors, status: :unprocessable_entity + head 401 end - else - head 401 end - end - private - # Only allow a trusted parameter "white list" through. - def tour_params - ActiveModelSerializers::Deserialization - .jsonapi_parse( - params, only: [ - :title, :description, - :is_geo, :modes, :published, :theme_id, - :mode, :meta_description, :stops, - :media, :users, :flat_pages, :map_type, - :theme, :use_directions, :default_lng, - :link_address, :link_text - ] - ) - end + private + # Only allow a trusted parameter "white list" through. + def tour_params + ActiveModelSerializers::Deserialization + .jsonapi_parse( + params, only: [ + :title, :description, + :is_geo, :modes, :published, :theme_id, + :mode, :meta_description, :stops, + :media, :users, :flat_pages, :map_type, + :theme, :use_directions, :default_lng, + :link_address, :link_text + ] + ) + end - def set_record - _record = Tour.find(params[:id]) - @record = _record&.published || @allowed ? _record : Tour.new(id: params[:id]) - end + def set_record + _record = Tour.find(params[:id]) + @record = _record&.published || @allowed ? _record : Tour.new(id: params[:id]) + end - # def allowed? - # @allowed = crud_allowed? || - # end + # def allowed? + # @allowed = crud_allowed? || + # end - def crud_allowed? - set_record if @record.nil? && params[:id].present? - current_user&.current_tenant_admin? || current_user.tours.include?(@record) - end + def crud_allowed? + set_record if @record.nil? && params[:id].present? + current_user&.current_tenant_admin? || current_user.tours.include?(@record) + end + end end diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index aa23c71c..e33693e7 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true # app/controllers/v3/users_controller.rb +# +# Endpoints for User Model +# module V3 - # - # Endpoints for User Model - # class UsersController < V3Controller before_action :authenticate!, only: :me diff --git a/app/serializers/v3/flat_page_serializer.rb b/app/serializers/v3/flat_page_serializer.rb index 64cabedd..52fb55c0 100644 --- a/app/serializers/v3/flat_page_serializer.rb +++ b/app/serializers/v3/flat_page_serializer.rb @@ -1,4 +1,6 @@ -class V3::FlatPageSerializer < ActiveModel::Serializer - has_many :tours - attributes :id, :title, :body, :slug, :orphaned +module V3 + class FlatPageSerializer < ActiveModel::Serializer + has_many :tours + attributes :id, :title, :body, :slug, :orphaned + end end diff --git a/app/serializers/v3/map_icon_serializer.rb b/app/serializers/v3/map_icon_serializer.rb index 4946ed55..1fdf2c28 100644 --- a/app/serializers/v3/map_icon_serializer.rb +++ b/app/serializers/v3/map_icon_serializer.rb @@ -1,4 +1,6 @@ -class V3::MapIconSerializer < ActiveModel::Serializer - include Rails.application.routes.url_helpers - attributes :id, :base_sixty_four, :filename, :original_image_url +module V3 + class MapIconSerializer < ActiveModel::Serializer + include Rails.application.routes.url_helpers + attributes :id, :base_sixty_four, :filename, :original_image_url + end end diff --git a/app/serializers/v3/map_overlay_serializer.rb b/app/serializers/v3/map_overlay_serializer.rb index 9e05033f..94446148 100644 --- a/app/serializers/v3/map_overlay_serializer.rb +++ b/app/serializers/v3/map_overlay_serializer.rb @@ -1,4 +1,6 @@ -class V3::MapOverlaySerializer < ActiveModel::Serializer - include Rails.application.routes.url_helpers - attributes :id, :south, :north, :east, :west, :original_image_url, :filename +module V3 + class MapOverlaySerializer < ActiveModel::Serializer + include Rails.application.routes.url_helpers + attributes :id, :south, :north, :east, :west, :original_image_url, :filename + end end diff --git a/app/serializers/v3/medium_serializer.rb b/app/serializers/v3/medium_serializer.rb index 20c6eb05..7cbc4f87 100644 --- a/app/serializers/v3/medium_serializer.rb +++ b/app/serializers/v3/medium_serializer.rb @@ -1,29 +1,31 @@ # frozen_string_literal: true -class V3::MediumSerializer < ActiveModel::Serializer - # include Rails.application.routes.url_helpers - attributes :id, - :title, - :caption, - :video, - :provider, - :original_image, - :embed, - :files, - :orphaned, - :filename, - :original_image_url, - :lqip_width, - :mobile_width, - :tablet_width, - :desktop_width +module V3 + class MediumSerializer < ActiveModel::Serializer + # include Rails.application.routes.url_helpers + attributes :id, + :title, + :caption, + :video, + :provider, + :original_image, + :embed, + :files, + :orphaned, + :filename, + :original_image_url, + :lqip_width, + :mobile_width, + :tablet_width, + :desktop_width - # def files - # return nil unless object.file.attached? - # { - # mobile: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '200x200').processed), - # tablet: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '300x300').processed), - # desktop: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '750x750').processed) - # } - # end + # def files + # return nil unless object.file.attached? + # { + # mobile: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '200x200').processed), + # tablet: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '300x300').processed), + # desktop: Rails.application.routes.url_helpers.rails_representation_url(object.file.variant(resize: '750x750').processed) + # } + # end + end end diff --git a/app/serializers/v3/mode_serializer.rb b/app/serializers/v3/mode_serializer.rb index 755721b5..f5e7a842 100644 --- a/app/serializers/v3/mode_serializer.rb +++ b/app/serializers/v3/mode_serializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class V3::ModeSerializer < ActiveModel::Serializer - has_many :tours - attributes :id, :title, :icon +module V3 + class ModeSerializer < ActiveModel::Serializer + has_many :tours + attributes :id, :title, :icon + end end diff --git a/app/serializers/v3/stop_medium_serializer.rb b/app/serializers/v3/stop_medium_serializer.rb index 866a4bc2..c925d22b 100644 --- a/app/serializers/v3/stop_medium_serializer.rb +++ b/app/serializers/v3/stop_medium_serializer.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -class V3::StopMediumSerializer < ActiveModel::Serializer - belongs_to :stop - belongs_to :medium - attributes :id, :position +module V3 + class StopMediumSerializer < ActiveModel::Serializer + belongs_to :stop + belongs_to :medium + attributes :id, :position + end end diff --git a/app/serializers/v3/stop_serializer.rb b/app/serializers/v3/stop_serializer.rb index 70de14a3..767923ac 100644 --- a/app/serializers/v3/stop_serializer.rb +++ b/app/serializers/v3/stop_serializer.rb @@ -1,28 +1,30 @@ # frozen_string_literal: true -class V3::StopSerializer < ActiveModel::Serializer - has_many :media - has_many :stop_media - has_many :tours - belongs_to :map_icon - attributes :id, - :title, - :slug, - :description, - :sanitized_description, - :sanitized_direction_notes, - :lat, - :lng, - :address, - :meta_description, - :article_link, - :video_embed, - :video_poster, - :parking_lat, - :parking_lng, - :direction_intro, - :direction_notes, - :splash, - :orphaned, - :icon_color +module V3 + class StopSerializer < ActiveModel::Serializer + has_many :media + has_many :stop_media + has_many :tours + belongs_to :map_icon + attributes :id, + :title, + :slug, + :description, + :sanitized_description, + :sanitized_direction_notes, + :lat, + :lng, + :address, + :meta_description, + :article_link, + :video_embed, + :video_poster, + :parking_lat, + :parking_lng, + :direction_intro, + :direction_notes, + :splash, + :orphaned, + :icon_color + end end diff --git a/app/serializers/v3/theme_serializer.rb b/app/serializers/v3/theme_serializer.rb index 50afdade..88a37ae3 100644 --- a/app/serializers/v3/theme_serializer.rb +++ b/app/serializers/v3/theme_serializer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true -class V3::ThemeSerializer < ActiveModel::Serializer - attributes :id, :title +module V3 + class ThemeSerializer < ActiveModel::Serializer + attributes :id, :title + end end diff --git a/app/serializers/v3/tour_author_serializer.rb b/app/serializers/v3/tour_author_serializer.rb index 5850a7a5..9f8bff5d 100644 --- a/app/serializers/v3/tour_author_serializer.rb +++ b/app/serializers/v3/tour_author_serializer.rb @@ -1,5 +1,7 @@ -class V3::TourAuthorSerializer < ActiveModel::Serializer - belongs_to :tour - belongs_to :user - attributes :id +module V3 + class TourAuthorSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :user + attributes :id + end end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 0f7f778d..819a58d4 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -3,47 +3,49 @@ include ActionView::Helpers::DateHelper # app/serializers/tour_serializer.rb -class V3::TourBaseSerializer < ActiveModel::Serializer - has_one :map_overlay - attributes :id, - :title, - :slug, - :description, - :is_geo, - :published, - :sanitized_description, - :position, - :theme_title, - :meta_description, - :tenant, - :tenant_title, - :stop_count, - :map_type, - :splash, - :use_directions, - :default_lng, - :stop_count, - :est_time, - :link_address, - :link_text - - def est_time - return nil if object.duration.nil? - - "#{distance_of_time_in_words(object.duration).capitalize} #{object.mode.title.downcase}" - end +module V3 + class TourBaseSerializer < ActiveModel::Serializer + has_one :map_overlay + attributes :id, + :title, + :slug, + :description, + :is_geo, + :published, + :sanitized_description, + :position, + :theme_title, + :meta_description, + :tenant, + :tenant_title, + :stop_count, + :map_type, + :splash, + :use_directions, + :default_lng, + :stop_count, + :est_time, + :link_address, + :link_text + + def est_time + return nil if object.duration.nil? + + "#{distance_of_time_in_words(object.duration).capitalize} #{object.mode.title.downcase}" + end - def map_type - object.map_type || 'hybrid' - end + def map_type + object.map_type || 'hybrid' + end - def bounds - return object.bounds if object.bounds.present? + def bounds + return object.bounds if object.bounds.present? - if @instance_options[:loc].present? - return @instance_options[:loc] - end + if @instance_options[:loc].present? + return @instance_options[:loc] + end - nil + nil + end end end diff --git a/app/serializers/v3/tour_flat_page_serializer.rb b/app/serializers/v3/tour_flat_page_serializer.rb index 904ccb18..67dfef6f 100644 --- a/app/serializers/v3/tour_flat_page_serializer.rb +++ b/app/serializers/v3/tour_flat_page_serializer.rb @@ -1,5 +1,7 @@ -class V3::TourFlatPageSerializer < ActiveModel::Serializer - belongs_to :tour - belongs_to :flat_page - attributes :id, :position +module V3 + class TourFlatPageSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :flat_page + attributes :id, :position + end end diff --git a/app/serializers/v3/tour_medium_serializer.rb b/app/serializers/v3/tour_medium_serializer.rb index 4e67cdf7..465bc51f 100644 --- a/app/serializers/v3/tour_medium_serializer.rb +++ b/app/serializers/v3/tour_medium_serializer.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -class V3::TourMediumSerializer < ActiveModel::Serializer - belongs_to :tour - belongs_to :medium - attributes :id, :position +module V3 + class TourMediumSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :medium + attributes :id, :position + end end diff --git a/app/serializers/v3/tour_mode_serializer.rb b/app/serializers/v3/tour_mode_serializer.rb index 2857abf7..f69200b6 100644 --- a/app/serializers/v3/tour_mode_serializer.rb +++ b/app/serializers/v3/tour_mode_serializer.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true -class V3::TourModeSerializer < ActiveModel::Serializer - belongs_to :tour - belongs_to :mode - attributes :id +module V3 + class TourModeSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :mode + attributes :id + end end diff --git a/app/serializers/v3/tour_serializer.rb b/app/serializers/v3/tour_serializer.rb index b084455e..052c7f74 100644 --- a/app/serializers/v3/tour_serializer.rb +++ b/app/serializers/v3/tour_serializer.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true # app/serializers/tour_serializer.rb -class V3::TourSerializer < V3::TourBaseSerializer - has_many :tour_modes - has_many :tour_stops - has_many :stops - belongs_to :mode - belongs_to :theme - has_many :modes - has_many :media - has_many :tour_media - has_many :flat_pages - has_many :tour_flat_pages - has_many :users +module V3 + class TourSerializer < V3::TourBaseSerializer + has_many :tour_modes + has_many :tour_stops + has_many :stops + belongs_to :mode + belongs_to :theme + has_many :modes + has_many :media + has_many :tour_media + has_many :flat_pages + has_many :tour_flat_pages + has_many :users - attributes :bounds + attributes :bounds + end end diff --git a/app/serializers/v3/tour_set_admin_serializer.rb b/app/serializers/v3/tour_set_admin_serializer.rb index 0efe3f85..4a1508c3 100644 --- a/app/serializers/v3/tour_set_admin_serializer.rb +++ b/app/serializers/v3/tour_set_admin_serializer.rb @@ -1,3 +1,5 @@ -class TourSetAdminSerializer < ActiveModel::Serializer - attributes :id +module V3 + class TourSetAdminSerializer < ActiveModel::Serializer + attributes :id + end end diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index bca6b8e0..8426896c 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true -class V3::TourSetSerializer < ActiveModel::Serializer - # attribute :tenant_admins - include Rails.application.routes.url_helpers - has_many :admins - attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url, :logo +module V3 + class TourSetSerializer < ActiveModel::Serializer + # attribute :tenant_admins + include Rails.application.routes.url_helpers + has_many :admins + attributes :id, :name, :subdir, :published_tours, :mapable_tours, :logo_url, :logo - def admins - begin - object.admins if current_user&.super || current_user&.current_tenant_admin? - rescue NameError - # This is a problem when using the serializer directly - nil + def admins + begin + object.admins if current_user&.super || current_user&.current_tenant_admin? + rescue NameError + # This is a problem when using the serializer directly + nil + end end end end diff --git a/app/serializers/v3/tour_stop_serializer.rb b/app/serializers/v3/tour_stop_serializer.rb index a0641df9..ad8bef77 100644 --- a/app/serializers/v3/tour_stop_serializer.rb +++ b/app/serializers/v3/tour_stop_serializer.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true # /app/serializers/tour_stop_serializer.rb -class V3::TourStopSerializer < ActiveModel::Serializer - belongs_to :tour - belongs_to :stop - attributes :id, :position, :previous, :slug, :next, :next_slug, :previous_slug +module V3 + class TourStopSerializer < ActiveModel::Serializer + belongs_to :tour + belongs_to :stop + attributes :id, :position, :previous, :slug, :next, :next_slug, :previous_slug + end end diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index 1e5d93b9..93c1897c 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true # app/serializer/v3/user_serializer.rb -class V3::UserSerializer < ActiveModel::Serializer - has_many :tours - has_many :tour_authors - has_many :tour_sets - attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email, :all_tours +module V3 + class UserSerializer < ActiveModel::Serializer + has_many :tours + has_many :tour_authors + has_many :tour_sets + attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email, :all_tours - def current_tenant_admin - object.current_tenant_admin? + def current_tenant_admin + object.current_tenant_admin? + end end end From cdd81afe23da080bdd2b4055b40de900df539514 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Tue, 12 Oct 2021 17:35:09 -0400 Subject: [PATCH 106/160] Update Auth Engine for multiple browser support --- Gemfile.lock | 6 +++--- config/initializers/apartment.rb | 2 +- db/schema.rb | 12 +++++++++--- spec/factories/login.rb | 1 - spec/factories/token.rb | 11 +++++++++++ spec/support/signed_cookie.rb | 6 +++--- 6 files changed, 27 insertions(+), 11 deletions(-) create mode 100755 spec/factories/token.rb diff --git a/Gemfile.lock b/Gemfile.lock index 96f63e55..cc9dcc96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/ecds/ecds_rails_auth_engine.git - revision: 511b5def2258980b5a23f78e63c2992fb04af93e + revision: 4a55d7e9ddb38e7c6422bc001874a30abd0162bd branch: feature/fauxoauth specs: - ecds_rails_auth_engine (0.1.6) + ecds_rails_auth_engine (0.2.0) cancancan httparty jwt @@ -205,7 +205,7 @@ GEM jmespath (1.4.0) json (2.5.1) jsonapi-renderer (0.2.2) - jwt (2.2.3) + jwt (2.3.0) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 2b6faae7..46ac452a 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -3,6 +3,6 @@ # require 'directory_elevator' Apartment.configure do |config| config.tenant_names = -> { TourSet.pluck :subdir } - config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'Theme'] + config.excluded_models = ['User', 'Role', 'TourSetAdmin', 'TourSet', 'EcdsRailsAuthEngine::Login', 'EcdsRailsAuthEngine::Token', 'Theme'] config.persistent_schemas = ['shared_extensions'] end diff --git a/db/schema.rb b/db/schema.rb index 98aea1f2..935988e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_10_11_122625) do +ActiveRecord::Schema.define(version: 2021_10_12_142213) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -47,7 +47,6 @@ create_table "ecds_rails_auth_engine_logins", force: :cascade do |t| t.string "who" - t.string "token" t.string "provider" t.bigint "user_id" t.datetime "created_at", null: false @@ -55,6 +54,13 @@ t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" end + create_table "ecds_rails_auth_engine_tokens", force: :cascade do |t| + t.string "token" + t.bigint "login_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "flat_pages", force: :cascade do |t| t.string "title" t.text "body" @@ -308,7 +314,7 @@ t.string "link_address" t.string "link_text" t.integer "duration" - t.integer "saved_stop_order", array: true, default: [] + t.integer "saved_stop_order", array: true t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" diff --git a/spec/factories/login.rb b/spec/factories/login.rb index 600d1749..4d7ba13c 100644 --- a/spec/factories/login.rb +++ b/spec/factories/login.rb @@ -6,7 +6,6 @@ FactoryBot.define do factory :login, class: EcdsRailsAuthEngine::Login do - token { JWT.encode(Faker::Beer.style, Faker::Address.zip, 'HS256') } provider { Faker::Internet.domain_name } user_id { nil } end diff --git a/spec/factories/token.rb b/spec/factories/token.rb new file mode 100755 index 00000000..a3093281 --- /dev/null +++ b/spec/factories/token.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# spec/factories/login.rb +require 'faker' +require 'jwt' + +FactoryBot.define do + factory :token, class: EcdsRailsAuthEngine::Token do + token { JWT.encode(Faker::Beer.style, Faker::Address.zip, 'HS256') } + end +end diff --git a/spec/support/signed_cookie.rb b/spec/support/signed_cookie.rb index d11f9cbf..9a86c888 100755 --- a/spec/support/signed_cookie.rb +++ b/spec/support/signed_cookie.rb @@ -7,10 +7,10 @@ module SignedCookieHelper def signed_cookie(user) login = EcdsRailsAuthEngine::Login.create!(who: user.email) login.user_id = user.id - login.token = TokenService.create(login) login.save! - cookies[:auth] = { - value: login.token, + create(:token, login: login, token: TokenService.create(login)) + cookies.signed[:auth] = { + value: login.tokens.first.token, httponly: true, same_site: :none, secure: 'Secure' From 5da658f16fa239d323bf259f56f3cf89360e95fd Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 14 Oct 2021 09:12:09 -0400 Subject: [PATCH 107/160] Fix removing TourSet logo --- app/controllers/v3/tour_sets_controller.rb | 2 +- app/models/tour_set.rb | 41 ++++++++++++---------- spec/models/tour_set_spec.rb | 22 ++++++++++++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index ef00dc92..000ed9c5 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -85,7 +85,7 @@ def record_params ActiveModelSerializers::Deserialization .jsonapi_parse( params, only: [ - :name, :tours, :admins, :base_sixty_four + :name, :tours, :admins, :base_sixty_four, :logo_title ] ) end diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index 71b0ca81..21244dac 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -3,7 +3,7 @@ # Model class for tour sets. This is the main model for "instances" of Open Tour Builder. class TourSet < ApplicationRecord before_save :set_subdir - around_update :attach_file + before_save :attach_file after_create :create_tenant after_create :create_defaults before_destroy :drop_tenant @@ -121,28 +121,33 @@ def tmp_file_path # # def attach_file - return if base_sixty_four.nil? #&& !logo.attached? + return if base_sixty_four.nil? && !logo.attached? - headers, self.base_sixty_four = base_sixty_four.split(',') - # content_type = Regexp.last_match(1).split(';base64').first - return if base_sixty_four.nil? #&& !logo.attached? + return if !self.will_save_change_to_base_sixty_four? && logo.attached? - File.open(tmp_file_path, 'wb') do |f| - f.write(Base64.decode64(base_sixty_four)) - end - self.base_sixty_four = nil + if base_sixty_four.nil? && logo.attached? + logo.purge + else + headers, self.base_sixty_four = base_sixty_four.split(',') - image = MiniMagick::Image.open(tmp_file_path) + return if base_sixty_four.nil? - if image[:height] > 80 - image.resize('300x80') - image.write(tmp_file_path) - end + File.open(tmp_file_path, 'wb') do |f| + f.write(Base64.decode64(base_sixty_four)) + end + + image = MiniMagick::Image.open(tmp_file_path) - self.logo.attach( - io: File.open(tmp_file_path), - filename: logo_title - ) + if image[:height] > 80 + image.resize('300x80') + image.write(tmp_file_path) + end + + self.logo.attach( + io: File.open(tmp_file_path), + filename: logo_title + ) + end end end diff --git a/spec/models/tour_set_spec.rb b/spec/models/tour_set_spec.rb index 5172643b..e299b5a8 100644 --- a/spec/models/tour_set_spec.rb +++ b/spec/models/tour_set_spec.rb @@ -10,4 +10,26 @@ Apartment::Tenant.switch! tour_set.subdir expect(Mode.count).to eq(4) end + + it 'attaches logo' do + tour_set = create(:tour_set) + expect(tour_set.logo.attached?).to be false + tour_set.update( + logo_title: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/png_base64.txt')) + ) + expect(tour_set.logo.attached?).to be true + end + + it 'removes logo' do + tour_set = create( + :tour_set, + logo_title: Faker::File.file_name(dir: '', ext: 'png', directory_separator: ''), + base_sixty_four: File.read(Rails.root.join('spec/factories/images/png_base64.txt')) + ) + tour_set.save + expect(tour_set.logo.attached?).to be true + tour_set.update(base_sixty_four: nil) + expect(tour_set.logo.attached?).to be false + end end From a7c2ddfc872e3075db37fdff3fcd17517a952b5f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 21 Oct 2021 10:05:21 -0400 Subject: [PATCH 108/160] Fix language codes --- app/models/tour.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 10066dd5..bd5476a1 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -30,8 +30,8 @@ class Tour < ApplicationRecord belongs_to :theme, default: -> { Theme.first } enum default_lng: { - en: 0, fr: 1, de: 2, pl: 3, nl: 4, fi: 5, sv: 6, it: 7, es: 8, pt: 9, - ru: 10, "pt-BR": 11, "es-MX": 12, "zh-CN": 13, "zh-TW": 14, ja: 15, ko: 16 + "en-US": 0, "fr-FR": 1, "de-DE": 2, "pl-PL": 3, "nl-NL": 4, "fi-FI": 5, "sv-SE": 6, "it-IT": 7, "es-ES": 8, "pt-PT": 9, + "ru-RU": 10, "pt-BR": 11, "es-MX": 12, "zh-CN": 13, "zh-TW": 14, "ja-JP": 15, "ko-KR": 16 } validates :title, presence: true From 0399f3e70d44ef1f41f89efdbd2472aec3951562 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 8 Nov 2021 15:13:03 -0500 Subject: [PATCH 109/160] Only collect all tours when requesting specific tour --- app/controllers/v3/users_controller.rb | 2 +- app/serializers/v3/user_serializer.rb | 7 +++++++ spec/controllers/v3/users_controller_spec.rb | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index e33693e7..cd54ee7f 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -24,7 +24,7 @@ def index # GET /users/1 def show if current_user == @record || current_user.super - render json: @record + render json: @record, include_tours: true else render json: { message: 'You are not autorized to to view this resource.' }.to_json, status: 401 end diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index 93c1897c..df4ebc36 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -11,5 +11,12 @@ class UserSerializer < ActiveModel::Serializer def current_tenant_admin object.current_tenant_admin? end + + def all_tours + if @instance_options[:include_tours] + return object.all_tours + end + [] + end end end diff --git a/spec/controllers/v3/users_controller_spec.rb b/spec/controllers/v3/users_controller_spec.rb index 52be2b1f..91dec985 100644 --- a/spec/controllers/v3/users_controller_spec.rb +++ b/spec/controllers/v3/users_controller_spec.rb @@ -34,11 +34,14 @@ it 'returns list of users when requested by super' do create_list(:user, rand(4..7)) + User.all.each {|user| user.tours << create_list(:tour, rand(0..3))} user = User.last user.update(super: true) signed_cookie(user) get :index, params: { tenant: Apartment::Tenant.current } expect(json.count).to eq(User.count) + expect(User.all.map(&:all_tours).all? { |tours| tours.empty? }).not_to be true + expect(json.map { |user| user[:attributes][:all_tours].empty? }).to all(be true) end end end @@ -79,10 +82,12 @@ it 'returns user when requested by super' do user.update(super: true) + other_user.tours << create_list(:tour, 3) signed_cookie(user) get :show, params: { id: other_user.to_param, tenant: Apartment::Tenant.current } expect(response.status).to eq(200) expect(json[:id]).to eq(other_user.id.to_s) + expect(json[:attributes][:all_tours].count).to eq(3) end end From 9cd2e73ffa29688700e7a124b2798e9e29ceb56c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 12 Nov 2021 12:46:28 -0500 Subject: [PATCH 110/160] Fix for listing sites for non super users --- app/controllers/v3/tour_sets_controller.rb | 6 ++++-- app/serializers/v3/tour_set_serializer.rb | 2 +- spec/controllers/v3/tour_sets_controller_spec.rb | 10 ++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 000ed9c5..4a2b322c 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -18,7 +18,9 @@ def index if current_user.current_tenant_admin? || current_user.super render json: @records, include: [ 'admins' ] else - @records = @records.reject { |ts| ts.published_tours.empty? } + if current_user&.tour_sets.empty? + @records = @records.reject { |ts| ts.published_tours.empty? } + end render json: @records end end @@ -72,7 +74,7 @@ def allowed? @allowed = if @record.nil? crud_allowed? else - current_user&.current_tenant_admin? || @record.published_tours.present? + current_user&.current_tenant_admin? || @record.published_tours.present? || current_user.tour_sets.include?(@record) end end diff --git a/app/serializers/v3/tour_set_serializer.rb b/app/serializers/v3/tour_set_serializer.rb index 8426896c..497a509d 100644 --- a/app/serializers/v3/tour_set_serializer.rb +++ b/app/serializers/v3/tour_set_serializer.rb @@ -9,7 +9,7 @@ class TourSetSerializer < ActiveModel::Serializer def admins begin - object.admins if current_user&.super || current_user&.current_tenant_admin? + object.admins if current_user&.super || current_user&.tour_sets.include?(object) rescue NameError # This is a problem when using the serializer directly nil diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index c16d45b5..f35c7996 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -70,6 +70,14 @@ it 'returns TourSet objects when requested by admin' do user = create(:user, super: false) user.tour_sets << [TourSet.first, TourSet.last] + + # Make sure no sets are included because of published tours. + [TourSet.first.subdir, TourSet.last.subdir].each do |ts| + Apartment::Tenant.switch! ts + Tour.all.update(published: false) + end + + Apartment::Tenant.reset signed_cookie(user) get :index, params: { tenant: 'public' } expect(response.status).to eq(200) @@ -101,6 +109,7 @@ get :show, params: { tenant: 'public', id: TourSet.last.to_param } expect(response.status).to eq(200) expect(attributes[:name]).to eq(TourSet.last.name) + expect(relationships[:admins][:data]).to be_empty end end @@ -132,6 +141,7 @@ get :show, params: { tenant: 'public', id: TourSet.last.to_param } expect(response.status).to eq(200) expect(attributes[:name]).to eq(TourSet.last.name) + expect(relationships[:admins][:data].map { |admin| admin[:id] }).to include(user.id.to_s) end it 'returns a success response and TourSet when requested by super' do From 03d36500ee98d4d95fb15424868fb44e3b02fd8b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 12 Nov 2021 14:10:20 -0500 Subject: [PATCH 111/160] Another fix for listing tour sets --- app/controllers/v3/tour_sets_controller.rb | 12 ++++++++---- spec/controllers/v3/tour_sets_controller_spec.rb | 10 ++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 4a2b322c..7940467f 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -9,17 +9,17 @@ def index @records = [] if params[:subdir] && params[:subdir] != 'public' @records = TourSet.where(subdir: params[:subdir]) - elsif current_user.id.present? && !current_user.super - @records = current_user.tour_sets + elsif current_user&.tour_sets.present? && !current_user.super + @records = published.concat(current_user.tour_sets).uniq else @records = TourSet.all end - if current_user.current_tenant_admin? || current_user.super + if current_user.tour_sets.present? || current_user.super render json: @records, include: [ 'admins' ] else if current_user&.tour_sets.empty? - @records = @records.reject { |ts| ts.published_tours.empty? } + @records = published end render json: @records end @@ -82,6 +82,10 @@ def crud_allowed? current_user&.super end + def published + TourSet.all.reject { |tour_set| tour_set.published_tours.empty? } + end + # Only allow a trusted parameter "white list" through. def record_params ActiveModelSerializers::Deserialization diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index f35c7996..af393534 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -71,17 +71,23 @@ user = create(:user, super: false) user.tour_sets << [TourSet.first, TourSet.last] - # Make sure no sets are included because of published tours. + # Make sure a set doesn't slip in because of published tours. [TourSet.first.subdir, TourSet.last.subdir].each do |ts| Apartment::Tenant.switch! ts Tour.all.update(published: false) end + # Make a new set with published tour to make sure it's included. + published_set = create(:tour_set) + Apartment::Tenant.switch! published_set.subdir + create(:tour, published: true, stops: create_list(:stop, 2)) + Apartment::Tenant.reset + puts TourSet.all.reject { |tour_set| tour_set.published_tours.empty? }.count signed_cookie(user) get :index, params: { tenant: 'public' } expect(response.status).to eq(200) - expect(json.count).to eq(2) + expect(json.count).to be > 2 end it 'returns no TourSet objects when requested by non admin' do From 5b8fefdb67001fd1c5084579bdd3fa75f905781e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 27 Jan 2022 09:30:08 -0500 Subject: [PATCH 112/160] Use default bounds is stop count is < 2 --- app/models/map_overlay.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/map_overlay.rb b/app/models/map_overlay.rb index 35f7e3d6..7fca50c3 100644 --- a/app/models/map_overlay.rb +++ b/app/models/map_overlay.rb @@ -14,7 +14,7 @@ def published end def set_initial_bounds - return if tour&.bounds.nil? + return if tour&.bounds.nil? || tour&.stop_count < 2 if tour self.south = self.tour.bounds[:south] From bbc3d8fb1189a71d80b3091761268451e8529d5c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 16 Feb 2022 11:54:59 -0500 Subject: [PATCH 113/160] Add map bound restrictions controls --- app/controllers/v3/tours_controller.rb | 3 +- app/models/tour.rb | 42 +++++++++-- app/serializers/v3/tour_base_serializer.rb | 5 +- .../20210610183825_add_title_to_map_icons.rb | 2 +- ...3352_add_over_bounds_restrition_to_tour.rb | 5 ++ .../20220208172935_add_blank_map_to_tour.rb | 5 ++ db/migrate/20220210160507_set_geo_default.rb | 5 ++ ...20211142554_add_restrict_bounds_to_tour.rb | 5 ++ db/schema.rb | 7 +- lib/snippets.rb | 69 +++++++++++++++---- spec/models/map_overlay_spec.rb | 2 +- spec/models/tour_spec.rb | 59 ++++++++++++++++ 12 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 db/migrate/20220207133352_add_over_bounds_restrition_to_tour.rb create mode 100644 db/migrate/20220208172935_add_blank_map_to_tour.rb create mode 100644 db/migrate/20220210160507_set_geo_default.rb create mode 100644 db/migrate/20220211142554_add_restrict_bounds_to_tour.rb diff --git a/app/controllers/v3/tours_controller.rb b/app/controllers/v3/tours_controller.rb index cba8d778..12abeec4 100644 --- a/app/controllers/v3/tours_controller.rb +++ b/app/controllers/v3/tours_controller.rb @@ -79,7 +79,8 @@ def tour_params :mode, :meta_description, :stops, :media, :users, :flat_pages, :map_type, :theme, :use_directions, :default_lng, - :link_address, :link_text + :link_address, :link_text, :restrict_bounds, + :restrict_bounds_to_overlay, :blank_map ] ) end diff --git a/app/models/tour.rb b/app/models/tour.rb index bd5476a1..daba8b59 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -42,6 +42,7 @@ class Tour < ApplicationRecord before_validation :update_saved_stop_order before_save :calculate_duration before_save :check_url + before_save :check_for_overlay after_save :ensure_slug after_create :add_modes @@ -96,17 +97,33 @@ def stop_count end def bounds - return nil if stops.empty? + if self.restrict_bounds_to_overlay && self.map_overlay.present? + box = RGeo::Cartesian::BoundingBox.create_from_points( + RGeo::Geographic.spherical_factory.point(self.map_overlay.east.to_f, self.map_overlay.south.to_f), + RGeo::Geographic.spherical_factory.point(self.map_overlay.west.to_f, self.map_overlay.north.to_f) + ) + + return { + south: box.min_y - (box.y_span / 8), + north: box.max_y + (box.y_span / 8), + east: box.max_x + (box.x_span / 8), + west: box.min_x - (box.x_span / 8), + centerLat: box.center_y, + centerLng: box.center_x + } + elsif stops.empty? + return nil + end points = stops.map { |stop| RGeo::Geographic.spherical_factory.point(stop.lng, stop.lat) } box = RGeo::Cartesian::BoundingBox.create_from_points(points.pop, points.pop) points.each { |point| box.add(point) } { - south: box.min_y, - north: box.max_y, - east: box.max_x, - west: box.min_x, + south: box.min_y - (box.y_span / 8), + north: box.max_y + (box.y_span / 8), + east: box.max_x + (box.x_span / 8), + west: box.min_x - (box.x_span / 8), centerLat: box.center_y, centerLng: box.center_x } @@ -154,4 +171,19 @@ def check_url def update_saved_stop_order self.saved_stop_order = self.tour_stops.order(:position).map(&:stop_id) end + + def check_for_overlay + if self.restrict_bounds_to_overlay && self.map_overlay.nil? + self.restrict_bounds_to_overlay = false + # self.restrict_bounds = false + end + + if !self.restrict_bounds_to_overlay_was && self.restrict_bounds_to_overlay && self.map_overlay.present? + self.restrict_bounds = false + end + + if self.restrict_bounds && !self.restrict_bounds_was + self.restrict_bounds_to_overlay = false + end + end end diff --git a/app/serializers/v3/tour_base_serializer.rb b/app/serializers/v3/tour_base_serializer.rb index 819a58d4..13adde0d 100644 --- a/app/serializers/v3/tour_base_serializer.rb +++ b/app/serializers/v3/tour_base_serializer.rb @@ -26,7 +26,10 @@ class TourBaseSerializer < ActiveModel::Serializer :stop_count, :est_time, :link_address, - :link_text + :link_text, + :restrict_bounds, + :restrict_bounds_to_overlay, + :blank_map def est_time return nil if object.duration.nil? diff --git a/db/migrate/20210610183825_add_title_to_map_icons.rb b/db/migrate/20210610183825_add_title_to_map_icons.rb index 51968044..62ada50f 100644 --- a/db/migrate/20210610183825_add_title_to_map_icons.rb +++ b/db/migrate/20210610183825_add_title_to_map_icons.rb @@ -1,6 +1,6 @@ class AddTitleToMapIcons < ActiveRecord::Migration[6.0] def change - add_column :map_icons, :title, :string + add_column :map_icon, :title, :string add_reference :stops, :map_icons, null: true, foreign_key: true end end diff --git a/db/migrate/20220207133352_add_over_bounds_restrition_to_tour.rb b/db/migrate/20220207133352_add_over_bounds_restrition_to_tour.rb new file mode 100644 index 00000000..3c5d38dc --- /dev/null +++ b/db/migrate/20220207133352_add_over_bounds_restrition_to_tour.rb @@ -0,0 +1,5 @@ +class AddOverBoundsRestritionToTour < ActiveRecord::Migration[6.1] + def change + add_column :tours, :restrict_bounds_to_overlay, :boolean, default: false + end +end diff --git a/db/migrate/20220208172935_add_blank_map_to_tour.rb b/db/migrate/20220208172935_add_blank_map_to_tour.rb new file mode 100644 index 00000000..a3932223 --- /dev/null +++ b/db/migrate/20220208172935_add_blank_map_to_tour.rb @@ -0,0 +1,5 @@ +class AddBlankMapToTour < ActiveRecord::Migration[6.1] + def change + add_column :tours, :blank_map, :boolean, default: false + end +end diff --git a/db/migrate/20220210160507_set_geo_default.rb b/db/migrate/20220210160507_set_geo_default.rb new file mode 100644 index 00000000..6b30c1ed --- /dev/null +++ b/db/migrate/20220210160507_set_geo_default.rb @@ -0,0 +1,5 @@ +class SetGeoDefault < ActiveRecord::Migration[6.1] + def change + change_column :tours, :is_geo, :boolean, default: true + end +end diff --git a/db/migrate/20220211142554_add_restrict_bounds_to_tour.rb b/db/migrate/20220211142554_add_restrict_bounds_to_tour.rb new file mode 100644 index 00000000..2e6eecb9 --- /dev/null +++ b/db/migrate/20220211142554_add_restrict_bounds_to_tour.rb @@ -0,0 +1,5 @@ +class AddRestrictBoundsToTour < ActiveRecord::Migration[6.1] + def change + add_column :tours, :restrict_bounds, :boolean, default: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 935988e9..60d7c63a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_10_12_142213) do +ActiveRecord::Schema.define(version: 2022_02_11_142554) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -298,7 +298,7 @@ t.text "description" t.text "article_link" t.text "google_analytics" - t.boolean "is_geo" + t.boolean "is_geo", default: true t.boolean "published" t.bigint "theme_id" t.datetime "created_at", null: false @@ -315,6 +315,9 @@ t.string "link_text" t.integer "duration" t.integer "saved_stop_order", array: true + t.boolean "restrict_bounds_to_overlay", default: false + t.boolean "blank_map", default: false + t.boolean "restrict_bounds", default: true t.index ["medium_id"], name: "index_tours_on_medium_id" t.index ["mode_id"], name: "index_tours_on_mode_id" t.index ["theme_id"], name: "index_tours_on_theme_id" diff --git a/lib/snippets.rb b/lib/snippets.rb index 263aecf1..b820ec27 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -5,7 +5,7 @@ reload! ids = Medium.all.map(&:id) ids.each do |id| - Apartment::Tenant.switch! ts + # Apartment::Tenant.switch! ts m = Medium.find(id) next if m.video.nil? case m.provider @@ -61,26 +61,67 @@ end end +# sites = TourSet.all.map(&:subdir) +# sites.each do |ts| +# Apartment::Tenant.switch! ts +# reload! +# ids = Medium.all.map(&:id) +# ids.each do |id| +# m = Medium.find(id) +# next if m.file.attached? +# if m.original_image.path && File.exist?(m.original_image.path) +# m.file.attach( +# io: File.open(m.original_image.path), +# filename: m.original_image.path.split('/').last, +# content_type: m.original_image.content_type +# ) +# end +# end +# end + +require 'open-uri' sites = TourSet.all.map(&:subdir) sites.each do |ts| Apartment::Tenant.switch! ts reload! - ids = Medium.all.map(&:id) - ids.each do |id| - m = Medium.find(id) - next if m.file.attached? - if m.original_image.path && File.exist?(m.original_image.path) - m.file.attach( - io: File.open(m.original_image.path), - filename: m.original_image.path.split('/').last, - content_type: m.original_image.content_type - ) - end - end +ids = Medium.all.map(&:id) +ids.each do |id| + m = Medium.find(id) + next if m.file.attached? + # if m.original_image.path && File.exist?(m.original_image.path) + m.file.attach( + io: URI.open("https://api.opentour.emory.edu#{m.original_image.url}"), + filename: m.original_image.path.split('/').last, + content_type: m.original_image.content_type + ) + # end +end end -sites = TourSet.all.map(&:subdir) +media = [{"id":1,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315528664.png"},{"id":2,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/315299464.jpg"},{"id":4,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315585158.jpeg"},{"id":11,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365689809.jpeg"},{"id":8,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353452580.png"},{"id":9,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353452967.png"},{"id":15,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365704670.jpeg"},{"id":19,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365748166.jpeg"},{"id":16,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365706674.jpeg"},{"id":17,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365708608.jpeg"},{"id":3,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315583885.jpeg"},{"id":5,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315587373.png"},{"id":6,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16341496140.jpeg"},{"id":14,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365694188.png"},{"id":10,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353454287.jpeg"},{"id":7,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16341498295.png"},{"id":18,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365709114.jpeg"},{"id":12,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365693051.jpeg"},{"id":13,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365693188.jpeg"},{"id":22,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/671271790.jpg"},{"id":21,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/671271790.jpg"},{"id":23,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16442803933.png"},{"id":20,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365785848.jpeg"}] +ids = media.map {|m| m[:id]} +ids = Medium.all.map(&:id) +ids.each do |id| + Apartment::Tenant.switch! 'middle-passage-markers' + m = Medium.find(id) + m.file.purge + # next if m.file.attached? + # if m.original_image.path && File.exist?(m.original_image.path) + puts "https://api.opentour.emory.edu#{m.original_image.url}" + m.file.attach( + io: URI.open("https://api.opentour.emory.edu#{m.original_image.url}"), + filename: m.original_image.url.split('/').last, + content_type: m.original_image.content_type + ) + puts m.original_image.url.split('/').last + m.filename = m.original_image.url.split('/').last + m.save! + puts m.file.attached? + # end +end + +sites = TourSet.all.map(&:subdir) sites.each do |ts| Apartment::Tenant.switch! ts reload! diff --git a/spec/models/map_overlay_spec.rb b/spec/models/map_overlay_spec.rb index d025a48d..bc869cd2 100644 --- a/spec/models/map_overlay_spec.rb +++ b/spec/models/map_overlay_spec.rb @@ -9,6 +9,6 @@ it 'has values for south, east, north, and west based on tour stops' do tour = create(:tour, stops: create_list(:stop, 3)) mo = create(:map_overlay, tour: tour) - # expect([mo.south, mo.east, mo.north, mo.west]).to all(be_a BigDecimal) + expect([mo.south, mo.east, mo.north, mo.west]).to all(be_a String) end end diff --git a/spec/models/tour_spec.rb b/spec/models/tour_spec.rb index 68862c5a..590687d0 100644 --- a/spec/models/tour_spec.rb +++ b/spec/models/tour_spec.rb @@ -100,4 +100,63 @@ expect(tour.saved_change_to_attribute?(:saved_stop_order)).to be false expect(tour.duration).to eq(7336) end + + it 'when restricted to overlay bounds, tour bounds mirror overlay' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), published: false) + mo = create(:map_overlay, tour: tour) + mo.update( + south: '33.73324867399921', + north: '33.81498938289962', + east: '-84.25453244903566', + west: '-84.37135369046021' + ) + tour.update(restrict_bounds_to_overlay: true) + expect(tour.bounds[:south]).to eq(33.723031085386665) + end + + it 'has no bounds when no stops' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), published: false) + expect(tour.bounds).to be nil + end + + it 'does not restrict bounds to overlay when no overlay' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), restrict_bounds_to_overlay: true) + expect(tour.restrict_bounds_to_overlay).to be false + end + + it 'sets restrict_bounds to false when restricted to overlay bounds' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), restrict_bounds_to_overlay: true) + mo = create(:map_overlay, tour: tour) + expect(tour.restrict_bounds).to be true + tour.update(restrict_bounds_to_overlay: true) + expect(tour.restrict_bounds).to be false + expect(tour.restrict_bounds_to_overlay).to be true + end + + it 'sets restrict_to_overlay_bounds when updated to restrict_bounds' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5), restrict_bounds_to_overlay: true) + mo = create(:map_overlay, tour: tour) + expect(tour.restrict_bounds).to be true + tour.update(restrict_bounds_to_overlay: true) + expect(tour.restrict_bounds).to be false + expect(tour.restrict_bounds_to_overlay).to be true + tour.update(restrict_bounds: true) + expect(tour.restrict_bounds).to be true + expect(tour.restrict_bounds_to_overlay).to be false + end + + it 'allows both restrictions to be false' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5)) + mo = create(:map_overlay, tour: tour) + expect(tour.restrict_bounds).to be true + tour.update(restrict_bounds: false) + expect(tour.restrict_bounds).to be false + expect(tour.restrict_bounds_to_overlay).to be false + end + + it 'will not allow restriction to overlay if no overlay' do + tour = create(:tour, mode: Mode.find_by(title: 'BICYCLING'), stops: create_list(:stop, 5)) + tour.update(restrict_bounds_to_overlay: true) + expect(tour.restrict_bounds_to_overlay).to be false + end end From 1ecbd5ec36c582d3f9a054fdba7c2fc696908877 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 16 Feb 2022 12:06:07 -0500 Subject: [PATCH 114/160] Remove puts statement in spec --- spec/controllers/v3/tour_sets_controller_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index af393534..d0b12c83 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -83,7 +83,6 @@ create(:tour, published: true, stops: create_list(:stop, 2)) Apartment::Tenant.reset - puts TourSet.all.reject { |tour_set| tour_set.published_tours.empty? }.count signed_cookie(user) get :index, params: { tenant: 'public' } expect(response.status).to eq(200) From 420f752c4cf4c925257eb4fb5b7f00ab2397dfeb Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 2 Mar 2022 12:32:56 -0500 Subject: [PATCH 115/160] Update production env --- config/environments/production.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 167ec9f2..98b48202 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true Rails.application.configure do - config.hosts << 'api.opentour.emory.edu' - Rails.application.routes.default_url_options[:host] = 'https://api.opentour.emory.edu' + config.hosts << 'api.opentour.site' + Rails.application.routes.default_url_options[:host] = 'https://api.opentour.site' # Settings specified here will take precedence over those in config/application.rb. # Store uploaded files on the local file system in a temporary directory. @@ -87,7 +87,7 @@ # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false - ENV['BASE_URL'] = 'https://api.opentour.emory.edu' + ENV['BASE_URL'] = 'https://api.opentour.site' ENV['INSECURE_IMAGE_BASE_URL'] = 'http://otbimages.ecdsdev.org' end From f84bfddfd6311ff4733d6d3da3ed38c7a7bc1134 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 10 Mar 2022 10:27:21 -0500 Subject: [PATCH 116/160] Fix unauthenticated TourSet request by subdir --- app/controllers/v3/tour_sets_controller.rb | 6 ++ .../20210610183825_add_title_to_map_icons.rb | 4 +- lib/snippets.rb | 65 +++++++++++++------ .../v3/tour_sets_controller_spec.rb | 41 +++++++++++- 4 files changed, 92 insertions(+), 24 deletions(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 7940467f..702e1578 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -9,6 +9,12 @@ def index @records = [] if params[:subdir] && params[:subdir] != 'public' @records = TourSet.where(subdir: params[:subdir]) + if !@records.first.published_tours.empty? || current_user&.tour_sets.include?(@records.first) || current_user&.super + render json: @records + else + render json: TourSet.none + end + return elsif current_user&.tour_sets.present? && !current_user.super @records = published.concat(current_user.tour_sets).uniq else diff --git a/db/migrate/20210610183825_add_title_to_map_icons.rb b/db/migrate/20210610183825_add_title_to_map_icons.rb index 62ada50f..6aac4c6d 100644 --- a/db/migrate/20210610183825_add_title_to_map_icons.rb +++ b/db/migrate/20210610183825_add_title_to_map_icons.rb @@ -1,6 +1,6 @@ class AddTitleToMapIcons < ActiveRecord::Migration[6.0] def change - add_column :map_icon, :title, :string - add_reference :stops, :map_icons, null: true, foreign_key: true + add_column :map_icons, :title, :string + add_reference :stops, :map_icon, null: true, foreign_key: true end end diff --git a/lib/snippets.rb b/lib/snippets.rb index b820ec27..2e728bd6 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -10,14 +10,9 @@ next if m.video.nil? case m.provider when 'youtube' - m.embed = "//www.youtube.com/embed/#{m.video}" - puts m.embed - m.video_provider = 'youtube' - m.save + m.update(embed: "//www.youtube.com/embed/#{m.video}", provider: 'youtube') when 'vimeo' - m.embed = "//player.vimeo.com/video/#{m.video}" - m.video_provider = 'vimeo' - m.save + m.update(embed: "//player.vimeo.com/video/#{m.video}", video_provider: 'vimeo') end end end @@ -79,30 +74,37 @@ # end # end +# IMPORTANT!!!!!! comment out the hooks on the medium model require 'open-uri' sites = TourSet.all.map(&:subdir) sites.each do |ts| Apartment::Tenant.switch! ts - reload! -ids = Medium.all.map(&:id) -ids.each do |id| - m = Medium.find(id) - next if m.file.attached? - # if m.original_image.path && File.exist?(m.original_image.path) - m.file.attach( - io: URI.open("https://api.opentour.emory.edu#{m.original_image.url}"), - filename: m.original_image.path.split('/').last, - content_type: m.original_image.content_type - ) - # end -end + ids = Medium.all.map(&:id) + ids.each do |id| + m = Medium.find(id) + next if m.original_image.nil? + next if m.file.attached? + begin + puts "https://api.opentour.emory.edu/uploads/#{ts}/#{m.original_image}" + m.file.purge + m.file.attach( + io: URI.open("https://api.opentour.emory.edu/uploads/#{ts}/#{m.original_image}"), + filename: m.original_image + ) + m.filename = m.original_image + m.save! + puts "https://api.opentour.emory.edu/#{ts}/#{m.original_image}" + rescue OpenURI::HTTPError, URI::InvalidURIError + puts 'dang' + end + end end media = [{"id":1,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315528664.png"},{"id":2,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/315299464.jpg"},{"id":4,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315585158.jpeg"},{"id":11,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365689809.jpeg"},{"id":8,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353452580.png"},{"id":9,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353452967.png"},{"id":15,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365704670.jpeg"},{"id":19,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365748166.jpeg"},{"id":16,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365706674.jpeg"},{"id":17,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365708608.jpeg"},{"id":3,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315583885.jpeg"},{"id":5,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16315587373.png"},{"id":6,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16341496140.jpeg"},{"id":14,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365694188.png"},{"id":10,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16353454287.jpeg"},{"id":7,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16341498295.png"},{"id":18,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365709114.jpeg"},{"id":12,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365693051.jpeg"},{"id":13,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365693188.jpeg"},{"id":22,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/671271790.jpg"},{"id":21,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/671271790.jpg"},{"id":23,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16442803933.png"},{"id":20,"url":"https://api.opentour.emory.edu/uploads/middle-passage-markers/16365785848.jpeg"}] ids = media.map {|m| m[:id]} ids = Medium.all.map(&:id) ids.each do |id| - Apartment::Tenant.switch! 'middle-passage-markers' + Apartment::Tenant.switch! 'battle-of-atlanta' m = Medium.find(id) m.file.purge # next if m.file.attached? @@ -241,4 +243,25 @@ Medium.all.each do |m| next unless m.desktop_width.nil? m.save +end + +require 'open-uri' +sites = TourSet.all.map(&:subdir) +sites.each do |ts| + Apartment::Tenant.switch! ts + Medium.all.each do |m| + m.save + m.files.keys.each { |k| URI.open(m.files[k]) } + end +end + +sites = TourSet.all.map(&:subdir) +sites.each do |ts| + Apartment::Tenant.switch! ts + Tour.all.each { |t| t.update(is_geo: true) } +end + +sites.each do |ts| + Apartment::Tenant.switch! ts + Tour.all.each { |t| t.update(restrict_bounds: false) } end \ No newline at end of file diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index d0b12c83..e1fda2b5 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -50,7 +50,21 @@ expect(json.count).to eq(0) end - it 'returns a success response by and TourSet object by subdir when tour set has a published tour and not authorized' do + it 'returns a success response and only TourSet object by subdir when tour set has a published tour and not authorized' do + TourSet.all.each do |ts| + Apartment::Tenant.switch! ts.subdir + tour = create(:tour, published: true) + tour.stops << create(:stop) + end + Apartment::Tenant.reset + expect(TourSet.all.reject { |ts| ts.published_tours.empty? }.count).to be > 1 + get :index, params: { tenant: 'public', subdir: TourSet.second.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(1) + expect(attributes.first[:name]).to eq(TourSet.second.name) + end + + it 'returns a success response and only one TourSet object by subdir when tour set has a published tour and not authorized' do Apartment::Tenant.switch! TourSet.second.subdir tour = create(:tour, published: true) tour.stops << create(:stop) @@ -97,6 +111,31 @@ expect(response.status).to eq(200) expect(json.count).to eq(0) end + + it 'returns one unpublished TourSet object when requested by subdir and by super' do + user = create(:user, super: true) + signed_cookie(user) + Apartment::Tenant.switch! TourSet.second.subdir + Tour.all.each { |t| t.update(published: false) } + Apartment::Tenant.reset + get :index, params: { tenant: 'public', subdir: TourSet.second.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(1) + expect(attributes.first[:name]).to eq(TourSet.second.name) + end + + it 'returns one unpublished TourSet object when requested by subdir and by super' do + user = create(:user, super: false) + user.tour_sets << TourSet.last + signed_cookie(user) + Apartment::Tenant.switch! TourSet.last.subdir + Tour.all.each { |t| t.update(published: false) } + Apartment::Tenant.reset + get :index, params: { tenant: 'public', subdir: TourSet.last.subdir } + expect(response.status).to eq(200) + expect(json.count).to eq(1) + expect(attributes.first[:name]).to eq(TourSet.last.name) + end end describe 'GET #show' do From 5f2df823bdef1ac52494ea1c31f967238ceb1a5e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 14 Mar 2022 12:14:44 -0400 Subject: [PATCH 117/160] Paginate media responses --- Gemfile | 3 ++ Gemfile.lock | 13 +++++++++ app/controllers/v3/media_controller.rb | 29 ++++++++++++++++++++ config/initializers/kaminari_config.rb | 14 ++++++++++ config/initializers/pagy.rb | 3 ++ spec/controllers/v3/media_controller_spec.rb | 9 ++++++ spec/support/request_spec_helper.rb | 1 + 7 files changed, 72 insertions(+) create mode 100644 config/initializers/kaminari_config.rb create mode 100644 config/initializers/pagy.rb diff --git a/Gemfile b/Gemfile index 0ac366b4..9c752fa2 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,9 @@ gem 'mysql2' gem 'ros-apartment', require: 'apartment' # For JSONAPI responses gem 'active_model_serializers', '~> 0.10.12' +# For pagination +gem 'kaminari' +# gem 'pagy', '~> 5.10' # Use Puma as the app server gem 'puma', '~> 4.3.0' # Use Redis adapter to run Action Cable in production diff --git a/Gemfile.lock b/Gemfile.lock index cc9dcc96..18622160 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -206,6 +206,18 @@ GEM json (2.5.1) jsonapi-renderer (0.2.2) jwt (2.3.0) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -385,6 +397,7 @@ DEPENDENCIES ferrum image_processing (~> 1.2) ipinfo-rails + kaminari listen mini_magick mysql2 diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb index 24e76a8a..a6b09925 100644 --- a/app/controllers/v3/media_controller.rb +++ b/app/controllers/v3/media_controller.rb @@ -3,6 +3,7 @@ # app/controllers/v3/media_controller.rb module V3 class MediaController < V3Controller + # include Pagy::Backend before_action :set_record, only: [:show, :update, :destroy, :file] # GET /media @@ -14,6 +15,12 @@ def index else Medium.all.map { |medium| medium if medium.published }.compact end + # pagy, @media = pagy(@media) + # pagy_headers_merge(pagy) + if params[:page].present? + @media = @media.page params[:page] + self.set_pagination_header + end render json: @media end @@ -55,5 +62,27 @@ def record_params ] ) end + + private + + def set_pagination_header(name=:media, options = {}) + scope = instance_variable_get("@#{name}") + request_params = request.query_parameters + url_without_params = request.original_url.slice(0..(request.original_url.index("?")-1)) unless request_params.empty? + url_without_params ||= request.original_url + + page = {} + page[:first] = 1 if scope.total_pages > 1 && !scope.first_page? + page[:last] = scope.total_pages if scope.total_pages > 1 && !scope.last_page? + page[:next] = scope.current_page + 1 unless scope.last_page? + page[:prev] = scope.current_page - 1 unless scope.first_page? + + pagination_links = [] + page.each do |k, v| + new_request_hash= request_params.merge({:page => v}) + pagination_links << "<#{url_without_params}?#{new_request_hash.to_param}>; rel=\"#{k}\"" + end + headers["Link"] = pagination_links.join(", ") + end end end diff --git a/config/initializers/kaminari_config.rb b/config/initializers/kaminari_config.rb new file mode 100644 index 00000000..4ba6ee3e --- /dev/null +++ b/config/initializers/kaminari_config.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Kaminari.configure do |config| + # config.default_per_page = 25 + # config.max_per_page = nil + # config.window = 4 + # config.outer_window = 0 + # config.left = 0 + # config.right = 0 + # config.page_method_name = :page + # config.param_name = :page + # config.max_pages = nil + # config.params_on_first_page = false +end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 00000000..6149760f --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,3 @@ +# require 'pagy/extras/headers' +# Pagy::DEFAULT[:items] = 2 +# Pagy::DEFAULT.freeze diff --git a/spec/controllers/v3/media_controller_spec.rb b/spec/controllers/v3/media_controller_spec.rb index 160b8852..0e2116af 100644 --- a/spec/controllers/v3/media_controller_spec.rb +++ b/spec/controllers/v3/media_controller_spec.rb @@ -54,6 +54,15 @@ expect(json.count).to eq(Medium.count) expect(Medium.count).to be > Tour.published.map { |t| t.media.count }.sum end + + it 'returns a paginated list when page parameter is persent' do + user = create(:user, super: true) + signed_cookie(user) + published_tour = create(:tour, published: true) + create_list(:medium, 35).each { |m| published_tour.media << m } + get :index, params: { tenant: Apartment::Tenant.current, page: '2' } + expect(json.count).to eq(10) + end end describe 'GET #show' do diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb index 5f587f24..09d06be0 100644 --- a/spec/support/request_spec_helper.rb +++ b/spec/support/request_spec_helper.rb @@ -4,6 +4,7 @@ module RequestSpecHelper # Parse JSON response to ruby hash def json + puts response.headers JSON.parse(response.body).with_indifferent_access[:data] end From 0269dd966a9bdba5c3cb9598d9331767ee03dcd3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:39:52 -0400 Subject: [PATCH 118/160] Enforce unique tour title --- app/models/tour.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index daba8b59..76a16aef 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -34,7 +34,7 @@ class Tour < ApplicationRecord "ru-RU": 10, "pt-BR": 11, "es-MX": 12, "zh-CN": 13, "zh-TW": 14, "ja-JP": 15, "ko-KR": 16 } - validates :title, presence: true + validates :title, presence: true, uniqueness: { case_sensitive: false } before_validation -> { self.mode ||= Mode.last } before_validation -> { self.theme ||= Theme.first } From 8360c1a8ed665e56e0d76059fbf337a9190c141e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:41:16 -0400 Subject: [PATCH 119/160] Clean up debug statements --- app/models/map_icon.rb | 2 -- app/models/user.rb | 1 - spec/support/request_spec_helper.rb | 1 - 3 files changed, 4 deletions(-) diff --git a/app/models/map_icon.rb b/app/models/map_icon.rb index f8268b68..e5376c03 100644 --- a/app/models/map_icon.rb +++ b/app/models/map_icon.rb @@ -9,9 +9,7 @@ def published def check_dimensions return if base_sixty_four.nil? - # puts base_sixty_four - # headers, tmp_base_sixty_four = base_sixty_four.split(',') file = MiniMagick::Image.read(Base64.decode64(base_sixty_four)) if file[:height] > 80 || file[:width] > 80 diff --git a/app/models/user.rb b/app/models/user.rb index 849c816b..1ce93c2a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,7 +33,6 @@ def all_tours next if tours.empty? || current_tenant_admin? Apartment::Tenant.switch! tour_set.subdir _tours = TourAuthor.where(user: self) - # puts tours.ma all.push(_tours.map { |ta| { id: ta.tour.id, tenant: ta.tour.tenant, title: ta.tour.title } }) end Apartment::Tenant.reset diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb index 09d06be0..5f587f24 100644 --- a/spec/support/request_spec_helper.rb +++ b/spec/support/request_spec_helper.rb @@ -4,7 +4,6 @@ module RequestSpecHelper # Parse JSON response to ruby hash def json - puts response.headers JSON.parse(response.body).with_indifferent_access[:data] end From 0608921f2fa5e037eac1c25a1c0dc5a60f56ed10 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:50:22 -0400 Subject: [PATCH 120/160] Prevent caching of some responses --- app/controllers/application_controller.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8a14772f..9a16d01c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,4 +8,24 @@ class ApplicationController < ActionController::API if Rails.env == 'test' include ActiveStorage::SetCurrent end + + before_action :set_no_cache_control, only: [:index, :show] + + def set_no_cache_control + # Prevent the client from caching GET responses. + # If you, for example, look at a tour at https://battle-of-atlanta.opentour.site + # and your browser caches the API responses. then you go edit that tour + # at https://opentour.site/admin/battle-or-atlanta, your browser will use some of + # those previously cached responses. the problem is, those cached responses have a response header + # + # access-control-allow-origin: https://battle-of-atlanta.opentour.site + # + # But now your origin is https://opentour.site and the browser blocks the response + # and throws a cross origin error + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '-1' + expires_now() + stale?(SecureRandom.hex(10)) + end end From 49ffddc883178b716c8e1be5e371c171a71a9249 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:50:47 -0400 Subject: [PATCH 121/160] Update prod deploy --- config/deploy/production.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/deploy/production.rb b/config/deploy/production.rb index d3b5da21..84df0074 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,6 +1,9 @@ set :branch, 'develop' -server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value +# server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value +role :app, %w{34.239.167.5 54.174.249.237}, user: 'deploy' +role :web, %w{34.239.167.5 54.174.249.237}, user: 'deploy' +role :db, %w{34.239.167.5 54.174.249.237}, user: 'deploy' set :deploy_to, '/data/otb-api-server' From e98116618c65191a2f512fedcd7fe3dac938edaa Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:51:21 -0400 Subject: [PATCH 122/160] Fix for SoundCloud tests --- spec/rails_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b529fa18..b103ff3b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -177,7 +177,7 @@ headers: {} ) - stub_request(:get, 'https://i1.sndcdn.com/artworks-KsTDkyGJ8S6x-0-t500x500.jpg') + stub_request(:get, /https:\/\/i1\.sndcdn.com\/artworks-.*\.jpg/) .to_return( body: File.open(Rails.root + 'spec/factories/images/0.jpg'), status: 200, From 0f9503eff77e0e94a1dba3975e8e294520e1f335 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:52:44 -0400 Subject: [PATCH 123/160] Fix for anonymous requests --- app/controllers/v3/tour_sets_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 702e1578..78e39199 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -9,7 +9,7 @@ def index @records = [] if params[:subdir] && params[:subdir] != 'public' @records = TourSet.where(subdir: params[:subdir]) - if !@records.first.published_tours.empty? || current_user&.tour_sets.include?(@records.first) || current_user&.super + if !@records.first&.published_tours&.empty? || current_user&.tour_sets.include?(@records.first) || current_user&.super render json: @records else render json: TourSet.none From 7d159dfd8c2e20c5d6d4ac3837e90f93f141723e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:53:14 -0400 Subject: [PATCH 124/160] Spec for tour title uniqueness --- spec/controllers/v3/tours_controller_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/controllers/v3/tours_controller_spec.rb b/spec/controllers/v3/tours_controller_spec.rb index a414bee1..635f7cca 100644 --- a/spec/controllers/v3/tours_controller_spec.rb +++ b/spec/controllers/v3/tours_controller_spec.rb @@ -183,7 +183,9 @@ expect(response.status).to eq(201) expect(Tour.count).to eq(original_tour_count + 1) end + end + context 'with invalid params' do it 'returns 422 when invalid attributes' do user = create(:user, super: true) signed_cookie(user) @@ -193,6 +195,16 @@ expect(Tour.count).to eq(original_tour_count) expect(errors).to include('Title can\'t be blank') end + + it 'returns 422 when title already used' do + user = create(:user, super: true) + signed_cookie(user) + title = Faker::Movies::HitchhikersGuideToTheGalaxy.location + create(:tour, title: title) + post :create, params: { data: { type: 'tours', attributes: { title: title } }, tenant: Apartment::Tenant.current } + expect(response.status).to eq(422) + expect(Tour.where(title: title).count). to eq(1) + end end end From c735b2c6a3df1a1c86750d67652dec79a0fdff0e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 11:53:37 -0400 Subject: [PATCH 125/160] Some helpful snippets --- lib/snippets.rb | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/snippets.rb b/lib/snippets.rb index 2e728bd6..4cef4160 100644 --- a/lib/snippets.rb +++ b/lib/snippets.rb @@ -17,14 +17,20 @@ end end -media = Medium.all.map(&:id) +TourSet.all.each do |ts| + puts ts.subdir + Apartment::Tenant.switch! ts.subdir + Medium.all.each do |medium| + if !medium.file.attached? + # medium.delete + puts "#{medium.id} stops: #{medium.stops.count} tours: #{medium.tours.count}" + end + end +end -media.each do |m| - puts m - Apartment::Tenant.switch! 'july-22nd' - medium = Medium.find(m) - if !medium.file.attached? - medium.delete +Medium.all.each do |m| + if !m.file.attached? and (!m.tours.empty? or !m.stops.empty?) + print m.id end end From 8398c2069e26631ce46e4f7915b370c6d0576e7c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 9 May 2022 12:31:44 -0400 Subject: [PATCH 126/160] Chunk duration requests to stay below the limit --- app/models/tour.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index 76a16aef..ca5080c2 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -140,12 +140,18 @@ def calculate_duration return unless self.will_save_change_to_published? || self.will_save_change_to_saved_stop_order? || self.will_save_change_to_mode_id? + durations = [] destinations = tour_stops.order(:position).map { |tour_stop| [tour_stop.stop.lat, tour_stop.stop.lng] } - origin = destinations.shift - g_directions = GoogleDirections.new(origin, destinations, stops.count, mode.title) + # The direction matrix API limits the number of destinations to 25. + # Calculate the duration in chunks to stay below the limit. + destinations.each_slice(24) do |group| + origin = group.shift + g_directions = GoogleDirections.new(origin, group, group.count + 1, mode.title) + durations.push(g_directions.duration) + end - self.duration = g_directions.duration + self.duration = durations.sum.zero? ? nil : durations.sum end private From 64c59345180afb6429cec5c8b7eb170aa44d3065 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 11 May 2022 11:15:53 -0400 Subject: [PATCH 127/160] Clean up logs and update deploy config --- config/deploy/production.rb | 6 +++--- config/initializers/filter_parameter_logging.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/deploy/production.rb b/config/deploy/production.rb index 84df0074..f52fde35 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,9 +1,9 @@ set :branch, 'develop' # server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value -role :app, %w{34.239.167.5 54.174.249.237}, user: 'deploy' -role :web, %w{34.239.167.5 54.174.249.237}, user: 'deploy' -role :db, %w{34.239.167.5 54.174.249.237}, user: 'deploy' +role :app, %w{34.239.167.5 44.201.150.24}, user: 'deploy' +role :web, %w{34.239.167.5 44.201.150.24}, user: 'deploy' +role :db, %w{34.239.167.5 44.201.150.24}, user: 'deploy' set :deploy_to, '/data/otb-api-server' diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 7a4f47b4..a18206dc 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -3,4 +3,4 @@ # Be sure to restart your server when you modify this file. # Configure sensitive parameters which will be filtered from the log file. -Rails.application.config.filter_parameters += [:password] +Rails.application.config.filter_parameters += [:password, :base_sixty_four] From 979f110f46e71931d5b15d55a512bdf42fa3da66 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 11 May 2022 11:21:08 -0400 Subject: [PATCH 128/160] Properly encode StringIO objects --- app/models/concerns/video_props.rb | 17 +++++++++++++---- spec/models/medium_spec.rb | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/video_props.rb b/app/models/concerns/video_props.rb index 85483b40..619b72e6 100644 --- a/app/models/concerns/video_props.rb +++ b/app/models/concerns/video_props.rb @@ -63,12 +63,21 @@ def self.props(medium) return if downloaded_image.nil? medium.filename = "#{medium.video}.jpg" + medium.base_sixty_four = encode_image(downloaded_image) + medium.attach_file unless medium.file.attached? + end + + def self.encode_image(downloaded_image) begin - medium.base_sixty_four = Base64.encode64(downloaded_image.open.read) - downloaded_image.unlink + if downloaded_image.is_a? StringIO + base_sixty_four = Base64.encode64(downloaded_image.read) + else + base_sixty_four = Base64.encode64(downloaded_image.open.read) + downloaded_image.unlink + end rescue NoMethodError - medium.base_sixty_four = Base64.encode64(downloaded_image) + base_sixty_four = Base64.encode64(downloaded_image) end - medium.attach_file unless medium.file.attached? + base_sixty_four end end diff --git a/spec/models/medium_spec.rb b/spec/models/medium_spec.rb index b401dead..e6df5afb 100644 --- a/spec/models/medium_spec.rb +++ b/spec/models/medium_spec.rb @@ -13,6 +13,14 @@ expect(medium.file.attached?).to be true end + it 'gets image from youtube when downloaded image is a StrinIO object and sets embed' do + file = File.open(Rails.root + 'spec/factories/images/atl.png') + string_io = StringIO.new(file.read) + base64 = VideoProps.encode_image(string_io) + medium = create(:medium, base_sixty_four: base64) + expect(medium.file.attached?).to be true + end + it 'gets nothing when YouTube video is not found' do medium = create(:medium, video: 'CvmxYF9ULbm', base_sixty_four: nil, video_provider: 'youtube') expect(medium.embed).to be nil From 09117e14e031eb8bd51e08dffbf22aee1e66bfe2 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 27 Jul 2022 13:05:36 -0400 Subject: [PATCH 129/160] Update Rails to 7 --- Gemfile | 4 +- Gemfile.lock | 322 +++++++++++++++++++++++-------------------- spec/rails_helper.rb | 2 +- 3 files changed, 174 insertions(+), 154 deletions(-) diff --git a/Gemfile b/Gemfile index 9c752fa2..816b7c0d 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 6.1.0' +gem 'rails', '~> 7.0.2', '>= 7.0.2.3' gem 'rack', '>= 2.0.6' gem 'pg' gem 'mysql2' @@ -66,7 +66,7 @@ group :development do # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' - gem 'rspec-rails', '~> 4.0.2' + gem 'rspec-rails', '~> 5.1.2' # Use Capistrano for deployment gem 'capistrano-rails' gem 'capistrano-rbenv', '~> 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 18622160..7bde112e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/ecds/ecds_rails_auth_engine.git - revision: 4a55d7e9ddb38e7c6422bc001874a30abd0162bd + revision: fe56d7d214403689a0ab0ab79cbc9d343fdc63f6 branch: feature/fauxoauth specs: ecds_rails_auth_engine (0.2.0) @@ -11,11 +11,11 @@ GIT GIT remote: https://github.com/stympy/faker.git - revision: 148219533bac493259a6b734f45ac9310ac4e853 + revision: b98d32dcca8aa482525320d8463dff741af0d84d branch: master specs: - faker (2.19.0) - i18n (>= 1.6, < 2) + faker (2.21.0) + i18n (>= 1.8.11, < 2) GEM remote: https://rubygems.org/ @@ -24,102 +24,108 @@ GEM faraday (~> 1.0) json (~> 2.1) lru_redux (~> 1.1) - actioncable (6.1.4.1) - actionpack (= 6.1.4.1) - activesupport (= 6.1.4.1) + actioncable (7.0.3.1) + actionpack (= 7.0.3.1) + activesupport (= 7.0.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.4.1) - actionpack (= 6.1.4.1) - activejob (= 6.1.4.1) - activerecord (= 6.1.4.1) - activestorage (= 6.1.4.1) - activesupport (= 6.1.4.1) + actionmailbox (7.0.3.1) + actionpack (= 7.0.3.1) + activejob (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) mail (>= 2.7.1) - actionmailer (6.1.4.1) - actionpack (= 6.1.4.1) - actionview (= 6.1.4.1) - activejob (= 6.1.4.1) - activesupport (= 6.1.4.1) + net-imap + net-pop + net-smtp + actionmailer (7.0.3.1) + actionpack (= 7.0.3.1) + actionview (= 7.0.3.1) + activejob (= 7.0.3.1) + activesupport (= 7.0.3.1) mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.0) - actionpack (6.1.4.1) - actionview (= 6.1.4.1) - activesupport (= 6.1.4.1) - rack (~> 2.0, >= 2.0.9) + actionpack (7.0.3.1) + actionview (= 7.0.3.1) + activesupport (= 7.0.3.1) + rack (~> 2.0, >= 2.2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.4.1) - actionpack (= 6.1.4.1) - activerecord (= 6.1.4.1) - activestorage (= 6.1.4.1) - activesupport (= 6.1.4.1) + actiontext (7.0.3.1) + actionpack (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.4.1) - activesupport (= 6.1.4.1) + actionview (7.0.3.1) + activesupport (= 7.0.3.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.12) - actionpack (>= 4.1, < 6.2) - activemodel (>= 4.1, < 6.2) + active_model_serializers (0.10.13) + actionpack (>= 4.1, < 7.1) + activemodel (>= 4.1, < 7.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (6.1.4.1) - activesupport (= 6.1.4.1) + activejob (7.0.3.1) + activesupport (= 7.0.3.1) globalid (>= 0.3.6) - activemodel (6.1.4.1) - activesupport (= 6.1.4.1) - activerecord (6.1.4.1) - activemodel (= 6.1.4.1) - activesupport (= 6.1.4.1) - activestorage (6.1.4.1) - actionpack (= 6.1.4.1) - activejob (= 6.1.4.1) - activerecord (= 6.1.4.1) - activesupport (= 6.1.4.1) - marcel (~> 1.0.0) + activemodel (7.0.3.1) + activesupport (= 7.0.3.1) + activerecord (7.0.3.1) + activemodel (= 7.0.3.1) + activesupport (= 7.0.3.1) + activestorage (7.0.3.1) + actionpack (= 7.0.3.1) + activejob (= 7.0.3.1) + activerecord (= 7.0.3.1) + activesupport (= 7.0.3.1) + marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.4.1) + activesupport (7.0.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) - airbrussh (1.4.0) + airbrussh (1.4.1) sshkit (>= 1.6.1, != 1.7.0) aws-eventstream (1.2.0) - aws-partitions (1.502.0) - aws-sdk-core (3.121.0) + aws-partitions (1.610.0) + aws-sdk-core (3.131.3) aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.239.0) + aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.48.0) - aws-sdk-core (~> 3, >= 3.120.0) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.58.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.103.0) - aws-sdk-core (~> 3, >= 3.120.0) + aws-sdk-s3 (1.114.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) - aws-sigv4 (1.4.0) + aws-sigv4 (1.5.1) aws-eventstream (~> 1, >= 1.0.2) builder (3.2.4) - cancancan (3.3.0) - capistrano (3.16.0) + cancancan (3.4.0) + capistrano (3.17.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) sshkit (>= 1.9.0) - capistrano-bundler (2.0.1) + capistrano-bundler (2.1.0) capistrano (~> 3.1) capistrano-passenger (0.2.1) capistrano (~> 3.0) - capistrano-rails (1.6.1) + capistrano-rails (1.6.2) capistrano (~> 3.1) capistrano-bundler (>= 1.1, < 3) capistrano-rbenv (2.2.0) @@ -133,7 +139,7 @@ GEM case_transform (0.2) activesupport cliver (0.3.2) - concurrent-ruby (1.1.9) + concurrent-ruby (1.1.10) coveralls (0.7.1) multi_json (~> 1.3) rest-client @@ -149,63 +155,68 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - diff-lcs (1.4.4) + diff-lcs (1.5.0) + digest (3.1.0) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) erubi (1.10.0) - factory_bot (6.2.0) + factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) - faraday (1.7.2) + faraday (1.10.0) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.3) ferrum (0.11) addressable (~> 2.5) cliver (~> 0.3) concurrent-ruby (~> 1.1) websocket-driver (>= 0.6, < 0.8) - ffi (1.15.4) - globalid (0.5.2) + ffi (1.15.5) + globalid (1.0.0) activesupport (>= 5.0) hashdiff (1.0.1) http-accept (1.7.0) - http-cookie (1.0.4) + http-cookie (1.0.5) domain_name (~> 0.5) - httparty (0.19.0) + httparty (0.20.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.8.10) + i18n (1.12.0) concurrent-ruby (~> 1.0) - image_processing (1.12.1) + image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - ipinfo-rails (1.0.0) + ipinfo-rails (1.0.1) IPinfo (~> 1.0.1) rack (~> 2.0) - jmespath (1.4.0) - json (2.5.1) + jmespath (1.6.1) + json (2.6.2) jsonapi-renderer (0.2.2) - jwt (2.3.0) + jwt (2.4.1) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -218,76 +229,90 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - listen (3.7.0) + listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.12.0) + loofah (2.18.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (1.0.1) + marcel (1.0.2) method_source (1.0.0) - mime-types (3.3.1) + mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.0901) + mime-types-data (3.2022.0105) mini_magick (4.11.0) - mini_mime (1.1.1) - minitest (5.14.4) + mini_mime (1.1.2) + minitest (5.16.2) multi_json (1.15.0) multi_xml (0.6.0) - multipart-post (2.1.1) - mysql2 (0.5.3) - net-scp (3.0.0) - net-ssh (>= 2.6.5, < 7.0.0) - net-ssh (6.1.0) + multipart-post (2.2.3) + mysql2 (0.5.4) + net-imap (0.2.3) + digest + net-protocol + strscan + net-pop (0.1.1) + digest + net-protocol + timeout + net-protocol (0.1.3) + timeout + net-scp (4.0.0.rc1) + net-ssh (>= 2.6.5, < 8.0.0) + net-smtp (0.3.1) + digest + net-protocol + timeout + net-ssh (7.0.1) netrc (0.11.0) nio4r (2.5.8) - nokogiri (1.12.4-x86_64-darwin) + nokogiri (1.13.8-x86_64-darwin) racc (~> 1.4) - nokogiri (1.12.4-x86_64-linux) + nokogiri (1.13.8-x86_64-linux) racc (~> 1.4) - oauth (0.5.6) - parallel (1.21.0) - pg (1.2.3) - public_suffix (4.0.6) - puma (4.3.8) + oauth (0.5.10) + parallel (1.22.1) + pg (1.4.2) + public_suffix (4.0.7) + puma (4.3.12) nio4r (~> 2.0) - racc (1.5.2) - rack (2.2.3) + racc (1.6.0) + rack (2.2.4) rack-cors (1.1.1) rack (>= 2.0.0) - rack-test (1.1.0) - rack (>= 1.0, < 3) - rails (6.1.4.1) - actioncable (= 6.1.4.1) - actionmailbox (= 6.1.4.1) - actionmailer (= 6.1.4.1) - actionpack (= 6.1.4.1) - actiontext (= 6.1.4.1) - actionview (= 6.1.4.1) - activejob (= 6.1.4.1) - activemodel (= 6.1.4.1) - activerecord (= 6.1.4.1) - activestorage (= 6.1.4.1) - activesupport (= 6.1.4.1) + rack-test (2.0.2) + rack (>= 1.3) + rails (7.0.3.1) + actioncable (= 7.0.3.1) + actionmailbox (= 7.0.3.1) + actionmailer (= 7.0.3.1) + actionpack (= 7.0.3.1) + actiontext (= 7.0.3.1) + actionview (= 7.0.3.1) + activejob (= 7.0.3.1) + activemodel (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) bundler (>= 1.15.0) - railties (= 6.1.4.1) - sprockets-rails (>= 2.0.0) + railties (= 7.0.3.1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.2) + rails-html-sanitizer (1.4.3) loofah (~> 2.3) - railties (6.1.4.1) - actionpack (= 6.1.4.1) - activesupport (= 6.1.4.1) + railties (7.0.3.1) + actionpack (= 7.0.3.1) + activesupport (= 7.0.3.1) method_source - rake (>= 0.13) + rake (>= 12.2) thor (~> 1.0) + zeitwerk (~> 2.5) rake (13.0.6) - rb-fsevent (0.11.0) + rb-fsevent (0.11.1) rb-inotify (0.10.1) ffi (~> 1.0) redis (3.3.5) @@ -297,30 +322,30 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) rexml (3.2.5) - rgeo (2.3.0) - ros-apartment (2.10.0) - activerecord (>= 5.0.0, < 6.2) + rgeo (2.4.0) + ros-apartment (2.11.0) + activerecord (>= 5.0.0, < 7.1) parallel (< 2.0) public_suffix (>= 2.0.5, < 5.0) rack (>= 1.3.6, < 3.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-rails (4.0.2) - actionpack (>= 4.2) - activesupport (>= 4.2) - railties (>= 4.2) + rspec-support (~> 3.11.0) + rspec-rails (5.1.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + railties (>= 5.2) rspec-core (~> 3.10) rspec-expectations (~> 3.10) rspec-mocks (~> 3.10) rspec-support (~> 3.10) - rspec-support (3.10.2) - ruby-vips (2.1.3) + rspec-support (3.11.0) + ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) shoulda-matchers (4.5.1) @@ -331,33 +356,28 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov-lcov (0.8.0) - simplecov_json_formatter (0.1.3) + simplecov_json_formatter (0.1.4) spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) - sprockets (4.0.2) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) - sprockets (>= 3.0.0) sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) ssrf_filter (1.0.7) + strscan (3.0.4) sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) - thor (1.1.0) - tins (1.29.1) + thor (1.2.1) + timeout (0.3.0) + tins (1.31.1) sync - tzinfo (2.0.4) + tzinfo (2.0.5) concurrent-ruby (~> 1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8) + unf_ext (0.0.8.2) vimeo (1.5.4) httparty (>= 0.4.5) httpclient (>= 2.1.5.2) @@ -374,7 +394,7 @@ GEM youtube_rails (1.2.2) yt (0.33.4) activesupport - zeitwerk (2.4.2) + zeitwerk (2.6.0) PLATFORMS x86_64-darwin-20 @@ -405,11 +425,11 @@ DEPENDENCIES puma (~> 4.3.0) rack (>= 2.0.6) rack-cors - rails (~> 6.1.0) + rails (~> 7.0.2, >= 7.0.2.3) redis (~> 3.0) rgeo ros-apartment - rspec-rails (~> 4.0.2) + rspec-rails (~> 5.1.2) shoulda-matchers (~> 4.5.1) simplecov simplecov-lcov diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b103ff3b..1a6f468f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -84,7 +84,7 @@ Apartment::Tenant.switch! TourSet.find(TourSet.pluck(:id).sample).subdir # Set the host for ActiveStorage urls - ActiveStorage::Current.host = 'http://test.host' + ActiveStorage::Current.url_options = { host: 'http://test.host' } # host! 'atlanta.lvh.me' # load Rails.root + 'db/seeds.rb' From 47e3f28bb31a5847d6a85335808d16fe8c8ef5ad Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 27 Jul 2022 13:09:55 -0400 Subject: [PATCH 130/160] Expand who can edit tour sets --- app/controllers/v3/tour_sets_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/tour_sets_controller.rb b/app/controllers/v3/tour_sets_controller.rb index 78e39199..600c7f65 100644 --- a/app/controllers/v3/tour_sets_controller.rb +++ b/app/controllers/v3/tour_sets_controller.rb @@ -85,7 +85,7 @@ def allowed? end def crud_allowed? - current_user&.super + current_user&.super || (current_user.present? && @record&.in?(current_user.tour_sets)) end def published From 4921af80b50d49036cf39ffe1a3bcd4db35ddd9b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 27 Jul 2022 13:10:14 -0400 Subject: [PATCH 131/160] Remove unused var --- app/models/medium.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/medium.rb b/app/models/medium.rb index 4b070df0..48cdca2c 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -39,7 +39,6 @@ def files return nil if !self.file.attached? if file.content_type.include?('gif') - height = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata[:height] return { lqip: file.variant(resize_to_limit: [50, 50], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, mobile: file.variant(resize_to_limit: [300, 300], coalesce: true, layers: 'Optimize', deconstruct: true, loader: { page: nil }).processed.url, From 23e1289abe84e746539364b3a076c1c7b8c4b701 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 27 Jul 2022 13:11:00 -0400 Subject: [PATCH 132/160] Don't add http when link_addredss is empty string --- app/models/tour.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/tour.rb b/app/models/tour.rb index ca5080c2..17100c3a 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + require 'uri' # Model class for a tour. @@ -151,7 +152,7 @@ def calculate_duration durations.push(g_directions.duration) end - self.duration = durations.sum.zero? ? nil : durations.sum + self.duration = durations.compact.sum.zero? ? nil : durations.sum end private @@ -167,7 +168,7 @@ def add_modes end def check_url - return if link_address.nil? + return if link_address.blank? uri = URI(link_address) From bc5823d98013bcc5f94ec79712d9a7181d7fd779 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 27 Jul 2022 13:11:41 -0400 Subject: [PATCH 133/160] Spec for expanded TourSet CRUD --- spec/controllers/v3/tour_sets_controller_spec.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/spec/controllers/v3/tour_sets_controller_spec.rb b/spec/controllers/v3/tour_sets_controller_spec.rb index e1fda2b5..5c383f5a 100644 --- a/spec/controllers/v3/tour_sets_controller_spec.rb +++ b/spec/controllers/v3/tour_sets_controller_spec.rb @@ -292,13 +292,25 @@ expect(response).to have_http_status(401) end - it 'does not update TourSet when not super but is a tenant admin' do + it 'allows update TourSet when not super but is a tenant admin' do user = create(:user, super: false) user.tour_sets << TourSet.second signed_cookie(user) put :update, params: valid_params.merge({ id: TourSet.second.to_param }) + expect(response).to have_http_status(200) + end + + it 'does not update TourSet when not super not a tenant admin but is a tour author' do + Apartment::Tenant.switch! TourSet.second.subdir + tour = create(:tour) + user = create(:user, super: false) + user.tour_sets = [] + user.tours << tour + signed_cookie(user) + put :update, params: valid_params.merge({ id: TourSet.second.to_param }) expect(response).to have_http_status(401) end + end context 'when authenticated and authorized' do From 79c63da41296fff4fb02d05b2e288abec69b7608 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 29 Jul 2022 10:25:43 -0400 Subject: [PATCH 134/160] Add terms aceptanced to user modle --- app/controllers/v3/users_controller.rb | 2 +- app/serializers/v3/user_serializer.rb | 2 +- ...0220728131300_add_term_accepted_to_user.rb | 5 + db/schema.rb | 94 +++++++++---------- spec/models/user_spec.rb | 7 ++ 5 files changed, 61 insertions(+), 49 deletions(-) create mode 100644 db/migrate/20220728131300_add_term_accepted_to_user.rb diff --git a/app/controllers/v3/users_controller.rb b/app/controllers/v3/users_controller.rb index cd54ee7f..1a7bbd3c 100644 --- a/app/controllers/v3/users_controller.rb +++ b/app/controllers/v3/users_controller.rb @@ -76,7 +76,7 @@ def user_params params, only: [ :display_name, :identification, :password, :password_confirmation, :uid, :tour_sets, - :tours, :super, :email + :tours, :super, :email, :terms_accepted ] ) end diff --git a/app/serializers/v3/user_serializer.rb b/app/serializers/v3/user_serializer.rb index df4ebc36..fec6739d 100644 --- a/app/serializers/v3/user_serializer.rb +++ b/app/serializers/v3/user_serializer.rb @@ -6,7 +6,7 @@ class UserSerializer < ActiveModel::Serializer has_many :tours has_many :tour_authors has_many :tour_sets - attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email, :all_tours + attributes :id, :display_name, :super, :current_tenant_admin, :provider, :email, :all_tours, :terms_accepted def current_tenant_admin object.current_tenant_admin? diff --git a/db/migrate/20220728131300_add_term_accepted_to_user.rb b/db/migrate/20220728131300_add_term_accepted_to_user.rb new file mode 100644 index 00000000..63e2eed6 --- /dev/null +++ b/db/migrate/20220728131300_add_term_accepted_to_user.rb @@ -0,0 +1,5 @@ +class AddTermAcceptedToUser < ActiveRecord::Migration[7.0] + def change + add_column :users, :terms_accepted, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 60d7c63a..6d71c865 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_02_11_142554) do - +ActiveRecord::Schema[7.0].define(version: 2022_07_28_131300) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -22,7 +21,7 @@ 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.datetime "created_at", precision: nil, 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 @@ -34,7 +33,7 @@ t.text "metadata" t.bigint "byte_size", null: false t.string "checksum", null: false - t.datetime "created_at", null: false + t.datetime "created_at", precision: nil, null: false t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -49,23 +48,23 @@ t.string "who" t.string "provider" t.bigint "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" end create_table "ecds_rails_auth_engine_tokens", force: :cascade do |t| t.string "token" t.bigint "login_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "flat_pages", force: :cascade do |t| t.string "title" t.text "body" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "position" end @@ -76,8 +75,8 @@ t.string "uid" t.string "single_use_oauth2_token" t.bigint "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "provider" t.string "confirm_token" t.index ["user_id"], name: "index_logins_on_user_id" @@ -85,8 +84,8 @@ create_table "map_icons", force: :cascade do |t| t.text "base_sixty_four" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "filename" end @@ -97,8 +96,8 @@ t.string "west" t.bigint "tour_id" t.bigint "stop_id" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.text "base_sixty_four" t.text "filename" t.index ["stop_id"], name: "index_map_overlays_on_stop_id" @@ -109,8 +108,8 @@ t.string "title" t.text "caption" t.string "original_image" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "video" t.string "provider" t.string "embed" @@ -131,8 +130,8 @@ create_table "modes", force: :cascade do |t| t.string "title" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "icon" end @@ -143,16 +142,16 @@ create_table "slugs", force: :cascade do |t| t.string "slug" t.bigint "tour_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["tour_id"], name: "index_slugs_on_tour_id" end create_table "stop_media", force: :cascade do |t| t.bigint "stop_id" t.bigint "medium_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "position" t.index ["medium_id"], name: "index_stop_media_on_medium_id" t.index ["stop_id"], name: "index_stop_media_on_stop_id" @@ -161,8 +160,8 @@ create_table "stop_slugs", force: :cascade do |t| t.string "slug" t.bigint "stop_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.bigint "tour_id" t.index ["stop_id"], name: "index_stop_slugs_on_stop_id" t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" @@ -181,8 +180,8 @@ t.string "parking_lng" t.text "direction_intro" t.text "direction_notes" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "address" t.bigint "medium_id" t.string "parking_address" @@ -199,7 +198,7 @@ t.string "tagger_type" t.integer "tagger_id" t.string "context", limit: 128 - t.datetime "created_at" + t.datetime "created_at", precision: nil t.index ["context"], name: "index_taggings_on_context" t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" @@ -213,8 +212,8 @@ create_table "themes", force: :cascade do |t| t.string "title" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "tour_authors", force: :cascade do |t| @@ -226,16 +225,16 @@ create_table "tour_collections", force: :cascade do |t| t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "tour_flat_pages", force: :cascade do |t| t.bigint "tour_id" t.bigint "flat_page_id" t.integer "position" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["flat_page_id"], name: "index_tour_flat_pages_on_flat_page_id" t.index ["tour_id"], name: "index_tour_flat_pages_on_tour_id" end @@ -243,8 +242,8 @@ create_table "tour_media", force: :cascade do |t| t.bigint "tour_id" t.bigint "medium_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "position" t.index ["medium_id"], name: "index_tour_media_on_medium_id" t.index ["tour_id"], name: "index_tour_media_on_tour_id" @@ -253,8 +252,8 @@ create_table "tour_modes", force: :cascade do |t| t.bigint "tour_id" t.bigint "mode_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["mode_id"], name: "index_tour_modes_on_mode_id" t.index ["tour_id"], name: "index_tour_modes_on_tour_id" end @@ -271,8 +270,8 @@ create_table "tour_sets", force: :cascade do |t| t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "subdir" t.bigint "tour_id" t.string "external_url" @@ -287,8 +286,8 @@ t.bigint "tour_id" t.bigint "stop_id" t.integer "position" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["stop_id"], name: "index_tour_stops_on_stop_id" t.index ["tour_id"], name: "index_tour_stops_on_tour_id" end @@ -301,8 +300,8 @@ t.boolean "is_geo", default: true t.boolean "published" t.bigint "theme_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.bigint "mode_id" t.integer "position" t.bigint "splash_image_medium_id" @@ -326,10 +325,11 @@ create_table "users", force: :cascade do |t| t.string "display_name" t.bigint "login_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.boolean "super", default: false t.string "email" + t.boolean "terms_accepted", default: false t.index ["login_id"], name: "index_users_on_login_id", unique: true end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 22393feb..0f1a4be1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -37,4 +37,11 @@ expect(user.provider).to eq(login.provider) end end + + context 'has default' do + it 'terms accepted defaults to false' do + user = create(:user) + expect(user.terms_accepted).to be(false) + end + end end From 1b445b8e1ae370bbf11c1005892bad4ffd52ce7f Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 29 Jul 2022 11:09:52 -0400 Subject: [PATCH 135/160] Update CircleCI config --- .circleci/config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 20a3b15f..3ae81162 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2 orbs: - browser-tools: circleci/browser-tools@1.2.2 + browser-tools: circleci/browser-tools@1.4.0 jobs: build: @@ -10,7 +10,7 @@ jobs: # Primary container image where all commands run docker: - - image: circleci/ruby:3.0.2-browsers + - image: circleci/ruby:3.0-browsers environment: PGUSER: root RAILS_ENV: test @@ -41,6 +41,7 @@ jobs: - run: name: Install dependencies command: | + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - sudo apt update sudo apt install -y postgresql-client || true sudo apt install -y imagemagick From 8a99a26125ccf7badcc14deb0ca776179b64ebbb Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 29 Jul 2022 11:32:59 -0400 Subject: [PATCH 136/160] Update deploy config --- config/deploy.rb | 2 +- config/deploy/production.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/deploy.rb b/config/deploy.rb index 77b552a5..05d670ec 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # config valid for current version and patch releases of Capistrano -lock '~> 3.16.0' +lock '~> 3.17.0' set :application, 'otb-api-server' set :repo_url, 'git@github.com:ecds/otb-api-server.git' diff --git a/config/deploy/production.rb b/config/deploy/production.rb index f52fde35..6da612d8 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,9 +1,9 @@ set :branch, 'develop' # server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value -role :app, %w{34.239.167.5 44.201.150.24}, user: 'deploy' -role :web, %w{34.239.167.5 44.201.150.24}, user: 'deploy' -role :db, %w{34.239.167.5 44.201.150.24}, user: 'deploy' +role :app, %w{34.239.167.5 44.202.38.154}, user: 'deploy' +role :web, %w{34.239.167.5 44.202.38.154}, user: 'deploy' +role :db, %w{34.239.167.5 44.202.38.154}, user: 'deploy' set :deploy_to, '/data/otb-api-server' From efc64a71641345050bca4bde778571759f6416a7 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 10:09:57 -0400 Subject: [PATCH 137/160] Downgrade Rails --- Gemfile | 2 +- Gemfile.lock | 159 +++++++++++++++++++++++---------------------------- 2 files changed, 71 insertions(+), 90 deletions(-) diff --git a/Gemfile b/Gemfile index 816b7c0d..ae895d7b 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,7 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 7.0.2', '>= 7.0.2.3' +gem 'rails', '~> 6.1.0' gem 'rack', '>= 2.0.6' gem 'pg' gem 'mysql2' diff --git a/Gemfile.lock b/Gemfile.lock index 7bde112e..5e564cd3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,10 +11,10 @@ GIT GIT remote: https://github.com/stympy/faker.git - revision: b98d32dcca8aa482525320d8463dff741af0d84d + revision: efc6a90d845a25a34ddbcc9280ed951ecd346163 branch: master specs: - faker (2.21.0) + faker (2.22.0) i18n (>= 1.8.11, < 2) GEM @@ -24,47 +24,40 @@ GEM faraday (~> 1.0) json (~> 2.1) lru_redux (~> 1.1) - actioncable (7.0.3.1) - actionpack (= 7.0.3.1) - activesupport (= 7.0.3.1) + actioncable (6.1.6.1) + actionpack (= 6.1.6.1) + activesupport (= 6.1.6.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.3.1) - actionpack (= 7.0.3.1) - activejob (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) + actionmailbox (6.1.6.1) + actionpack (= 6.1.6.1) + activejob (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.0.3.1) - actionpack (= 7.0.3.1) - actionview (= 7.0.3.1) - activejob (= 7.0.3.1) - activesupport (= 7.0.3.1) + actionmailer (6.1.6.1) + actionpack (= 6.1.6.1) + actionview (= 6.1.6.1) + activejob (= 6.1.6.1) + activesupport (= 6.1.6.1) mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.3.1) - actionview (= 7.0.3.1) - activesupport (= 7.0.3.1) - rack (~> 2.0, >= 2.2.0) + actionpack (6.1.6.1) + actionview (= 6.1.6.1) + activesupport (= 6.1.6.1) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.3.1) - actionpack (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) - globalid (>= 0.6.0) + actiontext (6.1.6.1) + actionpack (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) nokogiri (>= 1.8.5) - actionview (7.0.3.1) - activesupport (= 7.0.3.1) + actionview (6.1.6.1) + activesupport (= 6.1.6.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -74,33 +67,34 @@ GEM activemodel (>= 4.1, < 7.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.0.3.1) - activesupport (= 7.0.3.1) + activejob (6.1.6.1) + activesupport (= 6.1.6.1) globalid (>= 0.3.6) - activemodel (7.0.3.1) - activesupport (= 7.0.3.1) - activerecord (7.0.3.1) - activemodel (= 7.0.3.1) - activesupport (= 7.0.3.1) - activestorage (7.0.3.1) - actionpack (= 7.0.3.1) - activejob (= 7.0.3.1) - activerecord (= 7.0.3.1) - activesupport (= 7.0.3.1) + activemodel (6.1.6.1) + activesupport (= 6.1.6.1) + activerecord (6.1.6.1) + activemodel (= 6.1.6.1) + activesupport (= 6.1.6.1) + activestorage (6.1.6.1) + actionpack (= 6.1.6.1) + activejob (= 6.1.6.1) + activerecord (= 6.1.6.1) + activesupport (= 6.1.6.1) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.3.1) + activesupport (6.1.6.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) + zeitwerk (~> 2.3) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.1) sshkit (>= 1.6.1, != 1.7.0) aws-eventstream (1.2.0) - aws-partitions (1.610.0) - aws-sdk-core (3.131.3) + aws-partitions (1.613.0) + aws-sdk-core (3.131.5) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) @@ -156,7 +150,6 @@ GEM database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) diff-lcs (1.5.0) - digest (3.1.0) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -250,27 +243,11 @@ GEM multi_xml (0.6.0) multipart-post (2.2.3) mysql2 (0.5.4) - net-imap (0.2.3) - digest - net-protocol - strscan - net-pop (0.1.1) - digest - net-protocol - timeout - net-protocol (0.1.3) - timeout - net-scp (4.0.0.rc1) - net-ssh (>= 2.6.5, < 8.0.0) - net-smtp (0.3.1) - digest - net-protocol - timeout + net-scp (1.2.1) + net-ssh (>= 2.6.5) net-ssh (7.0.1) netrc (0.11.0) nio4r (2.5.8) - nokogiri (1.13.8-x86_64-darwin) - racc (~> 1.4) nokogiri (1.13.8-x86_64-linux) racc (~> 1.4) oauth (0.5.10) @@ -285,32 +262,32 @@ GEM rack (>= 2.0.0) rack-test (2.0.2) rack (>= 1.3) - rails (7.0.3.1) - actioncable (= 7.0.3.1) - actionmailbox (= 7.0.3.1) - actionmailer (= 7.0.3.1) - actionpack (= 7.0.3.1) - actiontext (= 7.0.3.1) - actionview (= 7.0.3.1) - activejob (= 7.0.3.1) - activemodel (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) + rails (6.1.6.1) + actioncable (= 6.1.6.1) + actionmailbox (= 6.1.6.1) + actionmailer (= 6.1.6.1) + actionpack (= 6.1.6.1) + actiontext (= 6.1.6.1) + actionview (= 6.1.6.1) + activejob (= 6.1.6.1) + activemodel (= 6.1.6.1) + activerecord (= 6.1.6.1) + activestorage (= 6.1.6.1) + activesupport (= 6.1.6.1) bundler (>= 1.15.0) - railties (= 7.0.3.1) + railties (= 6.1.6.1) + sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) rails-html-sanitizer (1.4.3) loofah (~> 2.3) - railties (7.0.3.1) - actionpack (= 7.0.3.1) - activesupport (= 7.0.3.1) + railties (6.1.6.1) + actionpack (= 6.1.6.1) + activesupport (= 6.1.6.1) method_source rake (>= 12.2) thor (~> 1.0) - zeitwerk (~> 2.5) rake (13.0.6) rb-fsevent (0.11.1) rb-inotify (0.10.1) @@ -361,16 +338,21 @@ GEM spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) spring (>= 1.2, < 3.0) + sprockets (4.1.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) ssrf_filter (1.0.7) - strscan (3.0.4) sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) thor (1.2.1) - timeout (0.3.0) tins (1.31.1) sync tzinfo (2.0.5) @@ -397,7 +379,6 @@ GEM zeitwerk (2.6.0) PLATFORMS - x86_64-darwin-20 x86_64-linux DEPENDENCIES @@ -425,7 +406,7 @@ DEPENDENCIES puma (~> 4.3.0) rack (>= 2.0.6) rack-cors - rails (~> 7.0.2, >= 7.0.2.3) + rails (~> 6.1.0) redis (~> 3.0) rgeo ros-apartment From e3724aa1af5f2c4a9d31927f972f115e3c471a26 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 10:36:41 -0400 Subject: [PATCH 138/160] Fix migration version --- db/migrate/20220728131300_add_term_accepted_to_user.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20220728131300_add_term_accepted_to_user.rb b/db/migrate/20220728131300_add_term_accepted_to_user.rb index 63e2eed6..50c2427d 100644 --- a/db/migrate/20220728131300_add_term_accepted_to_user.rb +++ b/db/migrate/20220728131300_add_term_accepted_to_user.rb @@ -1,4 +1,4 @@ -class AddTermAcceptedToUser < ActiveRecord::Migration[7.0] +class AddTermAcceptedToUser < ActiveRecord::Migration[6.1] def change add_column :users, :terms_accepted, :boolean, default: false end From 5612f02594f09acb46ae681d8f445d7f1c0f3168 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 10:42:32 -0400 Subject: [PATCH 139/160] Fix schema version --- db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 6d71c865..e72fb2cc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_07_28_131300) do +ActiveRecord::Schema[6.1].define(version: 2022_07_28_131300) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" From ddbe785dc48aec2de1905fd282bc14bbe0f1bacf Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 10:46:20 -0400 Subject: [PATCH 140/160] Fix schema --- db/schema.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index e72fb2cc..12eb4c8b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,8 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[6.1].define(version: 2022_07_28_131300) do +ActiveRecord::Schema.define(version: 2022_02_11_142554) do + # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" From 4ef125f6fd9d964a499463275a78c98c5d0867a3 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 12:02:19 -0400 Subject: [PATCH 141/160] Replace migration, fix spec helper --- ...0728131300_add_term_accepted_to_user.rb.rb | 5 + db/schema.rb | 92 +++++++++---------- spec/rails_helper.rb | 4 +- 3 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 db/migrate/20220728131300_add_term_accepted_to_user.rb.rb diff --git a/db/migrate/20220728131300_add_term_accepted_to_user.rb.rb b/db/migrate/20220728131300_add_term_accepted_to_user.rb.rb new file mode 100644 index 00000000..314a1205 --- /dev/null +++ b/db/migrate/20220728131300_add_term_accepted_to_user.rb.rb @@ -0,0 +1,5 @@ +class AddTermsAcceptedToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :terms_accepted, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 12eb4c8b..9b66af81 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_02_11_142554) do +ActiveRecord::Schema.define(version: 2022_08_01_155837) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -22,7 +22,7 @@ t.string "record_type", null: false t.bigint "record_id", null: false t.bigint "blob_id", null: false - t.datetime "created_at", precision: nil, 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 @@ -34,7 +34,7 @@ t.text "metadata" t.bigint "byte_size", null: false t.string "checksum", null: false - t.datetime "created_at", precision: nil, null: false + t.datetime "created_at", null: false t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -49,23 +49,23 @@ t.string "who" t.string "provider" t.bigint "user_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["user_id"], name: "index_ecds_rails_auth_engine_logins_on_user_id" end create_table "ecds_rails_auth_engine_tokens", force: :cascade do |t| t.string "token" t.bigint "login_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end create_table "flat_pages", force: :cascade do |t| t.string "title" t.text "body" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "position" end @@ -76,8 +76,8 @@ t.string "uid" t.string "single_use_oauth2_token" t.bigint "user_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "provider" t.string "confirm_token" t.index ["user_id"], name: "index_logins_on_user_id" @@ -85,8 +85,8 @@ create_table "map_icons", force: :cascade do |t| t.text "base_sixty_four" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false t.string "filename" end @@ -97,8 +97,8 @@ t.string "west" t.bigint "tour_id" t.bigint "stop_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false t.text "base_sixty_four" t.text "filename" t.index ["stop_id"], name: "index_map_overlays_on_stop_id" @@ -109,8 +109,8 @@ t.string "title" t.text "caption" t.string "original_image" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "video" t.string "provider" t.string "embed" @@ -131,8 +131,8 @@ create_table "modes", force: :cascade do |t| t.string "title" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "icon" end @@ -143,16 +143,16 @@ create_table "slugs", force: :cascade do |t| t.string "slug" t.bigint "tour_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["tour_id"], name: "index_slugs_on_tour_id" end create_table "stop_media", force: :cascade do |t| t.bigint "stop_id" t.bigint "medium_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "position" t.index ["medium_id"], name: "index_stop_media_on_medium_id" t.index ["stop_id"], name: "index_stop_media_on_stop_id" @@ -161,8 +161,8 @@ create_table "stop_slugs", force: :cascade do |t| t.string "slug" t.bigint "stop_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.bigint "tour_id" t.index ["stop_id"], name: "index_stop_slugs_on_stop_id" t.index ["tour_id"], name: "index_stop_slugs_on_tour_id" @@ -181,8 +181,8 @@ t.string "parking_lng" t.text "direction_intro" t.text "direction_notes" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "address" t.bigint "medium_id" t.string "parking_address" @@ -199,7 +199,7 @@ t.string "tagger_type" t.integer "tagger_id" t.string "context", limit: 128 - t.datetime "created_at", precision: nil + t.datetime "created_at" t.index ["context"], name: "index_taggings_on_context" t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" @@ -213,8 +213,8 @@ create_table "themes", force: :cascade do |t| t.string "title" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end create_table "tour_authors", force: :cascade do |t| @@ -226,16 +226,16 @@ create_table "tour_collections", force: :cascade do |t| t.string "name" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end create_table "tour_flat_pages", force: :cascade do |t| t.bigint "tour_id" t.bigint "flat_page_id" t.integer "position" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["flat_page_id"], name: "index_tour_flat_pages_on_flat_page_id" t.index ["tour_id"], name: "index_tour_flat_pages_on_tour_id" end @@ -243,8 +243,8 @@ create_table "tour_media", force: :cascade do |t| t.bigint "tour_id" t.bigint "medium_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "position" t.index ["medium_id"], name: "index_tour_media_on_medium_id" t.index ["tour_id"], name: "index_tour_media_on_tour_id" @@ -253,8 +253,8 @@ create_table "tour_modes", force: :cascade do |t| t.bigint "tour_id" t.bigint "mode_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["mode_id"], name: "index_tour_modes_on_mode_id" t.index ["tour_id"], name: "index_tour_modes_on_tour_id" end @@ -271,8 +271,8 @@ create_table "tour_sets", force: :cascade do |t| t.string "name" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "subdir" t.bigint "tour_id" t.string "external_url" @@ -287,8 +287,8 @@ t.bigint "tour_id" t.bigint "stop_id" t.integer "position" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["stop_id"], name: "index_tour_stops_on_stop_id" t.index ["tour_id"], name: "index_tour_stops_on_tour_id" end @@ -301,8 +301,8 @@ t.boolean "is_geo", default: true t.boolean "published" t.bigint "theme_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.bigint "mode_id" t.integer "position" t.bigint "splash_image_medium_id" @@ -326,8 +326,8 @@ create_table "users", force: :cascade do |t| t.string "display_name" t.bigint "login_id" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "super", default: false t.string "email" t.boolean "terms_accepted", default: false diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 1a6f468f..8484bcbb 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -84,7 +84,9 @@ Apartment::Tenant.switch! TourSet.find(TourSet.pluck(:id).sample).subdir # Set the host for ActiveStorage urls - ActiveStorage::Current.url_options = { host: 'http://test.host' } + ActiveStorage::Current.host = 'http://test.host' + # Switch to the below version for Rails 7 + # ActiveStorage::Current.url_options = { host: 'http://test.host' } # host! 'atlanta.lvh.me' # load Rails.root + 'db/seeds.rb' From 390eac7e56c3fd8bd55ddab975e4929f46367e6a Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Mon, 1 Aug 2022 12:02:32 -0400 Subject: [PATCH 142/160] Clean up --- db/migrate/20220728131300_add_term_accepted_to_user.rb | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 db/migrate/20220728131300_add_term_accepted_to_user.rb diff --git a/db/migrate/20220728131300_add_term_accepted_to_user.rb b/db/migrate/20220728131300_add_term_accepted_to_user.rb deleted file mode 100644 index 50c2427d..00000000 --- a/db/migrate/20220728131300_add_term_accepted_to_user.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddTermAcceptedToUser < ActiveRecord::Migration[6.1] - def change - add_column :users, :terms_accepted, :boolean, default: false - end -end From 7808081dc148b02fd8a12a307336597062b9ed5b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Sun, 9 Oct 2022 13:47:12 -0400 Subject: [PATCH 143/160] Update GeoJSON view --- .../v3/geojson_tours_controller.rb | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index a5ca1f9b..255be403 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -5,30 +5,58 @@ # module V3 class GeojsonToursController < ApplicationController + include ActionView::Helpers::SanitizeHelper + def show @tour = Tour.find(params[:id]) - if @tour.published - render json: { type: 'FeatureCollection', features: @tour.stops.map { |s| feature(s) } }.to_json - else - head 401 - end + # if @tour.published + geojosn = { + type: 'FeatureCollection', + crs: { + type: 'name', + properties: { + name: 'urn:ogc:def:crs:EPSG::4326' + } + }, + meta: meta_content, + features: @tour.tour_stops.map { |tour_stop| feature(tour_stop.position, tour_stop.stop) } + } + render json: geojosn.to_json + # render json: { type: 'FeatureCollection', meta: meta_content, features: @tour.tour_stops.map { |tour_stop| feature(tour_stop.position, tour_stop.stop) } }.to_json + # else + # head 401 + # end end private - def feature(stop) + def meta_content + { + title: @tour.title, + intro: sanitize(@tour.description) + } + end + + def feature(position, stop) stop.media.map { |m| m.caption = nil if m.caption.blank? } { type: 'Feature', geometry: { - type: 'Point', - coordinates: [stop.lng.to_f, stop.lat.to_f] - }, - properties: { - title: stop.title, - description: stop.description, - images: stop.media.map { |m| { caption: m.caption, url: "#{request.protocol}#{request.host}/#{m.desktop}" } } - } + type: 'Point', + coordinates: [stop.lng.to_f, stop.lat.to_f] + }, + properties: { + title: stop.title, + description: stop.description, + position: position, + images: stop.media.map do |m| + { + caption: m.caption, + full: m.files[:desktop], + thumb: m.files[:mobile] + } + end + } } end end From 16a40eaf8c7be472bca3a59a48dc9903583991f7 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 26 Oct 2022 10:51:59 -0400 Subject: [PATCH 144/160] Custom parameterize_intl method --- app/models/flat_page.rb | 2 +- app/models/stop.rb | 2 +- app/models/tour.rb | 2 +- app/models/tour_set.rb | 2 +- config/initializers/string.rb | 32 ++++++++++++++++++++++++++++++++ spec/factories/stops.rb | 7 ++++--- spec/string_spec.rb | 16 ++++++++++++++++ 7 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 config/initializers/string.rb create mode 100644 spec/string_spec.rb diff --git a/app/models/flat_page.rb b/app/models/flat_page.rb index 8ef7d1d8..cba879a5 100644 --- a/app/models/flat_page.rb +++ b/app/models/flat_page.rb @@ -6,7 +6,7 @@ class FlatPage < ApplicationRecord validates :title, presence: true def slug - title ? title.parameterize : '' + title ? title.parameterize_intl : '' end def orphaned diff --git a/app/models/stop.rb b/app/models/stop.rb index ef50b2fe..3069a1c1 100644 --- a/app/models/stop.rb +++ b/app/models/stop.rb @@ -31,7 +31,7 @@ def sanitized_direction_notes end def slug - title ? title.parameterize : '' + title ? title.parameterize_intl : '' end def splash diff --git a/app/models/tour.rb b/app/models/tour.rb index 17100c3a..d6116d49 100644 --- a/app/models/tour.rb +++ b/app/models/tour.rb @@ -56,7 +56,7 @@ def sanitized_description end def slug - title.parameterize + title.parameterize_intl end def tenant diff --git a/app/models/tour_set.rb b/app/models/tour_set.rb index 21244dac..ad205817 100644 --- a/app/models/tour_set.rb +++ b/app/models/tour_set.rb @@ -66,7 +66,7 @@ def logo_url private def set_subdir - self.subdir = name.parameterize + self.subdir = name.parameterize_intl end def create_tenant diff --git a/config/initializers/string.rb b/config/initializers/string.rb new file mode 100644 index 00000000..d57e10a7 --- /dev/null +++ b/config/initializers/string.rb @@ -0,0 +1,32 @@ +require "active_support/inflector" + +class String + def parameterize_intl(separator: '-', preserve_case: false, locale: nil) + # Replace accented chars with their ASCII equivalents. + transliterated_string = ActiveSupport::Inflector.transliterate(self, replacement = '~', locale: locale) + + parameterized_string = if transliterated_string.include?('~') + self.gsub(/[!@#$%^&*()-=_+|;':",.<>?\s']/, separator) + else + transliterated_string.gsub(/[^a-z0-9\-_]+/i, separator) + end + + unless separator.nil? || separator.empty? + if separator == '_'.freeze + re_duplicate_separator = /-{2,}/ + re_leading_trailing_separator = /^-|-$/ + else + re_sep = Regexp.escape(separator) + re_duplicate_separator = /#{re_sep}{2,}/ + re_leading_trailing_separator = /^#{re_sep}|#{re_sep}$/ + end + # No more than one of the separator in a row. + parameterized_string.gsub!(re_duplicate_separator, separator) + # Remove leading/trailing separator. + parameterized_string.gsub!(re_leading_trailing_separator, ''.freeze) + end + + parameterized_string.downcase! unless preserve_case + parameterized_string + end +end diff --git a/spec/factories/stops.rb b/spec/factories/stops.rb index 7e48d36b..9ec35508 100644 --- a/spec/factories/stops.rb +++ b/spec/factories/stops.rb @@ -3,9 +3,10 @@ # spec/factories/stops.rb FactoryBot.define do factory :stop do - sequence :title do |s| - "#{Faker::Movies::HitchhikersGuideToTheGalaxy.planet}#{s}" - end + # sequence :title do |s| + # "#{Faker::Movies::HitchhikersGuideToTheGalaxy.planet}#{s}" + # end + title { Faker::Movies::HitchhikersGuideToTheGalaxy.planet } description { Faker::Hipster.paragraph(sentence_count: 2, supplemental: true, random_sentences_to_add: 4) } lat { Faker::Address.latitude } lng { Faker::Address.longitude } diff --git a/spec/string_spec.rb b/spec/string_spec.rb new file mode 100644 index 00000000..c87d4411 --- /dev/null +++ b/spec/string_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Using custom parameterize_intl method' do + it 'uses custom parameterize_intl method' do + expect('My Awesome Tour'.parameterize_intl).to eq('my-awesome-tour') + expect('Csodálatos túrám'.parameterize_intl).to eq('csodalatos-turam') + expect('הסיור המדהים שלי'.parameterize_intl).to eq('הסיור-המדהים-שלי') + expect('我的精 彩之旅!!'.parameterize_intl).to eq('我的精-彩之旅') + expect('Мій чудовий тур'.parameterize_intl).to eq('мій-чудовий-тур') + expect('جولتي الرائعة'.parameterize_intl).to eq('جولتي-الرائعة') + expect('我的精 彩之旅!! (mix)'.parameterize_intl).to eq('我的精-彩之旅-mix') + expect('💩 🤯 🤷🏼'.parameterize_intl).to eq('💩-🤯-🤷🏼') + end +end From ab5a19239ca1d8e19ed37b9834a7fefa27296a8e Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 26 Oct 2022 10:52:48 -0400 Subject: [PATCH 145/160] Add tour media to geojson --- app/controllers/v3/geojson_tours_controller.rb | 7 +++++++ spec/controllers/v3/tour_geojson_controller_spec.rb | 11 ++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index 255be403..f3107105 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -34,6 +34,13 @@ def meta_content { title: @tour.title, intro: sanitize(@tour.description) + images: tour.media.map do |m| + { + caption: m.caption, + full: m.files[:desktop], + thumb: m.files[:mobile] + } + end } end diff --git a/spec/controllers/v3/tour_geojson_controller_spec.rb b/spec/controllers/v3/tour_geojson_controller_spec.rb index ed72a58a..b3193615 100644 --- a/spec/controllers/v3/tour_geojson_controller_spec.rb +++ b/spec/controllers/v3/tour_geojson_controller_spec.rb @@ -15,11 +15,12 @@ expect(first_stop.media.map(&:caption)).to include geojson[:features].first[:properties][:images].first[:caption] end - it 'returns 401 when tour is unpublished' do - tour = create(:tour, published: false) - get :show, params: { id: tour.to_param, tenant: Apartment::Tenant.current } - expect(response.status).to eq(401) - end + # TODO: Renable after OpenWorld stuff + # it 'returns 401 when tour is unpublished' do + # tour = create(:tour, published: false) + # get :show, params: { id: tour.to_param, tenant: Apartment::Tenant.current } + # expect(response.status).to eq(401) + # end end end From ff18cd79464af4678ffb2eabd58c910375659dda Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Wed, 26 Oct 2022 10:53:41 -0400 Subject: [PATCH 146/160] Fix geojson controller --- app/controllers/v3/geojson_tours_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index f3107105..742e1823 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -33,7 +33,7 @@ def show def meta_content { title: @tour.title, - intro: sanitize(@tour.description) + intro: sanitize(@tour.description), images: tour.media.map do |m| { caption: m.caption, From 3b0553ba3cca59fdb03776034f8067f35e8b470b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 27 Oct 2022 09:14:35 -0400 Subject: [PATCH 147/160] Small fixes, bump Ruby version --- .circleci/config.yml | 2 +- .ruby-version | 2 +- Gemfile | 7 ++++++- Gemfile.lock | 12 ++++++++++++ app/controllers/v3/geojson_tours_controller.rb | 6 +++--- spec/factories/stops.rb | 8 ++++---- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3ae81162..f91ff6ce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ jobs: # Primary container image where all commands run docker: - - image: circleci/ruby:3.0-browsers + - image: cimg/ruby:3.1.2-browsers environment: PGUSER: root RAILS_ENV: test diff --git a/.ruby-version b/.ruby-version index b5021469..ef538c28 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.2 +3.1.2 diff --git a/Gemfile b/Gemfile index ae895d7b..7b968ed4 100644 --- a/Gemfile +++ b/Gemfile @@ -9,7 +9,9 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 6.1.0' +gem 'rails', '~> 7.0.4' +# gem 'rails', '~> 6.1.0' + gem 'rack', '>= 2.0.6' gem 'pg' gem 'mysql2' @@ -88,3 +90,6 @@ end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] +gem 'net-smtp', require: false +gem 'net-imap', require: false +gem 'net-pop', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5e564cd3..bfda39d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -243,8 +243,16 @@ GEM multi_xml (0.6.0) multipart-post (2.2.3) mysql2 (0.5.4) + net-imap (0.3.1) + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.1.3) + timeout net-scp (1.2.1) net-ssh (>= 2.6.5) + net-smtp (0.3.2) + net-protocol net-ssh (7.0.1) netrc (0.11.0) nio4r (2.5.8) @@ -353,6 +361,7 @@ GEM term-ansicolor (1.7.1) tins (~> 1.0) thor (1.2.1) + timeout (0.3.0) tins (1.31.1) sync tzinfo (2.0.5) @@ -402,6 +411,9 @@ DEPENDENCIES listen mini_magick mysql2 + net-imap + net-pop + net-smtp pg puma (~> 4.3.0) rack (>= 2.0.6) diff --git a/app/controllers/v3/geojson_tours_controller.rb b/app/controllers/v3/geojson_tours_controller.rb index 742e1823..3a00ff4e 100644 --- a/app/controllers/v3/geojson_tours_controller.rb +++ b/app/controllers/v3/geojson_tours_controller.rb @@ -33,8 +33,8 @@ def show def meta_content { title: @tour.title, - intro: sanitize(@tour.description), - images: tour.media.map do |m| + description: sanitize(@tour.description), + images: @tour.media.map do |m| { caption: m.caption, full: m.files[:desktop], @@ -54,7 +54,7 @@ def feature(position, stop) }, properties: { title: stop.title, - description: stop.description, + description: sanitize(stop.description), position: position, images: stop.media.map do |m| { diff --git a/spec/factories/stops.rb b/spec/factories/stops.rb index 9ec35508..677a4713 100644 --- a/spec/factories/stops.rb +++ b/spec/factories/stops.rb @@ -3,10 +3,10 @@ # spec/factories/stops.rb FactoryBot.define do factory :stop do - # sequence :title do |s| - # "#{Faker::Movies::HitchhikersGuideToTheGalaxy.planet}#{s}" - # end - title { Faker::Movies::HitchhikersGuideToTheGalaxy.planet } + sequence :title do |s| + # This ensures unique titles + "#{Faker::Movies::HitchhikersGuideToTheGalaxy.planet}#{s}" + end description { Faker::Hipster.paragraph(sentence_count: 2, supplemental: true, random_sentences_to_add: 4) } lat { Faker::Address.latitude } lng { Faker::Address.longitude } From 58cd61e82d54c70c1d271f8dc83eb6be27ed5e6b Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 27 Oct 2022 09:15:45 -0400 Subject: [PATCH 148/160] Fix Rails version --- Gemfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 7b968ed4..cfb512a8 100644 --- a/Gemfile +++ b/Gemfile @@ -9,8 +9,8 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem 'rails', '~> 7.0.4' -# gem 'rails', '~> 6.1.0' +# gem 'rails', '~> 7.0.4' +gem 'rails', '~> 6.1.0' gem 'rack', '>= 2.0.6' gem 'pg' From 990d9a50474e9aadb5f05023244327d7e7470073 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 27 Oct 2022 09:39:57 -0400 Subject: [PATCH 149/160] Install Chrome for CI --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f91ff6ce..594b0cce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -20,6 +20,7 @@ jobs: TEST_DB_NAME: otb MYSQL_ALLOW_EMPTY_PASSWORD: true CI: 'circleci' + BROWSER_PATH: /usr/bin/google-chrome - image: circleci/postgres:10 environment: @@ -44,7 +45,9 @@ jobs: wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - sudo apt update sudo apt install -y postgresql-client || true - sudo apt install -y imagemagick + sudo apt install -y imagemagick libappindicator1 fonts-liberation + wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo dpkg -i google-chrome*.deb gem install bundle bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 From e0af65ff1584db6e867c6198614f470fc949a37c Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Thu, 27 Oct 2022 09:42:25 -0400 Subject: [PATCH 150/160] Update Prod IPs --- config/deploy/production.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/deploy/production.rb b/config/deploy/production.rb index 6da612d8..906861df 100644 --- a/config/deploy/production.rb +++ b/config/deploy/production.rb @@ -1,9 +1,9 @@ set :branch, 'develop' # server '44.192.30.237', user: 'deploy', roles: %w{app db web}, primary: :my_value -role :app, %w{34.239.167.5 44.202.38.154}, user: 'deploy' -role :web, %w{34.239.167.5 44.202.38.154}, user: 'deploy' -role :db, %w{34.239.167.5 44.202.38.154}, user: 'deploy' +role :app, %w{3.86.138.59 3.236.252.227}, user: 'deploy' +role :web, %w{3.86.138.59 3.236.252.227}, user: 'deploy' +role :db, %w{3.86.138.59 3.236.252.227}, user: 'deploy' set :deploy_to, '/data/otb-api-server' From 51561891a1705af19d4d62d4399a8a24ff13d3ec Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 28 Oct 2022 08:37:39 -0400 Subject: [PATCH 151/160] Extend life of ActiveStorage objects --- config/initializers/s3.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/s3.rb b/config/initializers/s3.rb index 64912b61..e3e8b945 100644 --- a/config/initializers/s3.rb +++ b/config/initializers/s3.rb @@ -8,4 +8,4 @@ # }) # Sometimes the service URL expires too quickly. -Rails.application.config.active_storage.service_urls_expire_in = 1.hour +Rails.application.config.active_storage.service_urls_expire_in = 1.week From 5be734dae2a5a525138850b724be1e03523952b1 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 28 Oct 2022 08:47:04 -0400 Subject: [PATCH 152/160] Allow CORS for OpenWorld --- config/initializers/cors.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index e8d3ac5a..38b31af1 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -9,7 +9,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org', 'https://opentour.site', /.*\.opentour.site/, /.*\.lvh.me:4200/ + origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org', 'https://opentour.site', /.*\.opentour.site/, /.*\.lvh.me:4200/, /.*\.localhost:3000/, /.*\.urbanspatialhistory.org/ resource '*', headers: :any, From fc091a8f90da6acce6eb1d900d2af763090221c6 Mon Sep 17 00:00:00 2001 From: Jay Varner Date: Fri, 28 Oct 2022 08:51:09 -0400 Subject: [PATCH 153/160] Fix CORS config --- config/initializers/cors.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 38b31af1..f341144d 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -9,7 +9,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org', 'https://opentour.site', /.*\.opentour.site/, /.*\.lvh.me:4200/, /.*\.localhost:3000/, /.*\.urbanspatialhistory.org/ + origins 'https://lvh.me:4200', 'https://otb.ecdsdev.org', 'https://opentour.site', /.*\.opentour.site/, /.*\.lvh.me:4200/, /.*localhost:3000/, /.*\.urbanspatialhistory.org/ resource '*', headers: :any, From f76883ce394492228c8e86c80e86ae62e97864f5 Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Mon, 4 Aug 2025 08:27:52 -0400 Subject: [PATCH 154/160] Add Docker Compose setup for Rails app Fixes #35 --- .dockerignore | 54 +++++++++++ Dockerfile | 44 +++++++++ Gemfile | 2 +- Gemfile.backup | 80 +++++++++++++++++ Gemfile.lock | 15 ++-- Gemfile.original | 78 ++++++++++++++++ config/application.rb | 2 +- config/database.yml | 34 ++----- config/database.yml.backup | 45 ++++++++++ .../disable_ssl_in_development.rb | 1 + docker-compose.yml | 90 +++++++++++++++++++ temp_gemfile | 0 12 files changed, 406 insertions(+), 39 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Gemfile.backup create mode 100644 Gemfile.original create mode 100644 config/database.yml.backup create mode 100644 config/initializers/disable_ssl_in_development.rb create mode 100644 docker-compose.yml create mode 100644 temp_gemfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..93ded0d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Git +.git +.gitignore + +# Documentation +README.md +CHANGELOG.md +LICENSE + +# Development files +.env* + +# Dependencies +node_modules +vendor/bundle/.bundle + +# Build artifacts +tmp/* +log/* +coverage/* +public/storage/* +storage/* + +# Test files +spec/ +test/ +.rspec + +# Development tools +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Rails specific +/log/*.log +/tmp +/storage +/public/storage +/coverage + +# Docker +Dockerfile* +docker-compose* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4157ad24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Dockerfile for OpenTourBuilder API +FROM ruby:3.1.2-slim + +# Install system dependencies +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + postgresql-client \ + libpq-dev \ + libgdal-dev \ + gdal-bin \ + imagemagick \ + libmagickwand-dev \ + libvips \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Skip Chrome for now - we can add it later if needed for testing +# The Rails app will work fine without Chrome for basic API functionality + +# Set working directory +WORKDIR /rails + +# Copy Gemfile first for better layer caching +COPY Gemfile Gemfile.lock ./ + +# Install bundler and gems +RUN gem install bundler:2.2.22 +RUN bundle install + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p /data/tmp public/storage/tmp tmp/pids && \ + chmod 777 /data/tmp public/storage/tmp tmp/pids + +# Expose port +EXPOSE 3000 + +# Default command +CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] \ No newline at end of file diff --git a/Gemfile b/Gemfile index cfb512a8..d948983e 100644 --- a/Gemfile +++ b/Gemfile @@ -55,7 +55,7 @@ gem 'youtube_rails' gem 'rack-cors' # TODO: should probably only require this for :test -gem 'faker', git: 'https://github.com/stympy/faker.git', branch: 'master' +gem 'faker' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console diff --git a/Gemfile.backup b/Gemfile.backup new file mode 100644 index 00000000..eb885ebc --- /dev/null +++ b/Gemfile.backup @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +git_source(:github) do |repo_name| + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') + "https://github.com/#{repo_name}.git" +end + + +# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' +gem 'rails', '~> 5.2.0' +gem "rack", ">= 2.0.6" +gem 'pg' +gem 'mysql2' +# Multitenancy for Rails and ActiveRecord +gem 'apartment' +# For JSONAPI responses +gem 'active_model_serializers', '~> 0.10.0.rc3' +gem 'acts-as-taggable-on', '~> 5.0' +# Use Puma as the app server +gem 'puma', '~> 4.3.0' +# Use Redis adapter to run Action Cable in production +gem 'redis', '~> 3.0' +gem "actionview", ">= 5.2.2.1" +# Use ActiveModel has_secure_password +# gem 'bcrypt', '~> 3.1.7' + +# Social Auth +# gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' +gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', :tag => 'v0.1.5' +gem 'cancancan', '~> 2.0' + +# Active Storage will land in 5.2 +gem 'carrierwave', '~> 1.0' +gem 'mini_magick' +gem 'carrierwave-base64' + +# Vidoe provider APIs +gem 'vimeo' +gem 'yt' +gem 'youtube_rails' + +# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible +gem 'rack-cors' + +# TODO: should probably only require this for :test +gem 'faker', git: 'https://github.com/stympy/faker.git', branch: 'main' + +group :development, :test do + # Call 'byebug' anywhere in the code to stop execution and get a debugger console + # gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] + gem "test-prof" +end + +group :development do + gem 'listen', '>= 3.0.5', '< 3.2' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' + gem 'rspec-rails', '~> 3.5' + # Use Capistrano for deployment + gem 'capistrano-rails' + gem 'capistrano-rbenv', '~> 2.0' + gem 'capistrano-passenger' +end + + +group :test do + gem "factory_bot" + gem 'factory_bot_rails' + gem 'shoulda-matchers', git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5' + gem 'database_cleaner' + gem 'coveralls', require: false + gem 'webmock' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] +gem "mimemagic", ">= 0.3.5" diff --git a/Gemfile.lock b/Gemfile.lock index bfda39d5..1cb8aa3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,14 +9,6 @@ GIT jwt rails -GIT - remote: https://github.com/stympy/faker.git - revision: efc6a90d845a25a34ddbcc9280ed951ecd346163 - branch: master - specs: - faker (2.22.0) - i18n (>= 1.8.11, < 2) - GEM remote: https://rubygems.org/ specs: @@ -159,6 +151,8 @@ GEM factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) + faker (3.5.2) + i18n (>= 1.8.11, < 2) faraday (1.10.0) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -256,6 +250,8 @@ GEM net-ssh (7.0.1) netrc (0.11.0) nio4r (2.5.8) + nokogiri (1.13.8-aarch64-linux) + racc (~> 1.4) nokogiri (1.13.8-x86_64-linux) racc (~> 1.4) oauth (0.5.10) @@ -388,6 +384,7 @@ GEM zeitwerk (2.6.0) PLATFORMS + aarch64-linux x86_64-linux DEPENDENCIES @@ -403,7 +400,7 @@ DEPENDENCIES ecds_rails_auth_engine! factory_bot factory_bot_rails - faker! + faker ferrum image_processing (~> 1.2) ipinfo-rails diff --git a/Gemfile.original b/Gemfile.original new file mode 100644 index 00000000..e7d8b54d --- /dev/null +++ b/Gemfile.original @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +git_source(:github) do |repo_name| + repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/') + "https://github.com/#{repo_name}.git" +end + +# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' +gem 'rails', '~> 5.2.0' +gem "rack", ">= 2.0.6" +gem 'pg' +gem 'mysql2' +# Multitenancy for Rails and ActiveRecord +gem 'apartment', '~> 2.2.0' +# For JSONAPI responses +gem 'active_model_serializers', '~> 0.10.0.rc3' +gem 'acts-as-taggable-on', '~> 5.0' +# Use Puma as the app server +gem 'puma', '~> 4.3.0' +# Use Redis adapter to run Action Cable in production +gem 'redis', '~> 3.0' +gem "actionview", ">= 5.2.2.1" +# Use ActiveModel has_secure_password +# gem 'bcrypt', '~> 3.1.7' + +# Social Auth +# gem 'ecds_rails_auth_engine', path: '../ecds_auth_engine' +gem 'ecds_rails_auth_engine', git: 'https://github.com/ecds/ecds_rails_auth_engine.git', :tag => 'v0.1.5' +gem 'cancancan', '~> 2.0' + +# Active Storage will land in 5.2 +gem 'carrierwave', '~> 1.0' +gem 'mini_magick' +gem 'carrierwave-base64' + +# Video provider APIs +gem 'vimeo' +gem 'yt' +gem 'youtube_rails' + +# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible +gem 'rack-cors' + +# Faker gem - use version compatible with Ruby 2.7+ +gem 'faker', '~> 2.20' + +group :development, :test do + # Call 'byebug' anywhere in the code to stop execution and get a debugger console + # gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] + gem "test-prof" +end + +group :development do + gem 'listen', '>= 3.0.5', '< 3.2' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' + gem 'rspec-rails', '~> 3.5' + # Use Capistrano for deployment + gem 'capistrano-rails' + gem 'capistrano-rbenv', '~> 2.0' + gem 'capistrano-passenger' +end + +group :test do + gem "factory_bot", "~> 5.2" + gem 'factory_bot_rails', '~> 5.2' + gem 'shoulda-matchers', '~> 4.0' + gem 'database_cleaner' + gem 'coveralls', require: false + gem 'webmock' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] +gem "mimemagic", ">= 0.3.5" \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 21bcf027..753a3ec7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -46,7 +46,7 @@ def parse_tenant_name(request) config.middleware.use(ActionDispatch::Cookies) config.middleware.use(ActionDispatch::Session::CookieStore) config.action_dispatch.cookies_serializer = :json - config.middleware.use(IPinfoMiddleware, { token: Rails.application.credentials.dig(:ipinfo) }) + config.middleware.use(IPinfoMiddleware, { token: ENV['IPINFO_TOKEN'] || Rails.application.credentials.dig(:ipinfo) }) # Only loads a smaller set of middleware suitable for API only apps. # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. diff --git a/config/database.yml b/config/database.yml index 281ff781..faeb8607 100644 --- a/config/database.yml +++ b/config/database.yml @@ -6,39 +6,17 @@ default: &default username: <%= ENV['DB_USERNAME'] || 'user'%> host: <%= ENV['DB_HOSTNAME'] || 'localhost' %> schema_search_path: public, postgis - password: <%= ENV['DB_PASSWORD' || 'password'] %> + password: <%= ENV['DB_PASSWORD'] || 'password' %> database: <%= ENV['DB_NAME'] || 'otb' %> -mysql: &mysql - <<: *default - adapter: mysql2 - database: public - username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> - password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> - host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> - - development: <<: *default - database: <%= Rails.application.credentials.dig(:dbDev, :db) %> - username: <%= Rails.application.credentials.dig(:dbDev, :user) %> - password: <%= Rails.application.credentials.dig(:dbDev, :pw) %> - host: <%= Rails.application.credentials.dig(:dbDev, :host) %> - -staging: - <<: *default - database: <%= Rails.application.credentials.dig(:rdsStaging, :db) %> - username: <%= Rails.application.credentials.dig(:rdsStaging, :user) %> - password: <%= Rails.application.credentials.dig(:rdsStaging, :pw) %> - host: <%= Rails.application.credentials.dig(:rdsStaging, :host) %> - -production: - <<: *default - database: <%= Rails.application.credentials.dig(:rdsProduction, :db) %> - username: <%= Rails.application.credentials.dig(:rdsProduction, :user) %> - password: <%= Rails.application.credentials.dig(:rdsProduction, :pw) %> - host: <%= Rails.application.credentials.dig(:rdsProduction, :host) %> + database: <%= ENV['DB_NAME'] || 'otb_development' %> + username: <%= ENV['DB_USERNAME'] || 'user' %> + password: <%= ENV['DB_PASSWORD'] || 'password' %> + host: <%= ENV['DB_HOSTNAME'] || 'localhost' %> test: <<: *default database: <%= ENV['TEST_DB_NAME'] || 'otb_test' %> + \ No newline at end of file diff --git a/config/database.yml.backup b/config/database.yml.backup new file mode 100644 index 00000000..0693ea9e --- /dev/null +++ b/config/database.yml.backup @@ -0,0 +1,45 @@ +default: &default + adapter: <%= ENV['DB_ADAPTER'] || 'postgresql' %> + encoding: utf8 + pool: 50 + schema_search_path: "public,shared_extensions" + username: <%= ENV['DB_USERNAME'] || 'user'%> + host: <%= ENV['DB_HOSTNAME'] || 'localhost' %> + schema_search_path: public, postgis + password: <%= ENV['DB_PASSWORD'] || 'password' %> + database: <%= ENV['DB_NAME'] || 'otb' %> + +# Commented out mysql section that was causing encryption errors +# mysql: &mysql +# <<: *default +# adapter: mysql2 +# database: public +# username: <%= Rails.application.credentials.dig(:dbTest, :mysql, :user) %> +# password: <%= Rails.application.credentials.dig(:dbTest, :mysql, :pw) %> +# host: <%= Rails.application.credentials.dig(:dbTest, :mysql, :host) %> + +development: + <<: *default + database: <%= ENV['DB_NAME'] || 'otb_development' %> + username: <%= ENV['DB_USERNAME'] || 'user' %> + password: <%= ENV['DB_PASSWORD'] || 'password' %> + host: <%= ENV['DB_HOSTNAME'] || 'localhost' %> + +# Commented out staging and production - use environment variables when needed +# staging: +# <<: *default +# database: <%= Rails.application.credentials.dig(:rdsStaging, :db) %> +# username: <%= Rails.application.credentials.dig(:rdsStaging, :user) %> +# password: <%= Rails.application.credentials.dig(:rdsStaging, :pw) %> +# host: <%= Rails.application.credentials.dig(:rdsStaging, :host) %> + +# production: +# <<: *default +# database: <%= Rails.application.credentials.dig(:rdsProduction, :db) %> +# username: <%= Rails.application.credentials.dig(:rdsProduction, :user) %> +# password: <%= Rails.application.credentials.dig(:rdsProduction, :pw) %> +# host: <%= Rails.application.credentials.dig(:rdsProduction, :host) %> + +test: + <<: *default + database: <%= ENV['TEST_DB_NAME'] || 'otb_test' %> \ No newline at end of file diff --git a/config/initializers/disable_ssl_in_development.rb b/config/initializers/disable_ssl_in_development.rb new file mode 100644 index 00000000..13c334c7 --- /dev/null +++ b/config/initializers/disable_ssl_in_development.rb @@ -0,0 +1 @@ +Rails.application.configure { config.force_ssl = false } if Rails.env.development? diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..3c923a41 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +# Docker Compose Configuration for OpenTourBuilder API with HTTPS +# This enables proper HTTPS support as intended by the project + +services: + # PostgreSQL Database with PostGIS + postgres: + image: postgis/postgis:14-3.2 + platform: linux/amd64 + environment: + POSTGRES_DB: otb_development + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d otb_development"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis for Action Cable + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + + # Rails Application with HTTPS + web: + build: . + ports: + - "3000:3000" + environment: + # Database Configuration + DB_ADAPTER: postgresql + DB_HOSTNAME: postgres + DB_USERNAME: user + DB_PASSWORD: password + DB_NAME: otb_development + TEST_DB_NAME: otb_test + + # Redis Configuration + REDIS_URL: redis://redis:6379/1 + + # Rails Configuration + RAILS_ENV: development + RAILS_SERVE_STATIC_FILES: "true" + + # SSL Configuration + BASE_URL: "https://localhost:3000" + + # Bypass credentials temporarily + IPINFO_TOKEN: "development_token" + volumes: + - .:/rails + - tmp_data:/data/tmp + - storage_tmp:/rails/public/storage/tmp + - ssl_certs:/rails/ssl # Mount for SSL certificates + depends_on: + postgres: + condition: service_healthy + stdin_open: true + tty: true + command: > + bash -c " + echo 'Installing dependencies...' && + bundle install && + echo 'Creating necessary directories...' && + mkdir -p /data/tmp public/storage/tmp tmp/pids ssl && + chmod 777 /data/tmp public/storage/tmp && + echo 'Generating self-signed SSL certificate...' && + openssl req -x509 -newkey rsa:4096 -keyout ssl/key.pem -out ssl/cert.pem -days 365 -nodes -subj '/CN=localhost' && + echo 'Setting up database...' && + RAILS_ENV=development bundle exec rails db:create 2>/dev/null || echo 'Database already exists' && + RAILS_ENV=development bundle exec rails db:test:prepare 2>/dev/null || echo 'Test database setup complete' && + echo 'Loading schema (skipping problematic migrations)...' && + RAILS_ENV=development bundle exec rails db:schema:load && + echo 'Starting Rails server with HTTPS...' && + RAILS_ENV=development bundle exec puma -b 'ssl://0.0.0.0:3000?key=ssl/key.pem&cert=ssl/cert.pem' + " + +volumes: + postgres_data: + redis_data: + tmp_data: + storage_tmp: + ssl_certs: # Volume for SSL certificates \ No newline at end of file diff --git a/temp_gemfile b/temp_gemfile new file mode 100644 index 00000000..e69de29b From aaf633d86c3c0cae8c08d32c4cfd62fb59ae15a4 Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Wed, 13 Aug 2025 15:51:56 -0400 Subject: [PATCH 155/160] Add GitHub Actions workflows --- .github/workflows/deploy.yml | 142 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 117 +++++++++++++++++++++++++++++ Dockerfile | 13 +--- config/application.rb | 1 - docker-compose.yml | 15 +--- 5 files changed, 263 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ec49e53e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,142 @@ +name: Deploy + +on: + push: + branches: [ develop, main ] + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'staging' + type: choice + options: + - staging + - production + +env: + RAILS_ENV: production + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: otb_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.2 + bundler-cache: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client imagemagick libpq-dev gdal-bin libgdal-dev + + - name: Create directories + run: | + sudo mkdir -p /data/tmp + sudo chmod 777 /data/tmp + mkdir -p public/storage/tmp + + - name: Set up database + run: | + export DB_ADAPTER=postgresql + until pg_isready -h localhost -p 5432 -U postgres; do sleep 2; done + bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=postgresql + + - name: Run tests + run: | + DB_ADAPTER=postgresql bundle exec rspec --format progress + + deploy: + needs: test + runs-on: ubuntu-latest + + environment: + name: ${{ + github.event.inputs.environment || + (github.ref == 'refs/heads/main' && 'production') || + (github.ref == 'refs/heads/develop' && 'staging') + }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.2 + bundler-cache: true + + - name: Determine deployment environment + id: deploy-env + run: | + if [[ "${{ github.event.inputs.environment }}" != "" ]]; then + echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "environment=production" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then + echo "environment=staging" >> $GITHUB_OUTPUT + else + echo "No deployment environment determined" + exit 1 + fi + + - name: Configure SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + cat >> ~/.ssh/config << EOF + Host * + IdentityFile ~/.ssh/deploy_key + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + EOF + + - name: Add deployment servers to known hosts + run: | + ssh-keyscan -H 3.238.239.164 >> ~/.ssh/known_hosts + ssh-keyscan -H 3.86.138.59 >> ~/.ssh/known_hosts + ssh-keyscan -H 3.236.252.227 >> ~/.ssh/known_hosts + + - name: Deploy with Capistrano + run: | + environment="${{ steps.deploy-env.outputs.environment }}" + echo "Deploying to environment: $environment" + bundle exec cap $environment deploy + env: + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + + - name: Notify deployment status + if: always() + run: | + environment="${{ steps.deploy-env.outputs.environment }}" + if [[ "${{ job.status }}" == "success" ]]; then + echo "Successfully deployed to $environment" + else + echo "Deployment to $environment failed" + fi \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..35bfc9ae --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,117 @@ +name: Test + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + RAILS_ENV: test + PGUSER: postgres + DB_HOSTNAME: localhost + DB_USERNAME: postgres + DB_PASSWORD: postgres + TEST_DB_NAME: otb_test + CI: 'github-actions' + BROWSER_PATH: /usr/bin/google-chrome + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: otb_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: otb_test + MYSQL_USER: user + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + MYSQL_ALLOW_EMPTY_PASSWORD: yes + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + --default-authentication-plugin=mysql_native_password + ports: + - 3306:3306 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.2 + bundler-cache: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client imagemagick libpq-dev gdal-bin libgdal-dev libappindicator1 fonts-liberation + wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo dpkg -i google-chrome-stable_current_amd64.deb || true + sudo apt-get install -f -y + + - name: Create required directories + run: | + sudo mkdir -p /data/tmp + sudo chmod 777 /data/tmp + sudo chown $USER:$USER /data/tmp + mkdir -p public/storage/tmp + chmod 777 public/storage/tmp + + - name: Wait for databases to be ready + run: | + until pg_isready -h localhost -p 5432 -U postgres; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + until mysqladmin ping -h 127.0.0.1 -P 3306 -u root -ppassword --silent; do + echo "Waiting for MySQL..." + sleep 2 + done + + - name: Set up PostgreSQL database + run: | + export DB_ADAPTER=postgresql + bundle exec rake db:drop RAILS_ENV=test DB_ADAPTER=postgresql || true + bundle exec rake db:create RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:schema:load RAILS_ENV=test DB_ADAPTER=postgresql + bundle exec rake db:migrate RAILS_ENV=test DB_ADAPTER=postgresql + + - name: Run RSpec tests + run: | + DB_ADAPTER=postgresql bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec.xml + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: tmp/rspec.xml + + - name: Upload coverage to Coveralls + if: github.event_name == 'push' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: ./coverage/lcov.info + continue-on-error: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4157ad24..65c7fa34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ -# Dockerfile for OpenTourBuilder API + FROM ruby:3.1.2-slim -# Install system dependencies RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \ build-essential \ @@ -17,28 +16,18 @@ RUN apt-get update -qq && \ pkg-config \ && rm -rf /var/lib/apt/lists/* -# Skip Chrome for now - we can add it later if needed for testing -# The Rails app will work fine without Chrome for basic API functionality - -# Set working directory WORKDIR /rails -# Copy Gemfile first for better layer caching COPY Gemfile Gemfile.lock ./ -# Install bundler and gems RUN gem install bundler:2.2.22 RUN bundle install -# Copy application code COPY . . -# Create necessary directories RUN mkdir -p /data/tmp public/storage/tmp tmp/pids && \ chmod 777 /data/tmp public/storage/tmp tmp/pids -# Expose port EXPOSE 3000 -# Default command CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 753a3ec7..3933417c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# /config/application.rb require_relative 'boot' require 'rails' diff --git a/docker-compose.yml b/docker-compose.yml index 3c923a41..e553aae6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -# Docker Compose Configuration for OpenTourBuilder API with HTTPS -# This enables proper HTTPS support as intended by the project + services: - # PostgreSQL Database with PostGIS postgres: image: postgis/postgis:14-3.2 platform: linux/amd64 @@ -20,7 +18,6 @@ services: timeout: 5s retries: 5 - # Redis for Action Cable redis: image: redis:7-alpine ports: @@ -28,13 +25,11 @@ services: volumes: - redis_data:/data - # Rails Application with HTTPS web: build: . ports: - "3000:3000" environment: - # Database Configuration DB_ADAPTER: postgresql DB_HOSTNAME: postgres DB_USERNAME: user @@ -42,23 +37,19 @@ services: DB_NAME: otb_development TEST_DB_NAME: otb_test - # Redis Configuration REDIS_URL: redis://redis:6379/1 - # Rails Configuration RAILS_ENV: development RAILS_SERVE_STATIC_FILES: "true" - # SSL Configuration BASE_URL: "https://localhost:3000" - # Bypass credentials temporarily IPINFO_TOKEN: "development_token" volumes: - .:/rails - tmp_data:/data/tmp - storage_tmp:/rails/public/storage/tmp - - ssl_certs:/rails/ssl # Mount for SSL certificates + - ssl_certs:/rails/ssl depends_on: postgres: condition: service_healthy @@ -87,4 +78,4 @@ volumes: redis_data: tmp_data: storage_tmp: - ssl_certs: # Volume for SSL certificates \ No newline at end of file + ssl_certs: \ No newline at end of file From 8b4a020a8ef4aaa462c25f5f2663025021c57dcf Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Wed, 13 Aug 2025 16:03:04 -0400 Subject: [PATCH 156/160] Fix MySQL service container configuration in GitHub Actions --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 35bfc9ae..a162d5b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,6 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=3 - --default-authentication-plugin=mysql_native_password ports: - 3306:3306 From 7b9118701b20da12d5d2a30306197879cd3e5f54 Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Wed, 13 Aug 2025 16:09:24 -0400 Subject: [PATCH 157/160] Simplify test workflow to use PostgreSQL only --- .github/workflows/test.yml | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a162d5b7..d160ef0c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,22 +35,6 @@ jobs: ports: - 5432:5432 - mysql: - image: mysql:8.0 - env: - MYSQL_DATABASE: otb_test - MYSQL_USER: user - MYSQL_PASSWORD: password - MYSQL_ROOT_PASSWORD: password - MYSQL_ALLOW_EMPTY_PASSWORD: yes - options: >- - --health-cmd="mysqladmin ping" - --health-interval=10s - --health-timeout=5s - --health-retries=3 - ports: - - 3306:3306 - steps: - name: Checkout code uses: actions/checkout@v4 @@ -64,7 +48,7 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y postgresql-client imagemagick libpq-dev gdal-bin libgdal-dev libappindicator1 fonts-liberation + sudo apt-get install -y postgresql-client imagemagick libpq-dev gdal-bin libgdal-dev fonts-liberation wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb sudo dpkg -i google-chrome-stable_current_amd64.deb || true sudo apt-get install -f -y @@ -77,16 +61,12 @@ jobs: mkdir -p public/storage/tmp chmod 777 public/storage/tmp - - name: Wait for databases to be ready + - name: Wait for database to be ready run: | until pg_isready -h localhost -p 5432 -U postgres; do echo "Waiting for PostgreSQL..." sleep 2 done - until mysqladmin ping -h 127.0.0.1 -P 3306 -u root -ppassword --silent; do - echo "Waiting for MySQL..." - sleep 2 - done - name: Set up PostgreSQL database run: | From 44885de776a6def13fe41f8ae9844857a5d481bf Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Wed, 13 Aug 2025 16:13:56 -0400 Subject: [PATCH 158/160] Simplify test workflow to use PostgreSQL only --- .github/workflows/test.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d160ef0c..8139395a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,19 +78,12 @@ jobs: - name: Run RSpec tests run: | - DB_ADAPTER=postgresql bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec.xml - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: tmp/rspec.xml + DB_ADAPTER=postgresql bundle exec rspec --format progress - name: Upload coverage to Coveralls if: github.event_name == 'push' uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: ./coverage/lcov.info + path-to-lcov: ./coverage/lcov/otb-api-server.lcov continue-on-error: true \ No newline at end of file From 17864c09209773eabb2f576cc9742e0bacdcc41e Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Wed, 13 Aug 2025 16:36:18 -0400 Subject: [PATCH 159/160] Fix Faker::Internet.safe_email compatibility issue --- .github/workflows/test.yml | 10 +--------- spec/controllers/v3/users_controller_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8139395a..55109b48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,12 +78,4 @@ jobs: - name: Run RSpec tests run: | - DB_ADAPTER=postgresql bundle exec rspec --format progress - - - name: Upload coverage to Coveralls - if: github.event_name == 'push' - uses: coverallsapp/github-action@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: ./coverage/lcov/otb-api-server.lcov - continue-on-error: true \ No newline at end of file + DB_ADAPTER=postgresql bundle exec rspec --format progress \ No newline at end of file diff --git a/spec/controllers/v3/users_controller_spec.rb b/spec/controllers/v3/users_controller_spec.rb index 91dec985..872f7425 100644 --- a/spec/controllers/v3/users_controller_spec.rb +++ b/spec/controllers/v3/users_controller_spec.rb @@ -95,7 +95,7 @@ describe 'POST #create' do let(:user) { create(:user, super: false) } - let(:valid_params) { { data: { type: 'users', attributes: { display_name: Faker::Music::Hiphop.artist, email: Faker::Internet.safe_email } }, tenant: 'public' } } + let(:valid_params) { { data: { type: 'users', attributes: { display_name: Faker::Music::Hiphop.artist, email: Faker::Internet.email } }, tenant: 'public' } } context 'unauthorized' do it 'does not create a new User when unauthenticated' do @@ -276,4 +276,4 @@ end end end -end +end \ No newline at end of file From c1bfe5f63b2b0e368e01800c7cdbe62e3154bdfc Mon Sep 17 00:00:00 2001 From: Atharva Negi Date: Tue, 21 Oct 2025 13:47:25 -0400 Subject: [PATCH 160/160] Migrate to Docker/ECS deployment - Add build.sh script for Docker build and ECR push - Add entrypoint.sh for migrations and server startup - Update Dockerfile to use entrypoint script - Update deploy workflow for ECS deployment - Remove old credentials.yml.enc and docker-compose.yml --- .github/workflows/deploy.yml | 76 ++++++++++----------------------- Dockerfile | 5 ++- Gemfile.lock | 7 ++++ build.sh | 29 +++++++++++++ config/credentials.yml.enc | 1 - docker-compose.yml | 81 ------------------------------------ entrypoint.sh | 4 ++ 7 files changed, 66 insertions(+), 137 deletions(-) create mode 100755 build.sh delete mode 100644 config/credentials.yml.enc delete mode 100644 docker-compose.yml create mode 100755 entrypoint.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ec49e53e..4faa2c7f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,6 +17,10 @@ on: env: RAILS_ENV: production +permissions: + id-token: write # Required for AWS OIDC + contents: read + jobs: test: runs-on: ubuntu-latest @@ -72,71 +76,37 @@ jobs: needs: test runs-on: ubuntu-latest - environment: - name: ${{ - github.event.inputs.environment || - (github.ref == 'refs/heads/main' && 'production') || - (github.ref == 'refs/heads/develop' && 'staging') - }} - steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - ruby-version: 3.1.2 - bundler-cache: true - - - name: Determine deployment environment - id: deploy-env - run: | - if [[ "${{ github.event.inputs.environment }}" != "" ]]; then - echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - echo "environment=production" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref }}" == "refs/heads/develop" ]]; then - echo "environment=staging" >> $GITHUB_OUTPUT - else - echo "No deployment environment determined" - exit 1 - fi - - - name: Configure SSH key - run: | - mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - cat >> ~/.ssh/config << EOF - Host * - IdentityFile ~/.ssh/deploy_key - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - EOF + role-to-assume: ${{ secrets.AWS_ROLE }} + aws-region: us-east-1 + role-session-name: GitHub-OIDC-OTB - - name: Add deployment servers to known hosts - run: | - ssh-keyscan -H 3.238.239.164 >> ~/.ssh/known_hosts - ssh-keyscan -H 3.86.138.59 >> ~/.ssh/known_hosts - ssh-keyscan -H 3.236.252.227 >> ~/.ssh/known_hosts - - - name: Deploy with Capistrano - run: | - environment="${{ steps.deploy-env.outputs.environment }}" - echo "Deploying to environment: $environment" - bundle exec cap $environment deploy + - name: Deploy to ECS env: - RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + AWS_ECR: ${{ secrets.AWS_ECR }} + AWS_ECS_CLUSTER_DEV: ${{ secrets.AWS_ECS_CLUSTER_DEV }} + AWS_ECS_CLUSTER_PROD: ${{ secrets.AWS_ECS_CLUSTER_PROD }} + AWS_ECS_SERVICE_DEV: ${{ secrets.AWS_ECS_SERVICE_DEV }} + AWS_ECS_SERVICE_PROD: ${{ secrets.AWS_ECS_SERVICE_PROD }} + AWS_REGION: ${{ secrets.AWS_REGION }} + BRANCH: ${{ github.ref_name }} + run: | + chmod +x build.sh + ./build.sh - name: Notify deployment status if: always() run: | - environment="${{ steps.deploy-env.outputs.environment }}" + branch="${{ github.ref_name }}" + environment=$([ "$branch" == "main" ] && echo "production" || echo "staging") if [[ "${{ job.status }}" == "success" ]]; then - echo "Successfully deployed to $environment" + echo "Successfully deployed to $environment via ECS" else echo "Deployment to $environment failed" fi \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 65c7fa34..a2610361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,3 @@ - FROM ruby:3.1.2-slim RUN apt-get update -qq && \ @@ -30,4 +29,6 @@ RUN mkdir -p /data/tmp public/storage/tmp tmp/pids && \ EXPOSE 3000 -CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] \ No newline at end of file +RUN chmod +x ./entrypoint.sh + +ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 1cb8aa3d..48a30662 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -252,6 +252,10 @@ GEM nio4r (2.5.8) nokogiri (1.13.8-aarch64-linux) racc (~> 1.4) + nokogiri (1.13.8-arm64-darwin) + racc (~> 1.4) + nokogiri (1.13.8-x86_64-darwin) + racc (~> 1.4) nokogiri (1.13.8-x86_64-linux) racc (~> 1.4) oauth (0.5.10) @@ -385,6 +389,9 @@ GEM PLATFORMS aarch64-linux + arm64-darwin-24 + x86_64-darwin-20 + x86_64-darwin-21 x86_64-linux DEPENDENCIES diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..ef706fc2 --- /dev/null +++ b/build.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +TAG=$([ "$BRANCH" == "main" ] && echo "stable" || echo "latest") + +AWS_ECS_CLUSTER=$([ "$BRANCH" == "main" ] && echo "$AWS_ECS_CLUSTER_PROD" || echo "$AWS_ECS_CLUSTER_DEV") + +AWS_ECS_SERVICE=$([ "$BRANCH" == "main" ] && echo "$AWS_ECS_SERVICE_PROD" || echo "$AWS_ECS_SERVICE_DEV") + +echo "Building image for branch: $BRANCH with tag: $TAG" + +docker build \ + --file Dockerfile \ + -t otb \ + . + +echo "Logging in to AWS" +aws ecr get-login-password --region us-east-1 | + docker login --username AWS --password-stdin "${AWS_ECR}" +echo "Logged in successfully" + +echo "Tagging image with $TAG" +docker tag otb "${AWS_ECR}/otb:${TAG}" + +echo "Pushing image" +docker push "${AWS_ECR}/otb:${TAG}" + +echo "Force update service" +aws ecs update-service --cluster ${AWS_ECS_CLUSTER} --service ${AWS_ECS_SERVICE} --force-new-deployment --region ${AWS_REGION} \ No newline at end of file diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 13a0f81c..00000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -6/CEABsiU2EAFVnKvXYSVL+cgaekTmMBMB4Lf8LI6W9Yb80z/g2hSG4wwfgCdIkGivjIhadD8/NUrJ5ubn4uBrl0/rcz5WYqnrR2//DzbRTFnxELA7ceOBtXgQ1JVtwzZwuuSc2GxKFqcUNga/orpaPKneFgbFX3rolfvmlPxQGAtwt9+HuzjjmKklJR5KQ6FIMLn1gWWcL2utZnyl/EiTMNkK1zt+Io6YmIx5IkChSbcDF3Rs7dMBy5xHKKyaOaxGFLu6WfSWQ0B5gSVA+NWPNGIWHrWVW5WEhdddomx20KVesbI0Mtl5BTjVQRFUICv7z6rfnFNH0UMecXm/JT8GC+JgHTo28n8feUeXkgr3Du76gAUilRZERKUhP0cyEx1UBGslT5dTcqHdjC4E3FpQ2jqw84udtIBU+R7KiFJqxVMniarDwhS4khgK6cQpZLNy7S9ZztZYmEJLJhBTpFyB9tQ0G8TquqglTfK1CJx5x0KFVN/6I5FlHwoD1f8L+qxZjC8x5EZV9k2G3Nzfyajx+281SYE0b1nuG2FxJIc014apJVSkyaSX3wn20aLBC9+pAFAGuIE5cnX/iNXRpc7gfL1aP8yRdJR+xRSQKq0hvfNFXPy+Jep7ysDb12YdjRzKSYqM/vd6hO4TgK9x7c8dqaSisSj4N3WzVdyAakAzPRX6Y1EXJYhrC+bU7zR4PFBC3voWketntkA9OZXjLvckKiZQgBgbPPwE+aFYmdI4Tmut9rdj6pvfRNlnS12YtQVTKZEvMzyXtlknrOqoZ8nnb/BUIE3I7FOlVr7CdgCx04c7AnbkUYFswoMtD5vLiR3c5vK5Z3qcqbw5T6Qs1rnRVAG5cLhMTyGjx+SPgbDtwz/wGT/SwiE5RrViADNDL4RpW1rKseTzYzvIM2GU4EuJMigrU2Grujn6hbzSAcfDbXeqD44bgvMVlrDiMvodJrFyA5wynhWApLwklTXcQuD3rqyj+tdXNswVf9XtCXgGYPncwjiJlsFtB+HL2l13VKgD9Z8irwf3gEdazcBUCl4DIkvDHnUobFGB/3iGsDcuYAWfEUrHsEOVXWrxt8YabyWr8usKYK5Ma01Wm4uQMTGd0hn4/BPMelZjXaN1fEZA9LYKPAsW7EkokDqEmWAGN17QUFreLrWzPjj8XwmSHw6cy5MKoLHfzyCPhbaDY2EYsNUOV7mVW35V8RtCZlJ2Z1OEyBac2wmFFz2JMv1ZLLN8+gHxuScdF3DOQN24ChmPxXQl1FtFdCjfApjeNd/4jx+wBeSw2FhPG0Ba3gYEQkt2g=--RSoluR1gW7U5RgSO--BLWcG/JoZs7xUk7LRJtNhQ== \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e553aae6..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,81 +0,0 @@ - - -services: - postgres: - image: postgis/postgis:14-3.2 - platform: linux/amd64 - environment: - POSTGRES_DB: otb_development - POSTGRES_USER: user - POSTGRES_PASSWORD: password - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U user -d otb_development"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - ports: - - "6379:6379" - volumes: - - redis_data:/data - - web: - build: . - ports: - - "3000:3000" - environment: - DB_ADAPTER: postgresql - DB_HOSTNAME: postgres - DB_USERNAME: user - DB_PASSWORD: password - DB_NAME: otb_development - TEST_DB_NAME: otb_test - - REDIS_URL: redis://redis:6379/1 - - RAILS_ENV: development - RAILS_SERVE_STATIC_FILES: "true" - - BASE_URL: "https://localhost:3000" - - IPINFO_TOKEN: "development_token" - volumes: - - .:/rails - - tmp_data:/data/tmp - - storage_tmp:/rails/public/storage/tmp - - ssl_certs:/rails/ssl - depends_on: - postgres: - condition: service_healthy - stdin_open: true - tty: true - command: > - bash -c " - echo 'Installing dependencies...' && - bundle install && - echo 'Creating necessary directories...' && - mkdir -p /data/tmp public/storage/tmp tmp/pids ssl && - chmod 777 /data/tmp public/storage/tmp && - echo 'Generating self-signed SSL certificate...' && - openssl req -x509 -newkey rsa:4096 -keyout ssl/key.pem -out ssl/cert.pem -days 365 -nodes -subj '/CN=localhost' && - echo 'Setting up database...' && - RAILS_ENV=development bundle exec rails db:create 2>/dev/null || echo 'Database already exists' && - RAILS_ENV=development bundle exec rails db:test:prepare 2>/dev/null || echo 'Test database setup complete' && - echo 'Loading schema (skipping problematic migrations)...' && - RAILS_ENV=development bundle exec rails db:schema:load && - echo 'Starting Rails server with HTTPS...' && - RAILS_ENV=development bundle exec puma -b 'ssl://0.0.0.0:3000?key=ssl/key.pem&cert=ssl/cert.pem' - " - -volumes: - postgres_data: - redis_data: - tmp_data: - storage_tmp: - ssl_certs: \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 00000000..71261527 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ + +/usr/local/bin/bundle exec rake db:migrate + +/usr/local/bin/bundle exec rails server -b 0.0.0.0 \ No newline at end of file