diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..c9381d3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,47 @@ +require: + - rubocop-capybara + - rubocop-rails + - rubocop-rspec + +AllCops: + NewCops: enable + Exclude: + - 'db/migrate/2023041*.rb' + - 'db/schema.rb' + - 'bin/bundle' + +Rails: + Enabled: true + +# Personal Customizations +Layout/LineLength: + Max: 150 + +Layout/EmptyLineAfterGuardClause: + Enabled: false + +Metrics/BlockLength: + Exclude: + - 'config/environments/*.rb' + +# follow standardrb +Style/StringLiterals: + EnforcedStyle: double_quotes + +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: no_space + +Layout/ArgumentAlignment: + EnforcedStyle: with_fixed_indentation + +Layout/ArrayAlignment: + EnforcedStyle: with_fixed_indentation + +Metrics/MethodLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Rails/I18nLocaleTexts: + Enabled: false \ No newline at end of file diff --git a/Gemfile b/Gemfile index 5235267..f8f8483 100644 --- a/Gemfile +++ b/Gemfile @@ -1,69 +1,52 @@ +# frozen_string_literal: true + source "https://rubygems.org" + git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.2.1" -gem "rails", "~> 7.0.4", ">= 7.0.4.3" - -gem "sprockets-rails" -gem "pg", "~> 1.1" -gem "puma", "~> 5.0" - -# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] -gem "importmap-rails" -gem "turbo-rails" -gem "stimulus-rails" -gem "tailwindcss-rails" -gem "jbuilder" - -# Use Redis adapter to run Action Cable in production -# gem "redis", "~> 4.0" - -# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] -# gem "kredis" - -# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -gem "bcrypt", "~> 3.1.7" -gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ] +# gem 'googleauth' +# gem 'image_processing', '~> 1.2' # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem 'importmap-rails', '~> 1.1' +# gem 'kredis' # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] +# gem 'omniauth-rails_csrf_protection' +# gem 'redis', '~> 4.0' # Use Redis adapter to run Action Cable in production +# gem 'sassc-rails' # Use Sass to process CSS +# gem 'attr_encrypted' +gem "activerecord-session_store" +gem "active_storage-postgresql" # https://github.com/lsylvester/active_storage-postgresql +gem "bcrypt", "~> 3.1.7" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bootsnap", require: false - -# Use Sass to process CSS -# gem "sassc-rails" - -# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] -# gem "image_processing", "~> 1.2" - -################################################################################ -# forestbot +gem "google-apis-core" +gem "google-apis-docs_v1" +gem "google-apis-drive_v3" +gem "google-apis-sheets_v4" +gem "importmap-rails" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "jbuilder" gem "omniauth" gem "omniauth-google-oauth2" -# gem "omniauth-rails_csrf_protection" +gem "pg", "~> 1.1" +gem "puma", "~> 5.0" gem "rack-cors" -gem "google-apis-drive_v3" -gem "google-apis-sheets_v4" -gem "google-apis-docs_v1" -gem 'google-apis-core' -# gem "googleauth" -gem "activerecord-session_store" -gem 'redis-rails' -# gem 'attr_encrypted' -# gem "importmap-rails", "~> 1.1" - -# 5.0 doesn't work with session state storage crap -gem "redis", "~> 4.8.1", "< 5" - -# Store activestorage files in postgres: -# rails active_storage:install -# rails active_storage:postgresql:install -# rails db:migrate -# see config in https://github.com/lsylvester/active_storage-postgresql -gem 'active_storage-postgresql' - -################################################################################ +gem "rails", "~> 7.0.4", ">= 7.0.4.3" +gem "redis", "~> 4.8.1", "< 5" # 5.0 doesn't work with session state storage crap +gem "redis-rails" +gem "rspec-rails", "~> 6.0" +gem "rubocop", require: false +gem "rubocop-rails", require: false +gem "rubocop-rspec", require: false +gem "sprockets-rails" +gem "standard", "~> 1.26" +gem "stimulus-rails" +gem "tailwindcss-rails" +gem "turbo-rails" +gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] +gem "yard", "~> 0.9.34" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri mingw x64_mingw ] + gem "debug", platforms: %i[mri mingw x64_mingw] end group :development do @@ -71,10 +54,10 @@ group :development do gem "web-console" # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] - # gem "rack-mini-profiler" + # gem 'rack-mini-profiler' # Speed up commands on slow machines / big apps [https://github.com/rails/spring] - # gem "spring" + # gem 'spring' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 493a4e4..ba97769 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -77,6 +77,7 @@ GEM tzinfo (~> 2.0) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) bcrypt (3.1.18) bindex (0.8.1) bootsnap (1.16.0) @@ -98,6 +99,7 @@ GEM irb (>= 1.5.0) reline (>= 0.3.1) declarative (0.0.20) + diff-lcs (1.5.0) erubi (1.12.0) faraday (2.7.4) faraday-net_http (>= 2.0, < 3.1) @@ -140,7 +142,9 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + json (2.6.3) jwt (2.7.0) + language_server-protocol (3.17.0.3) loofah (2.20.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -192,6 +196,9 @@ GEM oauth2 (>= 1.4, < 3) omniauth (~> 2.0) os (1.1.4) + parallel (1.22.1) + parser (3.2.2.0) + ast (~> 2.4.1) pg (1.4.6) public_suffix (5.0.1) puma (5.6.5) @@ -230,6 +237,7 @@ GEM rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) + rainbow (3.1.1) rake (13.0.6) redis (4.8.1) redis-actionpack (5.3.0) @@ -257,6 +265,48 @@ GEM uber (< 0.2.0) retriable (3.1.2) rexml (3.2.5) + rspec-core (3.12.1) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-rails (6.0.1) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.11) + rspec-expectations (~> 3.11) + rspec-mocks (~> 3.11) + rspec-support (~> 3.11) + rspec-support (3.12.0) + rubocop (1.48.1) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.26.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.28.0) + parser (>= 3.2.1.0) + rubocop-capybara (2.17.1) + rubocop (~> 1.41) + rubocop-performance (1.16.0) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rails (2.19.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + rubocop-rspec (2.19.0) + rubocop (~> 1.33) + rubocop-capybara (~> 2.17) + ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) selenium-webdriver (4.8.6) @@ -278,6 +328,10 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + standard (1.26.0) + language_server-protocol (~> 3.17.0.2) + rubocop (~> 1.48.1) + rubocop-performance (~> 1.16.0) stimulus-rails (1.2.1) railties (>= 6.0.0) tailwindcss-rails (2.0.27-arm64-darwin) @@ -294,6 +348,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) + unicode-display_width (2.4.2) version_gem (1.1.2) web-console (4.2.0) actionview (>= 6.0.0) @@ -311,6 +366,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yard (0.9.34) zeitwerk (2.6.7) PLATFORMS @@ -338,14 +394,20 @@ DEPENDENCIES rails (~> 7.0.4, >= 7.0.4.3) redis (~> 4.8.1, < 5) redis-rails + rspec-rails (~> 6.0) + rubocop + rubocop-rails + rubocop-rspec selenium-webdriver sprockets-rails + standard (~> 1.26) stimulus-rails tailwindcss-rails turbo-rails tzinfo-data web-console webdrivers + yard (~> 0.9.34) RUBY VERSION ruby 3.2.1p31 diff --git a/Rakefile b/Rakefile index 9a5ea73..22e3fdf 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,6 @@ +# Rakefile +# frozen_string_literal: true + # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb index d672697..5a4c720 100644 --- a/app/channels/application_cable/channel.rb +++ b/app/channels/application_cable/channel.rb @@ -1,3 +1,6 @@ +# app/channels/application_cable/channel.rb +# frozen_string_literal: true + module ApplicationCable class Channel < ActionCable::Channel::Base end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442..c95613a 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,3 +1,6 @@ +# app/channels/application_cable/connection.rb +# frozen_string_literal: true + module ApplicationCable class Connection < ActionCable::Connection::Base end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 119ca68..20ebe96 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,11 +1,15 @@ # app/controllers/application_controller.rb +# frozen_string_literal: true +# ApplicationController is the base class for all controllers in this application. +# It provides methods that are available to all controllers. +# We are using it to globally require authentication for all controllers. class ApplicationController < ActionController::Base protect_from_forgery with: :exception helper_method :current_user, :logged_in? before_action :authenticate_user! - skip_before_action :authenticate_user!, only: [:omniauth_callback, :index, :login] + skip_before_action :authenticate_user!, only: %i[omniauth_callback index destroy] # rubocop:disable Rails/LexicallyScopedActionFilter private @@ -18,14 +22,12 @@ def logged_in? # Current oauth user def current_user entry_dbg - @current_user ||= User.find_by_id(session[:user_id]) + @current_user ||= User.find_by(id: session[:user_id]) end # Ensure user is logged in before allowing them to access a page def authenticate_user! entry_dbg - unless logged_in? - redirect_to root_path, alert: 'You need to sign in first!' - end + logged_in? ? true : redirect_to(root_path, alert: "You need to sign in first!") end end diff --git a/app/controllers/forest_projects_controller.rb b/app/controllers/forest_projects_controller.rb index 64c5cea..0b44ae9 100644 --- a/app/controllers/forest_projects_controller.rb +++ b/app/controllers/forest_projects_controller.rb @@ -1,5 +1,10 @@ -require 'csv' +# app/controllers/forest_projects_controller.rb +# frozen_string_literal: true +require "csv" + +# ForestProjectsController is responsible for handling the CRUD actions for +# ForestProject records. class ForestProjectsController < ApplicationController before_action :authenticate_user! @@ -9,6 +14,32 @@ def index current_user.refresh_tokens! end + def show + entry_dbg + @forest_project = current_user.forest_projects.find(params[:id]) + + @species_summary = @forest_project.species_summary || nil + @condition_summary = @forest_project.condition_summary || nil + @dbh_summary = @forest_project.dbh_summary || nil + @total_sites = @forest_project.total_sites || nil + + @fraxinus_species_summary = @forest_project.fraxinus_species_summary || nil + @fraxinus_condition_summary = @forest_project.fraxinus_condition_summary || nil + @fraxinus_dbh_summary = @forest_project.fraxinus_dbh_summary || nil + @fraxinus_total_sites = @forest_project.fraxinus_total_sites || nil + + if @forest_project.csv_url + csv_contents = @forest_project.csv_url.download + @csv_data = CSV.parse(csv_contents, headers: true, header_converters: :symbol)&.map(&:to_h) + @csv_filename = @forest_project.csv_url.filename.to_s + @csv_url = url_for(@forest_project.csv_url) if @forest_project.csv_url.attached? + end + + @google_folder = @forest_project.google_folder || nil + @google_sheet = @forest_project.google_sheet || nil + @google_doc = @forest_project.google_doc || nil + end + def new entry_dbg @forest_project = ForestProject.new @@ -16,7 +47,7 @@ def new def status @forest_project = current_user.forest_projects.find(params[:id]) - render json: { status: @forest_project.status } + render json: {status: @forest_project.status} end def create @@ -25,9 +56,9 @@ def create if @forest_project.save ForestProjectJob.perform_later(@forest_project.id, session[:user_id]) - redirect_to forest_project_path(@forest_project), notice: 'Project created successfully.' + redirect_to forest_project_path(@forest_project), notice: "Project created successfully." else - render :new + redirect_to new_forest_project_path, alert: "Project creation failed." end end @@ -37,39 +68,12 @@ def reprocess if @forest_project.present? ForestProjectJob.perform_later(@forest_project.id, session[:user_id]) - redirect_to forest_project_path(@forest_project), notice: 'File reprocessing started.' + redirect_to forest_project_path(@forest_project), notice: "File reprocessing started." else - redirect_to forest_projects_path, alert: 'Project not found.' + redirect_to forest_projects_path, alert: "Project not found." end end - def show - entry_dbg - user_id = session[:user_id] - @forest_project = current_user.forest_projects.find(params[:id]) - - @species_summary = @forest_project.species_summary || nil - @condition_summary = @forest_project.condition_summary || nil - @dbh_summary = @forest_project.dbh_summary || nil - @total_sites = @forest_project.total_sites || nil - - @fraxinus_species_summary = @forest_project.fraxinus_species_summary || nil - @fraxinus_condition_summary = @forest_project.fraxinus_condition_summary || nil - @fraxinus_dbh_summary = @forest_project.fraxinus_dbh_summary || nil - @fraxinus_total_sites = @forest_project.fraxinus_total_sites || nil - - if @forest_project.csv - csv_contents = @forest_project.csv.download - @csv_data = CSV.parse(csv_contents, headers: true, header_converters: :symbol).map(&:to_h) - @csv_filename = @forest_project.csv.filename.to_s - @csv_url = url_for(@forest_project.csv) if @forest_project.csv.attached? - end - - @google_folder = @forest_project.google_folder || nil - @google_sheet = @forest_project.google_sheet || nil - @google_doc = @forest_project.google_doc || nil - end - def download_csv entry_dbg @forest_project = current_user.forest_projects.find(params[:id]) @@ -80,6 +84,6 @@ def download_csv def project_params entry_dbg - params.require(:forest_project).permit(:client_name, :project_name, :csv, :project_date) + params.require(:forest_project).permit(:client_name, :project_name, :csv_url, :project_date) end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 4807e25..6a7d18e 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,9 +1,9 @@ +# app/controllers/home_controller.rb # frozen_string_literal: true +# HomeController is responsible for handling the root path. class HomeController < ApplicationController def index - end - - def login + # noop end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cc36b1a..18e78d5 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,8 @@ # app/controllers/sessions_controller.rb - # frozen_string_literal: true +# SessionsController is responsible for handling the OAuth callback from +# Google and for handling user logout. class SessionsController < ApplicationController skip_before_action :verify_authenticity_token, only: :omniauth_callback @@ -28,8 +29,6 @@ def destroy def auth entry_dbg - request.env['omniauth.auth'] + request.env["omniauth.auth"] end - - end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..3e24f8d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,8 @@ +# app/helpers/application_helper.rb +# frozen_string_literal: true + +# ApplicationHelper is a module that is included in all controllers and views. +# It is a good place to put methods that are used in multiple places in your +# application. module ApplicationHelper end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d..9cb89b0 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,6 @@ +# app/jobs/application_job.rb +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/app/jobs/forest_project_job.rb b/app/jobs/forest_project_job.rb index bb65ac0..fa0a098 100644 --- a/app/jobs/forest_project_job.rb +++ b/app/jobs/forest_project_job.rb @@ -1,9 +1,20 @@ # app/jobs/forest_project_job.rb # fka app/jobs/process_forest_project.rb - -require 'csv' -require 'google_drive_client' - +# frozen_string_literal: true + +require "csv" +require "google_drive_client" + +# This job is responsible for processing a ForestProject +# It will: +# - Read the CSV file +# - Populate the ForestProject with statistics from the CSV file +# - Create a Google Drive folder +# - Create a Google Sheet containing the CSV data +# - Create a Google Doc, a report based on the statistics +# - Populate the Google Doc with data from the CSV file +# - Populate the Google Sheet with data from the CSV file +# - Create a Google Table in the Google Doc with data from the CSV file class ForestProjectJob < ApplicationJob queue_as :default @@ -15,25 +26,25 @@ def perform(project_id, user_id) project.update(error_message: nil) if project.error_message user = User.find(user_id) - project.update(status: '👩🏻🔬Calculating') + project.update(status: "👩🏻🔬Calculating") - csv_data = read_and_validate_csv(project.csv) + csv_data = read_and_validate_csv(project.csv_url) tree_counts = count_trees(csv_data) tree_counts.merge!(count_trees(csv_data, only_fraxinus: true)) total_ash_trees = tree_counts[:fraxinus_total_sites] - project.update(status: '👩🏻💻Creating Folder', tree_counts: tree_counts) + project.update(status: "👩🏻💻Creating Folder", tree_counts:) google = GoogleDriveClient.new(user) folder = google.create_folder( "#{project.client_name} - #{project.project_name} - #{project.project_date}", - ENV['GOOGLE_DRIVE_FOLDER_ID'] + ENV.fetch("GOOGLE_DRIVE_FOLDER_ID") ) _ = project.update!(status: "👩🏻💻Creating Sheet", google_drive_folder_id: folder.id) sheet = google.copy_file( - ENV['GOOGLE_SHEETS_TEMPLATE_ID'], + ENV.fetch("GOOGLE_SHEETS_TEMPLATE_ID"), "Data- #{project.client_name} - #{project.project_name} - #{project.project_date}", folder.id ) @@ -43,32 +54,31 @@ def perform(project_id, user_id) _ = project.update!(status: "👩🏻💻Creating Doc", google_spreadsheet_id: sheet.id) doc = google.copy_file( - ENV['GOOGLE_DOCS_TEMPLATE_ID'], + ENV.fetch("GOOGLE_DOCS_TEMPLATE_ID"), "Report- #{project.client_name} - #{project.project_name} - #{project.project_date}", folder.id ) _ = project.update!(status: "👩🏻💻Populating Doc", google_doc_id: doc.id) - google.replace_placeholders_in_doc_with_values( + google.replace_doc_placeholders( doc.id, [ - { '{{client_name}}': project.client_name.to_s }, - { '{{project_name}}': project.project_name.to_s }, - { '{{project_date}}': project.project_date.to_s }, - { '{{total_sites}}': project.total_sites.to_s }, - { '{{total_ash_trees}}': total_ash_trees.to_s }, - { '{{data_table}}': "=IMPORTDATA(\"https://docs.google.com/spreadsheets/d/#{sheet.id}/gviz/tq?tqx=out:csv&sheet=Data\")" } + {"{{client_name}}": project.client_name.to_s}, + {"{{project_name}}": project.project_name.to_s}, + {"{{project_date}}": project.project_date.to_s}, + {"{{total_sites}}": project.total_sites.to_s}, + {"{{total_ash_trees}}": total_ash_trees.to_s} ] ) _ = project.update!(status: "👩🏻💻Inserting Table") google.insert_table_into_doc(doc.id, csv_data) - project.update(status: '✅ Done') - rescue StandardError => e - # Update project with the error message and set status to 'Error' + project.update(status: "✅ Done") + rescue => e # rubocop:disable Style/RescueStandardError + # Update project with the error message and set status to "Error" now = Time.current - project.update(status: '🛑 Processing Error', error_message: "#{now}\n\n#{e.message}\n\n#{e.backtrace.join("\n")}") + project.update(status: "🛑 Processing Error", error_message: "#{now}\n\n#{e.message}\n\n#{e.backtrace.join("\n")}") end end @@ -78,7 +88,7 @@ def read_and_validate_csv(csv) entry_dbg begin # Define the expected header row - mandatory_headers = %w[pid common_name scientific_name condition dbh] + mandatory_headers = %w[{pid common_name scientific_name condition dbh] csv_contents = csv.download csv_data = CSV.parse(csv_contents, headers: true, header_converters: :symbol).map(&:to_h) @@ -87,9 +97,8 @@ def read_and_validate_csv(csv) actual_headers = csv_data.first.keys actual_headers_s = actual_headers.map(&:to_s) missing_headers = mandatory_headers - actual_headers_s - if missing_headers.any? - raise "Missing headers: #{missing_headers}. Expected: #{mandatory_headers}, Actual: #{actual_headers_s}" - end + + raise "Missing headers: #{missing_headers}. Expected: #{mandatory_headers}, Actual: #{actual_headers_s}" if missing_headers.any? # Create a mapping of header names and their indices header_mapping = {} @@ -101,8 +110,8 @@ def read_and_validate_csv(csv) csv_data.map do |row| row.transform_keys! { |key| key.downcase.to_sym } end - rescue => e - @project.update(status: '🛑 Error Parsing CSV', error_message: e.message) + rescue => e # rubocop:disable Style/RescueStandardError + @project.update(status: "🛑 Error Parsing CSV", error_message: e.message) raise e end end @@ -111,39 +120,39 @@ def read_and_validate_csv_old(csv) entry_dbg begin # Define the expected header row - expected_headers = %w[pid common_name scientific_name condition tree_workpruning tree_workother tree_tag dbh within_years cycle tree_height_estimated crown_spread notesmgmt observation_comments observationscharacteristics special_equipment] + expected_headers = %w[pid common_name scientific_name condition tree_workpruning tree_workother tree_tag dbh within_years cycle + tree_height_estimated crown_spread notesmgmt observation_comments observationscharacteristics special_equipment] csv_contents = csv.download csv_data = CSV.parse(csv_contents, headers: true, header_converters: :symbol).map(&:to_h) # Validate the header row actual_headers = csv_data.first.keys.map(&:to_s) - unless actual_headers == expected_headers - raise "Invalid header row. Expected: #{expected_headers}, Actual: #{actual_headers}" - end + + raise "Invalid header row. Expected: #{expected_headers}, Actual: #{actual_headers}" unless actual_headers == expected_headers csv_data - rescue => e - @project.update(status: '🛑 Error Parsing CSV', error_message: e.message) + rescue => e # rubocop:disable Style/RescueStandardError + @project.update(status: "🛑 Error Parsing CSV", error_message: e.message) raise e end end def summarize_species(csv_data, only_fraxinus: false) entry_dbg - species_counts = Hash.new + species_counts = {} dbg "CSV data: #{csv_data}" csv_data.each do |row| dbg "Row: #{row}" - # If the scientific_name is nil, include it in the 'Unspecified' category - sci_name = row[:scientific_name] || 'Unspecified' + # If the scientific_name is nil, include it in the "Unspecified" category + sci_name = row[:scientific_name] || "Unspecified" key = sci_name.downcase.strip - # If the common_name is nil, include it in the 'Unspecified' category - common_name = row[:common_name] || 'Unspecified' + # If the common_name is nil, include it in the "Unspecified" category + common_name = row[:common_name] || "Unspecified" # If only_fraxinus is true, skip the row unless the species starts with either Fraxinus or Chionanthus - next if only_fraxinus && !row[:scientific_name].to_s.start_with?('Fraxinus', 'Chionanthus') + next if only_fraxinus && !row[:scientific_name].to_s.start_with?("Fraxinus", "Chionanthus") unless species_counts[key] species_counts[key] = { @@ -163,7 +172,7 @@ def summarize_dbh(csv_data, only_fraxinus: false) max_dbh = -Float::INFINITY csv_data.each do |row| # If only_fraxinus is true, skip the row unless the species starts with either Fraxinus or Chionanthus - next if only_fraxinus && !row[:scientific_name].to_s.start_with?('Fraxinus', 'Chionanthus') + next if only_fraxinus && !row[:scientific_name].to_s.start_with?("Fraxinus", "Chionanthus") # If row[:dbh] is nil, skip the row next if row[:dbh].nil? @@ -173,7 +182,7 @@ def summarize_dbh(csv_data, only_fraxinus: false) max_dbh = [max_dbh, dbh].max end - { min_dbh: min_dbh, max_dbh: max_dbh } + {min_dbh:, max_dbh:} end def summarize_conditions(csv_data, only_fraxinus: false) @@ -185,13 +194,13 @@ def summarize_conditions(csv_data, only_fraxinus: false) # Iterate through the CSV data csv_data.each do |row| # If the species is "Vacant", skip the row - next if row[:scientific_name] == 'Vacant' + next if row[:scientific_name] == "Vacant" - # If the condition is nil, include it in the 'Unspecified' category - condition_name = row[:condition].nil? ? 'Unspecified' : row[:condition] + # If the condition is nil, include it in the "Unspecified" category + condition_name = row[:condition].nil? ? "Unspecified" : row[:condition] # If only_fraxinus is true, skip the row unless the species starts with either Fraxinus or Chionanthus - next if only_fraxinus && !row[:scientific_name].to_s.start_with?('Fraxinus', 'Chionanthus') + next if only_fraxinus && !row[:scientific_name].to_s.start_with?("Fraxinus", "Chionanthus") condition_name = condition_name.downcase.strip condition_counts[condition_name] += 1 @@ -203,21 +212,19 @@ def summarize_conditions(csv_data, only_fraxinus: false) def count_trees(csv_data, only_fraxinus: false) entry_dbg begin - condition_summary = summarize_conditions(csv_data, only_fraxinus: only_fraxinus) - species_summary = summarize_species(csv_data, only_fraxinus: only_fraxinus) - dbh_summary = summarize_dbh(csv_data, only_fraxinus: only_fraxinus) - total_sites =species_summary.values.map { |v| v[:count] }.sum + condition_summary = summarize_conditions(csv_data, only_fraxinus:) + species_summary = summarize_species(csv_data, only_fraxinus:) + dbh_summary = summarize_dbh(csv_data, only_fraxinus:) + total_sites = species_summary.values.pluck(:count).sum { - "#{only_fraxinus ? 'fraxinus_' : ''}condition_summary": condition_summary, - "#{only_fraxinus ? 'fraxinus_' : ''}species_summary": species_summary, - "#{only_fraxinus ? 'fraxinus_' : ''}dbh_summary": dbh_summary, - "#{only_fraxinus ? 'fraxinus_' : ''}total_sites": total_sites + "#{only_fraxinus ? "fraxinus_" : ""}condition_summary": condition_summary, + "#{only_fraxinus ? "fraxinus_" : ""}species_summary": species_summary, + "#{only_fraxinus ? "fraxinus_" : ""}dbh_summary": dbh_summary, + "#{only_fraxinus ? "fraxinus_" : ""}total_sites": total_sites } - rescue => e - @project.update(status: '🛑 Error Counting Trees', error_message: e.message) + rescue => e # rubocop:disable Style/RescueStandardError + @project.update(status: "🛑 Error Counting Trees", error_message: e.message) raise e end end - end - diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..26400b5 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,9 @@ +# app/mailers/application_mailer.rb +# frozen_string_literal: true + +# ApplicationMailer is the base class for all mailers in your application. +# It sets some default values for the mailer, and allows you to define +# helper methods that can be used in all mailers. class ApplicationMailer < ActionMailer::Base default from: "from@example.com" layout "mailer" diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb..18e161f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,9 @@ +# app/models/application_record.rb +# frozen_string_literal: true + +# ApplicationRecord is the base class for all models in the application. +# It is an abstract class, meaning that it cannot be instantiated. +# It is used to define methods that are common to all models. class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/app/models/forest_project.rb b/app/models/forest_project.rb index fd2cc01..6f7ea2a 100644 --- a/app/models/forest_project.rb +++ b/app/models/forest_project.rb @@ -1,10 +1,14 @@ # app/models/forest_project.rb +# frozen_string_literal: true -require 'google_drive_client' +require "google_drive_client" +# ForestProject is a model for storing information about an urban forest +# collection project. Each is associated with a user, and has a CSV file +# attached. The CSV file is parsed and the tree counts are stored in the +# tree_counts field. class ForestProject < ApplicationRecord belongs_to :user - has_one :forest_job has_one_attached :csv @@ -20,70 +24,52 @@ def tree_counts end def species_summary - return {} unless tree_counts - tree_counts['species_summary'] || {} + tree_counts ? tree_counts["species_summary"] || {} : {} end def condition_summary - return {} unless tree_counts - tree_counts['condition_summary'] || {} + tree_counts ? tree_counts["condition_summary"] || {} : {} end def dbh_summary - return {} unless tree_counts - tree_counts['dbh_summary'] || {} + tree_counts ? tree_counts["dbh_summary"] || {} : {} end def total_sites - return {} unless tree_counts - tree_counts['total_sites'] || {} + tree_counts ? tree_counts["total_sites"] || {} : {} end def fraxinus_species_summary - return {} unless tree_counts - tree_counts['fraxinus_species_summary'] || {} + tree_counts ? tree_counts["fraxinus_species_summary"] || {} : {} end def fraxinus_condition_summary - return {} unless tree_counts - tree_counts['fraxinus_condition_summary'] || {} + tree_counts ? tree_counts["fraxinus_condition_summary"] || {} : {} end def fraxinus_dbh_summary - return {} unless tree_counts - tree_counts['fraxinus_dbh_summary'] || {} + tree_counts ? tree_counts["fraxinus_dbh_summary"] || {} : {} end def fraxinus_total_sites - return {} unless tree_counts - tree_counts['fraxinus_total_sites'] || {} + tree_counts ? tree_counts["fraxinus_total_sites"] || {} : {} end def google_folder - entry_dbg - id = self.google_drive_folder_id - return nil unless id - "https://drive.google.com/drive/u/2/folders/#{id}" + google_drive_folder_id ? "https://drive.google.com/drive/u/2/folders/#{google_drive_folder_id}" : nil end def google_sheet - entry_dbg - id = self.google_spreadsheet_id - return nil unless id - "https://docs.google.com/spreadsheets/d/#{id}/edit#gid=0" + google_spreadsheet_id ? "https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit#gid=0" : nil end def google_doc - entry_dbg - id = self.google_doc_id - return nil unless id - "https://docs.google.com/document/d/#{id}/edit" + google_doc_id ? "https://docs.google.com/document/d/#{google_doc_id}/edit" : nil end private def google_client(user) - entry_dbg @google_client ||= GoogleDriveClient.new(user) end end diff --git a/app/models/user.rb b/app/models/user.rb index 48611d2..104690c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,14 +1,21 @@ # app/models/user.rb +# frozen_string_literal: true +# User is the model for a user of the application. It is used to +# authenticate users and to store their Google OAuth tokens, which +# are used to log into the app, and to access Google Drive. class User < ApplicationRecord has_secure_password - #attr_encrypted :google_token, key: ENV['ATTR_ENCRYPTED_KEY'] - #attr_encrypted :google_refresh_token, key: ENV['ATTR_ENCRYPTED_KEY'] - #attr_encrypted :google_token_expires_at, key: ENV['ATTR_ENCRYPTED_KEY'] + # attr_encrypted :google_token, key: ENV['ATTR_ENCRYPTED_KEY'] + # attr_encrypted :google_refresh_token, key: ENV['ATTR_ENCRYPTED_KEY'] + # attr_encrypted :google_token_expires_at, key: ENV['ATTR_ENCRYPTED_KEY'] validates :uid, presence: true validates :name, presence: true - validates :email, presence: true, format: { with: /\A[\w+\-.]+@cfs\.eco\z/i, message: "must be from the cfs.eco domain" } + validates :email, presence: true, format: { + with: /\A[\w+\-.]+@cfs\.eco\z/i, + message: "must be from the cfs.eco domain" + } validates :password_digest, presence: true validates :provider, presence: true @@ -16,39 +23,44 @@ class User < ApplicationRecord def update_tokens(auth) entry_dbg + dbg "google_token: #{auth.credentials.token}" + dbg "google_token_expires_at: #{Time.at(auth.credentials.expires_at).utc}" + dbg "google_refresh_token: #{auth.credentials.refresh_token}" self.google_token = auth.credentials.token - self.google_token_expires_at = Time.at(auth.credentials.expires_at) - self.google_refresh_token = auth.credentials.refresh_token unless self.google_refresh_token.present? + self.google_token_expires_at = Time.at(auth.credentials.expires_at).utc + if google_refresh_token.blank? + self.google_refresh_token = auth.credentials.refresh_token + end save end def refresh_tokens! entry_dbg # Is the users token expired? - if self.google_token_expires_at.to_datetime.past? + if google_token_expires_at.to_datetime.past? oauth = OmniAuth::Strategies::GoogleOauth2.new( nil, # App - nil seems to be ok?! - ENV['GOOGLE_CLIENT_ID'], - ENV['GOOGLE_CLIENT_SECRET'] + ENV.fetch("GOOGLE_CLIENT_ID"), + ENV.fetch("GOOGLE_CLIENT_SECRET") ) token = OAuth2::AccessToken.new( oauth.client, - Time.at(self.google_token_expires_at), - { refresh_token: self.google_refresh_token } + Time.at(google_token_expires_at).utc.to_s, + {refresh_token: google_refresh_token} ) new_token = token.refresh! if new_token.present? - self.update( + update( google_token: new_token.token, - google_token_expires_at: Time.at(new_token.expires_at), - google_refresh_token: self.google_refresh_token || new_token.refresh_token + google_token_expires_at: Time.at(new_token.expires_at).utc, + google_refresh_token: google_refresh_token || new_token.refresh_token ) dbg "Refreshed expired user token" else warn "User refresh did not work, time to clear the session and force a re-auth." - #destroy + # destroy end end - self.google_token + google_token end end diff --git a/app/services/google_drive_client.rb b/app/services/google_drive_client.rb index 78b4dbf..36f9c3f 100644 --- a/app/services/google_drive_client.rb +++ b/app/services/google_drive_client.rb @@ -1,14 +1,15 @@ # app/services/google_drive_client.rb - # frozen_string_literal: true -require 'google/apis/drive_v3' -require 'google/apis/sheets_v4' -require 'google/apis/docs_v1' -require 'google/api_client/client_secrets' +require "google/apis/drive_v3" +require "google/apis/sheets_v4" +require "google/apis/docs_v1" +require "google/api_client/client_secrets" +# GoogleDriveClient is a wrapper around the Google Drive API, +# providing methods for interacting with the API, and handling +# secrets and requesting token refreshes. class GoogleDriveClient - def initialize(user) entry_dbg @user = user @@ -16,16 +17,15 @@ def initialize(user) @sheets_service = Google::Apis::SheetsV4::SheetsService.new @sheets_service.authorization = google_secret.to_authorization - #@sheets_service.authorization.refresh! + # @sheets_service.authorization.refresh! @drive_service = Google::Apis::DriveV3::DriveService.new @drive_service.authorization = google_secret.to_authorization - #@drive_service.authorization.refresh! + # @drive_service.authorization.refresh! @docs_service = Google::Apis::DocsV1::DocsService.new @docs_service.authorization = google_secret.to_authorization - #@docs_service.authorization.refresh! - + # @docs_service.authorization.refresh! end # 0ABpZS99k3z4gUk9PVA @@ -35,7 +35,7 @@ def url_for_file(file_id) @drive_service.get_file( file_id, supports_all_drives: true, - fields: 'webViewLink' + fields: "webViewLink" ).web_view_link end @@ -43,15 +43,15 @@ def get_file(file_id) entry_dbg @drive_service.get_file( file_id, - supports_all_drives: true, + supports_all_drives: true ) end def create_folder(name, parent_id) entry_dbg folder = Google::Apis::DriveV3::File.new( - name: name, - mime_type: 'application/vnd.google-apps.folder', + name:, + mime_type: "application/vnd.google-apps.folder", parents: [parent_id], supports_all_drives: true, include_items_from_all_drives: true, @@ -63,8 +63,8 @@ def create_folder(name, parent_id) def copy_file(file_id, name, parent_id) entry_dbg file = Google::Apis::DriveV3::File.new( - name: name, - parents: [parent_id], + name:, + parents: [parent_id] ) new_file = @drive_service.copy_file( file_id, @@ -77,11 +77,11 @@ def copy_file(file_id, name, parent_id) def move_file(file_id, parent_id) entry_dbg old_file = get_file(file_id) - old_parents = old_file&.parents&.join(',') || '' + old_parents = old_file&.parents&.join(",") || "" @drive_service.update_file( file_id, remove_parents: old_parents, - add_parents: "#{parent_id}", + add_parents: parent_id.to_s, supports_all_drives: true ) end @@ -92,14 +92,14 @@ def append_csv_to_spreadsheet(spreadsheet_id, csv_data) data = [ { - range: 'A1', + range: "A1", values: data_array } ] batch_update_values = Google::Apis::SheetsV4::BatchUpdateValuesRequest.new( - data: data, - value_input_option: 'USER_ENTERED' + data:, + value_input_option: "USER_ENTERED" ) @sheets_service.batch_update_values( spreadsheet_id, @@ -110,7 +110,7 @@ def append_csv_to_spreadsheet(spreadsheet_id, csv_data) def convert_csv_data_to_2d_array(csv_data) # Convert the array of hashes into a 2D array headers = csv_data.first.keys - data_array = csv_data.map { |row| row.values } + data_array = csv_data.map { |row| row.values } # rubocop:disable Style/SymbolProc # Add headers as the first row in the 2D array data_array.unshift(headers) @@ -118,11 +118,11 @@ def convert_csv_data_to_2d_array(csv_data) data_array end - def replace_placeholders_in_doc_with_values(document_id, replacements) + def replace_doc_placeholders(document_id, replacements) entry_dbg # replacements looks like: # [ - # { "{{first_name}}": "John" }, + # { '{{first_name}}': 'John' }, # ] requests = [] replacements.each do |replacement| @@ -138,7 +138,7 @@ def replace_placeholders_in_doc_with_values(document_id, replacements) } end end - batch_update = Google::Apis::DocsV1::BatchUpdateDocumentRequest.new(requests: requests) + batch_update = Google::Apis::DocsV1::BatchUpdateDocumentRequest.new(requests:) @docs_service.batch_update_document(document_id, batch_update) end @@ -154,41 +154,40 @@ def insert_table_into_doc(document_id, csv_data) request = Google::Apis::DocsV1::Request.new request.insert_table = table_request batch_update = Google::Apis::DocsV1::BatchUpdateDocumentRequest.new(requests: [request]) - result = @docs_service.batch_update_document(document_id, batch_update) + @docs_service.batch_update_document(document_id, batch_update) # Get the last element of the document, which should be the table document = @docs_service.get_document(document_id) body = document.body # iterate from the end of document.body.content until we find a StructuralElement with a table attribute - table_element = body.content.reverse.find { |element| element.table } - table_start_index = table_element.start_index - table_end_index = table_element.end_index + table_element = body.content.reverse.find { |element| element.table } # rubocop:disable Style/SymbolProc table = table_element.table # Insert the data into the table, working from the bottom right cell in reverse-order # so that we don't have to worry about the indexes changing as we insert text - requests = [] + batch = [] row_idx = data_array.length - 1 table.table_rows.reverse_each do |row| cell_idx = data_array[row_idx].length - 1 row.table_cells.reverse_each do |cell| - cell_start_index = cell.content[0].paragraph.elements[0].start_index + # cell_start_index = cell.content[0].paragraph.elements[0].start_index cell_end_index = cell.content[0].paragraph.elements[0].end_index cell_request = { insert_text: { - text: "#{data_array[row_idx][cell_idx] || '-'}", + text: data_array[row_idx][cell_idx].to_s || "-", location: { index: cell_end_index - 1 } } } - requests << cell_request + batch << cell_request cell_idx -= 1 end + # noinspection RubyUnusedLocalVariable row_idx -= 1 end - batch_update = Google::Apis::DocsV1::BatchUpdateDocumentRequest.new(requests: requests) + batch_update = Google::Apis::DocsV1::BatchUpdateDocumentRequest.new(requests: batch) @docs_service.batch_update_document(document_id, batch_update) end @@ -203,93 +202,14 @@ def google_secret def secret_hash entry_dbg - { "web" => - { "access_token" => @user.google_token, - "refresh_token" => @user.google_refresh_token, - "client_id" => ENV['GOOGLE_CLIENT_ID'], - "client_secret" => ENV['GOOGLE_CLIENT_SECRET'], - "grant_type" => "refresh_token" - } + { + "web" => { + "access_token" => @user.google_token, + "refresh_token" => @user.google_refresh_token, + "client_id" => ENV.fetch("GOOGLE_CLIENT_ID"), + "client_secret" => ENV.fetch("GOOGLE_CLIENT_SECRET"), + "grant_type" => "refresh_token" + } } end - - def table_for_data(csv_data) - rows = [] - # column_properties = [] - header_array = nil - csv_data.each_with_index do |row, idx_row| - new_row = row_for_table(row, idx_row, header_row: header_array) - if idx_row == 0 - header_array = row - end - rows << new_row - end - table = Google::Apis::DocsV1::Table.new - table.rows = csv_data.length - table.columns = csv_data.first.length - table.table_rows = rows - - table_style = Google::Apis::DocsV1::TableStyle.new - # table_style.table_column_properties = column_properties - table - end - - HEADER_BG_COLOR = Google::Apis::DocsV1::OptionalColor.new(color: Google::Apis::DocsV1::Color.new(rgb_color: Google::Apis::DocsV1::RgbColor.new(red: 0.0, green: 0.8, blue: 0.2))) - ALT_BG_COLOR = Google::Apis::DocsV1::OptionalColor.new(color: Google::Apis::DocsV1::Color.new(rgb_color: Google::Apis::DocsV1::RgbColor.new(red: 0.2, green: 0.2, blue: 0.2))) - TABLE_BORDER = Google::Apis::DocsV1::TableCellBorder.new( - color: Google::Apis::DocsV1::OptionalColor.new( - color: Google::Apis::DocsV1::Color.new( - rgb_color: Google::Apis::DocsV1::RgbColor.new( - red: 0.0, green: 0.0, blue: 0.0 - ) - ) - ), - width: Google::Apis::DocsV1::Dimension.new( - magnitude: 1.0, unit: 'PT' - ) - ) - - def row_for_table(csv_row, row_idx, header_row = nil) - cells = [] - csv_row.each_with_index do |cell, idx_col| - # if necessary, handle TableColumnProperties here - width and widthType - header_cell = header_row ? header_row[idx_col] : nil - cells << cell_for_row(cell, row_idx, idx_col, header_value: header_cell) - end - row = Google::Apis::DocsV1::TableRow.new - row.table_cells = cells - table_row_style = Google::Apis::DocsV1::TableRowStyle.new - table_row_style.table_header = true if row_idx == 0 - row.table_row_style = table_row_style - row - end - - def cell_for_row(cell_value, row_idx, col_idx, header_value = nil) - text_run = Google::Apis::DocsV1::TextRun.new - text_run.content = cell_value - text_run_style = Google::Apis::DocsV1::TextStyle.new - text_run_style.font_size = Google::Apis::DocsV1::Dimension.new(magnitude: 9.0, unit: 'PT') - if row_idx == 0 - text_run_style.bold = true - end - text_run.text_style = text_run_style - paragraph_element = Google::Apis::DocsV1::ParagraphElement.new(text_run: text_run) - paragraph = Google::Apis::DocsV1::Paragraph.new(elements: [paragraph_element]) - structural_element = Google::Apis::DocsV1::StructuralElement.new(paragraph: paragraph) - cell = Google::Apis::DocsV1::TableCell.new - cell.content = [structural_element] - cell_style = Google::Apis::DocsV1::TableCellStyle.new - if row_idx == 0 - cell_style.background_color = HEADER_BG_COLOR - elsif row_idx.even? - cell_style.background_color = ALT_BG_COLOR - end - cell_style.content_alignment = 'LEFT' - cell_style.border_bottom = TABLE_BORDER - cell_style.border_left = TABLE_BORDER - cell_style.border_right = TABLE_BORDER - cell_style.border_top = TABLE_BORDER - cell.table_cell_style = cell_style - cell - end end diff --git a/app/views/forest_projects/new.html.erb b/app/views/forest_projects/new.html.erb index a97bd01..9804fe3 100644 --- a/app/views/forest_projects/new.html.erb +++ b/app/views/forest_projects/new.html.erb @@ -48,8 +48,8 @@