diff --git a/Gemfile b/Gemfile index 28c727ae2..412769b2e 100644 --- a/Gemfile +++ b/Gemfile @@ -62,6 +62,9 @@ gem "possessive" # Strip whitespace from active record attributes gem "auto_strip_attributes" # Use sidekiq for background processing +gem "method_source", "~> 1.1" +gem "rails_admin", "~> 3.0" +gem "ruby-openai" gem "sidekiq" gem "sidekiq-cron" gem "unread" @@ -105,4 +108,5 @@ group :test do end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "cssbundling-rails" gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index f2fdaf8b5..8f9ea9b52 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -51,6 +51,10 @@ GEM globalid (>= 0.3.6) activemodel (7.0.8.4) activesupport (= 7.0.8.4) + activemodel-serializers-xml (1.0.2) + activemodel (> 5.x) + activesupport (> 5.x) + builder (~> 3.1) activerecord (7.0.8.4) activemodel (= 7.0.8.4) activesupport (= 7.0.8.4) @@ -130,6 +134,8 @@ GEM bigdecimal rexml crass (1.0.6) + cssbundling-rails (1.4.0) + railties (>= 6.0.0) date (3.3.4) devise (4.9.3) bcrypt (~> 3.0) @@ -160,6 +166,7 @@ GEM erubi (1.12.0) et-orbi (1.2.7) tzinfo + event_stream_parser (1.0.0) excon (0.109.0) factory_bot (6.4.6) activesupport (>= 5.0.0) @@ -168,6 +175,12 @@ GEM railties (>= 5.0.0) faker (3.2.3) i18n (>= 1.8.11, < 2) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (3.1.0) + net-http ffi (1.16.3) fugit (1.10.0) et-orbi (~> 1, >= 1.2.7) @@ -199,6 +212,18 @@ GEM addressable (>= 2.8) jwt (2.8.0) base64 + 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) launchy (2.5.2) addressable (~> 2.8) listen (3.9.0) @@ -218,6 +243,10 @@ GEM mini_mime (1.1.5) minitest (5.23.1) msgpack (1.7.2) + multipart-post (2.4.1) + nested_form (0.3.2) + net-http (0.4.1) + uri net-imap (0.4.12) date net-protocol @@ -304,6 +333,12 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) + rails_admin (3.1.2) + activemodel-serializers-xml (>= 1.0) + kaminari (>= 0.14, < 2.0) + nested_form (~> 0.3) + rails (>= 6.0, < 8) + turbo-rails (~> 1.0) railties (7.0.8.4) actionpack (= 7.0.8.4) activesupport (= 7.0.8.4) @@ -378,6 +413,10 @@ GEM rubocop (~> 1.0) rubocop-rspec (2.7.0) rubocop (~> 1.19) + ruby-openai (7.0.1) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -413,12 +452,17 @@ GEM thor (1.3.1) timecop (0.9.8) timeout (0.4.1) + turbo-rails (1.5.0) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uk_postcode (2.1.8) unicode-display_width (2.5.0) unread (0.13.1) activerecord (>= 6.1) + uri (0.13.0) view_component (3.10.0) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) @@ -461,6 +505,7 @@ DEPENDENCIES capybara capybara-lockstep capybara-screenshot + cssbundling-rails devise devise_two_factor_authentication dotenv-rails @@ -473,6 +518,7 @@ DEPENDENCIES jsbundling-rails json-schema listen (~> 3.3) + method_source (~> 1.1) notifications-ruby-client overcommit (>= 0.37.0) paper_trail @@ -489,12 +535,14 @@ DEPENDENCIES rack-attack rack-mini-profiler (~> 2.0) rails (~> 7.0.8.3) + rails_admin (~> 3.0) redis (~> 4.8) roo rspec-rails rubocop-govuk (= 4.3.0) rubocop-performance rubocop-rails + ruby-openai selenium-webdriver sentry-rails sentry-ruby diff --git a/app/assets/stylesheets/rails_admin.scss b/app/assets/stylesheets/rails_admin.scss new file mode 100644 index 000000000..031f53d6c --- /dev/null +++ b/app/assets/stylesheets/rails_admin.scss @@ -0,0 +1,2 @@ +$fa-font-path: "."; +@import "rails_admin/src/rails_admin/styles/base"; diff --git a/app/javascript/rails_admin.js b/app/javascript/rails_admin.js new file mode 100644 index 000000000..1b0b308be --- /dev/null +++ b/app/javascript/rails_admin.js @@ -0,0 +1 @@ +import 'rails_admin/src/rails_admin/base' diff --git a/app/models/log_validation.rb b/app/models/log_validation.rb new file mode 100644 index 000000000..f818c21c2 --- /dev/null +++ b/app/models/log_validation.rb @@ -0,0 +1,2 @@ +class LogValidation < ApplicationRecord +end diff --git a/app/services/documentation_generator.rb b/app/services/documentation_generator.rb new file mode 100644 index 000000000..d77a4e093 --- /dev/null +++ b/app/services/documentation_generator.rb @@ -0,0 +1,371 @@ +class DocumentationGenerator + include Validations::Sales::SetupValidations + include Validations::Sales::HouseholdValidations + include Validations::Sales::PropertyValidations + include Validations::Sales::FinancialValidations + include Validations::Sales::SaleInformationValidations + include Validations::SharedValidations + include Validations::LocalAuthorityValidations + include Validations::SoftValidations + include Validations::Sales::SoftValidations + + def describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) + form = FormHandler.instance.forms["current_#{log_type}"] + + all_validation_methods.each do |meth| + if LogValidation.where(validation_name: meth.to_s, bulk_upload_specific: false, log_type:).exists? + Rails.logger.info("Validation #{meth} already exists") + next + end + + validation_source = method(meth).source + helper_methods_source = all_helper_methods.map { |helper_method| + if validation_source.include?(helper_method.to_s) + method(helper_method).source + end + }.compact.join("\n") + + response = describe_hard_validation(client, meth, validation_source, helper_methods_source, form) + next unless response + + begin + result = JSON.parse(response.dig("choices", 0, "message", "tool_calls", 0, "function", "arguments")) + + save_hard_validation(result, meth, form, log_type) + rescue StandardError => e + Rails.logger.error("Failed to save #{meth} for #{form.start_date.year}") + Rails.logger.error("Error #{e.message}") + end + end + end + + def describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) + all_validation_methods.each do |meth| + if LogValidation.where(validation_name: meth.to_s, bulk_upload_specific: true, from: form.start_date, log_type:).exists? + Rails.logger.info("Validation #{meth} already exists for #{form.start_date.year}") + next + end + + validation_source = row_parser_class.instance_method(meth).source + helper_methods_source = all_helper_methods.map { |helper_method| + if validation_source.include?(helper_method.to_s) + row_parser_class.instance_method(helper_method).source + end + }.compact.join("\n") + + response = describe_hard_validation(client, meth, validation_source, helper_methods_source, form) + next unless response + + begin + result = JSON.parse(response.dig("choices", 0, "message", "tool_calls", 0, "function", "arguments")) + + save_bu_validation(result, meth, form, log_type, field_mapping_for_errors) + rescue StandardError => e + Rails.logger.error("Failed to save #{meth} for #{form.start_date.year}") + Rails.logger.error("Error #{e.message}") + end + end + end + + def describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) + validation_descriptions = {} + all_validation_methods.each do |meth| + validation_source = method(meth).source + helper_methods_source = all_helper_methods.map { |helper_method| + if validation_source.include?(helper_method.to_s) + method(helper_method).source + end + }.compact.join("\n") + + response = soft_validation_description(client, meth, validation_source, helper_methods_source) + next unless response + + result = JSON.parse(response.dig("choices", 0, "message", "tool_calls", 0, "function", "arguments")) + + validation_descriptions[meth.to_s] = result + end + + current_form = FormHandler.instance.forms["current_#{log_type}"] + previous_form = FormHandler.instance.forms["previous_#{log_type}"] + + [current_form, previous_form].each do |form| + interruption_screen_pages = form.pages.select { |page| page.questions.first.type == "interruption_screen" } + interruption_screen_pages_grouped_by_question = interruption_screen_pages.group_by { |page| page.questions.first.id } + interruption_screen_pages_grouped_by_question.each do |_question_id, pages| + pages.map do |page| + save_soft_validation(form, page, validation_descriptions, log_type) + end + end + end + end + +private + + def describe_hard_validation(client, meth, validation_source, helper_methods_source, form) + en_yml = File.read("./config/locales/en.yml") + + begin + client.chat( + parameters: { + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: "You write amazing documentation, as a senior technical writer. Your audience is non-technical team members. You have been asked to document the validations in a Rails application. The application collects social housing data for different collection years. There are validations on different fields, sometimes the validations depend on several fields. + Describe what given validation does, be very explicit about all the different validation cases (be specific about the years for which these validations would run, which values would be invalid or which values are required, look at private helper methods to understand what is being checked in more detail). Quote the error messages that would be added in each case exactly. Here is the translation file for validation messages: #{en_yml}. + You should create `create_documentation_for_given_validation` method. Call it once, and include the documentation for given validation.", + }, + { + role: "user", + content: "Describe #{meth} validation in detail. Here is the content of the validation: + + #{validation_source} + Look at these helper methods where needed to understand what is being checked in more detail: #{helper_methods_source}", + }, + ], + tools: [ + { + type: "function", + function: { + name: "create_documentation_for_given_validation", + description: "Use this function to save the complete documentation, covering given validation in the provided code.", + parameters: { + type: :object, + properties: { + description: { + type: :string, + description: "A human-readbale description of the validation", + }, + cases: { + type: :array, + description: "A list of cases that this validation triggers on, each with specific details", + items: { + type: :object, + description: "Information about a single case that triggers this validation", + properties: { + case_description: { + type: :string, + description: "A human-readable description of the case in which this validation triggers", + }, + errors: { + type: :array, + description: "The error messages that would be added if this case triggers the validation", + items: { + type: :object, + description: "Information about a single error message for a specific field", + properties: { + error_message: { + type: :string, + description: "A single error message", + }, + field: { + type: :string, + description: "The field that the error message would be added to.", + }, + }, + required: %w[error_message field], + }, + }, + from: { + type: :number, + description: "the year from which the validation starts. If this validation runs for logs with a startdate after a certain year, specify that year here, only if it is not specified in the validation method, leave this field blank", + }, + to: { + type: :number, + description: "the year in which the validation ends. If this validation runs for logs with a startdate before a certain year, specify that year here, only if it is not specified in the validation method, leave this field blank", + }, + validation_type: { + type: :string, + enum: %w[presence format minimum maximum range inclusion length other], + description: "The type of validation that is being performed. This should be one of the following: presence (validates that the question is answered), format (validates that the answer format is valid), minimum (validates that entered value is more than minimum allowed value), maximum (validates that entered value is less than maximum allowed value), range (values must be between two values), inclusion (validates that the values that are not allowed arent selected), length (validates the length of the answer), other", + }, + other_validated_models: { + type: :string, + description: "Comma separated list of any other models (other than log) that were used in this validation. These are possible models (only add a value to this field if other validation models are one of these models): User, Organisation, Scheme, Location, Organisation_relationship, LaRentRange. Only leave this blank if no other models were used in this validation.", + }, + }, + required: %w[case_description errors validation_type other_validated_models], + }, + }, + }, + required: %w[description cases], + }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "create_documentation_for_given_validation" } }, + }, + ) + rescue StandardError => e + Rails.logger.error("Failed to describe #{meth} for #{form.start_date.year}") + Rails.logger.error("Error #{e.message}") + sleep(15) + false + end + end + + def soft_validation_description(client, meth, validation_source, helper_methods_source) + client.chat( + parameters: { + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: "You write amazing documentation, as a senior technical writer. Your audience is non-technical team members. You have been asked to document the validations in a Rails application. The application collects social housing data for different collection years. There are validations on different fields, sometimes the validations depend on several fields. + You are given a method that contains a validation. Describe what given method does, be very explicit about all the different validation cases (be specific about the years for which these validations would run, which values would be invalid or which values are required, look at private helper methods to understand what is being checked in more detail). +You should create `create_documentation_for_given_validation` method. Call it once, and include the documentation for given validation.", + }, + { + role: "user", + content: "Describe #{meth} validation in detail. Here is the content of the validation: + +#{validation_source} +Look at these helper methods where needed to understand what is being checked in more detail: #{helper_methods_source}", + }, + ], + tools: [ + { + type: "function", + function: { + name: "create_documentation_for_given_validation", + description: "Use this function to save the complete documentation, covering given validation in the provided code.", + parameters: { + type: :object, + properties: { + description: { + type: :string, + description: "A human-readbale description of the validation", + }, + validation_type: { + type: :string, + enum: %w[presence format minimum maximum range inclusion length other], + description: "The type of validation that is being performed. This should be one of the following: presence (validates that the question is answered), format (validates that the answer format is valid), minimum (validates that entered value is more than minimum allowed value), maximum (validates that entered value is less than maximum allowed value), range (values must be between two values), inclusion (validates that the values that are not allowed arent selected), length (validates the length of the answer), other", + }, + other_validated_models: { + type: :string, + description: "Comma separated list of any other models (other than log) that were used in this validation. These are possible models (only add a value to this field if other validation models are one of these models): User, Organisation, Scheme, Location, Organisation_relationship, LaRentRange. Only leave this blank if no other models were used in this validation.", + }, + }, + required: %w[description validation_type other_validated_models], + }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "create_documentation_for_given_validation" } }, + }, + ) + rescue StandardError => e + Rails.logger.error("Failed to describe #{meth}") + Rails.logger.error("Error #{e.message}") + sleep(15) + false + end + + def save_hard_validation(result, meth, form, log_type) + result["cases"].each do |case_info| + case_info["errors"].each do |error| + LogValidation.create!(log_type:, + validation_name: meth.to_s, + description: result["description"], + field: error["field"], + error_message: error["error_message"], + case: case_info["case_description"], + section: form.get_question(error["field"], nil)&.subsection&.id, + from: case_info["from"] || "", + to: case_info["to"] || "", + validation_type: case_info["validation_type"], + hard_soft: "hard", + other_validated_models: case_info["other_validated_models"]) + end + end + + Rails.logger.info("******** described #{meth} ********") + end + + def save_bu_validation(result, meth, form, log_type, field_mapping_for_errors) + result["cases"].each do |case_info| + case_info["errors"].each do |error| + error_fields = field_mapping_for_errors.select { |_key, values| values.include?(error["field"].to_sym) }.keys + error_fields = [error["field"]] if error_fields.empty? + error_fields.each do |error_field| + LogValidation.create!(log_type:, + validation_name: meth.to_s, + description: result["description"], + field: error_field, + error_message: error["error_message"], + case: case_info["case_description"], + section: form.get_question(error_field, nil)&.subsection&.id, + from: form.start_date, + to: form.start_date + 1.year, + validation_type: case_info["validation_type"], + hard_soft: "hard", + other_validated_models: case_info["other_validated_models"], + bulk_upload_specific: true) + end + end + end + + Rails.logger.info("******** described #{meth} for #{form.start_date.year} ********") + end + + def save_soft_validation(form, page, validation_descriptions, log_type) + subsection_pages = form.subsection_for_page(page).pages + page_index = subsection_pages.index(page) + page_the_validation_applied_to = subsection_pages[page_index - 1] + + loop do + break unless page_the_validation_applied_to.questions.first.type == "interruption_screen" + + page_index -= 1 + page_the_validation_applied_to = subsection_pages[page_index - 1] + end + + validation_depends_on_hash = page.depends_on.each_with_object({}) do |depends_on, result| + depends_on.each do |key, value| + if validation_descriptions.include?(key) + result[key] = value + end + end + end + + if validation_depends_on_hash.empty? + Rails.logger.error("No validation description found for #{page.questions.first.id}") + return + end + + if LogValidation.where(validation_name: validation_depends_on_hash.keys.first, field: page_the_validation_applied_to.questions.first.id, from: form.start_date, log_type:).exists? + Rails.logger.info("Validation #{validation_depends_on_hash.keys.first} already exists for #{page_the_validation_applied_to.questions.first.id} for start year #{form.start_date.year}") + return + end + + result = validation_descriptions[validation_depends_on_hash.keys.first] + + informative_text = page.informative_text + if informative_text.present? && !(informative_text.is_a? String) + informative_text = I18n.t(page.informative_text["translation"]) + end + + title_text = page.title_text + if title_text.present? && !(title_text.is_a? String) + title_text = I18n.t(page.title_text["translation"]) + end + + error_message = [title_text, informative_text, page.questions.first.hint_text].compact.join("\n") + + case_info = page.depends_on.first.values.first ? "Provided values fulfill the description" : "Provided values do not fulfill the description" + LogValidation.create!(log_type:, + validation_name: validation_depends_on_hash.keys.first.to_s, + description: result["description"], + field: page_the_validation_applied_to.questions.first.id, + error_message:, + case: case_info, + section: form.get_question(page_the_validation_applied_to.questions.first.id, nil)&.subsection&.id, + from: form.start_date, + to: form.start_date + 1.year, + validation_type: result["validation_type"], + hard_soft: "soft", + other_validated_models: result["other_validated_models"]) + + Rails.logger.info("******** described #{validation_depends_on_hash.keys.first} for #{page_the_validation_applied_to.questions.first.id} ********") + end +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..e4e6a0e6c --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1 @@ +Rails.application.config.assets.paths << Rails.root.join("node_modules/@fortawesome/fontawesome-free/webfonts") diff --git a/config/initializers/rails_admin.rb b/config/initializers/rails_admin.rb new file mode 100644 index 000000000..87e44ebe9 --- /dev/null +++ b/config/initializers/rails_admin.rb @@ -0,0 +1,46 @@ +RailsAdmin.config do |config| + config.asset_source = :webpack + + ### Popular gems integration + + ## == Devise == + config.authenticate_with do + warden.authenticate! scope: :user + end + config.current_user_method(&:current_user) + + config.authorize_with do + redirect_to main_app.root_path unless current_user&.support? + end + ## == CancanCan == + # config.authorize_with :cancancan + + ## == Pundit == + # config.authorize_with :pundit + + ## == PaperTrail == + # config.audit_with :paper_trail, 'User', 'PaperTrail::Version' # PaperTrail >= 3.0.0 + + ### More at https://github.com/railsadminteam/rails_admin/wiki/Base-configuration + + ## == Gravatar integration == + ## To disable Gravatar integration in Navigation Bar set to false + # config.show_gravatar = true + config.included_models = %w[LogValidation] + + config.actions do + dashboard # mandatory + index # mandatory + new + export + bulk_delete + show + edit + delete + show_in_app + + ## With an audit adapter, you can add: + # history_index + # history_show + end +end diff --git a/config/routes.rb b/config/routes.rb index 1ce7095e7..5361f5733 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html Rails.application.routes.draw do + mount RailsAdmin::Engine => "/admin", as: "rails_admin" mount_sidekiq = -> { mount Sidekiq::Web => "/sidekiq" } authenticate(:user, :support?.to_proc, &mount_sidekiq) diff --git a/db/migrate/20240523153434_add_validations_table.rb b/db/migrate/20240523153434_add_validations_table.rb new file mode 100644 index 000000000..efe65a098 --- /dev/null +++ b/db/migrate/20240523153434_add_validations_table.rb @@ -0,0 +1,20 @@ +class AddValidationsTable < ActiveRecord::Migration[7.0] + def change + create_table :validations do |t| + t.column :log_type, :string + t.column :section, :string + t.column :validation_name, :string + t.column :description, :string + t.column :case, :string + t.column :field, :string + t.column :error_message, :string + t.column :from, :datetime + t.column :to, :datetime + t.column :validation_type, :string + t.column :hard_soft, :string + t.column :bulk_upload_specific, :boolean, default: false + t.column :other_validated_models, :string + t.timestamps + end + end +end diff --git a/db/migrate/20240529133005_rename_validations_table.rb b/db/migrate/20240529133005_rename_validations_table.rb new file mode 100644 index 000000000..7ea6227f4 --- /dev/null +++ b/db/migrate/20240529133005_rename_validations_table.rb @@ -0,0 +1,5 @@ +class RenameValidationsTable < ActiveRecord::Migration[7.0] + def change + rename_table :validations, :log_validations + end +end diff --git a/db/schema.rb b/db/schema.rb index ceeab8122..1430da65f 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: 2024_04_08_102550) do +ActiveRecord::Schema[7.0].define(version: 2024_05_29_133005) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -381,6 +381,24 @@ ActiveRecord::Schema[7.0].define(version: 2024_04_08_102550) do t.index ["scheme_id"], name: "index_locations_on_scheme_id" end + create_table "log_validations", force: :cascade do |t| + t.string "log_type" + t.string "section" + t.string "validation_name" + t.string "description" + t.string "case" + t.string "field" + t.string "error_message" + t.datetime "from" + t.datetime "to" + t.string "validation_type" + t.string "hard_soft" + t.boolean "bulk_upload_specific", default: false + t.string "other_validated_models" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "logs_exports", force: :cascade do |t| t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "started_at", null: false diff --git a/lib/tasks/generate_lettings_documentation.rake b/lib/tasks/generate_lettings_documentation.rake new file mode 100644 index 000000000..8b11e3ff5 --- /dev/null +++ b/lib/tasks/generate_lettings_documentation.rake @@ -0,0 +1,90 @@ +namespace :generate_lettings_documentation do + desc "Generate documentation for hard lettings validations" + task describe_lettings_validations: :environment do + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + include Validations::SetupValidations + include Validations::HouseholdValidations + include Validations::PropertyValidations + include Validations::FinancialValidations + include Validations::TenancyValidations + include Validations::DateValidations + include Validations::LocalAuthorityValidations + all_validation_methods = public_methods.select { |method| method.starts_with?("validate_") } + all_methods = [Validations::SetupValidations, + Validations::HouseholdValidations, + Validations::PropertyValidations, + Validations::FinancialValidations, + Validations::TenancyValidations, + Validations::DateValidations, + Validations::LocalAuthorityValidations].map { |x| x.instance_methods + x.private_instance_methods }.flatten + all_helper_methods = all_methods - all_validation_methods + + DocumentationGenerator.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, "lettings") + end + + desc "Generate documentation for soft lettings validations" + task describe_soft_lettings_validations: :environment do + include Validations::SoftValidations + + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + + all_helper_methods = Validations::SoftValidations.private_instance_methods + all_validation_methods = Validations::SoftValidations.instance_methods + + DocumentationGenerator.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, "lettings") + end + + desc "Generate documentation for hard bu lettings validations" + task describe_bu_lettings_validations: :environment do + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + + [[FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2023, "lettings")], BulkUpload::Lettings::Year2023::RowParser], + [FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2024, "lettings")], BulkUpload::Lettings::Year2024::RowParser]].each do |form, row_parser_class| + all_validation_methods = row_parser_class.private_instance_methods.select { |method| method.starts_with?("validate_") } + + all_helper_methods = row_parser_class.private_instance_methods(false) + row_parser_class.instance_methods(false) - all_validation_methods + + field_mapping_for_errors = row_parser_class.new.send("field_mapping_for_errors") + DocumentationGenerator.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, "lettings") + end + end + + desc "Generate documentation for lettings numeric validations" + task add_numeric_lettings_validations: :environment do + form = FormHandler.instance.forms["current_lettings"] + + form.numeric_questions.each do |question| + next unless question.min || question.max + + field = question.id + min = [question.prefix, question.min].join("") if question.min + max = [question.prefix, question.max].join("") if question.max + + error_message = I18n.t("validations.numeric.above_min", field:, min:) + validation_name = "minimum" + validation_description = "Field value is lower than the minimum value" + + if min && max + validation_name = "range" + error_message = I18n.t("validations.numeric.within_range", field:, min:, max:) + validation_description = "Field value is lower than the minimum value or higher than the maximum value" + end + + if LogValidation.where(validation_name:, field:, log_type: "lettings").exists? + + Rails.logger.info("Validation #{validation_name} already exists for #{field}") + next + end + + LogValidation.create!(log_type: "lettings", + validation_name:, + description: validation_description, + field:, + error_message:, + case: validation_description, + section: form.get_question(field, nil)&.subsection&.id, + validation_type: validation_name, + hard_soft: "hard") + end + end +end diff --git a/lib/tasks/generate_sales_documentation.rake b/lib/tasks/generate_sales_documentation.rake new file mode 100644 index 000000000..62272f6f8 --- /dev/null +++ b/lib/tasks/generate_sales_documentation.rake @@ -0,0 +1,89 @@ +namespace :generate_sales_documentation do + desc "Generate documentation for hard sales validations" + task describe_sales_validations: :environment do + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + include Validations::Sales::SetupValidations + include Validations::Sales::HouseholdValidations + include Validations::Sales::PropertyValidations + include Validations::Sales::FinancialValidations + include Validations::Sales::SaleInformationValidations + include Validations::SharedValidations + include Validations::LocalAuthorityValidations + all_validation_methods = public_methods.select { |method| method.starts_with?("validate_") } + all_methods = [Validations::Sales::SetupValidations, + Validations::Sales::HouseholdValidations, + Validations::Sales::PropertyValidations, + Validations::Sales::FinancialValidations, + Validations::Sales::SaleInformationValidations, + Validations::SharedValidations, + Validations::LocalAuthorityValidations].map { |x| x.instance_methods + x.private_instance_methods }.flatten + + all_helper_methods = all_methods - all_validation_methods + + DocumentationGenerator.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, "sales") + end + + desc "Generate documentation for soft sales validations" + task describe_soft_sales_validations: :environment do + include Validations::SoftValidations + include Validations::Sales::SoftValidations + + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + all_helper_methods = Validations::SoftValidations.private_instance_methods + Validations::Sales::SoftValidations.private_instance_methods + all_validation_methods = Validations::SoftValidations.instance_methods + Validations::Sales::SoftValidations.instance_methods + + DocumentationGenerator.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, "sales") + end + + desc "Generate documentation for hard bu sales validations" + task describe_bu_sales_validations: :environment do + client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) + [[FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2023, "sales")], BulkUpload::Sales::Year2023::RowParser], + [FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2024, "sales")], BulkUpload::Sales::Year2024::RowParser]].each do |form, row_parser_class| + all_validation_methods = row_parser_class.private_instance_methods.select { |method| method.starts_with?("validate_") } + all_helper_methods = row_parser_class.private_instance_methods(false) + row_parser_class.instance_methods(false) - all_validation_methods + field_mapping_for_errors = row_parser_class.new.send("field_mapping_for_errors") + + DocumentationGenerator.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, "sales") + end + end + + desc "Generate documentation for sales numeric validations" + task add_numeric_sales_validations: :environment do + form = FormHandler.instance.forms["current_sales"] + + form.numeric_questions.each do |question| + next unless question.min || question.max + + field = question.id + min = [question.prefix, question.min].join("") if question.min + max = [question.prefix, question.max].join("") if question.max + + error_message = I18n.t("validations.numeric.above_min", field:, min:) + validation_name = "minimum" + validation_description = "Field value is lower than the minimum value" + + if min && max + validation_name = "range" + error_message = I18n.t("validations.numeric.within_range", field:, min:, max:) + validation_description = "Field value is lower than the minimum value or higher than the maximum value" + end + + if LogValidation.where(validation_name:, field:, log_type: "sales").exists? + + Rails.logger.info("Validation #{validation_name} already exists for #{field}") + next + end + + LogValidation.create!(log_type: "sales", + validation_name:, + description: validation_description, + field:, + error_message:, + case: validation_description, + section: form.get_question(field, nil)&.subsection&.id, + validation_type: validation_name, + hard_soft: "hard") + end + end +end diff --git a/package.json b/package.json index dedf7b6b7..40fd90036 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "html5shiv": "^3.7.3", "intersection-observer": "^0.12.0", "mini-css-extract-plugin": "^2.6.0", + "rails_admin": "3.1.2", "regenerator-runtime": "^0.13.9", "sass": "^1.49.9", "sass-loader": "^12.6.0", @@ -60,6 +61,7 @@ "extends": "stylelint-config-gds/scss" }, "scripts": { - "build": "webpack --config webpack.config.js" + "build": "webpack --config webpack.config.js", + "build:css": "sass ./app/assets/stylesheets/rails_admin.scss:./app/assets/builds/rails_admin.css --no-source-map --load-path=node_modules" } } diff --git a/spec/lib/tasks/generate_lettings_documentation_spec.rb b/spec/lib/tasks/generate_lettings_documentation_spec.rb new file mode 100644 index 000000000..eca123b4e --- /dev/null +++ b/spec/lib/tasks/generate_lettings_documentation_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" +require "rake" + +RSpec.describe "generate_lettings_documentation" do + describe ":add_numeric_lettings_validations", type: :task do + subject(:task) { Rake::Task["generate_lettings_documentation:add_numeric_lettings_validations"] } + + before do + Rake.application.rake_require("tasks/generate_lettings_documentation") + Rake::Task.define_task(:environment) + task.reenable + end + + context "when the rake task is run" do + it "creates new validation documentation records" do + expect { task.invoke }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "minimum").count).to be_positive + expect(LogValidation.where(validation_name: "range").count).to be_positive + any_min_validation = LogValidation.where(validation_name: "minimum").first + expect(any_min_validation.description).to include("Field value is lower than the minimum value") + expect(any_min_validation.field).not_to be_empty + expect(any_min_validation.error_message).to include("must be at least") + expect(any_min_validation.case).to include("Field value is lower than the minimum value") + expect(any_min_validation.from).to be_nil + expect(any_min_validation.to).to be_nil + expect(any_min_validation.validation_type).to eq("minimum") + expect(any_min_validation.hard_soft).to eq("hard") + expect(any_min_validation.other_validated_models).to be_nil + expect(any_min_validation.log_type).to eq("lettings") + end + + it "skips if the validation already exists in the database" do + task.invoke + expect { task.invoke }.not_to change(LogValidation, :count) + end + end + end +end diff --git a/spec/lib/tasks/generate_sales_documentation_spec.rb b/spec/lib/tasks/generate_sales_documentation_spec.rb new file mode 100644 index 000000000..4626f1a42 --- /dev/null +++ b/spec/lib/tasks/generate_sales_documentation_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" +require "rake" + +RSpec.describe "generate_sales_documentation" do + describe ":add_numeric_sales_validations", type: :task do + subject(:task) { Rake::Task["generate_sales_documentation:add_numeric_sales_validations"] } + + before do + Rake.application.rake_require("tasks/generate_sales_documentation") + Rake::Task.define_task(:environment) + task.reenable + end + + context "when the rake task is run" do + it "creates new validation documentation records" do + expect { task.invoke }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "minimum").count).to be_positive + expect(LogValidation.where(validation_name: "range").count).to be_positive + any_min_validation = LogValidation.where(validation_name: "minimum").first + expect(any_min_validation.description).to include("Field value is lower than the minimum value") + expect(any_min_validation.field).not_to be_empty + expect(any_min_validation.error_message).to include("must be at least") + expect(any_min_validation.case).to include("Field value is lower than the minimum value") + expect(any_min_validation.from).to be_nil + expect(any_min_validation.to).to be_nil + expect(any_min_validation.validation_type).to eq("minimum") + expect(any_min_validation.hard_soft).to eq("hard") + expect(any_min_validation.other_validated_models).to be_nil + expect(any_min_validation.log_type).to eq("sales") + end + + it "skips if the validation already exists in the database" do + task.invoke + expect { task.invoke }.not_to change(LogValidation, :count) + end + end + end +end diff --git a/spec/requests/rails_admin_controller_spec.rb b/spec/requests/rails_admin_controller_spec.rb new file mode 100644 index 000000000..4d0bbb7c2 --- /dev/null +++ b/spec/requests/rails_admin_controller_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "RailsAdmin", type: :request do + let(:user) { create(:user) } + let(:support_user) { create(:user, :support) } + let(:page) { Capybara::Node::Simple.new(response.body) } + + before do + allow(support_user).to receive(:need_two_factor_authentication?).and_return(false) + end + + describe "GET /admin" do + context "when the user is not signed in" do + it "routes user to the sign in page" do + get rails_admin_path + follow_redirect! + expect(path).to eq("/account/sign-in") + expect(page).to have_content("Sign in to your account") + end + end + + context "when the user is signed in as a non support user" do + before do + sign_in user + end + + it "routes user to the home page" do + get rails_admin_path + follow_redirect! + expect(path).to eq("/") + expect(page).to have_content("Welcome back") + end + end + + context "when the user is signed in as a support user" do + before do + sign_in support_user + end + + it "routes user to the admin page" do + get rails_admin_path + expect(page).to have_content("Site Administration") + end + end + end +end diff --git a/spec/services/documentation_generator_spec.rb b/spec/services/documentation_generator_spec.rb new file mode 100644 index 000000000..47bc813f7 --- /dev/null +++ b/spec/services/documentation_generator_spec.rb @@ -0,0 +1,230 @@ +require "rails_helper" + +describe DocumentationGenerator do + let(:client) { instance_double(OpenAI::Client) } + let(:response) do + { "choices" => [{ "message" => { "tool_calls" => [{ "function" => { "arguments" => + "{\n \"description\": \"Validates the format.\",\n \"cases\": [\n {\n \"case_description\": \"Previous postcode is known and current postcode is blank\",\n \"errors\": [\n {\n \"error_message\": \"Enter a valid postcode\",\n \"field\": \"ppostcode_full\"\n }\n ],\n \"validation_type\": \"format\",\n \"other_validated_models\": \"User\" }]\n}" } }] } }] } + end + let(:all_validation_methods) { %w[validate_numeric_min_max] } + let(:all_helper_methods) { [] } + let(:log_type) { "lettings" } + + before do + allow(client).to receive(:chat).and_return(response) + end + + describe ":describe_hard_validations" do + context "when the service is run with lettings type" do + let(:log_type) { "lettings" } + + it "creates new validation documentation records" do + expect(Rails.logger).to receive(:info).with(/described/).at_least(:once) + expect { described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "validate_numeric_min_max").count).to eq(1) + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).to eq("ppostcode_full") + expect(any_validation.error_message).to eq("Enter a valid postcode") + expect(any_validation.case).to eq("Previous postcode is known and current postcode is blank") + expect(any_validation.from).to be_nil + expect(any_validation.to).to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("hard") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("lettings") + end + + it "calls the client" do + expect(client).to receive(:chat) + described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) + end + + it "skips if the validation already exists in the database" do + described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) + expect { described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) }.not_to change(LogValidation, :count) + end + + context "when the response is not a JSON" do + let(:response) { "not a JSON" } + + it "raises an error" do + expect(Rails.logger).to receive(:error).with(/Failed to save/).at_least(:once) + expect(Rails.logger).to receive(:error).with(/Error/).at_least(:once) + described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) + end + end + + context "when the response does not have expected fields" do + let(:response) { { "choices" => [{ "message" => { "tool_calls" => [{ "function" => { "arguments" => "{}" } }] } }] } } + + it "raises an error" do + expect(Rails.logger).to receive(:error).with(/Failed to save/).at_least(:once) + expect(Rails.logger).to receive(:error).with(/Error/).at_least(:once) + described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) + end + end + end + + context "when the service is run with sales type" do + let(:log_type) { "sales" } + + it "creates new validation documentation records" do + expect(Rails.logger).to receive(:info).with(/described/).at_least(:once) + expect { described_class.new.describe_hard_validations(client, all_validation_methods, all_helper_methods, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "validate_numeric_min_max").count).to eq(1) + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).to eq("ppostcode_full") + expect(any_validation.error_message).to eq("Enter a valid postcode") + expect(any_validation.case).to eq("Previous postcode is known and current postcode is blank") + expect(any_validation.from).to be_nil + expect(any_validation.to).to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("hard") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("sales") + end + end + end + + describe ":describe_soft_validations" do + let(:all_validation_methods) { ["rent_in_soft_min_range?"] } + let(:response) do + { "choices" => [{ "message" => { "tool_calls" => [{ "function" => { "arguments" => + "{\n \"description\": \"Validates the format.\",\n \"validation_type\": \"format\",\n \"other_validated_models\": \"User\"}" } }] } }] } + end + + context "when the service is run for lettings" do + let(:log_type) { "lettings" } + + it "creates new validation documentation records" do + expect { described_class.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "rent_in_soft_min_range?").count).to be_positive + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).not_to be_empty + expect(any_validation.error_message).not_to be_empty + expect(any_validation.case).to eq("Provided values fulfill the description") + expect(any_validation.from).not_to be_nil + expect(any_validation.to).not_to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("soft") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("lettings") + end + + it "calls the client" do + expect(client).to receive(:chat) + described_class.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) + end + + it "skips if the validation already exists in the database" do + described_class.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) + expect { described_class.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) }.not_to change(LogValidation, :count) + end + end + + context "when the service is run for sales" do + let(:log_type) { "sales" } + let(:all_validation_methods) { ["income2_under_soft_min?"] } + + it "creates new validation documentation records" do + expect { described_class.new.describe_soft_validations(client, all_validation_methods, all_helper_methods, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "income2_under_soft_min?").count).to be_positive + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).not_to be_empty + expect(any_validation.error_message).not_to be_empty + expect(any_validation.case).to eq("Provided values fulfill the description") + expect(any_validation.from).not_to be_nil + expect(any_validation.to).not_to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("soft") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("sales") + end + end + end + + describe ":describe_bu_validations", type: :task do + let(:all_validation_methods) { %w[validate_owning_org_data_given] } + let(:field_mapping_for_errors) { row_parser_class.new.send("field_mapping_for_errors") } + + context "when the service is run for lettings" do + let(:log_type) { "lettings" } + let(:form) { FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2023, "lettings")] } + let(:row_parser_class) { BulkUpload::Lettings::Year2023::RowParser } + + it "creates new validation documentation records" do + expect(Rails.logger).to receive(:info).with(/described/).at_least(:once) + expect { described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "validate_owning_org_data_given").count).to eq(1) + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).to eq("ppostcode_full") + expect(any_validation.error_message).to eq("Enter a valid postcode") + expect(any_validation.case).to eq("Previous postcode is known and current postcode is blank") + expect(any_validation.from).not_to be_nil + expect(any_validation.to).not_to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("hard") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("lettings") + end + + it "calls the client" do + expect(client).to receive(:chat) + described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) + end + + it "skips if the validation already exists in the database" do + described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) + expect { described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) }.not_to change(LogValidation, :count) + end + + context "when the response is not a JSON" do + let(:response) { "not a JSON" } + + it "raises an error" do + expect(Rails.logger).to receive(:error).with(/Failed to save/).at_least(:once) + expect(Rails.logger).to receive(:error).with(/Error/).at_least(:once) + described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) + end + end + + context "when the response does not have expected fields" do + let(:response) { { "choices" => [{ "message" => { "tool_calls" => [{ "function" => { "arguments" => "{}" } }] } }] } } + + it "raises an error" do + expect(Rails.logger).to receive(:error).with(/Failed to save/).at_least(:once) + expect(Rails.logger).to receive(:error).with(/Error/).at_least(:once) + described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) + end + end + end + + context "when the service is run for sales" do + let(:log_type) { "sales" } + let(:form) { FormHandler.instance.forms[FormHandler.instance.form_name_from_start_year(2023, "sales")] } + let(:row_parser_class) { BulkUpload::Sales::Year2023::RowParser } + + it "creates new validation documentation records" do + expect(Rails.logger).to receive(:info).with(/described/).at_least(:once) + expect { described_class.new.describe_bu_validations(client, form, row_parser_class, all_validation_methods, all_helper_methods, field_mapping_for_errors, log_type) }.to change(LogValidation, :count) + expect(LogValidation.where(validation_name: "validate_owning_org_data_given").count).to eq(1) + any_validation = LogValidation.first + expect(any_validation.description).to eq("Validates the format.") + expect(any_validation.field).to eq("ppostcode_full") + expect(any_validation.error_message).to eq("Enter a valid postcode") + expect(any_validation.case).to eq("Previous postcode is known and current postcode is blank") + expect(any_validation.from).not_to be_nil + expect(any_validation.to).not_to be_nil + expect(any_validation.validation_type).to eq("format") + expect(any_validation.hard_soft).to eq("hard") + expect(any_validation.other_validated_models).to eq("User") + expect(any_validation.log_type).to eq("sales") + end + end + end +end diff --git a/webpack.config.js b/webpack.config.js index 8a4f8600b..32d4030c0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,7 +13,8 @@ module.exports = { entry: { application: [ './app/frontend/application.js' - ] + ], + rails_admin: ['./app/javascript/rails_admin.js'] }, module: { rules: [ diff --git a/yarn.lock b/yarn.lock index e67ab3514..ef18aea5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -950,6 +950,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.16.7": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e" + integrity sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" @@ -1042,11 +1049,29 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@fortawesome/fontawesome-free@>=5.15.0 <7.0.0": + version "6.5.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz#310fe90cb5a8dee9698833171b98e7835404293d" + integrity sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q== + "@hotwired/stimulus@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.1.0.tgz#20215251e5afe6e0a3787285181ba1bfc9097df0" integrity sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg== +"@hotwired/turbo-rails@^7.1.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.3.0.tgz#422c21752509f3edcd6c7b2725bbe9e157815f51" + integrity sha512-fvhO64vp/a2UVQ3jue9WTc2JisMv9XilIC7ViZmXAREVwiQ2S4UC7Go8f9A1j4Xu7DBI6SbFdqILk5ImqVoqyA== + dependencies: + "@hotwired/turbo" "^7.3.0" + "@rails/actioncable" "^7.0" + +"@hotwired/turbo@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.3.0.tgz#2226000fff1aabda9fd9587474565c9929dbf15d" + integrity sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g== + "@humanwhocodes/config-array@^0.10.4": version "0.10.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.5.tgz#bb679745224745fff1e9a41961c1d45a49f81c04" @@ -1158,6 +1183,21 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@popperjs/core@^2.11.0": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + +"@rails/actioncable@^7.0": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.1.3.tgz#4db480347775aeecd4dde2405659eef74a458881" + integrity sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA== + +"@rails/ujs@^6.1.4-1": + version "6.1.7" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.1.7.tgz#b09dc5b2105dd267e8374c47e4490240451dc7f6" + integrity sha512-0e7WQ4LE/+LEfW2zfAw9ppsB6A8RmxbdAUPAF++UT80epY+7emuQDkKXmaK0a9lp6An50RvzezI0cIQjp1A58w== + "@socket.io/component-emitter@~3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" @@ -1789,6 +1829,11 @@ body-parser@1.20.2, body-parser@^1.20.2: type-is "~1.6.18" unpipe "1.0.0" +bootstrap@^5.1.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.3.tgz#de35e1a765c897ac940021900fcbb831602bac38" + integrity sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -3020,6 +3065,11 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" +flatpickr@^4.6.9: + version "4.6.13" + resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.13.tgz#8a029548187fd6e0d670908471e43abe9ad18d94" + integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw== + flatted@^3.1.0: version "3.2.7" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" @@ -3719,6 +3769,18 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +jquery-ui@^1.12.1: + version "1.13.3" + resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.3.tgz#d9f5292b2857fa1f2fdbbe8f2e66081664eb9bc5" + integrity sha512-D2YJfswSJRh/B8M/zCowDpNFfwsDmtfnMPwjJTyvl+CBqzpYwQ+gFYIbUUlzijy/Qvoy30H1YhoSui4MNYpRwA== + dependencies: + jquery ">=1.8.0 <4.0.0" + +"jquery@>=1.8.0 <4.0.0", jquery@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" + integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== + js-sdsl@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.4.tgz#78793c90f80e8430b7d8dc94515b6c77d98a26a6" @@ -4629,6 +4691,21 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +rails_admin@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/rails_admin/-/rails_admin-3.1.2.tgz#00d6d85b7a00c89c69b5dbf5f1f4620702626504" + integrity sha512-uIQHN27lBvlav6s5ppmOtVxKN8GIxyhHuDFc9ZbvWgFknR4zgG4/xEUGzKzQ9R34AEsfZ/t8cZbvtvgj+aXp4A== + dependencies: + "@babel/runtime" "^7.16.7" + "@fortawesome/fontawesome-free" ">=5.15.0 <7.0.0" + "@hotwired/turbo-rails" "^7.1.0" + "@popperjs/core" "^2.11.0" + "@rails/ujs" "^6.1.4-1" + bootstrap "^5.1.3" + flatpickr "^4.6.9" + jquery "^3.6.0" + jquery-ui "^1.12.1" + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -4751,6 +4828,11 @@ regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.9: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regenerator-transform@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"