From ebced1c77a3e461c27305da5f21b80afdca57c35 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 5 Nov 2025 10:13:38 -0500 Subject: [PATCH 01/38] Added ContentStatus ctrller --- app/controllers/content_status_controller.rb | 93 ++++++++++++++++++++ config/routes.rb | 2 + 2 files changed, 95 insertions(+) create mode 100644 app/controllers/content_status_controller.rb diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb new file mode 100644 index 000000000..8a5887fd5 --- /dev/null +++ b/app/controllers/content_status_controller.rb @@ -0,0 +1,93 @@ +class ContentStatusController < ApplicationController + def index + metrics = { + tools: { + default: Resource.left_joins(:resource_scores).joins(:resource_type).where( + resource_types: { name: 'tract' }, + resource_scores: { id: nil } + ).count, + featured: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'tract' }, + resource_scores: { featured: true } + ).count, + ranked: 0, + total: Resource.count + }, + lessons: { + default: Resource.left_joins(:resource_scores).joins(:resource_type).where( + resource_types: { name: 'lesson' }, + resource_scores: { id: nil } + ).count, + featured: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'lesson' }, + resource_scores: { featured: true } + ).count, + ranked: 0, + total: Resource.count + }, + countries: retrieve_countries_data + } + + render json: metrics, status: :ok + end + + private + + def uniq_languages_per_country(country = nil) + resource_score_languages = if country.present? + ResourceScore.where(country: country).select(:lang).distinct.pluck(:lang) + else + ResourceScore.select(:lang).distinct.pluck(:lang) + end + # resource_default_order_languages = ResourceDefaultOrder.select(:lang).distinct.pluck(:lang) + resource_default_order_languages = [] + languages = (resource_score_languages + resource_default_order_languages).flatten + languages.uniq + end + + def uniq_countries + ResourceScore.select(:country).distinct.pluck(:country) + end + + def retrieve_lessons_data(country, lang) + { + default: Resource.left_joins(:resource_scores).joins(:resource_type).where( + resource_types: { name: 'lesson' }, resource_scores: { id: nil, lang: lang } + ).count, + featured: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'lesson' }, resource_scores: { featured: true, lang: lang, country: country } + ).count, + ranked: 0 + } + end + + def retrieve_tools_data(country, lang) + { + default: Resource.left_joins(:resource_scores).joins(:resource_type).where( + resource_types: { name: 'tract' }, resource_scores: { id: nil, lang: lang } + ).count, + featured: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'tract' }, resource_scores: { featured: true, lang: lang, country: country } + ).count, + ranked: 0 + } + end + + def retrieve_language_data(country, lang) + { + language: lang, + lessons: retrieve_lessons_data(country, lang), + tools: retrieve_tools_data(country, lang) + } + end + + def retrieve_countries_data + uniq_countries.map do |country| + languages = uniq_languages_per_country(country) + { + country_code: country, + languages: languages.map { |lang| retrieve_language_data(country, lang) } + } + end + end +end diff --git a/config/routes.rb b/config/routes.rb index ebfd63503..a11012558 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -101,6 +101,8 @@ end end + get 'content_status', to: 'content_status#index' + if Rails.env.production? || Rails.env.staging? Sidekiq::Web.use Rack::Auth::Basic do |username, password| username == ENV.fetch("SIDEKIQ_USERNAME") && password == ENV.fetch("SIDEKIQ_PASSWORD") From 51b61623fbfa9132e375646a41fa6d3af3d93714 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Mon, 24 Nov 2025 22:09:35 -0500 Subject: [PATCH 02/38] Using Language association with ResourceScore and ResourceDefaultOrder --- .../resources/default_order_controller.rb | 36 +++++++++--------- .../resources/featured_controller.rb | 37 ++++++++++++++----- app/models/resource_default_order.rb | 10 ++--- app/models/resource_score.rb | 11 +++--- .../resource_default_order_serializer.rb | 8 +++- app/serializers/resource_score_serializer.rb | 8 +++- ...vert_resource_score_lang_to_language_id.rb | 25 +++++++++++++ ...ource_default_order_lang_to_language_id.rb | 29 +++++++++++++++ db/schema.rb | 10 ++--- .../default_order_controller_spec.rb | 30 +++++++-------- .../resources/featured_controller_spec.rb | 18 +++++---- spec/factories/languages.rb | 10 +++++ spec/factories/resource_default_orders.rb | 2 +- spec/factories/resource_scores.rb | 2 +- spec/models/resource_default_order_spec.rb | 24 ++++-------- spec/models/resource_score_spec.rb | 28 ++++++-------- 16 files changed, 182 insertions(+), 106 deletions(-) create mode 100644 db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb create mode 100644 db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb create mode 100644 spec/factories/languages.rb diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 6a05a6bb5..787095cd0 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -14,7 +14,11 @@ def index end def create - @resource_default_order = ResourceDefaultOrder.new(create_params) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order = ResourceDefaultOrder.new(sanitized_params) + @resource_default_order.language = language if language.present? @resource_default_order.save! render json: @resource_default_order, status: :created rescue => e @@ -31,7 +35,11 @@ def destroy def update @resource_default_order = ResourceDefaultOrder.find(params[:id]) - @resource_default_order.update!(create_params) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order.language = language if language.present? + @resource_default_order.update!(sanitized_params) render json: @resource_default_order, status: :ok rescue => e render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content @@ -42,28 +50,18 @@ def update def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) - scope = filter_by_lang(scope, lang) - scope = filter_by_resource_type(scope, resource_type) + if lang.present? + language = Language.find_by(code: lang.downcase) + scope = scope.left_joins(resource_default_orders: :language).where(languages: {id: language.id}) if language.present? + end + + scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") end def create_params - params.require(:data).require(:attributes).permit( - :resource_id, :lang, :position - ) - end - - def filter_by_lang(scope, lang) - return scope unless lang.present? - - scope.where("resource_default_orders.lang = LOWER(:lang)", lang:) - end - - def filter_by_resource_type(scope, resource_type) - return scope unless resource_type.present? - - scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + params.require(:data).require(:attributes).permit(:resource_id, :lang, :position) end end end diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index 4f1affb32..bfdd08144 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -5,8 +5,9 @@ class FeaturedController < ApplicationController before_action :authorize!, only: %i[create destroy update mass_update] def index + lang_code = params.dig(:filter, :lang) || params[:lang] featured_resources = all_featured_resources( - lang: params.dig(:filter, :lang) || params[:lang], + lang_code: lang_code, country: params.dig(:filter, :country) || params[:country], resource_type: params.dig(:filter, :resource_type) || params[:resource_type] ) @@ -15,7 +16,11 @@ def index end def create - @resource_score = ResourceScore.new(create_params) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score = ResourceScore.new(sanitized_params) + @resource_score.language = language if language.present? @resource_score.save! render json: @resource_score, status: :created rescue => e @@ -32,7 +37,12 @@ def destroy def update @resource_score = ResourceScore.find(params[:id]) - @resource_score.update!(create_params) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score.language = language if language.present? + @resource_score.update!(sanitized_params) + render json: @resource_score, status: :ok rescue ActiveRecord::RecordInvalid => e render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content @@ -40,14 +50,19 @@ def update def mass_update country = params.dig(:data, :attributes, :country)&.downcase - lang = params.dig(:data, :attributes, :lang)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase resource_type = params.dig(:data, :attributes, :resource_type) featured = params.dig(:data, :attributes, :featured) || true incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] resulting_resource_scores = [] + raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + current_scores = ResourceScore.where( - country: country, lang: lang, featured: featured + country: country, language_id: language.id, featured: featured ).order(featured_order: :asc) if resource_type.present? @@ -55,8 +70,6 @@ def mass_update .where(resource_types: {name: resource_type.downcase}) end - raise "Country and/or Lang should be provided" unless country.present? && lang.present? - current_scores = current_scores.to_a if incoming_resources.empty? @@ -108,7 +121,7 @@ def mass_update # No ResourceScore at this position, create a new one resulting_resource_scores << ResourceScore.create!( resource_id: resource_id, - lang: lang, + language_id: language.id, country: country, featured: featured, featured_order: current_featured_order @@ -129,10 +142,14 @@ def create_params ) end - def all_featured_resources(lang:, country:, resource_type: nil) + def all_featured_resources(lang_code:, country:, resource_type: nil) scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) - scope = scope.where("resource_scores.lang = LOWER(:lang)", lang:) if lang.present? + if lang_code.present? + language = Language.find_by(code: lang_code.downcase) + scope = scope.left_joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? + end + scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? diff --git a/app/models/resource_default_order.rb b/app/models/resource_default_order.rb index 49ad70f7b..07036fd7b 100644 --- a/app/models/resource_default_order.rb +++ b/app/models/resource_default_order.rb @@ -1,19 +1,15 @@ class ResourceDefaultOrder < ApplicationRecord belongs_to :resource + belongs_to :language validates :position, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 1} - validates :resource_id, presence: true, uniqueness: {scope: :lang, message: "should have only one resource per language"} - validates :lang, presence: true + validates :resource_id, presence: true, uniqueness: {scope: :language_id, message: "should have only one resource per language"} + validates :language, presence: true - before_save :downcase_lang after_commit :clear_resource_cache private - def downcase_lang - self.lang = lang.downcase if lang.present? - end - def clear_resource_cache Rails.cache.delete_matched("cache::resources/*") Rails.cache.delete_matched("resources/*") diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index e3163a5a3..19e7a0174 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -3,6 +3,7 @@ class ResourceScore < ApplicationRecord MAX_FEATURED_ORDER_POSITION = 10 belongs_to :resource + belongs_to :language validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 20}, allow_nil: true validates :featured_order, numericality: { @@ -10,28 +11,26 @@ class ResourceScore < ApplicationRecord }, allow_nil: true validates :resource_id, presence: true validates :country, presence: true - validates :lang, presence: true validate :resource_uniquness_per_country_lang_and_resource_type validate :featured_has_order_assigned validate :featured_order_is_available_for_country_lang_and_resource_type, if: -> { featured && featured_order.present? } - before_save :downcase_country_and_lang + before_save :downcase_country after_commit :clear_resource_cache private def resource_uniquness_per_country_lang_and_resource_type existing = ResourceScore.joins(:resource).where( - country: country, lang: lang, resource_id: resource_id, resources: {resource_type_id: resource.resource_type_id} + country: country, language_id: language_id, resource_id: resource_id, resources: {resource_type_id: resource.resource_type_id} ).where.not(id:) return unless existing.exists? errors.add(:resource_id, "should have only one score per country, language and resource type") end - def downcase_country_and_lang + def downcase_country self.country = country.downcase if country.present? - self.lang = lang.downcase if lang.present? end def featured_has_order_assigned @@ -42,7 +41,7 @@ def featured_has_order_assigned def featured_order_is_available_for_country_lang_and_resource_type existing = ResourceScore.joins(:resource) - .where(country: country, lang: lang, featured_order: featured_order) + .where(country: country, language_id: language_id, featured_order: featured_order) .where.not(id:) .where(resources: {resource_type_id: resource.resource_type_id}) return unless existing.exists? diff --git a/app/serializers/resource_default_order_serializer.rb b/app/serializers/resource_default_order_serializer.rb index 52fc923b2..d65c8c050 100644 --- a/app/serializers/resource_default_order_serializer.rb +++ b/app/serializers/resource_default_order_serializer.rb @@ -2,7 +2,13 @@ class ResourceDefaultOrderSerializer < ActiveModel::Serializer type "resource-default-order" - attributes :position, :lang, :created_at, :updated_at + attributes :position, :created_at, :updated_at + attribute :lang belongs_to :resource + belongs_to :language + + def lang + object.language&.code + end end diff --git a/app/serializers/resource_score_serializer.rb b/app/serializers/resource_score_serializer.rb index 665902756..742044d2f 100644 --- a/app/serializers/resource_score_serializer.rb +++ b/app/serializers/resource_score_serializer.rb @@ -2,7 +2,13 @@ class ResourceScoreSerializer < ActiveModel::Serializer type "resource-score" - attributes :featured, :country, :lang, :score, :user_score_average, :user_score_count, :featured_order, :created_at, :updated_at + attributes :featured, :country, :score, :user_score_average, :user_score_count, :featured_order, :created_at, :updated_at + attribute :lang belongs_to :resource + belongs_to :language + + def lang + object.language&.code + end end diff --git a/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb b/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb new file mode 100644 index 000000000..593b79340 --- /dev/null +++ b/db/migrate/20251124151349_convert_resource_score_lang_to_language_id.rb @@ -0,0 +1,25 @@ +class ConvertResourceScoreLangToLanguageId < ActiveRecord::Migration[7.1] + def up + add_column :resource_scores, :language_id, :integer + add_index :resource_scores, [:language_id, :country] + + # Migrate data from lang to language_id by joining with languages table + execute <<-SQL + UPDATE resource_scores + SET language_id = languages.id + FROM languages + WHERE LOWER(resource_scores.lang) = LOWER(languages.code) + SQL + + remove_index :resource_scores, [:lang, :country] if index_exists?(:resource_scores, [:lang, :country]) + remove_column :resource_scores, :lang if column_exists?(:resource_scores, :lang) + end + + def down + remove_index :resource_scores, [:language_id, :country] if index_exists?(:resource_scores, [:language_id, :country]) + remove_column :resource_scores, :language_id, :integer if column_exists?(:resource_scores, :language_id) + + add_column :resource_scores, :lang + add_index :resource_scores, [:lang, :country] + end +end diff --git a/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb b/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb new file mode 100644 index 000000000..09aca58bb --- /dev/null +++ b/db/migrate/20251124224217_convert_resource_default_order_lang_to_language_id.rb @@ -0,0 +1,29 @@ +class ConvertResourceDefaultOrderLangToLanguageId < ActiveRecord::Migration[7.1] + def up + add_column :resource_default_orders, :language_id, :integer + add_index :resource_default_orders, :language_id + + # Migrate data from lang to language_id by joining with languages table + execute <<-SQL + UPDATE resource_default_orders + SET language_id = languages.id + FROM languages + WHERE LOWER(resource_default_orders.lang) = LOWER(languages.code) + SQL + + if column_exists?(:resource_default_orders, :lang) + remove_index :resource_default_orders, :lang + remove_column :resource_default_orders, :lang + end + end + + def down + if column_exists?(:resource_default_orders, :language_id) + remove_index :resource_default_orders, :language_id + remove_column :resource_default_orders, :language_id, :integer + end + + add_column :resource_default_orders, :lang + add_index :resource_default_orders, :lang + end +end diff --git a/db/schema.rb b/db/schema.rb index 094efeac4..dc9dd2f32 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.1].define(version: 2025_10_22_202102) do +ActiveRecord::Schema[7.1].define(version: 2025_11_24_224217) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -185,10 +185,10 @@ create_table "resource_default_orders", force: :cascade do |t| t.integer "position" t.integer "resource_id" - t.string "lang" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["lang"], name: "index_resource_default_orders_on_lang" + t.integer "language_id" + t.index ["language_id"], name: "index_resource_default_orders_on_language_id" t.index ["resource_id"], name: "index_resource_default_orders_on_resource_id" end @@ -196,14 +196,14 @@ t.integer "resource_id" t.boolean "featured" t.string "country" - t.string "lang" t.integer "score" t.float "user_score_average" t.integer "user_score_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "featured_order" - t.index ["lang", "country"], name: "index_resource_scores_on_lang_and_country" + t.integer "language_id" + t.index ["language_id", "country"], name: "index_resource_scores_on_language_id_and_country" t.index ["resource_id"], name: "index_resource_scores_on_resource_id" end diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb index 0d1a5afaa..b8576c4d5 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resources/default_order_controller_spec.rb @@ -12,7 +12,9 @@ let(:authorization) { AuthToken.generic_token } let!(:resource) { Resource.first } - let!(:other_resource) { Resource.last } + let!(:other_resource) { Resource.second } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } before(:each) do ResourceDefaultOrder.delete_all @@ -20,8 +22,8 @@ get "resources/default_order" do before do - FactoryBot.create(:resource_default_order, resource: resource, lang: "en") - FactoryBot.create(:resource_default_order, resource: other_resource, lang: "en") + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + FactoryBot.create(:resource_default_order, resource: other_resource, language: language_en) end context "without filters" do @@ -55,25 +57,23 @@ end context "with resource_type filter" do - let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } - let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } - let!(:tool_order) { FactoryBot.create(:resource_default_order, resource: tool_resource, lang: "en") } + let(:resource_type) { resource.resource_type } it "returns default order resources for specified resource type" do - do_request resource_type: "metatool" + do_request resource_type: resource_type.name.downcase expect(status).to be(200) json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) + expect(json["data"].size).to eq(2) end context "inside filter param" do it "returns default order resources for specified resource type" do - do_request filter: {resource_type: "metatool"} + do_request filter: {resource_type: resource_type.name.downcase} expect(status).to be(200) json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) + expect(json["data"].size).to eq(2) end end end @@ -88,7 +88,7 @@ type: "resource_default_order", attributes: { resource_id: resource.id, - lang: "en", + lang: language_en.code, position: 2 } } @@ -119,7 +119,7 @@ delete "resources/default_order/:id" do requires_authorization - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, lang: "en") } + let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, language: language_en) } let(:id) { resource_default_order.id } it "deletes the default order resource" do @@ -143,14 +143,14 @@ patch "resources/default_order/:id" do requires_authorization - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, lang: "en") } + let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, language: language_en) } let(:id) { resource_default_order.id } let(:valid_update_params) do { data: { type: "resource_default_order", attributes: { - lang: "fr" + lang: language_fr.code } } } @@ -162,7 +162,7 @@ expect(status).to be(200) json = JSON.parse(response_body) - expect(json["data"]["attributes"]["lang"]).to eq("fr") + expect(json["data"]["relationships"]["language"]["data"]["id"]).to eq(language_fr.id.to_s) expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) end end diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb index 6d9618ee1..c537f12aa 100644 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ b/spec/acceptance/resources/featured_controller_spec.rb @@ -13,10 +13,12 @@ let!(:resource) { Resource.first } let!(:unfeatured_resource) { Resource.last } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } get "resources/featured" do let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end @@ -78,7 +80,7 @@ context "with resource_type filter" do let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } - let!(:tool_score) { FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2) } + let!(:tool_score) { FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, language: language_en) } it "returns featured resources for specified resource type" do do_request resource_type: "metatool" @@ -106,7 +108,7 @@ requires_authorization let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end @@ -117,7 +119,7 @@ type: "resource_score", attributes: { resource_id: resource.id, - lang: "en", + lang: language_en.code, country: "US", featured: true, featured_order: 1 @@ -153,7 +155,7 @@ let(:id) { resource_score.id } let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end @@ -181,7 +183,7 @@ requires_authorization let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", lang: "en") do |rs| + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end @@ -310,10 +312,10 @@ let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, lang: lang, featured: true, featured_order: 1) + ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, featured_order: 1) end let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, lang: lang, featured: true, featured_order: 2) + ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: true, featured_order: 2) end context "when sending an empty array" do diff --git a/spec/factories/languages.rb b/spec/factories/languages.rb new file mode 100644 index 000000000..b14d72856 --- /dev/null +++ b/spec/factories/languages.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :language do + name { "English" } + code { "en" } + direction { "ltr" } + force_language_name { false } + end +end diff --git a/spec/factories/resource_default_orders.rb b/spec/factories/resource_default_orders.rb index 4938a1741..19448a8b4 100644 --- a/spec/factories/resource_default_orders.rb +++ b/spec/factories/resource_default_orders.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :resource_default_order do resource + language { Language.find_by(code: "en") || FactoryBot.create(:language, code: "en") } sequence(:position) { |n| n } - lang { "en" } end end diff --git a/spec/factories/resource_scores.rb b/spec/factories/resource_scores.rb index 6c4ac2452..88de49c61 100644 --- a/spec/factories/resource_scores.rb +++ b/spec/factories/resource_scores.rb @@ -3,10 +3,10 @@ FactoryBot.define do factory :resource_score do resource + language { association :language, code: "en" } featured { false } featured_order { 1 } country { "us" } - lang { "en" } score { 1 } user_score_average { 1.5 } user_score_count { 1 } diff --git a/spec/models/resource_default_order_spec.rb b/spec/models/resource_default_order_spec.rb index dc91bd7ec..0e94eb2a3 100644 --- a/spec/models/resource_default_order_spec.rb +++ b/spec/models/resource_default_order_spec.rb @@ -1,35 +1,27 @@ require "rails_helper" RSpec.describe ResourceDefaultOrder, type: :model do - let(:resource) { Resource.first || FactoryBot.create(:resource) } - subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource) } + let(:resource) { Resource.first } + let(:language) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource, language: language) } describe "validations" do it { is_expected.to be_valid } context "uniqueness validation" do - before { FactoryBot.create(:resource_default_order, resource: resource, lang: "en", position: 1) } + before { FactoryBot.create(:resource_default_order, resource: resource, language: language, position: 1) } - it "validates uniqueness of position scoped to lang" do - duplicate = FactoryBot.build(:resource_default_order, resource: resource, lang: "en", position: 1) + it "validates uniqueness of position scoped to language_id" do + duplicate = FactoryBot.build(:resource_default_order, resource: resource, language: language, position: 1) expect(duplicate).not_to be_valid expect(duplicate.errors[:resource_id]).to include("should have only one resource per language") end it "allows same position for different language" do - different_lang = FactoryBot.build(:resource_default_order, resource: resource, lang: "es", position: 1) + different_lang = FactoryBot.build(:resource_default_order, resource: resource, language: other_language, position: 1) expect(different_lang).to be_valid end end end - - describe "callbacks" do - context "before_save" do - it "downcases lang" do - resource_default_order.lang = "EN" - resource_default_order.save - expect(resource_default_order.lang).to eq("en") - end - end - end end diff --git a/spec/models/resource_score_spec.rb b/spec/models/resource_score_spec.rb index 0776a8281..7b6da2624 100644 --- a/spec/models/resource_score_spec.rb +++ b/spec/models/resource_score_spec.rb @@ -4,23 +4,25 @@ RSpec.describe ResourceScore, type: :model do let(:resource) { Resource.first } - subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource) } + let(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource, language: language_en) } describe "validations" do let(:resource_score_with_resource) do FactoryBot.create( - :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", lang: "en" + :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", language: language_en ) end it { is_expected.to be_valid } context "uniqueness validation" do let!(:previous_resource_score) do - FactoryBot.create(:resource_score, resource: resource, country: "us", lang: "en") + FactoryBot.create(:resource_score, resource: resource, country: "us", language: language_en) end - it "validates uniqueness of resource_id scoped to country, lang and resource_type" do - duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", lang: "en") + it "validates uniqueness of resource_id scoped to country, language and resource_type" do + duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", language: language_en) expect(duplicate).not_to be_valid expect(duplicate.errors[:resource_id]).to include("should have only one score per country, language and resource type") end @@ -36,27 +38,27 @@ it "validates uniqueness of featured_order within country, language and resource type" do resource_score_with_resource - duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", lang: "en") + duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", language: language_en) expect(duplicate).not_to be_valid expect(duplicate.errors[:featured_order]).to include("is already taken for this country, language and resource type") end context "having a resource score created previously" do let!(:previous_resource_score) do - ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", lang: "en") + ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", language: language_en) end it "allows same featured_order for different country" do resource2 = Resource.last different_country = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "CA", lang: "en") + country: "CA", language: language_en) expect(different_country).to be_valid end it "allows same featured_order for different language" do resource2 = Resource.last different_lang = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "US", lang: "es") + country: "US", language: other_language) expect(different_lang).to be_valid end end @@ -64,7 +66,7 @@ it "allows same featured_order for different resources" do resource_score_with_resource different_resource = FactoryBot.build(:resource_score, resource: Resource.last, featured: true, - featured_order: 1, country: "US", lang: "en") + featured_order: 1, country: "US", language: language_en) expect(different_resource).to be_valid end end @@ -77,12 +79,6 @@ resource_score.save expect(resource_score.country).to eq("us") end - - it "downcases lang" do - resource_score.lang = "EN" - resource_score.save - expect(resource_score.lang).to eq("en") - end end end end From ce1f9c338ee1bb4abbe0dc884237b262668d12fe Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Mon, 24 Nov 2025 23:23:54 -0500 Subject: [PATCH 03/38] Updated Content Status response --- app/controllers/content_status_controller.rb | 67 +++++++++----------- app/models/language.rb | 1 + 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb index 8a5887fd5..7e26833bb 100644 --- a/app/controllers/content_status_controller.rb +++ b/app/controllers/content_status_controller.rb @@ -10,8 +10,11 @@ def index resource_types: { name: 'tract' }, resource_scores: { featured: true } ).count, - ranked: 0, - total: Resource.count + ranked: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'tract' }, + resource_scores: { featured: true } + ).where.not(resource_scores: { score: nil }).count, + total: Resource.joins(:resource_type).where(resource_types: { name: 'tract' }).count }, lessons: { default: Resource.left_joins(:resource_scores).joins(:resource_type).where( @@ -22,71 +25,63 @@ def index resource_types: { name: 'lesson' }, resource_scores: { featured: true } ).count, - ranked: 0, - total: Resource.count + ranked: Resource.joins(:resource_type, :resource_scores).where( + resource_types: { name: 'lesson' }, + resource_scores: { featured: true } + ).where.not(resource_scores: { score: nil }).count, + total: Resource.joins(:resource_type).where(resource_types: { name: 'lesson' }).count }, countries: retrieve_countries_data } render json: metrics, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private - def uniq_languages_per_country(country = nil) - resource_score_languages = if country.present? - ResourceScore.where(country: country).select(:lang).distinct.pluck(:lang) - else - ResourceScore.select(:lang).distinct.pluck(:lang) - end - # resource_default_order_languages = ResourceDefaultOrder.select(:lang).distinct.pluck(:lang) - resource_default_order_languages = [] - languages = (resource_score_languages + resource_default_order_languages).flatten - languages.uniq - end - def uniq_countries ResourceScore.select(:country).distinct.pluck(:country) end - def retrieve_lessons_data(country, lang) + def retrieve_lessons_data(country, language) { - default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: { name: 'lesson' }, resource_scores: { id: nil, lang: lang } - ).count, - featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'lesson' }, resource_scores: { featured: true, lang: lang, country: country } - ).count, + featured: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: { name: 'lesson' }, resource_scores: { featured: true, country: country } + ).where(resource_scores: { language: language }).count, ranked: 0 } end - def retrieve_tools_data(country, lang) + def retrieve_tools_data(country, language) { - default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: { name: 'tract' }, resource_scores: { id: nil, lang: lang } - ).count, - featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'tract' }, resource_scores: { featured: true, lang: lang, country: country } - ).count, + featured: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: { name: 'tract' }, resource_scores: { featured: true, country: country } + ).where(resource_scores: { language: language }).count, ranked: 0 } end - def retrieve_language_data(country, lang) + def retrieve_language_data(country, language) { - language: lang, - lessons: retrieve_lessons_data(country, lang), - tools: retrieve_tools_data(country, lang) + language_code: language.code.downcase, + language_name: language.name, + lessons: retrieve_lessons_data(country, language), + tools: retrieve_tools_data(country, language), + last_updated: Resource.joins(:resource_scores).where( + resource_scores: { country: country, language: language } + ).maximum(:updated_at)&.strftime('%d-%m-%y') || 'N/A' } end def retrieve_countries_data uniq_countries.map do |country| - languages = uniq_languages_per_country(country) { country_code: country, - languages: languages.map { |lang| retrieve_language_data(country, lang) } + languages: Language.joins(:resource_scores).where(resource_scores: { country: country }).distinct.map do |language| + retrieve_language_data(country, language) + end } end end diff --git a/app/models/language.rb b/app/models/language.rb index 5231a0c1c..04c5874f7 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -6,6 +6,7 @@ class Language < ActiveRecord::Base has_many :custom_tips, dependent: :restrict_with_error has_many :translated_pages, dependent: :restrict_with_error has_many :language_attributes, dependent: :restrict_with_error + has_many :resource_scores, dependent: :restrict_with_error validates :name, presence: true validates :code, presence: true From b1acd1d01f4bd09c09b36f1ad231f549cce1704a Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Mon, 24 Nov 2025 23:33:40 -0500 Subject: [PATCH 04/38] Touching resource on score or default order update/creation --- app/controllers/content_status_controller.rb | 46 ++++++++++---------- app/models/resource_default_order.rb | 5 +++ app/models/resource_score.rb | 5 +++ config/routes.rb | 2 +- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb index 7e26833bb..a99810dbf 100644 --- a/app/controllers/content_status_controller.rb +++ b/app/controllers/content_status_controller.rb @@ -3,33 +3,33 @@ def index metrics = { tools: { default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: { name: 'tract' }, - resource_scores: { id: nil } + resource_types: {name: "tract"}, + resource_scores: {id: nil} ).count, featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'tract' }, - resource_scores: { featured: true } + resource_types: {name: "tract"}, + resource_scores: {featured: true} ).count, ranked: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'tract' }, - resource_scores: { featured: true } - ).where.not(resource_scores: { score: nil }).count, - total: Resource.joins(:resource_type).where(resource_types: { name: 'tract' }).count + resource_types: {name: "tract"}, + resource_scores: {featured: true} + ).where.not(resource_scores: {score: nil}).count, + total: Resource.joins(:resource_type).where(resource_types: {name: "tract"}).count }, lessons: { default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: { name: 'lesson' }, - resource_scores: { id: nil } + resource_types: {name: "lesson"}, + resource_scores: {id: nil} ).count, featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'lesson' }, - resource_scores: { featured: true } + resource_types: {name: "lesson"}, + resource_scores: {featured: true} ).count, ranked: Resource.joins(:resource_type, :resource_scores).where( - resource_types: { name: 'lesson' }, - resource_scores: { featured: true } - ).where.not(resource_scores: { score: nil }).count, - total: Resource.joins(:resource_type).where(resource_types: { name: 'lesson' }).count + resource_types: {name: "lesson"}, + resource_scores: {featured: true} + ).where.not(resource_scores: {score: nil}).count, + total: Resource.joins(:resource_type).where(resource_types: {name: "lesson"}).count }, countries: retrieve_countries_data } @@ -48,8 +48,8 @@ def uniq_countries def retrieve_lessons_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'lesson' }, resource_scores: { featured: true, country: country } - ).where(resource_scores: { language: language }).count, + resource_types: {name: "lesson"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, ranked: 0 } end @@ -57,8 +57,8 @@ def retrieve_lessons_data(country, language) def retrieve_tools_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'tract' }, resource_scores: { featured: true, country: country } - ).where(resource_scores: { language: language }).count, + resource_types: {name: "tract"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, ranked: 0 } end @@ -70,8 +70,8 @@ def retrieve_language_data(country, language) lessons: retrieve_lessons_data(country, language), tools: retrieve_tools_data(country, language), last_updated: Resource.joins(:resource_scores).where( - resource_scores: { country: country, language: language } - ).maximum(:updated_at)&.strftime('%d-%m-%y') || 'N/A' + resource_scores: {country: country, language: language} + ).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" } end @@ -79,7 +79,7 @@ def retrieve_countries_data uniq_countries.map do |country| { country_code: country, - languages: Language.joins(:resource_scores).where(resource_scores: { country: country }).distinct.map do |language| + languages: Language.joins(:resource_scores).where(resource_scores: {country: country}).distinct.map do |language| retrieve_language_data(country, language) end } diff --git a/app/models/resource_default_order.rb b/app/models/resource_default_order.rb index 07036fd7b..d0e66d945 100644 --- a/app/models/resource_default_order.rb +++ b/app/models/resource_default_order.rb @@ -7,6 +7,7 @@ class ResourceDefaultOrder < ApplicationRecord validates :language, presence: true after_commit :clear_resource_cache + after_commit :touch_resource, on: [:create, :update] private @@ -14,4 +15,8 @@ def clear_resource_cache Rails.cache.delete_matched("cache::resources/*") Rails.cache.delete_matched("resources/*") end + + def touch_resource + resource&.touch(:updated_at) + end end diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index 19e7a0174..46f602a93 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -17,6 +17,7 @@ class ResourceScore < ApplicationRecord before_save :downcase_country after_commit :clear_resource_cache + after_commit :touch_resource, on: [:create, :update] private @@ -53,4 +54,8 @@ def clear_resource_cache Rails.cache.delete_matched("cache::resources/*") Rails.cache.delete_matched("resources/*") end + + def touch_resource + resource&.touch(:updated_at) + end end diff --git a/config/routes.rb b/config/routes.rb index 30dd9078a..2cff3997d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,7 +107,7 @@ end end - get 'content_status', to: 'content_status#index' + get "content_status", to: "content_status#index" if Rails.env.production? || Rails.env.staging? Sidekiq::Web.use Rack::Auth::Basic do |username, password| From 4f16f57ad093642fb256200a6e09f9cf399aa88e Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Tue, 25 Nov 2025 09:12:13 -0500 Subject: [PATCH 05/38] Added spec file --- .../content_status_controller_spec.rb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 spec/acceptance/content_status_controller_spec.rb diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb new file mode 100644 index 000000000..19a58534a --- /dev/null +++ b/spec/acceptance/content_status_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "acceptance_helper" +require "sidekiq/testing" + +resource "ContentStatus" do + include ActiveJob::TestHelper + + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" + + get "content_status" do + it "returns statistics JSON" do + do_request + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["tools"]["total"]).to eq(Resource.joins(:resource_type).where(resource_types: {name: "tract"}).count) + end + end +end From 74333d31816b86cd81d6c9c9636a05d63a210599 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Tue, 25 Nov 2025 10:23:39 -0500 Subject: [PATCH 06/38] Updated spec for content_status --- spec/acceptance/content_status_controller_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb index 19a58534a..a8fce2856 100644 --- a/spec/acceptance/content_status_controller_spec.rb +++ b/spec/acceptance/content_status_controller_spec.rb @@ -10,6 +10,20 @@ header "Content-Type", "application/vnd.api+json" get "content_status" do + let(:country) { "us" } + let!(:resource) { Resource.first } + let!(:unfeatured_resource) { Resource.last } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, featured_order: 1) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, featured_order: nil) + end + it "returns statistics JSON" do do_request From 3ee6646410d4a8be7ca0cf2fd8e3796ba1067ff7 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Tue, 25 Nov 2025 14:37:16 -0500 Subject: [PATCH 07/38] Fixed unassigned scope on default_orders --- app/controllers/resources/default_order_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 787095cd0..d85fda33a 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -52,10 +52,10 @@ def all_default_order_resources(lang:, resource_type: nil) if lang.present? language = Language.find_by(code: lang.downcase) - scope = scope.left_joins(resource_default_orders: :language).where(languages: {id: language.id}) if language.present? + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end - scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") end From 2ed61cc516e633890e8647de96afb28f85b2689b Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 26 Nov 2025 12:13:34 -0500 Subject: [PATCH 08/38] Added mass_update endpoint for default orders and mass_update_ranked endpoint for ranked resources --- .../resources/default_order_controller.rb | 81 ++++++- .../resources/featured_controller.rb | 69 +++++- app/models/resource_score.rb | 3 +- config/routes.rb | 9 +- .../default_order_controller_spec.rb | 151 ++++++++++++ .../resources/featured_controller_spec.rb | 227 ++++++++++++++++++ 6 files changed, 534 insertions(+), 6 deletions(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index d85fda33a..b015cc96b 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -2,7 +2,7 @@ module Resources class DefaultOrderController < ApplicationController - before_action :authorize!, only: %i[create destroy update] + before_action :authorize!, only: %i[create destroy update mass_update] def index default_order_resources = all_default_order_resources( @@ -45,6 +45,85 @@ def update render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end + def mass_update + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type = params.dig(:data, :attributes, :resource_type) + incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] + resulting_resource_default_orders = [] + + raise "Lang should be provided" unless lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) + + if resource_type.present? + current_orders = current_orders.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + current_orders = current_orders.to_a + + if incoming_resources.empty? + current_orders.each do |ro| + ro.destroy! + end + current_orders.reject! { |ro| !ro.persisted? } + + return render json: current_orders, status: :ok + end + + ResourceDefaultOrder.transaction do + incoming_resources.each_with_index do |resource_id, index| + current_position = index + 1 + + if resource_id.nil? + # Remove any existing resource default order at this position + resource_order_to_remove = current_orders.find { |ro| ro.position == current_position } + resource_order_to_remove&.destroy! + next + end + + incoming_resource_order = current_orders.find { |ro| ro.resource_id == resource_id } + current_resource_order_at_position = current_orders.find { |ro| ro.position == current_position } + + if incoming_resource_order + if incoming_resource_order.position != current_position + # Incoming ResourceDefaultOrder exists but at a different position + # Remove ResourceDefaultOrder currently at this position, if any + if current_resource_order_at_position + current_resource_order_at_position.destroy! + current_orders.reject! { |ro| ro.id == current_resource_order_at_position.id } + end + + # Move incoming ResourceDefaultOrder to the new position + incoming_resource_order.update!(position: current_position) + resulting_resource_default_orders << incoming_resource_order + else + # Incoming ResourceDefaultOrder exists and is already at the correct position + resulting_resource_default_orders << incoming_resource_order + next + end + elsif current_resource_order_at_position + # There is a ResourceDefaultOrder at this position, update it to the new resource_id + current_resource_order_at_position.update!(resource_id: resource_id) + resulting_resource_default_orders << current_resource_order_at_position + else + # No ResourceDefaultOrder at this position, create a new one + resulting_resource_default_orders << ResourceDefaultOrder.create!( + resource_id: resource_id, + language_id: language.id, + position: current_position + ) + end + end + end + render json: resulting_resource_default_orders, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + private def all_default_order_resources(lang:, resource_type: nil) diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index c19b9954a..b326cf6ce 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -2,7 +2,7 @@ module Resources class FeaturedController < ApplicationController - before_action :authorize!, only: %i[create destroy update mass_update] + before_action :authorize!, only: %i[create destroy update mass_update mass_update_ranked] def index lang_code = params.dig(:filter, :lang) || params[:lang] @@ -77,7 +77,7 @@ def mass_update end current_scores.reject! { |rs| !rs.persisted? } - return render json: current_scores, status: :ok + return render json: current_scores, include: params[:include], status: :ok end ResourceScore.transaction do @@ -129,7 +129,70 @@ def mass_update end end end - render json: resulting_resource_scores, status: :ok + render json: resulting_resource_scores, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + def mass_update_ranked + country = params.dig(:data, :attributes, :country)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type = params.dig(:data, :attributes, :resource_type) + incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] + resulting_resource_scores = [] + + raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + current_scores = ResourceScore.where( + country: country, language_id: language.id + ).order(score: :desc) + + if resource_type.present? + current_scores = current_scores.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + current_scores = current_scores.to_a + + if incoming_resources.empty? + current_scores.each do |rs| + rs.update!(score: nil) + end + + return render json: current_scores, include: params[:include], status: :ok + end + + ResourceScore.transaction do + incoming_resources.each do |incoming_resource| + symbolized_incoming_resource = incoming_resource + resource_id = symbolized_incoming_resource[:resource_id] + score = symbolized_incoming_resource[:score] + incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } + + if incoming_resource_score + # Update existing ResourceScore with new score + incoming_resource_score.update!(score: score) + resulting_resource_scores << incoming_resource_score + else + # Create new ResourceScore with the provided score + new_resource_score = ResourceScore.create!( + resource_id: resource_id, + language_id: language.id, + country: country, + score: score + ) + resulting_resource_scores << new_resource_score + end + end + end + + # Sort resulting_resource_scores by score descending before rendering + resulting_resource_scores.sort_by! { |rs| -rs.score.to_i } + + render json: resulting_resource_scores, include: params[:include], status: :ok rescue => e render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index 46f602a93..574b8e52d 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -2,10 +2,11 @@ class ResourceScore < ApplicationRecord MAX_FEATURED_ORDER_POSITION = 10 + MAX_SCORE = 20 belongs_to :resource belongs_to :language - validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 20}, allow_nil: true + validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE}, allow_nil: true validates :featured_order, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_FEATURED_ORDER_POSITION }, allow_nil: true diff --git a/config/routes.rb b/config/routes.rb index 2cff3997d..97996be62 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,12 +23,19 @@ post "translations/publish", to: "resources#publish_translation" collection do resources :featured, only: [:index, :create, :update, :destroy], module: :resources do + collection do + put :mass_update + patch :mass_update + put :mass_update_ranked + patch :mass_update_ranked + end + end + resources :default_order, only: [:index, :create, :update, :destroy], module: :resources do collection do put :mass_update patch :mass_update end end - resources :default_order, only: [:index, :create, :update, :destroy], module: :resources end end resources :drafts, only: [:index, :show, :create, :destroy] diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb index b8576c4d5..50cb358cb 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resources/default_order_controller_spec.rb @@ -187,4 +187,155 @@ end end end + + patch "resources/default_order/mass_update" do + requires_authorization + + let(:lang) { "en" } + let(:resource_ids) { [] } + let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:params) { {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} } + + context "with no lang param" do + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with lang param" do + context "with no previous resource default order" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "returns an array with 1 resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + end + end + + context "when sending more than 1 resource default order" do + let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with more than 1 resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + end + end + + context "with previous resource default orders" do + let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en, position: 1) + end + let!(:resource_default_order2) do + FactoryBot.create(:resource_default_order, resource: resource2, language: language_en, position: 2) + end + + context "when sending an empty array" do + let(:resource_ids) { [] } + + it "deletes all matching resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + expect(ResourceDefaultOrder.exists?(resource_default_order.id)).to be false + expect(ResourceDefaultOrder.exists?(resource_default_order2.id)).to be false + end + end + + context "when sending 1 resource to replace" do + let(:resource_ids) { [resource3.id, resource2.id] } + + it "returns an array with the replaced resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + end + + context "when sending more than 1 resource to replace" do + let(:resource_ids) { [resource2.id, resource3.id, resource.id] } + + it "returns an array with the replaced resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][2]["attributes"]["position"]).to eq(3) + end + end + + context "when sending the same resource to replace" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with the resource default orders in new positions" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["position"]).to eq(2) + end + end + end + end + end end diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb index fd2f619a4..e6fdf4d4f 100644 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ b/spec/acceptance/resources/featured_controller_spec.rb @@ -398,4 +398,231 @@ end end end + + patch "resources/featured/mass_update_ranked" do + requires_authorization + + let(:country) { "US" } + let(:lang) { "en" } + let(:ranked_resources) { [] } + let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:params) { {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, resource_type: resource_type.name}}} } + + context "with no country and lang params" do + let(:country) { nil } + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with country and lang params" do + context "with no previous resource scores" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an array with 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(10) + end + end + + context "when sending more than 1 ranked resource" do + let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } + + it "returns an array with more than 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["score"]).to eq(10) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + end + + context "with previous resource scores" do + let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, score: 5) + end + + context "when sending an empty array" do + let(:ranked_resources) { [] } + + it "clears all scores for matching resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + + resource_score.reload + resource_score2.reload + expect(resource_score.score).to be_nil + expect(resource_score2.score).to be_nil + end + end + + context "when sending 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 20}] } + + it "returns an array with the updated resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + end + + it "updates only the existing resource score" do + do_request(params) + + resource_score.reload + expect(resource_score.score).to eq(20) + end + end + + context "when sending more than 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 18}, {resource_id: resource2.id, score: 12}] } + + it "returns an array with the updated resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(18) + expect(json["data"][1]["attributes"]["score"]).to eq(12) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when sending a new resource to add" do + let(:ranked_resources) { [{resource_id: resource3.id, score: 16}] } + + it "creates a new resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(16) + end + end + + context "when sending a mix of existing and new resources" do + let(:ranked_resources) { [{resource_id: resource.id, score: 19}, {resource_id: resource3.id, score: 14}] } + + it "returns an array with updated and new resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(19) + expect(json["data"][1]["attributes"]["score"]).to eq(14) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when resource_type filter is applied" do + let(:ranked_resources) { [{resource_id: resource.id, score: 17}] } + + it "only updates resources of the specified type" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when updating with different scores" do + let(:ranked_resources) do + [ + {resource_id: resource.id, score: 20}, + {resource_id: resource2.id, score: 13}, + {resource_id: resource3.id, score: 7} + ] + end + + it "returns all resources sorted by score in descending order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["attributes"]["score"]).to eq(13) + expect(json["data"][2]["attributes"]["score"]).to eq(7) + end + end + end + end + end end From b283daf930a491da05e6b0bddcf27a5ea9e1bfd0 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 26 Nov 2025 12:24:31 -0500 Subject: [PATCH 09/38] Updated spec --- .../resources/default_order_controller_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb index 50cb358cb..9a9e48377 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resources/default_order_controller_spec.rb @@ -335,6 +335,20 @@ expect(json["data"][1]["attributes"]["position"]).to eq(2) end end + + context "when sending nil resource_id at a position" do + let(:resource_ids) { [resource.id, nil] } + + it "removes the resource default order at that position" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + end + end end end end From 6d7424cd449ee1885fb4c743cb12beade4f72b78 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 3 Dec 2025 11:30:53 -0500 Subject: [PATCH 10/38] Updated queries --- app/controllers/content_status_controller.rb | 74 ++++++++++---------- app/models/language.rb | 3 +- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb index a99810dbf..fea91f0db 100644 --- a/app/controllers/content_status_controller.rb +++ b/app/controllers/content_status_controller.rb @@ -2,41 +2,37 @@ class ContentStatusController < ApplicationController def index metrics = { tools: { - default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: {name: "tract"}, - resource_scores: {id: nil} - ).count, - featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: {name: "tract"}, - resource_scores: {featured: true} - ).count, - ranked: Resource.joins(:resource_type, :resource_scores).where( - resource_types: {name: "tract"}, - resource_scores: {featured: true} - ).where.not(resource_scores: {score: nil}).count, - total: Resource.joins(:resource_type).where(resource_types: {name: "tract"}).count + default: Language.joins(resource_default_orders: { resource: :resource_type }).where( + resource_types: { name: 'tract' } + ).distinct('languages.id').count, + featured: Language.joins(resource_scores: { resource: :resource_type }).where( + resource_types: { name: 'tract' }, + resource_scores: { featured: true } + ).distinct('languages.id').count, + ranked: Language.joins(resource_scores: { resource: :resource_type }).where( + resource_types: { name: 'tract' } + ).where.not(resource_scores: { score: nil }).distinct('languages.id').count, + total: Language.joins(resource_scores: { resource: :resource_type }).where(resource_types: { name: 'tract' }).distinct('languages.id').count }, lessons: { - default: Resource.left_joins(:resource_scores).joins(:resource_type).where( - resource_types: {name: "lesson"}, - resource_scores: {id: nil} - ).count, - featured: Resource.joins(:resource_type, :resource_scores).where( - resource_types: {name: "lesson"}, - resource_scores: {featured: true} - ).count, - ranked: Resource.joins(:resource_type, :resource_scores).where( - resource_types: {name: "lesson"}, - resource_scores: {featured: true} - ).where.not(resource_scores: {score: nil}).count, - total: Resource.joins(:resource_type).where(resource_types: {name: "lesson"}).count + default: Language.joins(resource_default_orders: { resource: :resource_type }).where( + resource_types: { name: 'lesson' } + ).distinct('languages.id').count, + featured: Language.joins(resource_scores: { resource: :resource_type }).where( + resource_types: { name: 'lesson' }, + resource_scores: { featured: true } + ).distinct('languages.id').count, + ranked: Language.joins(resource_scores: { resource: :resource_type }).where( + resource_types: { name: 'lesson' } + ).where.not(resource_scores: { score: nil }).distinct('languages.id').count, + total: Language.joins(resource_scores: { resource: :resource_type }).where(resource_types: { name: 'lesson' }).distinct('languages.id').count }, countries: retrieve_countries_data } render json: metrics, status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end private @@ -48,18 +44,22 @@ def uniq_countries def retrieve_lessons_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: {name: "lesson"}, resource_scores: {featured: true, country: country} - ).where(resource_scores: {language: language}).count, - ranked: 0 + resource_types: { name: 'lesson' }, resource_scores: { featured: true, country: country } + ).where(resource_scores: { language: language }).count, + ranked: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: { name: 'lesson' }, resource_scores: { country: country } + ).where(resource_scores: { language: language }).where.not(resource_scores: { score: nil }).count } end def retrieve_tools_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: {name: "tract"}, resource_scores: {featured: true, country: country} - ).where(resource_scores: {language: language}).count, - ranked: 0 + resource_types: { name: 'tract' }, resource_scores: { featured: true, country: country } + ).where(resource_scores: { language: language }).count, + ranked: Resource.joins(:resource_type, resource_scores: :language).where( + resource_types: { name: 'tract' }, resource_scores: { country: country } + ).where(resource_scores: { language: language }).where.not(resource_scores: { score: nil }).count } end @@ -70,8 +70,8 @@ def retrieve_language_data(country, language) lessons: retrieve_lessons_data(country, language), tools: retrieve_tools_data(country, language), last_updated: Resource.joins(:resource_scores).where( - resource_scores: {country: country, language: language} - ).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" + resource_scores: { country: country, language: language } + ).maximum(:updated_at)&.strftime('%d-%m-%y') || 'N/A' } end @@ -79,7 +79,7 @@ def retrieve_countries_data uniq_countries.map do |country| { country_code: country, - languages: Language.joins(:resource_scores).where(resource_scores: {country: country}).distinct.map do |language| + languages: Language.joins(:resource_scores).where(resource_scores: { country: country }).distinct.map do |language| retrieve_language_data(country, language) end } diff --git a/app/models/language.rb b/app/models/language.rb index 04c5874f7..a6f23b179 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -7,8 +7,9 @@ class Language < ActiveRecord::Base has_many :translated_pages, dependent: :restrict_with_error has_many :language_attributes, dependent: :restrict_with_error has_many :resource_scores, dependent: :restrict_with_error + has_many :resource_default_orders, dependent: :restrict_with_error validates :name, presence: true validates :code, presence: true - validates_with LanguageValidator, on: [:create, :update] + validates_with LanguageValidator, on: %i[create update] end From 3ba7024f2576a39b8431b5b0d08d7712d1ebb0bb Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 3 Dec 2025 11:44:44 -0500 Subject: [PATCH 11/38] Fixed lints + Updated tests --- app/controllers/content_status_controller.rb | 70 +++++++-------- .../content_status_controller_spec.rb | 87 ++++++++++++++----- 2 files changed, 102 insertions(+), 55 deletions(-) diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb index fea91f0db..0b4279c68 100644 --- a/app/controllers/content_status_controller.rb +++ b/app/controllers/content_status_controller.rb @@ -2,37 +2,37 @@ class ContentStatusController < ApplicationController def index metrics = { tools: { - default: Language.joins(resource_default_orders: { resource: :resource_type }).where( - resource_types: { name: 'tract' } - ).distinct('languages.id').count, - featured: Language.joins(resource_scores: { resource: :resource_type }).where( - resource_types: { name: 'tract' }, - resource_scores: { featured: true } - ).distinct('languages.id').count, - ranked: Language.joins(resource_scores: { resource: :resource_type }).where( - resource_types: { name: 'tract' } - ).where.not(resource_scores: { score: nil }).distinct('languages.id').count, - total: Language.joins(resource_scores: { resource: :resource_type }).where(resource_types: { name: 'tract' }).distinct('languages.id').count + default: Language.joins(resource_default_orders: {resource: :resource_type}).where( + resource_types: {name: "tract"} + ).distinct("languages.id").count, + featured: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "tract"}, + resource_scores: {featured: true} + ).distinct("languages.id").count, + ranked: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "tract"} + ).where.not(resource_scores: {score: nil}).distinct("languages.id").count, + total: Language.joins(resource_scores: {resource: :resource_type}).where(resource_types: {name: "tract"}).distinct("languages.id").count }, lessons: { - default: Language.joins(resource_default_orders: { resource: :resource_type }).where( - resource_types: { name: 'lesson' } - ).distinct('languages.id').count, - featured: Language.joins(resource_scores: { resource: :resource_type }).where( - resource_types: { name: 'lesson' }, - resource_scores: { featured: true } - ).distinct('languages.id').count, - ranked: Language.joins(resource_scores: { resource: :resource_type }).where( - resource_types: { name: 'lesson' } - ).where.not(resource_scores: { score: nil }).distinct('languages.id').count, - total: Language.joins(resource_scores: { resource: :resource_type }).where(resource_types: { name: 'lesson' }).distinct('languages.id').count + default: Language.joins(resource_default_orders: {resource: :resource_type}).where( + resource_types: {name: "lesson"} + ).distinct("languages.id").count, + featured: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "lesson"}, + resource_scores: {featured: true} + ).distinct("languages.id").count, + ranked: Language.joins(resource_scores: {resource: :resource_type}).where( + resource_types: {name: "lesson"} + ).where.not(resource_scores: {score: nil}).distinct("languages.id").count, + total: Language.joins(resource_scores: {resource: :resource_type}).where(resource_types: {name: "lesson"}).distinct("languages.id").count }, countries: retrieve_countries_data } render json: metrics, status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private @@ -44,22 +44,22 @@ def uniq_countries def retrieve_lessons_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'lesson' }, resource_scores: { featured: true, country: country } - ).where(resource_scores: { language: language }).count, + resource_types: {name: "lesson"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, ranked: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'lesson' }, resource_scores: { country: country } - ).where(resource_scores: { language: language }).where.not(resource_scores: { score: nil }).count + resource_types: {name: "lesson"}, resource_scores: {country: country} + ).where(resource_scores: {language: language}).where.not(resource_scores: {score: nil}).count } end def retrieve_tools_data(country, language) { featured: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'tract' }, resource_scores: { featured: true, country: country } - ).where(resource_scores: { language: language }).count, + resource_types: {name: "tract"}, resource_scores: {featured: true, country: country} + ).where(resource_scores: {language: language}).count, ranked: Resource.joins(:resource_type, resource_scores: :language).where( - resource_types: { name: 'tract' }, resource_scores: { country: country } - ).where(resource_scores: { language: language }).where.not(resource_scores: { score: nil }).count + resource_types: {name: "tract"}, resource_scores: {country: country} + ).where(resource_scores: {language: language}).where.not(resource_scores: {score: nil}).count } end @@ -70,8 +70,8 @@ def retrieve_language_data(country, language) lessons: retrieve_lessons_data(country, language), tools: retrieve_tools_data(country, language), last_updated: Resource.joins(:resource_scores).where( - resource_scores: { country: country, language: language } - ).maximum(:updated_at)&.strftime('%d-%m-%y') || 'N/A' + resource_scores: {country: country, language: language} + ).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" } end @@ -79,7 +79,7 @@ def retrieve_countries_data uniq_countries.map do |country| { country_code: country, - languages: Language.joins(:resource_scores).where(resource_scores: { country: country }).distinct.map do |language| + languages: Language.joins(:resource_scores).where(resource_scores: {country: country}).distinct.map do |language| retrieve_language_data(country, language) end } diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb index a8fce2856..3b0517ebe 100644 --- a/spec/acceptance/content_status_controller_spec.rb +++ b/spec/acceptance/content_status_controller_spec.rb @@ -1,35 +1,82 @@ # frozen_string_literal: true -require "acceptance_helper" -require "sidekiq/testing" +require 'acceptance_helper' +require 'sidekiq/testing' -resource "ContentStatus" do +resource 'ContentStatus' do include ActiveJob::TestHelper - header "Accept", "application/vnd.api+json" - header "Content-Type", "application/vnd.api+json" - - get "content_status" do - let(:country) { "us" } - let!(:resource) { Resource.first } - let!(:unfeatured_resource) { Resource.last } - let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } - let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } - let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, featured_order: 1) + header 'Accept', 'application/vnd.api+json' + header 'Content-Type', 'application/vnd.api+json' + + get 'content_status' do + let!(:resource_type_tract) { ResourceType.find_or_create_by!(name: 'tract', dtd_file: 'tract.xsd') } + let!(:resource_type_lesson) { ResourceType.find_or_create_by!(name: 'lesson', dtd_file: 'lesson.xsd') } + let!(:language_en) { Language.find_or_create_by!(code: 'en', name: 'English') } + let!(:language_fr) { Language.find_or_create_by!(code: 'fr', name: 'French') } + let!(:tract_resource) do + Resource.create!(name: 'Test Tract', resource_type: resource_type_tract, system: System.first, + abbreviation: 'test-tract') + end + let!(:lesson_resource) do + Resource.create!(name: 'Test Lesson', resource_type: resource_type_lesson, system: System.first, + abbreviation: 'test-lesson') + end + let!(:resource_score_tract) do + ResourceScore.create!( + resource: tract_resource, + country: 'us', + language: language_en, + featured: true, + featured_order: 1, + score: 5 + ) end - let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, featured_order: nil) + let!(:resource_score_lesson) do + ResourceScore.create!( + resource: lesson_resource, + country: 'us', + language: language_en, + featured: false, + featured_order: nil, + score: 3 + ) + end + let!(:resource_default_order) do + ResourceDefaultOrder.create!( + resource: tract_resource, + language: language_en, + position: 1 + ) end - it "returns statistics JSON" do + it 'returns statistics JSON with metrics by resource type and country/language breakdown' do do_request expect(status).to be(200) json = JSON.parse(response_body) - expect(json["tools"]["total"]).to eq(Resource.joins(:resource_type).where(resource_types: {name: "tract"}).count) + + expect(json).to have_key('tools') + expect(json).to have_key('lessons') + expect(json).to have_key('countries') + + expect(json['tools']).to include('default', 'featured', 'ranked', 'total') + expect(json['lessons']).to include('default', 'featured', 'ranked', 'total') + + expect(json['countries']).to be_an(Array) + expect(json['countries'][0]).to include('country_code', 'languages') + + country_data = json['countries'].find { |c| c['country_code'] == 'us' } + expect(country_data).not_to be_nil + + language_data = country_data['languages'].find { |l| l['language_code'] == 'en' } + expect(language_data).to include( + 'language_code', + 'language_name', + 'lessons', + 'tools', + 'last_updated' + ) end end end From 05f45b88001684783fe2b92a40ae305963e4b70b Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 3 Dec 2025 11:51:31 -0500 Subject: [PATCH 12/38] Fixed lint issues --- .../content_status_controller_spec.rb | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb index 3b0517ebe..6a0395384 100644 --- a/spec/acceptance/content_status_controller_spec.rb +++ b/spec/acceptance/content_status_controller_spec.rb @@ -1,31 +1,31 @@ # frozen_string_literal: true -require 'acceptance_helper' -require 'sidekiq/testing' +require "acceptance_helper" +require "sidekiq/testing" -resource 'ContentStatus' do +resource "ContentStatus" do include ActiveJob::TestHelper - header 'Accept', 'application/vnd.api+json' - header 'Content-Type', 'application/vnd.api+json' + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" - get 'content_status' do - let!(:resource_type_tract) { ResourceType.find_or_create_by!(name: 'tract', dtd_file: 'tract.xsd') } - let!(:resource_type_lesson) { ResourceType.find_or_create_by!(name: 'lesson', dtd_file: 'lesson.xsd') } - let!(:language_en) { Language.find_or_create_by!(code: 'en', name: 'English') } - let!(:language_fr) { Language.find_or_create_by!(code: 'fr', name: 'French') } + get "content_status" do + let!(:resource_type_tract) { ResourceType.find_or_create_by!(name: "tract", dtd_file: "tract.xsd") } + let!(:resource_type_lesson) { ResourceType.find_or_create_by!(name: "lesson", dtd_file: "lesson.xsd") } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } let!(:tract_resource) do - Resource.create!(name: 'Test Tract', resource_type: resource_type_tract, system: System.first, - abbreviation: 'test-tract') + Resource.create!(name: "Test Tract", resource_type: resource_type_tract, system: System.first, + abbreviation: "test-tract") end let!(:lesson_resource) do - Resource.create!(name: 'Test Lesson', resource_type: resource_type_lesson, system: System.first, - abbreviation: 'test-lesson') + Resource.create!(name: "Test Lesson", resource_type: resource_type_lesson, system: System.first, + abbreviation: "test-lesson") end let!(:resource_score_tract) do ResourceScore.create!( resource: tract_resource, - country: 'us', + country: "us", language: language_en, featured: true, featured_order: 1, @@ -35,7 +35,7 @@ let!(:resource_score_lesson) do ResourceScore.create!( resource: lesson_resource, - country: 'us', + country: "us", language: language_en, featured: false, featured_order: nil, @@ -50,32 +50,32 @@ ) end - it 'returns statistics JSON with metrics by resource type and country/language breakdown' do + it "returns statistics JSON with metrics by resource type and country/language breakdown" do do_request expect(status).to be(200) json = JSON.parse(response_body) - expect(json).to have_key('tools') - expect(json).to have_key('lessons') - expect(json).to have_key('countries') + expect(json).to have_key("tools") + expect(json).to have_key("lessons") + expect(json).to have_key("countries") - expect(json['tools']).to include('default', 'featured', 'ranked', 'total') - expect(json['lessons']).to include('default', 'featured', 'ranked', 'total') + expect(json["tools"]).to include("default", "featured", "ranked", "total") + expect(json["lessons"]).to include("default", "featured", "ranked", "total") - expect(json['countries']).to be_an(Array) - expect(json['countries'][0]).to include('country_code', 'languages') + expect(json["countries"]).to be_an(Array) + expect(json["countries"][0]).to include("country_code", "languages") - country_data = json['countries'].find { |c| c['country_code'] == 'us' } + country_data = json["countries"].find { |c| c["country_code"] == "us" } expect(country_data).not_to be_nil - language_data = country_data['languages'].find { |l| l['language_code'] == 'en' } + language_data = country_data["languages"].find { |l| l["language_code"] == "en" } expect(language_data).to include( - 'language_code', - 'language_name', - 'lessons', - 'tools', - 'last_updated' + "language_code", + "language_name", + "lessons", + "tools", + "last_updated" ) end end From 01f6df06c0ded60744c208b4970b07ec01bb3795 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 11:38:12 -0500 Subject: [PATCH 13/38] Added CRUD endpoints for ResourceScores --- app/controllers/resource_scores_controller.rb | 237 +++++++ .../resources/featured_controller.rb | 11 +- app/controllers/resources_controller.rb | 55 +- config/routes.rb | 59 +- .../resource_scores_controller_spec.rb | 657 ++++++++++++++++++ spec/acceptance/resources_controller_spec.rb | 175 ++++- 6 files changed, 1126 insertions(+), 68 deletions(-) create mode 100644 app/controllers/resource_scores_controller.rb create mode 100644 spec/acceptance/resource_scores_controller_spec.rb diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb new file mode 100644 index 000000000..88d394c2a --- /dev/null +++ b/app/controllers/resource_scores_controller.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +class ResourceScoresController < ApplicationController + before_action :authorize!, only: %i[create destroy update mass_update mass_update_ranked] + + def index + lang_code = params.dig(:filter, :lang) || params[:lang] + resource_scores = all_resource_scores( + lang_code: lang_code, + country: params.dig(:filter, :country) || params[:country], + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: resource_scores, include: params[:include], status: :ok + end + + def create + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score = ResourceScore.new(sanitized_params) + @resource_score.language = language if language.present? + @resource_score.save! + render json: @resource_score, status: :created + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def destroy + @resource_score = ResourceScore.find(params[:id]) + @resource_score.destroy! + render json: {}, status: :ok + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content + end + + def update + @resource_score = ResourceScore.find(params[:id]) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_score.language = language if language.present? + @resource_score.update!(sanitized_params) + + render json: @resource_score, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def mass_update + country = params.dig(:data, :attributes, :country)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type = params.dig(:data, :attributes, :resource_type) + incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] + resulting_resource_scores = [] + + raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + current_scores = ResourceScore.where( + country: country, language_id: language.id + ).order(featured_order: :asc) + + if resource_type.present? + current_scores = current_scores.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + current_scores = current_scores.to_a + + if incoming_resources.empty? + current_scores.each do |rs| + soft_delete_resource_score(rs) + end + current_scores.reject! { |rs| !rs.persisted? } + + return render json: current_scores, include: params[:include], status: :ok + end + + ResourceScore.transaction do + ResourceScore::MAX_FEATURED_ORDER_POSITION.times do |index| + resource_id = incoming_resources[index] + current_featured_order = index + 1 + + if resource_id.nil? + # Remove any existing resource score at this position + resource_score_to_remove = current_scores.find { |rs| rs.featured_order == current_featured_order } + soft_delete_resource_score(resource_score_to_remove) + next + end + + incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } + current_resource_score_at_position = current_scores.find do |rs| + rs.featured_order == current_featured_order && rs.featured == true + end + + if incoming_resource_score + if incoming_resource_score.featured_order != current_featured_order + # Incoming ResourceScore exists but at a different position + # Remove ResourceScore currently at this position, if any + if current_resource_score_at_position + soft_delete_resource_score(current_resource_score_at_position) + current_scores.reject! { |rs| rs.id == current_resource_score_at_position.id } + end + + # Move incoming ResourceScore to the new position + incoming_resource_score.update!(featured_order: current_featured_order, featured: true) + resulting_resource_scores << incoming_resource_score + else + # Incoming ResourceScore exists and is already at the correct position + incoming_resource_score.update!(featured: true) + resulting_resource_scores << incoming_resource_score + next + end + elsif current_resource_score_at_position + # There is a ResourceScore at this position, update it to the new resource_id + current_resource_score_at_position.update!(resource_id: resource_id, featured: true) + resulting_resource_scores << current_resource_score_at_position + else + # No ResourceScore at this position, create a new one + resulting_resource_scores << ResourceScore.create!( + resource_id: resource_id, + language_id: language.id, + country: country, + featured: true, + featured_order: current_featured_order + ) + end + end + end + render json: resulting_resource_scores, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + def mass_update_ranked + country = params.dig(:data, :attributes, :country)&.downcase + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type = params.dig(:data, :attributes, :resource_type) + incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] + resulting_resource_scores = [] + + raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + current_scores = ResourceScore.where( + country: country, language_id: language.id + ).order(score: :desc) + + if resource_type.present? + current_scores = current_scores.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + current_scores = current_scores.to_a + + if incoming_resources.empty? + current_scores.each do |rs| + rs.update!(score: nil) + end + + return render json: current_scores, include: params[:include], status: :ok + end + + ResourceScore.transaction do + incoming_resources.each do |incoming_resource| + symbolized_incoming_resource = incoming_resource + resource_id = symbolized_incoming_resource[:resource_id] + score = symbolized_incoming_resource[:score] + incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } + + if incoming_resource_score + # Update existing ResourceScore with new score + incoming_resource_score.update!(score: score) + resulting_resource_scores << incoming_resource_score + else + # Create new ResourceScore with the provided score + new_resource_score = ResourceScore.create!( + resource_id: resource_id, + language_id: language.id, + country: country, + score: score + ) + resulting_resource_scores << new_resource_score + end + end + end + + # Sort resulting_resource_scores by score descending before rendering + resulting_resource_scores.sort_by! { |rs| -rs.score.to_i } + + render json: resulting_resource_scores, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + private + + def create_params + params.require(:data).require(:attributes).permit( + :resource_id, :lang, :country, :score, :featured_order, :featured + ) + end + + def all_resource_scores(lang_code:, country:, resource_type: nil) + scope = ResourceScore.all + + if lang_code.present? + language = Language.find_by(code: lang_code.downcase) + scope = scope.where(language_id: language.id) if language.present? + end + + scope = scope.where("LOWER(country) = LOWER(?)", country) if country.present? + + if resource_type.present? + scope = scope.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + scope.order("featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC") + end + + def soft_delete_resource_score(resource_score) + return if resource_score.nil? + + if resource_score.score.present? + resource_score.update!(featured: false, featured_order: nil) + else + resource_score.destroy! + end + end +end diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index b326cf6ce..7ced84710 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -32,7 +32,8 @@ def destroy @resource_score.destroy! render json: {}, status: :ok rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, status: :unprocessable_content + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content end def update @@ -93,7 +94,9 @@ def mass_update end incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - current_resource_score_at_position = current_scores.find { |rs| rs.featured_order == current_featured_order && rs.featured == true } + current_resource_score_at_position = current_scores.find do |rs| + rs.featured_order == current_featured_order && rs.featured == true + end if incoming_resource_score if incoming_resource_score.featured_order != current_featured_order @@ -214,7 +217,9 @@ def all_featured_resources(lang_code:, country:, resource_type: nil) end scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ resource_scores.score DESC NULLS LAST, \ diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index dde281ad2..f9416cec2 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ResourcesController < ApplicationController - before_action :authorize!, only: [:create, :update, :publish_translation] + before_action :authorize!, only: %i[create update publish_translation] def index render json: cached_index_json, status: :ok @@ -18,9 +18,7 @@ def create def update resource = load_resource - if resource.update!(permitted_params) - resource.set_data_attributes!(data_attrs) - end + resource.set_data_attributes!(data_attrs) if resource.update!(permitted_params) render json: resource, status: :ok end @@ -29,11 +27,23 @@ def suggestions render json: ToolFilterService.new(params).call, include: params[:include], fields: field_params, status: :ok end + def featured + lang_code = params.dig(:filter, :lang) || params[:lang] + featured_resources = all_featured_resources( + lang_code: lang_code, + country: params.dig(:filter, :country) || params[:country], + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: featured_resources, include: params[:include], status: :ok + end + def publish_translation if valid_publish_params? render json: publish_translations, status: :ok else - render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, status: :unprocessable_content + render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, + status: :unprocessable_content end end @@ -55,11 +65,11 @@ def publish_translations def publish_translation_for_language(language_data) draft_translation = find_or_create_draft_translation(language_data["id"]) - if draft_translation - draft_translation.update(publishing_errors: nil) - PublishTranslationJob.perform_async(draft_translation.id) - draft_translation - end + return unless draft_translation + + draft_translation.update(publishing_errors: nil) + PublishTranslationJob.perform_async(draft_translation.id) + draft_translation end def find_or_create_draft_translation(language_id) @@ -91,9 +101,7 @@ def all_resources Resource.all end - if params.dig(:filter, :abbreviation) - resources = resources.where(abbreviation: params[:filter][:abbreviation]) - end + resources = resources.where(abbreviation: params[:filter][:abbreviation]) if params.dig(:filter, :abbreviation) if params.dig(:filter, :resource_type) resources = resources.joins(:resource_type).where(resource_types: {name: params[:filter][:resource_type].downcase}) @@ -102,11 +110,30 @@ def all_resources resources end + def all_featured_resources(lang_code:, country:, resource_type: nil) + scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) + + if lang_code.present? + language = Language.find_by(code: lang_code.downcase) + scope = scope.joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? + end + + scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + + scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ + resource_scores.score DESC NULLS LAST, \ + resources.created_at DESC") + end + def load_resource Resource.find(params[:id]) end def permitted_params - permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, :metatool_id, :default_variant_id) + permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, + :metatool_id, :default_variant_id) end end diff --git a/config/routes.rb b/config/routes.rb index 97996be62..2e256d016 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,17 +12,18 @@ # Defines the root path route ("/") # root "posts#index" - resources :systems, only: [:index, :show] + resources :systems, only: %i[index show] resources :languages - resources :resource_types, only: [:index, :show] + resources :resource_types, only: %i[index show] get "resources/suggestions", to: "resources#suggestions" resources :resources do - resources :languages, controller: :resource_languages, only: [:update, :show] - resources :translated_attributes, path: "translated-attributes", only: [:create, :update, :destroy] + resources :languages, controller: :resource_languages, only: %i[update show] + resources :translated_attributes, path: "translated-attributes", only: %i[create update destroy] post "translations/publish", to: "resources#publish_translation" collection do - resources :featured, only: [:index, :create, :update, :destroy], module: :resources do + get :featured + resources :featured, only: %i[index create update destroy], module: :resources do collection do put :mass_update patch :mass_update @@ -30,7 +31,7 @@ patch :mass_update_ranked end end - resources :default_order, only: [:index, :create, :update, :destroy], module: :resources do + resources :default_order, only: %i[index create update destroy], module: :resources do collection do put :mass_update patch :mass_update @@ -38,26 +39,36 @@ end end end - resources :drafts, only: [:index, :show, :create, :destroy] - resources :translations, only: [:index, :show] - resources :pages, only: [:create, :update, :show] - resources :tips, only: [:create, :update] - resources :custom_pages, only: [:create, :update, :destroy, :show] - resources :custom_tips, only: [:create, :destroy] - resources :attributes, only: [:create, :update, :destroy, :show] - resources :translated_pages, only: [:create, :update, :destroy, :show] + resources :resource_scores, only: %i[index create update destroy] do + collection do + put :mass_update + patch :mass_update + put :mass_update_ranked + patch :mass_update_ranked + end + end + + resources :drafts, only: %i[index show create destroy] + resources :translations, only: %i[index show] + resources :pages, only: %i[create update show] + resources :tips, only: %i[create update] + resources :custom_pages, only: %i[create update destroy show] + resources :custom_tips, only: %i[create destroy] + + resources :attributes, only: %i[create update destroy show] + resources :translated_pages, only: %i[create update destroy show] resources :views, only: [:create] resources :follow_ups, only: [:create] resources :attachments - resources :auth, only: [:create, :show] + resources :auth, only: %i[create show] - resources :custom_manifests, only: [:create, :update, :destroy, :show] + resources :custom_manifests, only: %i[create update destroy show] - resources :tool_groups, path: "tool-groups", only: [:create, :destroy, :index, :show, :update] do + resources :tool_groups, path: "tool-groups", only: %i[create destroy index show update] do post "tools", to: "tool_groups#create_tool" put "tools/:id", to: "tool_groups#update_tool" delete "tools/:id", to: "tool_groups#delete_tool" @@ -65,17 +76,17 @@ # Rule Languages resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_languages, path: "rules-language", only: [:create, :destroy, :update] + resources :rule_languages, path: "rules-language", only: %i[create destroy update] end # Rule Countries resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_countries, path: "rules-country", only: [:create, :destroy, :update] + resources :rule_countries, path: "rules-country", only: %i[create destroy update] end # Rule Praxis resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_praxes, path: "rules-praxis", only: [:create, :destroy, :update] + resources :rule_praxes, path: "rules-praxis", only: %i[create destroy update] end patch "user/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v5.7.0-v6.0.0 @@ -87,12 +98,12 @@ patch "users/:id", to: "users#update" scope "users/:user_id/relationships" do - resources :favorite_tools, path: "favorite-tools", only: [:index, :create] + resources :favorite_tools, path: "favorite-tools", only: %i[index create] end delete "users/:user_id/relationships/favorite-tools", to: "favorite_tools#destroy" scope "users/:user_id" do - resources :training_tips, path: "training-tips", only: [:create, :update, :destroy] + resources :training_tips, path: "training-tips", only: %i[create update destroy] end get "monitors/commit" @@ -101,7 +112,9 @@ get "analytics/global", to: "global_activity_analytics#show" get "translations/files/:path", - to: redirect("https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302), + to: redirect( + "https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%s", status: 302 + ), format: false, # these next lines are required to have the extension be part of path default: {format: "html"}, constraints: {path: /.*/} diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb new file mode 100644 index 000000000..d58d7e465 --- /dev/null +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -0,0 +1,657 @@ +# frozen_string_literal: true + +require "acceptance_helper" +require "sidekiq/testing" + +resource "ResourceScores" do + include ActiveJob::TestHelper + + header "Accept", "application/vnd.api+json" + header "Content-Type", "application/vnd.api+json" + let(:raw_post) { params.to_json } + let(:authorization) { AuthToken.generic_token } + + let!(:resource) { Resource.first } + let!(:unfeatured_resource) { Resource.last } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + + get "resource_scores" do + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + context "without filters" do + it "returns resource scores" do + do_request include: "resource" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with language filter" do + it "returns resource scores for specified language" do + do_request lang: "fr" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns resource scores for specified language" do + do_request filter: {lang: "fr"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + end + + context "with country filter" do + it "returns resource scores for specified country" do + do_request country: "us" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + + context "inside filter param" do + it "returns resource scores for specified country" do + do_request filter: {country: "us"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_score) do + FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, + language: language_en) + end + + it "returns resource scores for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(tool_resource.id.to_s) + end + + context "inside filter param" do + it "returns resource scores for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(tool_resource.id.to_s) + end + end + end + end + + post "resource_scores" do + requires_authorization + + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + let(:valid_params) do + { + data: { + type: "resource_score", + attributes: { + resource_id: resource.id, + lang: language_en.code, + country: "US", + featured: true, + featured_order: 1 + } + } + } + end + + context "with valid parameters" do + it "creates a new resource score" do + do_request(valid_params) + + expect(status).to be(201) + json = JSON.parse(response_body) + expect(json["data"]["attributes"]["featured"]).to be true + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_score", attributes: {featured: true, resource_id: resource.id}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + end + + delete "resource_scores/:id" do + requires_authorization + + let(:id) { resource_score.id } + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + it "deletes the resource score" do + do_request + + expect(status).to be(200) + expect(ResourceScore.exists?(id)).to be false + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns a not found error" do + do_request + + expect(status).to be(404) + end + end + end + + patch "resource_scores/:id" do + requires_authorization + + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + let(:id) { resource_score.id } + let(:valid_update_params) do + { + data: { + type: "resource_score", + attributes: { + featured_order: 2, + country: "CA" + } + } + } + end + + context "with valid parameters" do + it "updates the resource score" do + do_request(valid_update_params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]["attributes"]["featured-order"]).to eq(2) + expect(json["data"]["attributes"]["country"]).to eq("CA".downcase) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_score", attributes: {featured_order: "invalid"}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end + + context "when an incorrect ID is sent" do + let(:id) { "unknownId" } + + it "returns a not found error" do + do_request(valid_update_params) + + expect(status).to be(404) + end + end + end + + patch "resource_scores/mass_update" do + requires_authorization + + let(:country) { "US" } + let(:lang) { "en" } + let(:resource_ids) { [] } + let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:featured) { true } + let(:params) do + {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, + resource_type: resource_type.name}}} + end + + context "with no country and lang params" do + let(:country) { nil } + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with country and lang params" do + context "with no previous resource score" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an array with 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when sending more than 1 resource score" do + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with more than 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + end + end + + context "with previous resource scores" do + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let!(:resource3) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, [resource.id, resource2.id]).first + end + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, + featured_order: 1) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, + featured_order: nil) + end + + context "when sending an empty array" do + let(:resource_ids) { [] } + + it "deletes all matching resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + + context "when a resource has a score" do + let(:resource_ids) { [] } + + before do + resource_score.update!(score: 5) + end + + it "removes featured status but keeps the score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + + resource_score.reload + expect(resource_score.featured).to be false + expect(resource_score.featured_order).to be_nil + expect(resource_score.score).to eq(5) + end + end + end + + context "when sending 1 resource to replace" do + let(:resource_ids) { [resource3.id, resource2.id] } + + it "returns an array with the replaced resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + end + + context "when sending more than 1 resource to replace" do + let(:resource_ids) { [resource2.id, resource3.id, resource.id] } + + it "returns an array with the replaced resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["featured"]).to eq(true) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when sending the same resource to replace" do + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an array with the replaced resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + end + end + end + end + end + + patch "resource_scores/mass_update_ranked" do + requires_authorization + + let(:country) { "US" } + let(:lang) { "en" } + let(:ranked_resources) { [] } + let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:params) do + {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, + resource_type: resource_type.name}}} + end + + context "with no country and lang params" do + let(:country) { nil } + let(:lang) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with country and lang params" do + context "with no previous resource scores" do + context "when sending an empty array" do + it "returns an empty array" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(0) + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an array with 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(10) + end + end + + context "when sending more than 1 ranked resource" do + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } + + it "returns an array with more than 1 resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][1]["attributes"]["score"]).to eq(10) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + end + + context "with previous resource scores" do + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let!(:resource3) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, [resource.id, resource2.id]).first + end + let!(:resource_score) do + ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) + end + let!(:resource_score2) do + ResourceScore.create!(resource: resource2, country: country, language: language_en, score: 5) + end + + context "when sending an empty array" do + let(:ranked_resources) { [] } + + it "clears all scores for matching resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + + resource_score.reload + resource_score2.reload + expect(resource_score.score).to be_nil + expect(resource_score2.score).to be_nil + end + end + + context "when sending 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 20}] } + + it "returns an array with the updated resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + end + + it "updates only the existing resource score" do + do_request(params) + + resource_score.reload + expect(resource_score.score).to eq(20) + end + end + + context "when sending more than 1 ranked resource to update" do + let(:ranked_resources) { [{resource_id: resource.id, score: 18}, {resource_id: resource2.id, score: 12}] } + + it "returns an array with the updated resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(18) + expect(json["data"][1]["attributes"]["score"]).to eq(12) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when sending a new resource to add" do + let(:ranked_resources) { [{resource_id: resource3.id, score: 16}] } + + it "creates a new resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) + expect(json["data"][0]["attributes"]["score"]).to eq(16) + end + end + + context "when sending a mix of existing and new resources" do + let(:ranked_resources) { [{resource_id: resource.id, score: 19}, {resource_id: resource3.id, score: 14}] } + + it "returns an array with updated and new resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(2) + expect(json["data"][0]["attributes"]["score"]).to eq(19) + expect(json["data"][1]["attributes"]["score"]).to eq(14) + end + + it "returns resources sorted by score descending" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] + end + end + + context "when resource_type filter is applied" do + let(:ranked_resources) { [{resource_id: resource.id, score: 17}] } + + it "only updates resources of the specified type" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end + + context "when updating with different scores" do + let(:ranked_resources) do + [ + {resource_id: resource.id, score: 20}, + {resource_id: resource2.id, score: 13}, + {resource_id: resource3.id, score: 7} + ] + end + + it "returns all resources sorted by score in descending order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(3) + expect(json["data"][0]["attributes"]["score"]).to eq(20) + expect(json["data"][1]["attributes"]["score"]).to eq(13) + expect(json["data"][2]["attributes"]["score"]).to eq(7) + end + end + end + end + end +end diff --git a/spec/acceptance/resources_controller_spec.rb b/spec/acceptance/resources_controller_spec.rb index f4339360d..359d5ff5a 100644 --- a/spec/acceptance/resources_controller_spec.rb +++ b/spec/acceptance/resources_controller_spec.rb @@ -19,9 +19,9 @@ let(:resource_4) { Resource.find(4) } let(:resource_5) { Resource.find(5) } - let(:languages_fr_en) { ["fr", "en"] } - let(:languages_fr_it) { ["fr", "it"] } - let(:languages_fr_es) { ["fr", "es"] } + let(:languages_fr_en) { %w[fr en] } + let(:languages_fr_it) { %w[fr it] } + let(:languages_fr_es) { %w[fr es] } let(:languages_fr) { ["fr"] } let(:languages_it) { ["it"] } let(:languages_en) { ["en"] } @@ -30,8 +30,8 @@ let(:countries_fr) { ["FR"] } let(:countries_gb) { ["GB"] } let(:countries_nz) { ["NZ"] } - let(:countries_fr_us) { ["FR", "US"] } - let(:countries_gb_us_nz) { ["GB", "US", "NZ"] } + let(:countries_fr_us) { %w[FR US] } + let(:countries_gb_us_nz) { %w[GB US NZ] } let(:openness_1) { [1] } let(:openness_2) { [2] } @@ -47,7 +47,12 @@ let(:confidence_2_3) { [2, 3] } let(:confidence_3_4) { [3, 4] } - let(:resources_result) { ["metatool", "Knowing God Personally", "Questions About God", "Satisfied?", "Knowing God Personally Variant"].sort } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + + let(:resources_result) do + ["metatool", "Knowing God Personally", "Questions About God", "Satisfied?", "Knowing God Personally Variant"].sort + end get "resources/suggestions" do before(:each) do @@ -136,8 +141,12 @@ context "when matching a tool group with :include in params" do let!(:translation) { Resource.first.latest_translations.first } - let!(:translation_attribute_1) { FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) } - let!(:translation_attribute_2) { FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) } + let!(:translation_attribute_1) do + FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) + end + let!(:translation_attribute_2) do + FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) + end it "return suggested resources with :include and :fields key values for attributes expecified" do delete_all_rules @@ -159,7 +168,8 @@ it "return suggested resources ordered" do delete_all_rules - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -194,15 +204,21 @@ ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_two.id, suggestions_weight: 1.3) ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_two.id, suggestions_weight: 1.0) - ResourceToolGroup.create!(resource_id: resource_1.id, tool_group_id: tool_group_three.id, suggestions_weight: 2.0) - ResourceToolGroup.create!(resource_id: resource_2.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.5) - ResourceToolGroup.create!(resource_id: resource_3.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.1) - ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.0) - ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_three.id, suggestions_weight: 1.2) + ResourceToolGroup.create!(resource_id: resource_1.id, tool_group_id: tool_group_three.id, + suggestions_weight: 2.0) + ResourceToolGroup.create!(resource_id: resource_2.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.5) + ResourceToolGroup.create!(resource_id: resource_3.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.1) + ResourceToolGroup.create!(resource_id: resource_4.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.0) + ResourceToolGroup.create!(resource_id: resource_5.id, tool_group_id: tool_group_three.id, + suggestions_weight: 1.2) end it "return suggested resources ordered" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 # Result ordered # ---------------------------------- @@ -246,7 +262,8 @@ end it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) @@ -258,7 +275,8 @@ context "plus matching languages with negative rule as false" do it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -320,7 +338,8 @@ context "plus matching openness" do context "with negative rule as false" do it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_fr, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -363,7 +382,8 @@ end it "return suggested resources" do - do_request "filter[country]": "fr", "filter[language]": languages_es, "filter[openness]": 1, "filter[confidence]": 2 + do_request "filter[country]": "fr", "filter[language]": languages_es, "filter[openness]": 1, + "filter[confidence]": 2 expect(status).to be(200) data = JSON.parse(response_body)["data"] @@ -512,13 +532,18 @@ expect(status).to be(200) json = JSON.parse(response_body) metatool = json["data"].detect { |entry| entry["attributes"]["resource-type"] == "metatool" } - expect(metatool["relationships"]["variants"]["data"]).to match_array([{"id" => "1", "type" => "resource"}, {"id" => "5", "type" => "resource"}]) + expect(metatool["relationships"]["variants"]["data"]).to match_array([{"id" => "1", "type" => "resource"}, + {"id" => "5", "type" => "resource"}]) end context "with translation attributes" do let!(:translation) { Resource.first.latest_translations.first } - let!(:translation_attribute_1) { FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) } - let!(:translation_attribute_2) { FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) } + let!(:translation_attribute_1) do + FactoryBot.create(:translation_attribute, key: "key1", value: "translation content 1", translation: translation) + end + let!(:translation_attribute_2) do + FactoryBot.create(:translation_attribute, key: "key2", value: "translation content 2", translation: translation) + end it "get all resources, include latest-translations" do do_request "filter[system]": "GodTools", include: "latest-translations" @@ -536,7 +561,7 @@ expect(status).to be(200) data = JSON.parse(response_body)["data"][0] - expect(data["attributes"].keys).to match_array(["name", "attr-banner-image", "resource-type", "total-views"]) + expect(data["attributes"].keys).to match_array(%w[name attr-banner-image resource-type total-views]) expect(data["relationships"].keys).to eq ["system"] end @@ -560,6 +585,97 @@ end end + get "resources/featured" do + let!(:resource_score) do + ResourceScore.find_or_create_by!(resource: resource_1, country: "us", + language: Language.find_or_create_by!(code: "en", name: "English")) do |rs| + rs.featured = true + rs.featured_order = 1 + end + end + + context "without filters" do + it "returns featured resources" do + do_request include: "resource-score" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + end + + context "with language filter" do + it "returns featured resources for specified language" do + do_request lang: "fr" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns featured resources for specified language" do + do_request filter: {lang: "fr"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + end + + context "with country filter" do + it "returns featured resources for specified country" do + do_request country: "us" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + + context "inside filter param" do + it "returns featured resources for specified country" do + do_request filter: {country: "us"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_score) do + FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, + language: Language.find_or_create_by!(code: "en", name: "English")) + end + + it "returns featured resources for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) + end + + context "inside filter param" do + it "returns featured resources for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) + end + end + end + end + get "resources/:id" do let(:id) { 1 } @@ -640,7 +756,9 @@ expect(JSON.parse(response_body)["data"]).not_to be_nil end context "attribute present" do - let!(:something_else_attribute) { FactoryBot.create(:attribute, resource_id: id, key: "something_else", value: "current_value") } + let!(:something_else_attribute) do + FactoryBot.create(:attribute, resource_id: id, key: "something_else", value: "current_value") + end it "updates resource and updates existing attributes" do do_request data: {type: :resource, attributes: {:description => "hello, world", :"attr-language-attribute" => "language_value", @@ -705,7 +823,10 @@ end context "unpublished translation exists already" do - let!(:translation) { FactoryBot.create(:translation, version: 10, resource_id: resource_id, language_id: language.id, is_published: false) } + let!(:translation) do + FactoryBot.create(:translation, version: 10, resource_id: resource_id, language_id: language.id, + is_published: false) + end it "reuses an existing translation and publishes the resource" do expect do @@ -735,9 +856,7 @@ def delete_all_rules def resources_matched(data) resources = [] data.select do |element| - if element["attributes"] && element["attributes"]["name"] - resources << element["attributes"]["name"] - end + resources << element["attributes"]["name"] if element["attributes"] && element["attributes"]["name"] end resources.sort end From d251a4d7ef36fa7e68d93932f6e428a727d86600 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 12:01:36 -0500 Subject: [PATCH 14/38] Fixed route --- config/routes.rb | 86 ++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 2e256d016..51d3f4fe6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require "sidekiq/pro/web" +require 'sidekiq/pro/web' Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /monitors/lb that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. - get "monitors/lb", as: :rails_health_check + get 'monitors/lb', as: :rails_health_check # Defines the root path route ("/") # root "posts#index" @@ -15,12 +15,12 @@ resources :systems, only: %i[index show] resources :languages resources :resource_types, only: %i[index show] - get "resources/suggestions", to: "resources#suggestions" + get 'resources/suggestions', to: 'resources#suggestions' resources :resources do resources :languages, controller: :resource_languages, only: %i[update show] - resources :translated_attributes, path: "translated-attributes", only: %i[create update destroy] - post "translations/publish", to: "resources#publish_translation" + resources :translated_attributes, path: 'translated-attributes', only: %i[create update destroy] + post 'translations/publish', to: 'resources#publish_translation' collection do get :featured resources :featured, only: %i[index create update destroy], module: :resources do @@ -68,58 +68,58 @@ resources :custom_manifests, only: %i[create update destroy show] - resources :tool_groups, path: "tool-groups", only: %i[create destroy index show update] do - post "tools", to: "tool_groups#create_tool" - put "tools/:id", to: "tool_groups#update_tool" - delete "tools/:id", to: "tool_groups#delete_tool" + resources :tool_groups, path: 'tool-groups', only: %i[create destroy index show update] do + post 'tools', to: 'tool_groups#create_tool' + put 'tools/:id', to: 'tool_groups#update_tool' + delete 'tools/:id', to: 'tool_groups#delete_tool' end # Rule Languages - resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_languages, path: "rules-language", only: %i[create destroy update] + resources :tool_groups, path: 'tool-groups', only: [] do + resources :rule_languages, path: 'rules-language', only: %i[create destroy update] end # Rule Countries - resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_countries, path: "rules-country", only: %i[create destroy update] + resources :tool_groups, path: 'tool-groups', only: [] do + resources :rule_countries, path: 'rules-country', only: %i[create destroy update] end # Rule Praxis - resources :tool_groups, path: "tool-groups", only: [] do - resources :rule_praxes, path: "rules-praxis", only: %i[create destroy update] + resources :tool_groups, path: 'tool-groups', only: [] do + resources :rule_praxes, path: 'rules-praxis', only: %i[create destroy update] end - patch "user/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v5.7.0-v6.0.0 - patch "user/me/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v6.0.1+ - get "users/:user_id/counters", to: "user_counters#index" - patch "users/:user_id/counters/:id", to: "user_counters#update" - get "users/:id", to: "users#show" - delete "users/:id", to: "users#destroy" - patch "users/:id", to: "users#update" + patch 'user/counters/:id', to: 'user_counters#update' # Legacy route for GodTools Android v5.7.0-v6.0.0 + patch 'user/me/counters/:id', to: 'user_counters#update' # Legacy route for GodTools Android v6.0.1+ + get 'users/:user_id/counters', to: 'user_counters#index' + patch 'users/:user_id/counters/:id', to: 'user_counters#update' + get 'users/:id', to: 'users#show' + delete 'users/:id', to: 'users#destroy' + patch 'users/:id', to: 'users#update' - scope "users/:user_id/relationships" do - resources :favorite_tools, path: "favorite-tools", only: %i[index create] + scope 'users/:user_id/relationships' do + resources :favorite_tools, path: 'favorite-tools', only: %i[index create] end - delete "users/:user_id/relationships/favorite-tools", to: "favorite_tools#destroy" + delete 'users/:user_id/relationships/favorite-tools', to: 'favorite_tools#destroy' - scope "users/:user_id" do - resources :training_tips, path: "training-tips", only: %i[create update destroy] + scope 'users/:user_id' do + resources :training_tips, path: 'training-tips', only: %i[create update destroy] end - get "monitors/commit" + get 'monitors/commit' - get "attachments/:id/download", to: "attachments#download" - get "analytics/global", to: "global_activity_analytics#show" + get 'attachments/:id/download', to: 'attachments#download' + get 'analytics/global', to: 'global_activity_analytics#show' - get "translations/files/:path", - to: redirect( - "https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%s", status: 302 - ), - format: false, # these next lines are required to have the extension be part of path - default: {format: "html"}, - constraints: {path: /.*/} + get 'translations/files/:path', + to: redirect( + "https://#{ENV.fetch('MOBILE_CONTENT_API_BUCKET')}.s3.#{ENV.fetch('AWS_REGION')}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302 + ), + format: false, # these next lines are required to have the extension be part of path + default: { format: 'html' }, + constraints: { path: /.*/ } - scope "account" do + scope 'account' do resources :deletion_requests, only: [:show] do collection do post :facebook @@ -127,15 +127,15 @@ end end - get "content_status", to: "content_status#index" + get 'content_status', to: 'content_status#index' if Rails.env.production? || Rails.env.staging? Sidekiq::Web.use Rack::Auth::Basic do |username, password| - username == ENV.fetch("SIDEKIQ_USERNAME") && password == ENV.fetch("SIDEKIQ_PASSWORD") + username == ENV.fetch('SIDEKIQ_USERNAME') && password == ENV.fetch('SIDEKIQ_PASSWORD') end end - mount Sidekiq::Web, at: "/sidekiq" + mount Sidekiq::Web, at: '/sidekiq' - mount ActionCable.server => "/cable" - mount Raddocs::App => "/docs" + mount ActionCable.server => '/cable' + mount Raddocs::App => '/docs' end From d333ba187b27465a141c5126ff490361ef16004a Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 12:05:17 -0500 Subject: [PATCH 15/38] Updated brakeman.ignore --- config/brakeman.ignore | 56 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 477c90ab2..f6ab63c19 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,28 @@ { "ignored_warnings": [ + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "0bd6486ea46fa6dbb922f16405e32a2a4bfa40ac96aebb804d96b28b490f3945", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceScore#find`", + "file": "app/controllers/resource_scores_controller.rb", + "line": 39, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceScore.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceScoresController", + "method": "update" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -53,7 +76,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceScore#find`", "file": "app/controllers/resources/featured_controller.rb", - "line": 35, + "line": 40, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceScore.find(params[:id])", "render_path": null, @@ -138,6 +161,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "8a99a92cfa46c5cedaf6349c1bc54c065505922ebfc9b7fc2bed934660bd6701", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceScore#find`", + "file": "app/controllers/resource_scores_controller.rb", + "line": 30, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceScore.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceScoresController", + "method": "destroy" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -145,7 +191,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceDefaultOrder#find`", "file": "app/controllers/resources/default_order_controller.rb", - "line": 25, + "line": 29, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceDefaultOrder.find(params[:id])", "render_path": null, @@ -279,7 +325,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceDefaultOrder#find`", "file": "app/controllers/resources/default_order_controller.rb", - "line": 33, + "line": 37, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceDefaultOrder.find(params[:id])", "render_path": null, @@ -302,7 +348,7 @@ "check_name": "UnscopedFind", "message": "Unscoped call to `ResourceScore#find`", "file": "app/controllers/resources/featured_controller.rb", - "line": 27, + "line": 31, "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", "code": "ResourceScore.find(params[:id])", "render_path": null, @@ -319,5 +365,5 @@ "note": "" } ], - "brakeman_version": "7.1.0" + "brakeman_version": "7.1.1" } From c0ab60766ee928184761a367a950d88a93070d80 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 12:07:26 -0500 Subject: [PATCH 16/38] Fixed lint issues --- config/routes.rb | 86 ++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 51d3f4fe6..827fb5a7a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require 'sidekiq/pro/web' +require "sidekiq/pro/web" Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /monitors/lb that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. - get 'monitors/lb', as: :rails_health_check + get "monitors/lb", as: :rails_health_check # Defines the root path route ("/") # root "posts#index" @@ -15,12 +15,12 @@ resources :systems, only: %i[index show] resources :languages resources :resource_types, only: %i[index show] - get 'resources/suggestions', to: 'resources#suggestions' + get "resources/suggestions", to: "resources#suggestions" resources :resources do resources :languages, controller: :resource_languages, only: %i[update show] - resources :translated_attributes, path: 'translated-attributes', only: %i[create update destroy] - post 'translations/publish', to: 'resources#publish_translation' + resources :translated_attributes, path: "translated-attributes", only: %i[create update destroy] + post "translations/publish", to: "resources#publish_translation" collection do get :featured resources :featured, only: %i[index create update destroy], module: :resources do @@ -68,58 +68,58 @@ resources :custom_manifests, only: %i[create update destroy show] - resources :tool_groups, path: 'tool-groups', only: %i[create destroy index show update] do - post 'tools', to: 'tool_groups#create_tool' - put 'tools/:id', to: 'tool_groups#update_tool' - delete 'tools/:id', to: 'tool_groups#delete_tool' + resources :tool_groups, path: "tool-groups", only: %i[create destroy index show update] do + post "tools", to: "tool_groups#create_tool" + put "tools/:id", to: "tool_groups#update_tool" + delete "tools/:id", to: "tool_groups#delete_tool" end # Rule Languages - resources :tool_groups, path: 'tool-groups', only: [] do - resources :rule_languages, path: 'rules-language', only: %i[create destroy update] + resources :tool_groups, path: "tool-groups", only: [] do + resources :rule_languages, path: "rules-language", only: %i[create destroy update] end # Rule Countries - resources :tool_groups, path: 'tool-groups', only: [] do - resources :rule_countries, path: 'rules-country', only: %i[create destroy update] + resources :tool_groups, path: "tool-groups", only: [] do + resources :rule_countries, path: "rules-country", only: %i[create destroy update] end # Rule Praxis - resources :tool_groups, path: 'tool-groups', only: [] do - resources :rule_praxes, path: 'rules-praxis', only: %i[create destroy update] + resources :tool_groups, path: "tool-groups", only: [] do + resources :rule_praxes, path: "rules-praxis", only: %i[create destroy update] end - patch 'user/counters/:id', to: 'user_counters#update' # Legacy route for GodTools Android v5.7.0-v6.0.0 - patch 'user/me/counters/:id', to: 'user_counters#update' # Legacy route for GodTools Android v6.0.1+ - get 'users/:user_id/counters', to: 'user_counters#index' - patch 'users/:user_id/counters/:id', to: 'user_counters#update' - get 'users/:id', to: 'users#show' - delete 'users/:id', to: 'users#destroy' - patch 'users/:id', to: 'users#update' + patch "user/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v5.7.0-v6.0.0 + patch "user/me/counters/:id", to: "user_counters#update" # Legacy route for GodTools Android v6.0.1+ + get "users/:user_id/counters", to: "user_counters#index" + patch "users/:user_id/counters/:id", to: "user_counters#update" + get "users/:id", to: "users#show" + delete "users/:id", to: "users#destroy" + patch "users/:id", to: "users#update" - scope 'users/:user_id/relationships' do - resources :favorite_tools, path: 'favorite-tools', only: %i[index create] + scope "users/:user_id/relationships" do + resources :favorite_tools, path: "favorite-tools", only: %i[index create] end - delete 'users/:user_id/relationships/favorite-tools', to: 'favorite_tools#destroy' + delete "users/:user_id/relationships/favorite-tools", to: "favorite_tools#destroy" - scope 'users/:user_id' do - resources :training_tips, path: 'training-tips', only: %i[create update destroy] + scope "users/:user_id" do + resources :training_tips, path: "training-tips", only: %i[create update destroy] end - get 'monitors/commit' + get "monitors/commit" - get 'attachments/:id/download', to: 'attachments#download' - get 'analytics/global', to: 'global_activity_analytics#show' + get "attachments/:id/download", to: "attachments#download" + get "analytics/global", to: "global_activity_analytics#show" - get 'translations/files/:path', - to: redirect( - "https://#{ENV.fetch('MOBILE_CONTENT_API_BUCKET')}.s3.#{ENV.fetch('AWS_REGION')}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302 - ), - format: false, # these next lines are required to have the extension be part of path - default: { format: 'html' }, - constraints: { path: /.*/ } + get "translations/files/:path", + to: redirect( + "https://#{ENV.fetch("MOBILE_CONTENT_API_BUCKET")}.s3.#{ENV.fetch("AWS_REGION")}.amazonaws.com/#{Package::TRANSLATION_FILES_PATH}%{path}", status: 302 + ), + format: false, # these next lines are required to have the extension be part of path + default: {format: "html"}, + constraints: {path: /.*/} - scope 'account' do + scope "account" do resources :deletion_requests, only: [:show] do collection do post :facebook @@ -127,15 +127,15 @@ end end - get 'content_status', to: 'content_status#index' + get "content_status", to: "content_status#index" if Rails.env.production? || Rails.env.staging? Sidekiq::Web.use Rack::Auth::Basic do |username, password| - username == ENV.fetch('SIDEKIQ_USERNAME') && password == ENV.fetch('SIDEKIQ_PASSWORD') + username == ENV.fetch("SIDEKIQ_USERNAME") && password == ENV.fetch("SIDEKIQ_PASSWORD") end end - mount Sidekiq::Web, at: '/sidekiq' + mount Sidekiq::Web, at: "/sidekiq" - mount ActionCable.server => '/cable' - mount Raddocs::App => '/docs' + mount ActionCable.server => "/cable" + mount Raddocs::App => "/docs" end From bf905230a0cff17d5df7eb97beca08d807807b62 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 12:17:09 -0500 Subject: [PATCH 17/38] Removed duplicated endpoints --- .../resources/featured_controller.rb | 11 -- config/routes.rb | 2 +- .../resources/featured_controller_spec.rb | 146 +++++------------- 3 files changed, 43 insertions(+), 116 deletions(-) diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index 7ced84710..6251c4c41 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -4,17 +4,6 @@ module Resources class FeaturedController < ApplicationController before_action :authorize!, only: %i[create destroy update mass_update mass_update_ranked] - def index - lang_code = params.dig(:filter, :lang) || params[:lang] - featured_resources = all_featured_resources( - lang_code: lang_code, - country: params.dig(:filter, :country) || params[:country], - resource_type: params.dig(:filter, :resource_type) || params[:resource_type] - ) - - render json: featured_resources, include: params[:include], status: :ok - end - def create sanitized_params = create_params language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? diff --git a/config/routes.rb b/config/routes.rb index 827fb5a7a..cede6ac45 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,7 +23,7 @@ post "translations/publish", to: "resources#publish_translation" collection do get :featured - resources :featured, only: %i[index create update destroy], module: :resources do + resources :featured, only: %i[create update destroy], module: :resources do collection do put :mass_update patch :mass_update diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb index e6fdf4d4f..cb3e997dd 100644 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ b/spec/acceptance/resources/featured_controller_spec.rb @@ -16,103 +16,15 @@ let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } - get "resources/featured" do - let!(:resource_score) { - ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| - rs.featured = true - rs.featured_order = 1 - end - } - - context "without filters" do - it "returns featured resources" do - do_request include: "resource-score" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - end - - context "with language filter" do - it "returns featured resources for specified language" do - do_request lang: "fr" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - - context "inside filter param" do - it "returns featured resources for specified language" do - do_request filter: {lang: "fr"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(0) - end - end - end - - context "with country filter" do - it "returns featured resources for specified country" do - do_request country: "us" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - - context "inside filter param" do - it "returns featured resources for specified country" do - do_request filter: {country: "us"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(resource_score.id.to_s) - end - end - end - - context "with resource_type filter" do - let!(:tool_resource_type) { ResourceType.find_by_name("metatool") } - let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } - let!(:tool_score) { FactoryBot.create(:resource_score, resource: tool_resource, featured: true, featured_order: 2, language: language_en) } - - it "returns featured resources for specified resource type" do - do_request resource_type: "metatool" - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) - end - - context "inside filter param" do - it "returns featured resources for specified resource type" do - do_request filter: {resource_type: "metatool"} - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].size).to eq(1) - expect(json["data"][0]["relationships"]["resource-scores"]["data"][0]["id"]).to eq(tool_score.id.to_s) - end - end - end - end - post "resources/featured" do requires_authorization - let!(:resource_score) { + let!(:resource_score) do ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end - } + end let(:valid_params) do { data: { @@ -154,12 +66,12 @@ requires_authorization let(:id) { resource_score.id } - let!(:resource_score) { + let!(:resource_score) do ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end - } + end it "deletes the featured resource score" do do_request @@ -182,12 +94,12 @@ patch "resources/featured/:id" do requires_authorization - let!(:resource_score) { + let!(:resource_score) do ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| rs.featured = true rs.featured_order = 1 end - } + end let(:id) { resource_score.id } let(:valid_update_params) do { @@ -242,7 +154,10 @@ let(:resource_ids) { [] } let(:resource_type) { ResourceType.find(resource.resource_type_id) } let(:featured) { true } - let(:params) { {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} } + let(:params) do + {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, + resource_type: resource_type.name}}} + end context "with no country and lang params" do let(:country) { nil } @@ -293,7 +208,10 @@ end context "when sending more than 1 resource score" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end let(:resource_ids) { [resource.id, resource2.id] } it "returns an array with more than 1 resource score" do @@ -309,13 +227,21 @@ end context "with previous resource scores" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let!(:resource3) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, [resource.id, resource2.id]).first + end let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, featured_order: 1) + ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, + featured_order: 1) end let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, featured_order: nil) + ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, + featured_order: nil) end context "when sending an empty array" do @@ -406,7 +332,10 @@ let(:lang) { "en" } let(:ranked_resources) { [] } let(:resource_type) { ResourceType.find(resource.resource_type_id) } - let(:params) { {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, resource_type: resource_type.name}}} } + let(:params) do + {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, + resource_type: resource_type.name}}} + end context "with no country and lang params" do let(:country) { nil } @@ -458,7 +387,10 @@ end context "when sending more than 1 ranked resource" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } it "returns an array with more than 1 resource score" do @@ -484,8 +416,14 @@ end context "with previous resource scores" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let!(:resource3) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, [resource.id, resource2.id]).first + end let!(:resource_score) do ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) end From e1365edf7f0059117c7763a17d2b2c529830f40b Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 4 Dec 2025 12:22:07 -0500 Subject: [PATCH 18/38] Removed unused code --- .../resources/featured_controller.rb | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index 6251c4c41..b31c85d93 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -197,24 +197,6 @@ def create_params ) end - def all_featured_resources(lang_code:, country:, resource_type: nil) - scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) - - if lang_code.present? - language = Language.find_by(code: lang_code.downcase) - scope = scope.left_joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? - end - - scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? - if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) - end - - scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ - resource_scores.score DESC NULLS LAST, \ - resources.created_at DESC") - end - def soft_delete_resource_score(resource_score) return if resource_score.nil? From c0bd2d6f9827ad62ec39bf4881503ecd63a25c40 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Tue, 9 Dec 2025 11:43:23 -0500 Subject: [PATCH 19/38] Fixed lint issues --- .../resources/default_order_controller.rb | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 9b786f292..0a965b004 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -21,17 +21,17 @@ def create @resource_default_order.language = language if language.present? @resource_default_order.save! render json: @resource_default_order, status: :created - rescue StandardError => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def destroy @resource_default_order = ResourceDefaultOrder.find(params[:id]) @resource_default_order.destroy! render json: {}, status: :ok - rescue StandardError - render json: { errors: [{ source: { pointer: '/data/attributes/id' }, detail: e.message }] }, - status: :unprocessable_content + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content end def update @@ -42,8 +42,8 @@ def update @resource_default_order.language = language if language.present? @resource_default_order.update!(sanitized_params) render json: @resource_default_order, status: :ok - rescue StandardError => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def mass_update @@ -52,7 +52,7 @@ def mass_update incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] resulting_resource_default_orders = [] - raise 'Lang should be provided' unless lang_code.present? + raise "Lang should be provided" unless lang_code.present? language = Language.find_by(code: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? @@ -61,7 +61,7 @@ def mass_update if resource_type.present? current_orders = current_orders.joins(resource: :resource_type) - .where(resource_types: { name: resource_type.downcase }) + .where(resource_types: {name: resource_type.downcase}) end current_orders = current_orders.to_a @@ -121,8 +121,8 @@ def mass_update end end render json: resulting_resource_default_orders, status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private @@ -132,14 +132,14 @@ def all_default_order_resources(lang:, resource_type: nil) if lang.present? language = Language.find_by(code: lang.downcase) - scope = scope.joins(resource_default_orders: :language).where(languages: { id: language.id }) + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) end - scope.order('resource_default_orders.position ASC NULLS LAST, resources.created_at DESC') + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") end def create_params From 8d603acf7570214c1cf25f370ce4ca50b7af388a Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 10 Dec 2025 12:08:35 -0500 Subject: [PATCH 20/38] Moved Default Orders to ResourceDefaultOrder endpoints --- .../resource_default_orders_controller.rb | 149 +++++ .../resources/default_order_controller.rb | 148 ----- .../resources/featured_controller.rb | 210 ------- config/brakeman.ignore | 46 ++ config/routes.rb | 21 +- ...esource_default_orders_controller_spec.rb} | 43 +- .../resources/featured_controller_spec.rb | 566 ------------------ 7 files changed, 231 insertions(+), 952 deletions(-) create mode 100644 app/controllers/resource_default_orders_controller.rb delete mode 100644 app/controllers/resources/default_order_controller.rb delete mode 100644 app/controllers/resources/featured_controller.rb rename spec/acceptance/{resources/default_order_controller_spec.rb => resource_default_orders_controller_spec.rb} (89%) delete mode 100644 spec/acceptance/resources/featured_controller_spec.rb diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb new file mode 100644 index 000000000..ef5ba3592 --- /dev/null +++ b/app/controllers/resource_default_orders_controller.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +class ResourceDefaultOrdersController < ApplicationController + before_action :authorize!, only: %i[create destroy update mass_update] + + def index + default_order_resources = all_default_order_resources( + lang: params.dig(:filter, :lang) || params[:lang], + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: default_order_resources, include: params[:include], status: :ok + end + + def create + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order = ResourceDefaultOrder.new(sanitized_params) + @resource_default_order.language = language if language.present? + @resource_default_order.save! + render json: @resource_default_order, status: :created + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def destroy + @resource_default_order = ResourceDefaultOrder.find(params[:id]) + @resource_default_order.destroy! + render json: {}, status: :ok + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content + end + + def update + @resource_default_order = ResourceDefaultOrder.find(params[:id]) + sanitized_params = create_params + language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + sanitized_params.delete(:lang) if sanitized_params[:lang].present? + @resource_default_order.language = language if language.present? + @resource_default_order.update!(sanitized_params) + render json: @resource_default_order, status: :ok + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + end + + def mass_update + lang_code = params.dig(:data, :attributes, :lang)&.downcase + resource_type = params.dig(:data, :attributes, :resource_type) + incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] + resulting_resource_default_orders = [] + + raise "Lang should be provided" unless lang_code.present? + + language = Language.find_by(code: lang_code) + raise "Language not found for code: #{lang_code}" unless language.present? + + current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) + + if resource_type.present? + current_orders = current_orders.joins(resource: :resource_type) + .where(resource_types: {name: resource_type.downcase}) + end + + current_orders = current_orders.to_a + + if incoming_resources.empty? + current_orders.each do |ro| + ro.destroy! + end + current_orders.reject! { |ro| !ro.persisted? } + + return render json: current_orders, status: :ok + end + + ResourceDefaultOrder.transaction do + incoming_resources.each_with_index do |resource_id, index| + current_position = index + 1 + + if resource_id.nil? + # Remove any existing resource default order at this position + resource_order_to_remove = current_orders.find { |ro| ro.position == current_position } + resource_order_to_remove&.destroy! + next + end + + incoming_resource_order = current_orders.find { |ro| ro.resource_id == resource_id } + current_resource_order_at_position = current_orders.find { |ro| ro.position == current_position } + + if incoming_resource_order + if incoming_resource_order.position != current_position + # Incoming ResourceDefaultOrder exists but at a different position + # Remove ResourceDefaultOrder currently at this position, if any + if current_resource_order_at_position + current_resource_order_at_position.destroy! + current_orders.reject! { |ro| ro.id == current_resource_order_at_position.id } + end + + # Move incoming ResourceDefaultOrder to the new position + incoming_resource_order.update!(position: current_position) + resulting_resource_default_orders << incoming_resource_order + else + # Incoming ResourceDefaultOrder exists and is already at the correct position + resulting_resource_default_orders << incoming_resource_order + next + end + elsif current_resource_order_at_position + # There is a ResourceDefaultOrder at this position, update it to the new resource_id + current_resource_order_at_position.update!(resource_id: resource_id) + resulting_resource_default_orders << current_resource_order_at_position + else + # No ResourceDefaultOrder at this position, create a new one + resulting_resource_default_orders << ResourceDefaultOrder.create!( + resource_id: resource_id, + language_id: language.id, + position: current_position + ) + end + end + end + render json: resulting_resource_default_orders, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + + private + + def all_default_order_resources(lang:, resource_type: nil) + scope = Resource.joins(:resource_default_orders) + + if lang.present? + language = Language.find_by(code: lang.downcase) + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + end + + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + end + + def create_params + params.require(:data).require(:attributes).permit( + :resource_id, :lang, :position + ) + end +end diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb deleted file mode 100644 index 5fe9920e0..000000000 --- a/app/controllers/resources/default_order_controller.rb +++ /dev/null @@ -1,148 +0,0 @@ -# frozen_string_literal: true - -module Resources - class DefaultOrderController < ApplicationController - before_action :authorize!, only: %i[create destroy update mass_update] - - def index - default_order_resources = all_default_order_resources( - lang: params.dig(:filter, :lang) || params[:lang], - resource_type: params.dig(:filter, :resource_type) || params[:resource_type] - ) - - render json: default_order_resources, include: params[:include], status: :ok - end - - def create - sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? - sanitized_params.delete(:lang) if sanitized_params[:lang].present? - @resource_default_order = ResourceDefaultOrder.new(sanitized_params) - @resource_default_order.language = language if language.present? - @resource_default_order.save! - render json: @resource_default_order, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def destroy - @resource_default_order = ResourceDefaultOrder.find(params[:id]) - @resource_default_order.destroy! - render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, status: :unprocessable_content - end - - def update - @resource_default_order = ResourceDefaultOrder.find(params[:id]) - sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? - sanitized_params.delete(:lang) if sanitized_params[:lang].present? - @resource_default_order.language = language if language.present? - @resource_default_order.update!(sanitized_params) - render json: @resource_default_order, status: :ok - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def mass_update - lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) - incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] - resulting_resource_default_orders = [] - - raise "Lang should be provided" unless lang_code.present? - - language = Language.find_by(code: lang_code) - raise "Language not found for code: #{lang_code}" unless language.present? - - current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) - - if resource_type.present? - current_orders = current_orders.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) - end - - current_orders = current_orders.to_a - - if incoming_resources.empty? - current_orders.each do |ro| - ro.destroy! - end - current_orders.reject! { |ro| !ro.persisted? } - - return render json: current_orders, status: :ok - end - - ResourceDefaultOrder.transaction do - incoming_resources.each_with_index do |resource_id, index| - current_position = index + 1 - - if resource_id.nil? - # Remove any existing resource default order at this position - resource_order_to_remove = current_orders.find { |ro| ro.position == current_position } - resource_order_to_remove&.destroy! - next - end - - incoming_resource_order = current_orders.find { |ro| ro.resource_id == resource_id } - current_resource_order_at_position = current_orders.find { |ro| ro.position == current_position } - - if incoming_resource_order - if incoming_resource_order.position != current_position - # Incoming ResourceDefaultOrder exists but at a different position - # Remove ResourceDefaultOrder currently at this position, if any - if current_resource_order_at_position - current_resource_order_at_position.destroy! - current_orders.reject! { |ro| ro.id == current_resource_order_at_position.id } - end - - # Move incoming ResourceDefaultOrder to the new position - incoming_resource_order.update!(position: current_position) - resulting_resource_default_orders << incoming_resource_order - else - # Incoming ResourceDefaultOrder exists and is already at the correct position - resulting_resource_default_orders << incoming_resource_order - next - end - elsif current_resource_order_at_position - # There is a ResourceDefaultOrder at this position, update it to the new resource_id - current_resource_order_at_position.update!(resource_id: resource_id) - resulting_resource_default_orders << current_resource_order_at_position - else - # No ResourceDefaultOrder at this position, create a new one - resulting_resource_default_orders << ResourceDefaultOrder.create!( - resource_id: resource_id, - language_id: language.id, - position: current_position - ) - end - end - end - render json: resulting_resource_default_orders, status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content - end - - private - - def all_default_order_resources(lang:, resource_type: nil) - scope = Resource.joins(:resource_default_orders) - - if lang.present? - language = Language.find_by(code: lang.downcase) - scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) - end - - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) if resource_type.present? - - scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") - end - - def create_params - params.require(:data).require(:attributes).permit( - :resource_id, :lang, :position - ) - end - end -end diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb deleted file mode 100644 index b31c85d93..000000000 --- a/app/controllers/resources/featured_controller.rb +++ /dev/null @@ -1,210 +0,0 @@ -# frozen_string_literal: true - -module Resources - class FeaturedController < ApplicationController - before_action :authorize!, only: %i[create destroy update mass_update mass_update_ranked] - - def create - sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? - sanitized_params.delete(:lang) if sanitized_params[:lang].present? - @resource_score = ResourceScore.new(sanitized_params) - @resource_score.language = language if language.present? - @resource_score.save! - render json: @resource_score, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def destroy - @resource_score = ResourceScore.find(params[:id]) - @resource_score.destroy! - render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, - status: :unprocessable_content - end - - def update - @resource_score = ResourceScore.find(params[:id]) - sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? - sanitized_params.delete(:lang) if sanitized_params[:lang].present? - @resource_score.language = language if language.present? - @resource_score.update!(sanitized_params) - - render json: @resource_score, status: :ok - rescue ActiveRecord::RecordInvalid => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content - end - - def mass_update - country = params.dig(:data, :attributes, :country)&.downcase - lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) - incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] - resulting_resource_scores = [] - - raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? - - language = Language.find_by(code: lang_code) - raise "Language not found for code: #{lang_code}" unless language.present? - - current_scores = ResourceScore.where( - country: country, language_id: language.id - ).order(featured_order: :asc) - - if resource_type.present? - current_scores = current_scores.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) - end - - current_scores = current_scores.to_a - - if incoming_resources.empty? - current_scores.each do |rs| - soft_delete_resource_score(rs) - end - current_scores.reject! { |rs| !rs.persisted? } - - return render json: current_scores, include: params[:include], status: :ok - end - - ResourceScore.transaction do - ResourceScore::MAX_FEATURED_ORDER_POSITION.times do |index| - resource_id = incoming_resources[index] - current_featured_order = index + 1 - - if resource_id.nil? - # Remove any existing resource score at this position - resource_score_to_remove = current_scores.find { |rs| rs.featured_order == current_featured_order } - soft_delete_resource_score(resource_score_to_remove) - next - end - - incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - current_resource_score_at_position = current_scores.find do |rs| - rs.featured_order == current_featured_order && rs.featured == true - end - - if incoming_resource_score - if incoming_resource_score.featured_order != current_featured_order - # Incoming ResourceScore exists but at a different position - # Remove ResourceScore currently at this position, if any - if current_resource_score_at_position - soft_delete_resource_score(current_resource_score_at_position) - current_scores.reject! { |rs| rs.id == current_resource_score_at_position.id } - end - - # Move incoming ResourceScore to the new position - incoming_resource_score.update!(featured_order: current_featured_order, featured: true) - resulting_resource_scores << incoming_resource_score - else - # Incoming ResourceScore exists and is already at the correct position - incoming_resource_score.update!(featured: true) - resulting_resource_scores << incoming_resource_score - next - end - elsif current_resource_score_at_position - # There is a ResourceScore at this position, update it to the new resource_id - current_resource_score_at_position.update!(resource_id: resource_id, featured: true) - resulting_resource_scores << current_resource_score_at_position - else - # No ResourceScore at this position, create a new one - resulting_resource_scores << ResourceScore.create!( - resource_id: resource_id, - language_id: language.id, - country: country, - featured: true, - featured_order: current_featured_order - ) - end - end - end - render json: resulting_resource_scores, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content - end - - def mass_update_ranked - country = params.dig(:data, :attributes, :country)&.downcase - lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) - incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] - resulting_resource_scores = [] - - raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? - - language = Language.find_by(code: lang_code) - raise "Language not found for code: #{lang_code}" unless language.present? - - current_scores = ResourceScore.where( - country: country, language_id: language.id - ).order(score: :desc) - - if resource_type.present? - current_scores = current_scores.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) - end - - current_scores = current_scores.to_a - - if incoming_resources.empty? - current_scores.each do |rs| - rs.update!(score: nil) - end - - return render json: current_scores, include: params[:include], status: :ok - end - - ResourceScore.transaction do - incoming_resources.each do |incoming_resource| - symbolized_incoming_resource = incoming_resource - resource_id = symbolized_incoming_resource[:resource_id] - score = symbolized_incoming_resource[:score] - incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - - if incoming_resource_score - # Update existing ResourceScore with new score - incoming_resource_score.update!(score: score) - resulting_resource_scores << incoming_resource_score - else - # Create new ResourceScore with the provided score - new_resource_score = ResourceScore.create!( - resource_id: resource_id, - language_id: language.id, - country: country, - score: score - ) - resulting_resource_scores << new_resource_score - end - end - end - - # Sort resulting_resource_scores by score descending before rendering - resulting_resource_scores.sort_by! { |rs| -rs.score.to_i } - - render json: resulting_resource_scores, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content - end - - private - - def create_params - params.require(:data).require(:attributes).permit( - :resource_id, :lang, :country, :score, :featured_order, :featured - ) - end - - def soft_delete_resource_score(resource_score) - return if resource_score.nil? - - if resource_score.score.present? - resource_score.update!(featured: false, featured_order: nil) - else - resource_score.destroy! - end - end - end -end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index f6ab63c19..aaf2320de 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -46,6 +46,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "2fb5ff2fd47e751a6ef49f6b4de92d55d80c2ec6d13da50c2c9f1f833b17e458", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceDefaultOrder#find`", + "file": "app/controllers/resource_default_orders_controller.rb", + "line": 37, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceDefaultOrder.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceDefaultOrdersController", + "method": "update" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, @@ -318,6 +341,29 @@ ], "note": "" }, + { + "warning_type": "Unscoped Find", + "warning_code": 82, + "fingerprint": "f25882ed7eaf7fb1151c8a23125c104191f95878112eb3c66a7bfd1cb2fa49ec", + "check_name": "UnscopedFind", + "message": "Unscoped call to `ResourceDefaultOrder#find`", + "file": "app/controllers/resource_default_orders_controller.rb", + "line": 28, + "link": "https://brakemanscanner.org/docs/warning_types/unscoped_find/", + "code": "ResourceDefaultOrder.find(params[:id])", + "render_path": null, + "location": { + "type": "method", + "class": "ResourceDefaultOrdersController", + "method": "destroy" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "cwe_id": [ + 285 + ], + "note": "" + }, { "warning_type": "Unscoped Find", "warning_code": 82, diff --git a/config/routes.rb b/config/routes.rb index cede6ac45..e3854745f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,20 +23,6 @@ post "translations/publish", to: "resources#publish_translation" collection do get :featured - resources :featured, only: %i[create update destroy], module: :resources do - collection do - put :mass_update - patch :mass_update - put :mass_update_ranked - patch :mass_update_ranked - end - end - resources :default_order, only: %i[index create update destroy], module: :resources do - collection do - put :mass_update - patch :mass_update - end - end end end @@ -49,6 +35,13 @@ end end + resources :resource_default_orders, only: %i[index create update destroy] do + collection do + put :mass_update + patch :mass_update + end + end + resources :drafts, only: %i[index show create destroy] resources :translations, only: %i[index show] resources :pages, only: %i[create update show] diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb similarity index 89% rename from spec/acceptance/resources/default_order_controller_spec.rb rename to spec/acceptance/resource_default_orders_controller_spec.rb index 9a9e48377..a0f59bd2e 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -3,7 +3,7 @@ require "acceptance_helper" require "sidekiq/testing" -resource "Resources::DefaultOrder" do +resource "ResourceDefaultOrders" do include ActiveJob::TestHelper header "Accept", "application/vnd.api+json" @@ -20,7 +20,7 @@ ResourceDefaultOrder.delete_all end - get "resources/default_order" do + get "resource_default_orders" do before do FactoryBot.create(:resource_default_order, resource: resource, language: language_en) FactoryBot.create(:resource_default_order, resource: other_resource, language: language_en) @@ -79,7 +79,7 @@ end end - post "resources/default_order" do + post "resource_default_orders" do requires_authorization let(:valid_params) do @@ -116,10 +116,12 @@ end end - delete "resources/default_order/:id" do + delete "resource_default_orders/:id" do requires_authorization - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, language: language_en) } + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + end let(:id) { resource_default_order.id } it "deletes the default order resource" do @@ -132,7 +134,7 @@ context "when an incorrect ID is sent" do let(:id) { "unknownId" } - it "returns unprocessable entity" do + it "returns not found" do do_request expect(status).to be(404) @@ -140,10 +142,12 @@ end end - patch "resources/default_order/:id" do + patch "resource_default_orders/:id" do requires_authorization - let!(:resource_default_order) { FactoryBot.create(:resource_default_order, resource: resource, language: language_en) } + let!(:resource_default_order) do + FactoryBot.create(:resource_default_order, resource: resource, language: language_en) + end let(:id) { resource_default_order.id } let(:valid_update_params) do { @@ -180,7 +184,7 @@ context "when an incorrect ID is sent" do let(:id) { "unknownId" } - it "returns unprocessable entity" do + it "returns not found" do do_request(valid_update_params) expect(status).to be(404) @@ -188,13 +192,15 @@ end end - patch "resources/default_order/mass_update" do + patch "resource_default_orders/mass_update" do requires_authorization let(:lang) { "en" } let(:resource_ids) { [] } let(:resource_type) { ResourceType.find(resource.resource_type_id) } - let(:params) { {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} } + let(:params) do + {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} + end context "with no lang param" do let(:lang) { nil } @@ -245,7 +251,10 @@ end context "when sending more than 1 resource default order" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end let(:resource_ids) { [resource.id, resource2.id] } it "returns an array with more than 1 resource default order" do @@ -263,8 +272,14 @@ end context "with previous resource default orders" do - let!(:resource2) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, resource.id).first } - let!(:resource3) { Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", resource.resource_type.name, [resource.id, resource2.id]).first } + let!(:resource2) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, resource.id).first + end + let!(:resource3) do + Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", + resource.resource_type.name, [resource.id, resource2.id]).first + end let!(:resource_default_order) do FactoryBot.create(:resource_default_order, resource: resource, language: language_en, position: 1) end diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb deleted file mode 100644 index cb3e997dd..000000000 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ /dev/null @@ -1,566 +0,0 @@ -# frozen_string_literal: true - -require "acceptance_helper" -require "sidekiq/testing" - -resource "Resources::Featured" do - include ActiveJob::TestHelper - - header "Accept", "application/vnd.api+json" - header "Content-Type", "application/vnd.api+json" - let(:raw_post) { params.to_json } - let(:authorization) { AuthToken.generic_token } - - let!(:resource) { Resource.first } - let!(:unfeatured_resource) { Resource.last } - let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } - let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } - - post "resources/featured" do - requires_authorization - - let!(:resource_score) do - ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| - rs.featured = true - rs.featured_order = 1 - end - end - let(:valid_params) do - { - data: { - type: "resource_score", - attributes: { - resource_id: resource.id, - lang: language_en.code, - country: "US", - featured: true, - featured_order: 1 - } - } - } - end - - context "with valid parameters" do - it "creates a new featured resource score" do - do_request(valid_params) - - expect(status).to be(201) - json = JSON.parse(response_body) - expect(json["data"]["attributes"]["featured"]).to be true - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {type: "resource_score", attributes: {featured: true, resource_id: resource.id}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - end - - delete "resources/featured/:id" do - requires_authorization - - let(:id) { resource_score.id } - let!(:resource_score) do - ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| - rs.featured = true - rs.featured_order = 1 - end - end - - it "deletes the featured resource score" do - do_request - - expect(status).to be(200) - expect(ResourceScore.exists?(id)).to be false - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns a not found error" do - do_request - - expect(status).to be(404) - end - end - end - - patch "resources/featured/:id" do - requires_authorization - - let!(:resource_score) do - ResourceScore.find_or_create_by!(resource: resource, country: "us", language: language_en) do |rs| - rs.featured = true - rs.featured_order = 1 - end - end - let(:id) { resource_score.id } - let(:valid_update_params) do - { - data: { - type: "resource_score", - attributes: { - featured_order: 2, - country: "CA" - } - } - } - end - - context "with valid parameters" do - it "updates the featured resource score" do - do_request(valid_update_params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"]["attributes"]["featured-order"]).to eq(2) - expect(json["data"]["attributes"]["country"]).to eq("CA".downcase) - expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {type: "resource_score", attributes: {featured_order: "invalid"}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end - - context "when an incorrect ID is sent" do - let(:id) { "unknownId" } - - it "returns a not found error" do - do_request(valid_update_params) - - expect(status).to be(404) - end - end - end - - patch "resources/featured/mass_update" do - requires_authorization - - let(:country) { "US" } - let(:lang) { "en" } - let(:resource_ids) { [] } - let(:resource_type) { ResourceType.find(resource.resource_type_id) } - let(:featured) { true } - let(:params) do - {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, - resource_type: resource_type.name}}} - end - - context "with no country and lang params" do - let(:country) { nil } - let(:lang) { nil } - - context "when sending an empty array" do - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - - context "when sending 1 resource score" do - let(:resource_ids) { [resource.id] } - - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - end - - context "with country and lang params" do - context "with no previous resource score" do - context "when sending an empty array" do - it "returns an empty array" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(0) - end - end - - context "when sending 1 resource score" do - let(:resource_ids) { [resource.id] } - - it "returns an array with 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "when sending more than 1 resource score" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let(:resource_ids) { [resource.id, resource2.id] } - - it "returns an array with more than 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - end - - context "with previous resource scores" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let!(:resource3) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, [resource.id, resource2.id]).first - end - let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, - featured_order: 1) - end - let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, language: language_en, featured: false, - featured_order: nil) - end - - context "when sending an empty array" do - let(:resource_ids) { [] } - - it "deletes all matching resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(0) - end - - context "when a resource has a score" do - let(:resource_ids) { [] } - - before do - resource_score.update!(score: 5) - end - - it "removes featured status but keeps the score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - - resource_score.reload - expect(resource_score.featured).to be false - expect(resource_score.featured_order).to be_nil - expect(resource_score.score).to eq(5) - end - end - end - - context "when sending 1 resource to replace" do - let(:resource_ids) { [resource3.id, resource2.id] } - - it "returns an array with the replaced resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - - context "when sending more than 1 resource to replace" do - let(:resource_ids) { [resource2.id, resource3.id, resource.id] } - - it "returns an array with the replaced resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(3) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - expect(json["data"][0]["attributes"]["featured"]).to eq(true) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) - expect(json["data"][2]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "when sending the same resource to replace" do - let(:resource_ids) { [resource.id, resource2.id] } - - it "returns an array with the replaced resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - end - end - end - end - end - - patch "resources/featured/mass_update_ranked" do - requires_authorization - - let(:country) { "US" } - let(:lang) { "en" } - let(:ranked_resources) { [] } - let(:resource_type) { ResourceType.find(resource.resource_type_id) } - let(:params) do - {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, - resource_type: resource_type.name}}} - end - - context "with no country and lang params" do - let(:country) { nil } - let(:lang) { nil } - - context "when sending an empty array" do - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - - context "when sending 1 ranked resource" do - let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } - - it "returns an error" do - do_request(params) - - expect(status).to be(422) - end - end - end - - context "with country and lang params" do - context "with no previous resource scores" do - context "when sending an empty array" do - it "returns an empty array" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(0) - end - end - - context "when sending 1 ranked resource" do - let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } - - it "returns an array with 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][0]["attributes"]["score"]).to eq(10) - end - end - - context "when sending more than 1 ranked resource" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } - - it "returns an array with more than 1 resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][0]["attributes"]["score"]).to eq(20) - expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) - expect(json["data"][1]["attributes"]["score"]).to eq(10) - end - - it "returns resources sorted by score descending" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] - end - end - end - - context "with previous resource scores" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let!(:resource3) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, [resource.id, resource2.id]).first - end - let!(:resource_score) do - ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) - end - let!(:resource_score2) do - ResourceScore.create!(resource: resource2, country: country, language: language_en, score: 5) - end - - context "when sending an empty array" do - let(:ranked_resources) { [] } - - it "clears all scores for matching resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - - resource_score.reload - resource_score2.reload - expect(resource_score.score).to be_nil - expect(resource_score2.score).to be_nil - end - end - - context "when sending 1 ranked resource to update" do - let(:ranked_resources) { [{resource_id: resource.id, score: 20}] } - - it "returns an array with the updated resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][0]["attributes"]["score"]).to eq(20) - end - - it "updates only the existing resource score" do - do_request(params) - - resource_score.reload - expect(resource_score.score).to eq(20) - end - end - - context "when sending more than 1 ranked resource to update" do - let(:ranked_resources) { [{resource_id: resource.id, score: 18}, {resource_id: resource2.id, score: 12}] } - - it "returns an array with the updated resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["attributes"]["score"]).to eq(18) - expect(json["data"][1]["attributes"]["score"]).to eq(12) - end - - it "returns resources sorted by score descending" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] - end - end - - context "when sending a new resource to add" do - let(:ranked_resources) { [{resource_id: resource3.id, score: 16}] } - - it "creates a new resource score" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource3.id.to_s) - expect(json["data"][0]["attributes"]["score"]).to eq(16) - end - end - - context "when sending a mix of existing and new resources" do - let(:ranked_resources) { [{resource_id: resource.id, score: 19}, {resource_id: resource3.id, score: 14}] } - - it "returns an array with updated and new resource scores" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - expect(json["data"][0]["attributes"]["score"]).to eq(19) - expect(json["data"][1]["attributes"]["score"]).to eq(14) - end - - it "returns resources sorted by score descending" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"][0]["attributes"]["score"]).to be >= json["data"][1]["attributes"]["score"] - end - end - - context "when resource_type filter is applied" do - let(:ranked_resources) { [{resource_id: resource.id, score: 17}] } - - it "only updates resources of the specified type" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - end - end - - context "when updating with different scores" do - let(:ranked_resources) do - [ - {resource_id: resource.id, score: 20}, - {resource_id: resource2.id, score: 13}, - {resource_id: resource3.id, score: 7} - ] - end - - it "returns all resources sorted by score in descending order" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(3) - expect(json["data"][0]["attributes"]["score"]).to eq(20) - expect(json["data"][1]["attributes"]["score"]).to eq(13) - expect(json["data"][2]["attributes"]["score"]).to eq(7) - end - end - end - end - end -end From c86b9d482767487b963620d35f9ac4388bac3459 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 10 Dec 2025 14:10:12 -0500 Subject: [PATCH 21/38] Adding validation for resource_type param --- .../resource_default_orders_controller.rb | 2 +- app/controllers/resource_scores_controller.rb | 4 +- ...resource_default_orders_controller_spec.rb | 29 ++++++- .../resource_scores_controller_spec.rb | 80 ++++++++++++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index ef5ba3592..d1f02ed84 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -51,7 +51,7 @@ def mass_update incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] resulting_resource_default_orders = [] - raise "Lang should be provided" unless lang_code.present? + raise "Lang and Resource Type should be provided" unless lang_code.present? && resource_type.present? language = Language.find_by(code: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index 88d394c2a..d72375244 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -55,7 +55,7 @@ def mass_update incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] resulting_resource_scores = [] - raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? language = Language.find_by(code: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? @@ -143,7 +143,7 @@ def mass_update_ranked incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] resulting_resource_scores = [] - raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? + raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? language = Language.find_by(code: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index a0f59bd2e..0c625bdd4 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -199,7 +199,7 @@ let(:resource_ids) { [] } let(:resource_type) { ResourceType.find(resource.resource_type_id) } let(:params) do - {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type.name}}} + {data: {attributes: {lang: lang, resource_ids: resource_ids, resource_type: resource_type&.name}}} end context "with no lang param" do @@ -224,6 +224,33 @@ end end + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + + context "when sending 1 resource default order" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + end + context "with lang param" do context "with no previous resource default order" do context "when sending an empty array" do diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb index d58d7e465..f4fef4562 100644 --- a/spec/acceptance/resource_scores_controller_spec.rb +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -247,7 +247,7 @@ let(:featured) { true } let(:params) do {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, - resource_type: resource_type.name}}} + resource_type: resource_type&.name}}} end context "with no country and lang params" do @@ -273,6 +273,56 @@ end end + context "with no country, lang, and resource_type params" do + let(:country) { nil } + let(:lang) { nil } + let(:resource_type_attr) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + end + end + end + + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + + context "when sending 1 resource score" do + let(:resource_ids) { [resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + end + context "with country and lang params" do context "with no previous resource score" do context "when sending an empty array" do @@ -425,7 +475,7 @@ let(:resource_type) { ResourceType.find(resource.resource_type_id) } let(:params) do {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, - resource_type: resource_type.name}}} + resource_type: resource_type&.name}}} end context "with no country and lang params" do @@ -451,6 +501,32 @@ end end + context "with no resource_type param" do + let(:resource_type) { nil } + + context "when sending an empty array" do + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + + context "when sending 1 ranked resource" do + let(:ranked_resources) { [{resource_id: resource.id, score: 10}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resource Type") + end + end + end + context "with country and lang params" do context "with no previous resource scores" do context "when sending an empty array" do From 55b764e44c146d2167cb52f62d2e052f09b12688 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 11 Dec 2025 11:28:49 -0500 Subject: [PATCH 22/38] Added language validation on default_orders#index --- .../resources/default_order_controller.rb | 11 +++++++++- .../default_order_controller_spec.rb | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 0a965b004..fa1579143 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -5,12 +5,21 @@ class DefaultOrderController < ApplicationController before_action :authorize!, only: %i[create destroy update mass_update] def index + lang = params.dig(:filter, :lang) || params[:lang] + + if lang.present? + language = Language.find_by(code: lang.downcase) + raise "Language not found for code: #{lang.downcase}" unless language.present? + end + default_order_resources = all_default_order_resources( - lang: params.dig(:filter, :lang) || params[:lang], + lang: lang, resource_type: params.dig(:filter, :resource_type) || params[:resource_type] ) render json: default_order_resources, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end def create diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb index 9a9e48377..7b7cd7ac5 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resources/default_order_controller_spec.rb @@ -54,6 +54,28 @@ expect(json["data"].size).to eq(0) end end + + context "with non-existent language" do + it "returns an error" do + do_request lang: "non_existent_lang" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + + context "with non-existent language inside filter param" do + it "returns an error" do + do_request filter: {lang: "non_existent_lang"} + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end end context "with resource_type filter" do From 6945db8efc1620320c8188f381125b898eb00d9a Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 11 Dec 2025 11:31:24 -0500 Subject: [PATCH 23/38] Fixed lint issue --- app/controllers/resources/default_order_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index fa1579143..11a966f8d 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -6,7 +6,7 @@ class DefaultOrderController < ApplicationController def index lang = params.dig(:filter, :lang) || params[:lang] - + if lang.present? language = Language.find_by(code: lang.downcase) raise "Language not found for code: #{lang.downcase}" unless language.present? From 481dba5ec569f2467ea83cad025b78544dfa9ebe Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 11 Dec 2025 11:42:01 -0500 Subject: [PATCH 24/38] Added language validation on resource_default_orders#index --- .../resource_default_orders_controller.rb | 15 +++++++++---- ...resource_default_orders_controller_spec.rb | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index d1f02ed84..cbb5632cf 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -4,12 +4,19 @@ class ResourceDefaultOrdersController < ApplicationController before_action :authorize!, only: %i[create destroy update mass_update] def index - default_order_resources = all_default_order_resources( - lang: params.dig(:filter, :lang) || params[:lang], - resource_type: params.dig(:filter, :resource_type) || params[:resource_type] - ) + lang = params.dig(:filter, :lang) || params[:lang] + resource_type = params.dig(:filter, :resource_type) || params[:resource_type] + + if lang.present? + language = Language.find_by(code: lang.downcase) + raise "Language not found for code: #{lang}" unless language.present? + end + + default_order_resources = all_default_order_resources(lang: lang, resource_type: resource_type) render json: default_order_resources, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end def create diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index 0c625bdd4..2eba72971 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -45,6 +45,17 @@ expect(json["data"].size).to eq(0) end + context "with language not found" do + it "returns an error" do + do_request lang: "invalid_lang" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end + context "inside filter param" do it "returns default order resources for specified language" do do_request filter: {lang: "fr"} @@ -53,6 +64,17 @@ json = JSON.parse(response_body) expect(json["data"].size).to eq(0) end + + context "with language not found" do + it "returns an error" do + do_request filter: {lang: "invalid_lang"} + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + expect(json["errors"][0]["detail"]).to include("Language not found") + end + end end end From b9fb4ccffd2ba8fd89f90ffb84baa1cc77988193 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 11 Dec 2025 12:33:44 -0500 Subject: [PATCH 25/38] Updated language query --- app/controllers/resources/default_order_controller.rb | 6 +++--- app/controllers/resources/featured_controller.rb | 6 +++--- .../resources/default_order_controller_spec.rb | 11 +++++++++++ spec/acceptance/resources/featured_controller_spec.rb | 11 +++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 11a966f8d..9530d172d 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -8,7 +8,7 @@ def index lang = params.dig(:filter, :lang) || params[:lang] if lang.present? - language = Language.find_by(code: lang.downcase) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first raise "Language not found for code: #{lang.downcase}" unless language.present? end @@ -63,7 +63,7 @@ def mass_update raise "Lang should be provided" unless lang_code.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) @@ -140,7 +140,7 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.find_by(code: lang.downcase) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end diff --git a/app/controllers/resources/featured_controller.rb b/app/controllers/resources/featured_controller.rb index b326cf6ce..e69979021 100644 --- a/app/controllers/resources/featured_controller.rb +++ b/app/controllers/resources/featured_controller.rb @@ -57,7 +57,7 @@ def mass_update raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_scores = ResourceScore.where( @@ -143,7 +143,7 @@ def mass_update_ranked raise "Country and/or Lang should be provided" unless country.present? && lang_code.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_scores = ResourceScore.where( @@ -209,7 +209,7 @@ def all_featured_resources(lang_code:, country:, resource_type: nil) scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) if lang_code.present? - language = Language.find_by(code: lang_code.downcase) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first scope = scope.left_joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? end diff --git a/spec/acceptance/resources/default_order_controller_spec.rb b/spec/acceptance/resources/default_order_controller_spec.rb index 7b7cd7ac5..a2b01e356 100644 --- a/spec/acceptance/resources/default_order_controller_spec.rb +++ b/spec/acceptance/resources/default_order_controller_spec.rb @@ -15,6 +15,7 @@ let!(:other_resource) { Resource.second } let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } before(:each) do ResourceDefaultOrder.delete_all @@ -55,6 +56,16 @@ end end + context "with a different capitalization" do + it "returns default order resources for specified language" do + do_request filter: {lang: "am"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end + context "with non-existent language" do it "returns an error" do do_request lang: "non_existent_lang" diff --git a/spec/acceptance/resources/featured_controller_spec.rb b/spec/acceptance/resources/featured_controller_spec.rb index e6fdf4d4f..4d142d877 100644 --- a/spec/acceptance/resources/featured_controller_spec.rb +++ b/spec/acceptance/resources/featured_controller_spec.rb @@ -15,6 +15,7 @@ let!(:unfeatured_resource) { Resource.last } let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } + let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } get "resources/featured" do let!(:resource_score) { @@ -53,6 +54,16 @@ expect(json["data"].size).to eq(0) end end + + context "with a different capitalization" do + it "returns default order resources for specified language" do + do_request filter: {lang: "am"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + end end context "with country filter" do From 30640ed0e1cf03ad1f8f6b9b2ec46d6170e3e7c2 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Thu, 11 Dec 2025 12:34:50 -0500 Subject: [PATCH 26/38] Removed downcase from error msg --- app/controllers/resources/default_order_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/resources/default_order_controller.rb b/app/controllers/resources/default_order_controller.rb index 9530d172d..390ae9e72 100644 --- a/app/controllers/resources/default_order_controller.rb +++ b/app/controllers/resources/default_order_controller.rb @@ -9,7 +9,7 @@ def index if lang.present? language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first - raise "Language not found for code: #{lang.downcase}" unless language.present? + raise "Language not found for code: #{lang}" unless language.present? end default_order_resources = all_default_order_resources( From 55a0e910e44e8bcf93ef20ed05fab3fc2c07424d Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Fri, 12 Dec 2025 18:22:10 -0500 Subject: [PATCH 27/38] Fixed last_update value --- app/controllers/content_status_controller.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/controllers/content_status_controller.rb b/app/controllers/content_status_controller.rb index 0b4279c68..05fb04209 100644 --- a/app/controllers/content_status_controller.rb +++ b/app/controllers/content_status_controller.rb @@ -69,9 +69,7 @@ def retrieve_language_data(country, language) language_name: language.name, lessons: retrieve_lessons_data(country, language), tools: retrieve_tools_data(country, language), - last_updated: Resource.joins(:resource_scores).where( - resource_scores: {country: country, language: language} - ).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" + last_updated: ResourceScore.where(country: country, language: language).maximum(:updated_at)&.strftime("%d-%m-%y") || "N/A" } end From ab8a3d3fb661db847ccd9ecb02c7be0f00ff5d07 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Mon, 15 Dec 2025 11:45:24 -0500 Subject: [PATCH 28/38] Adding back default_order route + added language fallback --- .../resource_default_orders_controller.rb | 8 +- app/controllers/resource_scores_controller.rb | 10 +- app/controllers/resources_controller.rb | 32 ++++ config/routes.rb | 1 + spec/acceptance/resources_controller_spec.rb | 148 ++++++++++++++++++ 5 files changed, 190 insertions(+), 9 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index cbb5632cf..553bd47b0 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -8,7 +8,7 @@ def index resource_type = params.dig(:filter, :resource_type) || params[:resource_type] if lang.present? - language = Language.find_by(code: lang.downcase) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first raise "Language not found for code: #{lang}" unless language.present? end @@ -21,7 +21,7 @@ def index def create sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order = ResourceDefaultOrder.new(sanitized_params) @resource_default_order.language = language if language.present? @@ -43,7 +43,7 @@ def destroy def update @resource_default_order = ResourceDefaultOrder.find(params[:id]) sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order.language = language if language.present? @resource_default_order.update!(sanitized_params) @@ -137,7 +137,7 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.find_by(code: lang.downcase) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index d72375244..9417b2b9d 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -38,7 +38,7 @@ def destroy def update @resource_score = ResourceScore.find(params[:id]) sanitized_params = create_params - language = Language.find_by!(code: create_params[:lang].downcase) if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_score.language = language if language.present? @resource_score.update!(sanitized_params) @@ -57,7 +57,7 @@ def mass_update raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_scores = ResourceScore.where( @@ -145,7 +145,7 @@ def mass_update_ranked raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_scores = ResourceScore.where( @@ -211,8 +211,8 @@ def all_resource_scores(lang_code:, country:, resource_type: nil) scope = ResourceScore.all if lang_code.present? - language = Language.find_by(code: lang_code.downcase) - scope = scope.where(language_id: language.id) if language.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + scope = scope.left_joins(:language).where(languages: {id: language.id}) if language.present? end scope = scope.where("LOWER(country) = LOWER(?)", country) if country.present? diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index f9416cec2..b900f9c47 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -38,6 +38,24 @@ def featured render json: featured_resources, include: params[:include], status: :ok end + def default_order + lang = params.dig(:filter, :lang) || params[:lang] + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + raise "Language not found for code: #{lang}" unless language.present? + end + + default_order_resources = all_default_order_resources( + lang: lang, + resource_type: params.dig(:filter, :resource_type) || params[:resource_type] + ) + + render json: default_order_resources, include: params[:include], status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + end + def publish_translation if valid_publish_params? render json: publish_translations, status: :ok @@ -128,6 +146,20 @@ def all_featured_resources(lang_code:, country:, resource_type: nil) resources.created_at DESC") end + def all_default_order_resources(lang:, resource_type: nil) + scope = Resource.joins(:resource_default_orders) + + if lang.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + end + + if resource_type.present? + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + end + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + end + def load_resource Resource.find(params[:id]) end diff --git a/config/routes.rb b/config/routes.rb index e3854745f..18a16a1e1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,7 @@ post "translations/publish", to: "resources#publish_translation" collection do get :featured + get :default_order end end diff --git a/spec/acceptance/resources_controller_spec.rb b/spec/acceptance/resources_controller_spec.rb index 359d5ff5a..718c2a013 100644 --- a/spec/acceptance/resources_controller_spec.rb +++ b/spec/acceptance/resources_controller_spec.rb @@ -676,6 +676,154 @@ end end + get "resources/default_order" do + let!(:resource_default_order_1) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_1, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 1 + end + end + + let!(:resource_default_order_2) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_2, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 2 + end + end + + context "without filters" do + it "returns default order resources" do + do_request include: "language" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + expect(json["data"][1]["id"]).to eq(resource_2.id.to_s) + end + end + + context "with language filter" do + it "returns default order resources for specified language" do + do_request lang: "en" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + end + + it "returns empty array for non-existent language" do + do_request lang: "de" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(0) + end + + context "inside filter param" do + it "returns default order resources for specified language" do + do_request filter: {lang: "en"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + expect(json["data"][0]["id"]).to eq(resource_1.id.to_s) + end + end + + context "with case insensitive language code" do + it "returns default order resources" do + do_request lang: "EN" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(2) + end + end + end + + context "with resource_type filter" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_default_order) do + ResourceDefaultOrder.find_or_create_by!(resource: tool_resource, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 3 + end + end + + it "returns default order resources for specified resource type" do + do_request resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + + context "inside filter param" do + it "returns default order resources for specified resource type" do + do_request filter: {resource_type: "metatool"} + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + end + end + + context "with language and resource_type filters" do + let!(:tool_resource) { Resource.joins(:resource_type).where(resource_types: {name: "metatool"}).first } + let!(:tool_default_order) do + ResourceDefaultOrder.find_or_create_by!(resource: tool_resource, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 3 + end + end + + it "returns default order resources matching both filters" do + do_request lang: "en", resource_type: "metatool" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(1) + expect(json["data"][0]["id"]).to eq(tool_resource.id.to_s) + end + end + + context "with invalid language code" do + it "returns unprocessable content error" do + do_request lang: "invalid_lang_code_that_does_not_exist" + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"]).to be_present + expect(json["errors"].first["detail"]).to include("Language not found") + end + end + + context "returns resources in correct order" do + let!(:resource_default_order_3) do + ResourceDefaultOrder.find_or_create_by!(resource: resource_3, + language: Language.find_or_create_by!(code: "en", name: "English")) do |rdo| + rdo.position = 1 + end + end + + it "orders by position ascending" do + do_request lang: "en" + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].size).to eq(3) + expect(json["data"][0]["id"]).to eq(resource_3.id.to_s) + expect(json["data"][1]["id"]).to eq(resource_1.id.to_s) + expect(json["data"][2]["id"]).to eq(resource_2.id.to_s) + end + end + end + get "resources/:id" do let(:id) { 1 } From 2fd0b620cd808829c374b78200899de807703816 Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Tue, 16 Dec 2025 16:49:40 -0500 Subject: [PATCH 29/38] Fixed lang not found on default#mass_update --- app/controllers/resource_default_orders_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index 553bd47b0..b3da1c350 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -60,7 +60,7 @@ def mass_update raise "Lang and Resource Type should be provided" unless lang_code.present? && resource_type.present? - language = Language.find_by(code: lang_code) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) From fbde6e6ddeae54e262faaa0dc53789a0c56061ef Mon Sep 17 00:00:00 2001 From: Hans Gamarra Date: Wed, 14 Jan 2026 16:54:05 -0500 Subject: [PATCH 30/38] Ensuring mass_update endpoints remove all unselected resources --- .../resource_default_orders_controller.rb | 15 +++- app/controllers/resource_scores_controller.rb | 18 ++++- ...resource_default_orders_controller_spec.rb | 34 ++++++++ .../resource_scores_controller_spec.rb | 77 +++++++++++++++++++ 4 files changed, 139 insertions(+), 5 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index b3da1c350..bde04f3ac 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -21,7 +21,10 @@ def index def create sanitized_params = create_params - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order = ResourceDefaultOrder.new(sanitized_params) @resource_default_order.language = language if language.present? @@ -43,7 +46,10 @@ def destroy def update @resource_default_order = ResourceDefaultOrder.find(params[:id]) sanitized_params = create_params - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order.language = language if language.present? @resource_default_order.update!(sanitized_params) @@ -125,6 +131,11 @@ def mass_update ) end end + + # Destroy any current orders that are not in the incoming resources list + current_orders.each do |ro| + ro.destroy! unless incoming_resources.include?(ro.resource_id) + end end render json: resulting_resource_default_orders, status: :ok rescue => e diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index 9417b2b9d..da894d1d7 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -38,7 +38,10 @@ def destroy def update @resource_score = ResourceScore.find(params[:id]) sanitized_params = create_params - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: create_params[:lang]).first if create_params[:lang].present? + if create_params[:lang].present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first + end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_score.language = language if language.present? @resource_score.update!(sanitized_params) @@ -55,7 +58,9 @@ def mass_update incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] resulting_resource_scores = [] - raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? + unless country.present? && lang_code.present? && resource_type.present? + raise "Country, Lang, and Resource Type should be provided" + end language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? @@ -130,6 +135,11 @@ def mass_update ) end end + + # Soft-delete any current scores that are not in the incoming resources list + current_scores.each do |rs| + soft_delete_resource_score(rs) unless incoming_resources.include?(rs.resource_id) + end end render json: resulting_resource_scores, include: params[:include], status: :ok rescue => e @@ -143,7 +153,9 @@ def mass_update_ranked incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] resulting_resource_scores = [] - raise "Country, Lang, and Resource Type should be provided" unless country.present? && lang_code.present? && resource_type.present? + unless country.present? && lang_code.present? && resource_type.present? + raise "Country, Lang, and Resource Type should be provided" + end language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first raise "Language not found for code: #{lang_code}" unless language.present? diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index 682b3324b..2847f532b 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -446,6 +446,40 @@ expect(json["data"][0]["attributes"]["position"]).to eq(1) end end + + context "when omitting a resource from the incoming list" do + let(:resource_ids) { [resource.id] } + + it "removes the omitted resource default order" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(ResourceDefaultOrder.exists?(resource_default_order2.id)).to be false + end + end + + context "when omitting multiple resources from the incoming list" do + let!(:resource_default_order3) do + FactoryBot.create(:resource_default_order, resource: resource3, language: language_en, position: 3) + end + let(:resource_ids) { [resource2.id] } + + it "removes all omitted resource default orders" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(json["data"][0]["attributes"]["position"]).to eq(1) + expect(ResourceDefaultOrder.exists?(resource_default_order.id)).to be false + expect(ResourceDefaultOrder.exists?(resource_default_order3.id)).to be false + end + end end end end diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb index 998ed5338..e98b5dea4 100644 --- a/spec/acceptance/resource_scores_controller_spec.rb +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -225,6 +225,28 @@ expect(json["data"]["attributes"]["country"]).to eq("CA".downcase) expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) end + + context "when updating the language" do + let(:lang_update_params) do + { + data: { + type: "resource_score", + attributes: { + lang: language_fr.code + } + } + } + end + + it "updates the resource score language" do + do_request(lang_update_params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]["relationships"]["language"]["data"]["id"]).to eq(language_fr.id.to_s) + expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + end + end end context "with invalid parameters" do @@ -473,6 +495,61 @@ expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) end end + + context "when omitting a resource from the incoming list" do + let(:resource_ids) { [resource.id] } + + it "soft-deletes the omitted resource score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(ResourceScore.exists?(resource_score2.id)).to be false + end + end + + context "when omitting a resource with a score from the incoming list" do + let(:resource_ids) { [resource2.id] } + + before do + resource_score.update!(score: 10) + end + + it "removes featured status but keeps the score" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + + resource_score.reload + expect(resource_score.featured).to be false + expect(resource_score.featured_order).to be_nil + expect(resource_score.score).to eq(10) + end + end + + context "when omitting multiple resources from the incoming list" do + let!(:resource_score3) do + ResourceScore.create!(resource: resource3, country: country, language: language_en, featured: true, + featured_order: 3) + end + let(:resource_ids) { [resource2.id] } + + it "soft-deletes all omitted resource scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"].count).to eq(1) + expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) + expect(ResourceScore.exists?(resource_score.id)).to be false + expect(ResourceScore.exists?(resource_score3.id)).to be false + end + end end end end From 34cda59603fa10286ddacdbb44d295bec17f4fb9 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Tue, 27 Jan 2026 00:11:00 -0600 Subject: [PATCH 31/38] Added x86_64-darwin-25 to plaforms in Gemfile.lock. Added field param support for /featured and /default_order. Refactored mass update and mass update ranked controller actions. Need to take a look at mass update for default order controller, finish testing, and remove logs. --- .env | 5 + Gemfile.lock | 45 ++- app/controllers/resource_scores_controller.rb | 284 ++++++++++-------- app/controllers/resources_controller.rb | 54 ++-- app/models/resource_score.rb | 27 +- db/schema.rb | 2 +- 6 files changed, 248 insertions(+), 169 deletions(-) diff --git a/.env b/.env index 7fc3a09ee..9ee81fa59 100644 --- a/.env +++ b/.env @@ -38,6 +38,11 @@ OKTA_SERVER_URL=https://dev1-signon.okta.com OKTA_SERVER_PATH=https://dev1-signon.okta.com OKTA_SERVER_AUDIENCE=https://dev1-signon.okta.com +# TODO: ensure 'production' okta env vars are removed before git push +# OKTA_SERVER_URL=https://signon.okta.com +# OKTA_SERVER_PATH=https://signon.okta.com +# OKTA_SERVER_AUDIENCE=0oa1ll7gg0vVRLctz0h8 + FACEBOOK_APP_ID=facebook_app_id FACEBOOK_APP_SECRET=facebook_app_secret diff --git a/Gemfile.lock b/Gemfile.lock index 0afc1a86b..02fec5965 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -215,6 +215,10 @@ GEM faraday-net_http (3.4.1) net-http (>= 0.5.0) ffi (1.17.2) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) file_validators (3.0.0) activemodel (>= 3.2) @@ -301,7 +305,9 @@ GEM libdatadog (22.0.1.1.0-x86_64-linux) libddwaf (1.25.1.1.0) ffi (~> 1.0) - libddwaf (1.25.1.1.0-x86_64-linux) + libddwaf (1.30.0.0.0-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) lint_roller (1.1.0) listen (3.9.0) @@ -352,9 +358,12 @@ GEM net-smtp (0.5.1) net-protocol netrc (0.11.0) - nio4r (2.7.4) - nokogiri (1.18.10) - mini_portile2 (~> 2.8.2) + nio4r (2.7.5) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) @@ -386,6 +395,9 @@ GEM ast (~> 2.4.1) racc pg (1.6.2) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) pg (1.6.2-x86_64-linux-musl) pp (0.6.2) prettyprint @@ -631,7 +643,9 @@ GEM zeitwerk (2.7.3) PLATFORMS - ruby + arm64-darwin + x86_64-darwin-25 + x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES @@ -757,6 +771,9 @@ CHECKSUMS faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 faraday-net_http (3.4.1) sha256=095757fae7872b94eac839c08a1a4b8d84fd91d6886cfbe75caa2143de64ab3b ffi (1.17.2) sha256=297235842e5947cc3036ebe64077584bff583cd7a4e94e9a02fdec399ef46da6 + ffi (1.17.2-arm64-darwin) sha256=54dd9789be1d30157782b8de42d8f887a3c3c345293b57ffb6b45b4d1165f813 + ffi (1.17.2-x86_64-darwin) sha256=981f2d4e32ea03712beb26e55e972797c2c5a7b0257955d8667ba58f2da6440e + ffi (1.17.2-x86_64-linux-gnu) sha256=05d2026fc9dbb7cfd21a5934559f16293815b7ce0314846fee2ac8efbdb823ea ffi (1.17.2-x86_64-linux-musl) sha256=97c0eb3981414309285a64dc4d466bd149e981c279a56371ef811395d68cb95c file_validators (3.0.0) sha256=43700f0c1fe9b235bf7f4701375e1d2f9391352ab26723f123b934724db8067b formatador (1.1.0) sha256=54e23e2af4d60bb9327c7fac62b29968e4cf28cee0111f726d0bdeadc85e06d0 @@ -786,10 +803,11 @@ CHECKSUMS jsonapi-renderer (0.2.2) sha256=b5c44b033d61b4abdb6500fa4ab84807ca0b36ea0e59e47a2c3ca7095a6e447b jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - libdatadog (22.0.1.1.0) sha256=65e98a8ebe337ec8fcb5a9e507349cf14e82da9e571ba9c03942e2620ae5675f - libdatadog (22.0.1.1.0-x86_64-linux) sha256=3f727cc5c8c3a40ae48aabee5c230af4842c145ccc495e60955405f84cd9c05a - libddwaf (1.25.1.1.0) sha256=49fa30bb6ff0078ccd79991b52e5cd51662eb8f45156bc58e026bc7824b66b83 - libddwaf (1.25.1.1.0-x86_64-linux) sha256=f7d7eba613b8603e2c8669d89584d5d13d95f335fa7b0a8da925fb126ac3ce06 + libdatadog (24.0.1.1.0) sha256=5f911a5598deb11f8c4179077a46e50a57b778b87a152bf23859ae6e557b1751 + libdatadog (24.0.1.1.0-x86_64-linux) sha256=d15aea8b2be3e45ecfd9dec4ebc53b4c178e8540dee59c40cf2500bed67aacde + libddwaf (1.30.0.0.0) sha256=d5c350555ec5bdfb99534b37ad578163c83642ca03cecb3bae30fd29dc47d4fc + libddwaf (1.30.0.0.0-x86_64-darwin) sha256=749989cf5b3ad3689969019706875260ffff5a91ebafcbf44766a3d635a0ac90 + libddwaf (1.30.0.0.0-x86_64-linux) sha256=c8d7e1e097ed12bcdf5d4bcb716edcb98e72733c80dd884e0ee83a7cc43d6ae0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -817,8 +835,10 @@ CHECKSUMS net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f - nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 - nokogiri (1.18.10) sha256=d5cc0731008aa3b3a87b361203ea3d19b2069628cb55e46ac7d84a0445e69cc1 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.18.10-arm64-darwin) sha256=c2b0de30770f50b92c9323fa34a4e1cf5a0af322afcacd239cd66ee1c1b22c85 + nokogiri (1.18.10-x86_64-darwin) sha256=536e74bed6db2b5076769cab5e5f5af0cd1dccbbd75f1b3e1fa69d1f5c2d79e2 + nokogiri (1.18.10-x86_64-linux-gnu) sha256=ff5ba26ba2dbce5c04b9ea200777fd225061d7a3930548806f31db907e500f72 nokogiri (1.18.10-x86_64-linux-musl) sha256=0651fccf8c2ebbc2475c8b1dfd7ccac3a0a6d09f8a41b72db8c21808cb483385 notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738 oj (3.16.12) sha256=ad9fad6a06dabcf4cfe6a420690a4375377685c16eee0ae88e8d38a43ed7b556 @@ -830,6 +850,9 @@ CHECKSUMS parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 pg (1.6.2) sha256=58614afd405cc9c2c9e15bffe8432e0d6cfc58b722344ad4a47c73a85189c875 + pg (1.6.2-arm64-darwin) sha256=4d44500b28d5193b26674583d199a6484f80f1f2ea9cf54f7d7d06a1b7e316b6 + pg (1.6.2-x86_64-darwin) sha256=c441a55723584e2ae41749bf26024d7ffdfe1841b442308ed50cd6b7fda04115 + pg (1.6.2-x86_64-linux) sha256=525f438137f2d1411a1ebcc4208ec35cb526b5a3b285a629355c73208506a8ea pg (1.6.2-x86_64-linux-musl) sha256=e5c8668ffeaf7a9c3458a3dcb002dffa6d8ee1fca9ae534ffef861d2b15644ca pp (0.6.2) sha256=947ec3120c6f92195f8ee8aa25a7b2c5297bb106d83b41baa02983686577b6ff prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index da894d1d7..92f6766cf 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -22,25 +22,25 @@ def create @resource_score.language = language if language.present? @resource_score.save! render json: @resource_score, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content end def destroy @resource_score = ResourceScore.find(params[:id]) @resource_score.destroy! render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, - status: :unprocessable_content + rescue StandardError + render json: { errors: [{ source: { pointer: '/data/attributes/id' }, detail: e.message }] }, + status: :unprocessable_content end def update @resource_score = ResourceScore.find(params[:id]) sanitized_params = create_params if create_params[:lang].present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", - lang: create_params[:lang]).first + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_score.language = language if language.present? @@ -48,167 +48,201 @@ def update render json: @resource_score, status: :ok rescue ActiveRecord::RecordInvalid => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + end + + # TODO: remove logs + def mu_log(msg) + Rails.logger.debug("[mass_update] #{msg}") end def mass_update country = params.dig(:data, :attributes, :country)&.downcase lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] - resulting_resource_scores = [] - unless country.present? && lang_code.present? && resource_type.present? - raise "Country, Lang, and Resource Type should be provided" + mu_log("BEGIN mass update, requested resource IDs: #{incoming_resources}") + + unless country.present? && lang_code.present? && resource_type_name.present? + raise 'Country, Language, and Resource Type should be provided' end - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? - current_scores = ResourceScore.where( - country: country, language_id: language.id - ).order(featured_order: :asc) + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? - if resource_type.present? - current_scores = current_scores.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" end - current_scores = current_scores.to_a + current_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: { resource_type_id: resource_type.id }) + .order(:featured_order) + .lock + .to_a + + mu_log( + "Got current scores for country='#{country}', " \ + "lang='#{lang_code}', " \ + "resource_type='#{resource_type_name}': " \ + "#{current_scores.map do |rs| + { id: rs.id, resource_id: rs.resource_id, featured_order: rs.featured_order, score: rs.score } + end}" + ) - if incoming_resources.empty? + ResourceScore.transaction do + mu_log('Setting featured=false and featured_order=nil for all current scores') current_scores.each do |rs| - soft_delete_resource_score(rs) + rs.update!(featured: false, featured_order: nil) end - current_scores.reject! { |rs| !rs.persisted? } - - return render json: current_scores, include: params[:include], status: :ok - end - - ResourceScore.transaction do - ResourceScore::MAX_FEATURED_ORDER_POSITION.times do |index| - resource_id = incoming_resources[index] - current_featured_order = index + 1 - - if resource_id.nil? - # Remove any existing resource score at this position - resource_score_to_remove = current_scores.find { |rs| rs.featured_order == current_featured_order } - soft_delete_resource_score(resource_score_to_remove) - next - end - incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - current_resource_score_at_position = current_scores.find do |rs| - rs.featured_order == current_featured_order && rs.featured == true - end - - if incoming_resource_score - if incoming_resource_score.featured_order != current_featured_order - # Incoming ResourceScore exists but at a different position - # Remove ResourceScore currently at this position, if any - if current_resource_score_at_position - soft_delete_resource_score(current_resource_score_at_position) - current_scores.reject! { |rs| rs.id == current_resource_score_at_position.id } - end - - # Move incoming ResourceScore to the new position - incoming_resource_score.update!(featured_order: current_featured_order, featured: true) - resulting_resource_scores << incoming_resource_score - else - # Incoming ResourceScore exists and is already at the correct position - incoming_resource_score.update!(featured: true) - resulting_resource_scores << incoming_resource_score - next - end - elsif current_resource_score_at_position - # There is a ResourceScore at this position, update it to the new resource_id - current_resource_score_at_position.update!(resource_id: resource_id, featured: true) - resulting_resource_scores << current_resource_score_at_position + scores_by_resource_id = current_scores.index_by(&:resource_id) + incoming_resources.each_with_index do |resource_id, index| + relevant_score = scores_by_resource_id[resource_id] + if relevant_score + mu_log("found relevant score for resourceID=#{resource_id}, updating featured and featured_order. Initial state: #{relevant_score.attributes}") + relevant_score.update!(featured: true, featured_order: index + 1) else - # No ResourceScore at this position, create a new one - resulting_resource_scores << ResourceScore.create!( + mu_log("no relevant score for resourceID=#{resource_id}, creating new score") + ResourceScore.create!( resource_id: resource_id, - language_id: language.id, country: country, - featured: true, - featured_order: current_featured_order + language_id: language.id, + featured_order: index + 1, + featured: true ) end end - - # Soft-delete any current scores that are not in the incoming resources list current_scores.each do |rs| - soft_delete_resource_score(rs) unless incoming_resources.include?(rs.resource_id) + soft_delete_featured_score(rs) unless incoming_resources.include?(rs.resource_id) end end + + resulting_resource_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id, featured: true) + .where(resources: { resource_type_id: resource_type.id }) + .order(:featured_order) + + mu_log( + 'Resulting resource scores: ' \ + "#{resulting_resource_scores.map do |rs| + { + id: rs.id, + resource_id: rs.resource_id, + resource_name: rs.resource.name, + featured_order: rs.featured_order, + score: rs.score + } + end}" + ) + render json: resulting_resource_scores, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + rescue StandardError => e + # TODO: remove error log + Rails.logger.error("[mass_update] ERROR #{e.class}: #{e.message}") + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end def mass_update_ranked country = params.dig(:data, :attributes, :country)&.downcase lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] - resulting_resource_scores = [] - unless country.present? && lang_code.present? && resource_type.present? - raise "Country, Lang, and Resource Type should be provided" + mu_log("BEGIN mass update RANKED, requested rankings: #{incoming_resources}") + + unless country.present? && lang_code.present? && resource_type_name.present? + raise 'Country, Language, and Resource Type should be provided' end - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? - current_scores = ResourceScore.where( - country: country, language_id: language.id - ).order(score: :desc) + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? - if resource_type.present? - current_scores = current_scores.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" end - current_scores = current_scores.to_a - - if incoming_resources.empty? - current_scores.each do |rs| - rs.update!(score: nil) - end + current_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: { resource_type_id: resource_type.id }) + .order(score: :desc) + .lock + .to_a + + mu_log( + "Got current scores for country='#{country}', " \ + "lang='#{lang_code}', " \ + "resource_type='#{resource_type_name}': " \ + "#{current_scores.map do |rs| + { id: rs.id, resource_id: rs.resource_id, featured_order: rs.featured_order, score: rs.score } + end}" + ) - return render json: current_scores, include: params[:include], status: :ok - end + symbolized_incoming_resources = incoming_resources.map { |r| r.to_unsafe_h.deep_symbolize_keys } ResourceScore.transaction do - incoming_resources.each do |incoming_resource| - symbolized_incoming_resource = incoming_resource - resource_id = symbolized_incoming_resource[:resource_id] - score = symbolized_incoming_resource[:score] - incoming_resource_score = current_scores.find { |rs| rs.resource_id == resource_id } - - if incoming_resource_score - # Update existing ResourceScore with new score - incoming_resource_score.update!(score: score) - resulting_resource_scores << incoming_resource_score + scores_by_resource_id = current_scores.index_by(&:resource_id) + + symbolized_incoming_resources.each do |incoming_resource| + resource_id = incoming_resource[:resource_id] + score = incoming_resource[:score] + + relevant_score = scores_by_resource_id[resource_id] + + if relevant_score + mu_log("found relevant score for resourceID=#{resource_id}, updating score to #{score}. Initial state: #{relevant_score.attributes}") + relevant_score.update!(score: score) else - # Create new ResourceScore with the provided score - new_resource_score = ResourceScore.create!( + mu_log("no relevant score for resourceID=#{resource_id}, creating new score") + ResourceScore.create!( resource_id: resource_id, - language_id: language.id, country: country, + language_id: language.id, score: score ) - resulting_resource_scores << new_resource_score end end + preserved_resource_ids = symbolized_incoming_resources + .filter_map { |r| r[:resource_id] if r[:score].present? } + .to_set + + current_scores.each do |rs| + soft_delete_ranked_score(rs) unless preserved_resource_ids.include?(rs.resource_id) + end end - # Sort resulting_resource_scores by score descending before rendering - resulting_resource_scores.sort_by! { |rs| -rs.score.to_i } + resulting_resource_scores = ResourceScore + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: { resource_type_id: resource_type.id }) + .where.not(score: nil) + .order(score: :desc) + + mu_log( + 'Resulting resource scores: ' \ + "#{resulting_resource_scores.map do |rs| + { + id: rs.id, + resource_id: rs.resource_id, + featured_order: rs.featured_order, + score: rs.score + } + end}" + ) render json: resulting_resource_scores, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end private @@ -223,26 +257,40 @@ def all_resource_scores(lang_code:, country:, resource_type: nil) scope = ResourceScore.all if lang_code.present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first - scope = scope.left_joins(:language).where(languages: {id: language.id}) if language.present? + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code).first + scope = scope.left_joins(:language).where(languages: { id: language.id }) if language.present? end - scope = scope.where("LOWER(country) = LOWER(?)", country) if country.present? + scope = scope.where('LOWER(country) = LOWER(?)', country) if country.present? if resource_type.present? scope = scope.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) + .where(resource_types: { name: resource_type.downcase }) end - scope.order("featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC") + scope.order('featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC') end - def soft_delete_resource_score(resource_score) + def soft_delete_featured_score(resource_score) return if resource_score.nil? if resource_score.score.present? + mu_log("Detected 'score' for resourceID=#{resource_score.resource_id}, unfeaturing ResourceScore") resource_score.update!(featured: false, featured_order: nil) else + mu_log("No 'score' detected for resourceID=#{resource_score.resource_id}, deleting ResourceScore") + resource_score.destroy! + end + end + + def soft_delete_ranked_score(resource_score) + return if resource_score.nil? + + if resource_score.featured_order.present? + mu_log("Detected featured resource for resourceID=#{resource_score.resource_id}, updating score to nil") + resource_score.update!(score: nil) + else + mu_log("Featured resource NOT detected for resourceID=#{resource_score.resource_id}, deleting ResourceScore") resource_score.destroy! end end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index b900f9c47..f520e7262 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -35,14 +35,14 @@ def featured resource_type: params.dig(:filter, :resource_type) || params[:resource_type] ) - render json: featured_resources, include: params[:include], status: :ok + render json: featured_resources, include: params[:include], fields: field_params, status: :ok end def default_order lang = params.dig(:filter, :lang) || params[:lang] if lang.present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first raise "Language not found for code: #{lang}" unless language.present? end @@ -51,29 +51,29 @@ def default_order resource_type: params.dig(:filter, :resource_type) || params[:resource_type] ) - render json: default_order_resources, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + render json: default_order_resources, include: params[:include], fields: field_params, status: :ok + rescue StandardError => e + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end def publish_translation if valid_publish_params? render json: publish_translations, status: :ok else - render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, - status: :unprocessable_content + render json: { errors: { 'errors' => [{ source: { pointer: '/data/attributes/id' }, detail: 'Record not found.' }] } }, + status: :unprocessable_content end end private def valid_publish_params? - params.dig("data", "relationships", "languages", "data") && params["resource_id"] + params.dig('data', 'relationships', 'languages', 'data') && params['resource_id'] end def publish_translations draft_translations = [] - languages = params["data"]["relationships"]["languages"]["data"] + languages = params['data']['relationships']['languages']['data'] languages.each do |lang| draft_translations << publish_translation_for_language(lang) @@ -82,7 +82,7 @@ def publish_translations end def publish_translation_for_language(language_data) - draft_translation = find_or_create_draft_translation(language_data["id"]) + draft_translation = find_or_create_draft_translation(language_data['id']) return unless draft_translation draft_translation.update(publishing_errors: nil) @@ -91,7 +91,7 @@ def publish_translation_for_language(language_data) end def find_or_create_draft_translation(language_id) - resource = Resource.find(params["resource_id"]) + resource = Resource.find(params['resource_id']) draft = resource.create_draft(language_id) if resource draft @@ -99,8 +99,8 @@ def find_or_create_draft_translation(language_id) def cached_index_json cache_key = Resource.index_cache_key(all_resources, - include_param: params[:include], - fields_param: field_params) + include_param: params[:include], + fields_param: field_params) Rails.cache.fetch(cache_key, expires_in: 1.hour) { index_json } end @@ -114,31 +114,31 @@ def index_json def all_resources resources = if params.dig(:filter, :system) - Resource.system_name(params[:filter][:system]) - else - Resource.all - end + Resource.system_name(params[:filter][:system]) + else + Resource.all + end resources = resources.where(abbreviation: params[:filter][:abbreviation]) if params.dig(:filter, :abbreviation) if params.dig(:filter, :resource_type) - resources = resources.joins(:resource_type).where(resource_types: {name: params[:filter][:resource_type].downcase}) + resources = resources.joins(:resource_type).where(resource_types: { name: params[:filter][:resource_type].downcase }) end resources end def all_featured_resources(lang_code:, country:, resource_type: nil) - scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) + scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: { featured: true }) if lang_code.present? language = Language.find_by(code: lang_code.downcase) - scope = scope.joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? + scope = scope.joins(resource_scores: :language).where(languages: { id: language.id }) if language.present? end - scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? + scope = scope.where('resource_scores.country = LOWER(:country)', country:) if country.present? if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) end scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ @@ -150,14 +150,14 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first - scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: { id: language.id }) end if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) end - scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + scope.order('resource_default_orders.position ASC NULLS LAST, resources.created_at DESC') end def load_resource @@ -166,6 +166,6 @@ def load_resource def permitted_params permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, - :metatool_id, :default_variant_id) + :metatool_id, :default_variant_id) end end diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index 574b8e52d..5d372019a 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -6,7 +6,8 @@ class ResourceScore < ApplicationRecord belongs_to :resource belongs_to :language - validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE}, allow_nil: true + validates :score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE }, + allow_nil: true validates :featured_order, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_FEATURED_ORDER_POSITION }, allow_nil: true @@ -14,21 +15,23 @@ class ResourceScore < ApplicationRecord validates :country, presence: true validate :resource_uniquness_per_country_lang_and_resource_type validate :featured_has_order_assigned - validate :featured_order_is_available_for_country_lang_and_resource_type, if: -> { featured && featured_order.present? } + validate :featured_order_is_available_for_country_lang_and_resource_type, if: lambda { + featured && featured_order.present? + } before_save :downcase_country after_commit :clear_resource_cache - after_commit :touch_resource, on: [:create, :update] + after_commit :touch_resource, on: %i[create update] private def resource_uniquness_per_country_lang_and_resource_type existing = ResourceScore.joins(:resource).where( - country: country, language_id: language_id, resource_id: resource_id, resources: {resource_type_id: resource.resource_type_id} + country: country, language_id: language_id, resource_id: resource_id, resources: { resource_type_id: resource.resource_type_id } ).where.not(id:) return unless existing.exists? - errors.add(:resource_id, "should have only one score per country, language and resource type") + errors.add(:resource_id, 'should have only one score per country, language and resource type') end def downcase_country @@ -38,22 +41,22 @@ def downcase_country def featured_has_order_assigned return unless featured && featured_order.nil? - errors.add(:featured_order, "must be present if resource is featured") + errors.add(:featured_order, 'must be present if resource is featured') end def featured_order_is_available_for_country_lang_and_resource_type existing = ResourceScore.joins(:resource) - .where(country: country, language_id: language_id, featured_order: featured_order) - .where.not(id:) - .where(resources: {resource_type_id: resource.resource_type_id}) + .where(country: country, language_id: language_id, featured_order: featured_order) + .where.not(id:) + .where(resources: { resource_type_id: resource.resource_type_id }) return unless existing.exists? - errors.add(:featured_order, "is already taken for this country, language and resource type") + errors.add(:featured_order, 'is already taken for this country, language and resource type') end def clear_resource_cache - Rails.cache.delete_matched("cache::resources/*") - Rails.cache.delete_matched("resources/*") + Rails.cache.delete_matched('cache::resources/*') + Rails.cache.delete_matched('resources/*') end def touch_resource diff --git a/db/schema.rb b/db/schema.rb index dc9dd2f32..bb118e5dd 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.1].define(version: 2025_11_24_224217) do +ActiveRecord::Schema[7.2].define(version: 2025_11_24_224217) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" From 1c13db55fd6ed5ac55684dd4496303c28c281712 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Thu, 29 Jan 2026 01:33:11 -0600 Subject: [PATCH 32/38] Refactored ResourceDefaultOrdersController. Updated model validations for ResourceScore and ResourceDefaultOrder. Performed associated changes in /spec. Need to remove logs and comments --- .../resource_default_orders_controller.rb | 178 ++++++++++-------- app/controllers/resource_scores_controller.rb | 48 ++++- app/models/resource_default_order.rb | 27 ++- app/models/resource_score.rb | 50 ++--- spec/models/resource_default_order_spec.rb | 21 ++- spec/models/resource_score_spec.rb | 78 +++++--- 6 files changed, 249 insertions(+), 153 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index bde04f3ac..4e184dba4 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -8,138 +8,164 @@ def index resource_type = params.dig(:filter, :resource_type) || params[:resource_type] if lang.present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first raise "Language not found for code: #{lang}" unless language.present? end default_order_resources = all_default_order_resources(lang: lang, resource_type: resource_type) render json: default_order_resources, include: params[:include], status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end def create sanitized_params = create_params if create_params[:lang].present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", - lang: create_params[:lang]).first + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order = ResourceDefaultOrder.new(sanitized_params) @resource_default_order.language = language if language.present? @resource_default_order.save! render json: @resource_default_order, status: :created - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content end def destroy @resource_default_order = ResourceDefaultOrder.find(params[:id]) @resource_default_order.destroy! render json: {}, status: :ok - rescue - render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, - status: :unprocessable_content + rescue StandardError + render json: { errors: [{ source: { pointer: '/data/attributes/id' }, detail: e.message }] }, + status: :unprocessable_content end def update @resource_default_order = ResourceDefaultOrder.find(params[:id]) sanitized_params = create_params if create_params[:lang].present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", - lang: create_params[:lang]).first + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order.language = language if language.present? @resource_default_order.update!(sanitized_params) render json: @resource_default_order, status: :ok - rescue => e - render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content + rescue StandardError => e + render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + end + + # TODO: remove logs + def mu_log(msg) + Rails.logger.debug("[mass_update_default] #{msg}") end def mass_update lang_code = params.dig(:data, :attributes, :lang)&.downcase - resource_type = params.dig(:data, :attributes, :resource_type) - incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] - resulting_resource_default_orders = [] + resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase + incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] - raise "Lang and Resource Type should be provided" unless lang_code.present? && resource_type.present? + mu_log("BEGIN mass update, requested resource IDs: #{incoming_resource_ids}") - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + raise 'Language and Resource Type should be provided' unless lang_code.present? && resource_type_name.present? + + language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? - current_orders = ResourceDefaultOrder.where(language_id: language.id).order(position: :asc) + resource_type = ResourceType.find_by(name: resource_type_name) + raise "ResourceType '#{resource_type_name}' not found" unless resource_type.present? - if resource_type.present? - current_orders = current_orders.joins(resource: :resource_type) - .where(resource_types: {name: resource_type.downcase}) + unless %w[lesson tract].include?(resource_type.name.downcase) + raise "ResourceType '#{resource_type_name}' is not supported" end - current_orders = current_orders.to_a + unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) + raise 'resource_ids is expected to be an array of integers' + end - if incoming_resources.empty? - current_orders.each do |ro| - ro.destroy! - end - current_orders.reject! { |ro| !ro.persisted? } + raise 'resource_ids is expected to include a maximum of 9 ids' if incoming_resource_ids.length > 9 + + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise 'resource_ids cannot contain duplicate ids' + end - return render json: current_orders, status: :ok + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(', ')})" # rubocop:disable Layout/LineLength end + current_default_orders = ResourceDefaultOrder + .joins(:resource) + .where(language_id: language.id) + .where(resources: { resource_type_id: resource_type.id }) + .order(position: :asc) + .lock + .to_a + + mu_log( + "Got current orders for language='#{lang_code}', " \ + "resource_type='#{resource_type_name}': " \ + "#{current_default_orders.map do |rs| + { id: rs.id, resource_id: rs.resource_id, position: rs.position } + end}" + ) + ResourceDefaultOrder.transaction do - incoming_resources.each_with_index do |resource_id, index| - current_position = index + 1 - - if resource_id.nil? - # Remove any existing resource default order at this position - resource_order_to_remove = current_orders.find { |ro| ro.position == current_position } - resource_order_to_remove&.destroy! - next - end + mu_log('Setting position=nil for all current orders') + current_default_orders.each do |ro| + # Temporary nil assignment to avoid uniqueness validation conflicts + ro.update_column(:position, nil) + end - incoming_resource_order = current_orders.find { |ro| ro.resource_id == resource_id } - current_resource_order_at_position = current_orders.find { |ro| ro.position == current_position } - - if incoming_resource_order - if incoming_resource_order.position != current_position - # Incoming ResourceDefaultOrder exists but at a different position - # Remove ResourceDefaultOrder currently at this position, if any - if current_resource_order_at_position - current_resource_order_at_position.destroy! - current_orders.reject! { |ro| ro.id == current_resource_order_at_position.id } - end - - # Move incoming ResourceDefaultOrder to the new position - incoming_resource_order.update!(position: current_position) - resulting_resource_default_orders << incoming_resource_order - else - # Incoming ResourceDefaultOrder exists and is already at the correct position - resulting_resource_default_orders << incoming_resource_order - next - end - elsif current_resource_order_at_position - # There is a ResourceDefaultOrder at this position, update it to the new resource_id - current_resource_order_at_position.update!(resource_id: resource_id) - resulting_resource_default_orders << current_resource_order_at_position + orders_by_resource_id = current_default_orders.index_by(&:resource_id) + incoming_resource_ids.each_with_index do |resource_id, index| + relevant_order = orders_by_resource_id[resource_id] + + if relevant_order + mu_log("found relevant default order for resourceID=#{resource_id}, updating to position=#{index + 1}") + relevant_order.update!(position: index + 1) else - # No ResourceDefaultOrder at this position, create a new one - resulting_resource_default_orders << ResourceDefaultOrder.create!( + mu_log("no relevant default order for resourceID=#{resource_id}, creating new order") + ResourceDefaultOrder.create!( resource_id: resource_id, language_id: language.id, - position: current_position + position: index + 1 ) end end + current_default_orders.each do |ro| + unless incoming_resource_ids.include?(ro.resource_id) + mu_log("Deleting default order for resource_id #{ro.resource_id}") + end - # Destroy any current orders that are not in the incoming resources list - current_orders.each do |ro| - ro.destroy! unless incoming_resources.include?(ro.resource_id) + ro.destroy! unless incoming_resource_ids.include?(ro.resource_id) end + + resulting_default_orders = ResourceDefaultOrder + .joins(:resource) + .where(language_id: language.id) + .where(resources: { resource_type_id: resource_type.id }) + .order(position: :asc) + + mu_log( + 'Resulting default orders: ' \ + "#{resulting_default_orders.map do |ro| + { + id: ro.id, + resource_id: ro.resource_id, + position: ro.resource.name + } + end}" + ) + + render json: resulting_default_orders, status: :ok + rescue StandardError => e + render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content end - render json: resulting_resource_default_orders, status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private @@ -148,15 +174,15 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first - scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) + language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: { id: language.id }) end if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) + scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) end - scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") + scope.order('resource_default_orders.position ASC NULLS LAST, resources.created_at DESC') end def create_params diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index 92f6766cf..3d656eaf4 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -60,9 +60,9 @@ def mass_update country = params.dig(:data, :attributes, :country)&.downcase lang_code = params.dig(:data, :attributes, :lang)&.downcase resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase - incoming_resources = params.dig(:data, :attributes, :resource_ids) || [] + incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] - mu_log("BEGIN mass update, requested resource IDs: #{incoming_resources}") + mu_log("BEGIN mass update, requested resource IDs: #{incoming_resource_ids}") unless country.present? && lang_code.present? && resource_type_name.present? raise 'Country, Language, and Resource Type should be provided' @@ -78,6 +78,23 @@ def mass_update raise "ResourceType '#{resource_type_name}' is not supported" end + unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) + raise 'resource_ids is expected to be an array of integers' + end + + raise 'resource_ids is expected to include a maximum of 9 ids' if incoming_resource_ids.length > 9 + + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise 'resource_ids cannot contain duplicate ids' + end + + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise %(Resources not found or do not match the provided resource type. + Invalid IDs: #{invalid_resource_ids.join(', ')}) + end + current_scores = ResourceScore .joins(:resource) .where(country: country, language_id: language.id) @@ -102,7 +119,7 @@ def mass_update end scores_by_resource_id = current_scores.index_by(&:resource_id) - incoming_resources.each_with_index do |resource_id, index| + incoming_resource_ids.each_with_index do |resource_id, index| relevant_score = scores_by_resource_id[resource_id] if relevant_score mu_log("found relevant score for resourceID=#{resource_id}, updating featured and featured_order. Initial state: #{relevant_score.attributes}") @@ -119,7 +136,7 @@ def mass_update end end current_scores.each do |rs| - soft_delete_featured_score(rs) unless incoming_resources.include?(rs.resource_id) + soft_delete_featured_score(rs) unless incoming_resource_ids.include?(rs.resource_id) end end @@ -153,9 +170,10 @@ def mass_update_ranked country = params.dig(:data, :attributes, :country)&.downcase lang_code = params.dig(:data, :attributes, :lang)&.downcase resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase - incoming_resources = params.dig(:data, :attributes, :ranked_resources) || [] + incoming_resource_array = params.dig(:data, :attributes, :ranked_resources) || [] + symbolized_incoming_resource_array = incoming_resource_array.map { |r| r.to_unsafe_h.deep_symbolize_keys } - mu_log("BEGIN mass update RANKED, requested rankings: #{incoming_resources}") + mu_log("BEGIN mass update RANKED, requested rankings: #{incoming_resource_array}") unless country.present? && lang_code.present? && resource_type_name.present? raise 'Country, Language, and Resource Type should be provided' @@ -171,6 +189,18 @@ def mass_update_ranked raise "ResourceType '#{resource_type_name}' is not supported" end + incoming_resource_ids = symbolized_incoming_resource_array.map { |r| r[:resource_id] } + if incoming_resource_ids.uniq.length != incoming_resource_ids.length + raise 'resource_ids cannot contain duplicate ids' + end + + valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) + invalid_resource_ids = incoming_resource_ids - valid_resource_ids + if invalid_resource_ids.any? + raise "Resources not found or do not match the provided resource type. + Invalid resource ids: #{invalid_resource_ids.join(', ')}" + end + current_scores = ResourceScore .joins(:resource) .where(country: country, language_id: language.id) @@ -188,12 +218,10 @@ def mass_update_ranked end}" ) - symbolized_incoming_resources = incoming_resources.map { |r| r.to_unsafe_h.deep_symbolize_keys } - ResourceScore.transaction do scores_by_resource_id = current_scores.index_by(&:resource_id) - symbolized_incoming_resources.each do |incoming_resource| + symbolized_incoming_resource_array.each do |incoming_resource| resource_id = incoming_resource[:resource_id] score = incoming_resource[:score] @@ -212,7 +240,7 @@ def mass_update_ranked ) end end - preserved_resource_ids = symbolized_incoming_resources + preserved_resource_ids = symbolized_incoming_resource_array .filter_map { |r| r[:resource_id] if r[:score].present? } .to_set diff --git a/app/models/resource_default_order.rb b/app/models/resource_default_order.rb index d0e66d945..1209b8f57 100644 --- a/app/models/resource_default_order.rb +++ b/app/models/resource_default_order.rb @@ -1,19 +1,36 @@ class ResourceDefaultOrder < ApplicationRecord + MAX_DEFAULT_ORDER_POSITION = 9 belongs_to :resource belongs_to :language - validates :position, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 1} - validates :resource_id, presence: true, uniqueness: {scope: :language_id, message: "should have only one resource per language"} + validates :resource_id, presence: true, + uniqueness: { scope: :language_id, + message: 'should have only one ResourceDefaultOrder per language' } validates :language, presence: true + validates :position, presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 1, + less_than_or_equal_to: MAX_DEFAULT_ORDER_POSITION } + + validate :unique_position_per_language_and_resource_type after_commit :clear_resource_cache - after_commit :touch_resource, on: [:create, :update] + after_commit :touch_resource, on: %i[create update] private + def unique_position_per_language_and_resource_type + existing = ResourceDefaultOrder.joins(:resource) + .where(language_id: language_id, position: position) + .where(resources: { resource_type_id: resource.resource_type_id }) + .where.not(id:) + return unless existing.exists? + + errors.add(:position, 'is already taken for this language and resource type') + end + def clear_resource_cache - Rails.cache.delete_matched("cache::resources/*") - Rails.cache.delete_matched("resources/*") + Rails.cache.delete_matched('cache::resources/*') + Rails.cache.delete_matched('resources/*') end def touch_resource diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index 5d372019a..7f1bc3f9e 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -1,23 +1,25 @@ # frozen_string_literal: true class ResourceScore < ApplicationRecord - MAX_FEATURED_ORDER_POSITION = 10 + MAX_FEATURED_ORDER_POSITION = 9 MAX_SCORE = 20 belongs_to :resource belongs_to :language - validates :score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE }, - allow_nil: true + validates :resource_id, presence: true + validates :country, presence: true + validates :language, presence: true validates :featured_order, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_FEATURED_ORDER_POSITION }, allow_nil: true - validates :resource_id, presence: true - validates :country, presence: true - validate :resource_uniquness_per_country_lang_and_resource_type - validate :featured_has_order_assigned - validate :featured_order_is_available_for_country_lang_and_resource_type, if: lambda { + validates :score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE }, + allow_nil: true + + validate :unique_resource_score_per_country_and_language + validate :unique_featured_order_per_country_language_and_resource_type, if: lambda { featured && featured_order.present? } + validate :featured_and_featured_order_consistency before_save :downcase_country after_commit :clear_resource_cache @@ -25,35 +27,39 @@ class ResourceScore < ApplicationRecord private - def resource_uniquness_per_country_lang_and_resource_type - existing = ResourceScore.joins(:resource).where( - country: country, language_id: language_id, resource_id: resource_id, resources: { resource_type_id: resource.resource_type_id } - ).where.not(id:) - return unless existing.exists? - - errors.add(:resource_id, 'should have only one score per country, language and resource type') - end - def downcase_country self.country = country.downcase if country.present? end - def featured_has_order_assigned - return unless featured && featured_order.nil? + def unique_resource_score_per_country_and_language + existing = ResourceScore.where( + country: country, + language_id: language_id, + resource_id: resource_id + ).where.not(id:) + return unless existing.exists? - errors.add(:featured_order, 'must be present if resource is featured') + errors.add(:resource_id, 'should have only one ResourceScore per country and language') end - def featured_order_is_available_for_country_lang_and_resource_type + def unique_featured_order_per_country_language_and_resource_type existing = ResourceScore.joins(:resource) .where(country: country, language_id: language_id, featured_order: featured_order) - .where.not(id:) .where(resources: { resource_type_id: resource.resource_type_id }) + .where.not(id:) return unless existing.exists? errors.add(:featured_order, 'is already taken for this country, language and resource type') end + def featured_and_featured_order_consistency + if featured && featured_order.nil? + errors.add(:featured_order, 'must be present if resource is featured') + elsif !featured && featured_order.present? + errors.add(:featured, 'must be true if a featured_order is assigned') + end + end + def clear_resource_cache Rails.cache.delete_matched('cache::resources/*') Rails.cache.delete_matched('resources/*') diff --git a/spec/models/resource_default_order_spec.rb b/spec/models/resource_default_order_spec.rb index 0e94eb2a3..e90d3d0ba 100644 --- a/spec/models/resource_default_order_spec.rb +++ b/spec/models/resource_default_order_spec.rb @@ -1,25 +1,28 @@ -require "rails_helper" +# frozen_string_literal: true + +require 'rails_helper' RSpec.describe ResourceDefaultOrder, type: :model do let(:resource) { Resource.first } - let(:language) { Language.find_or_create_by!(code: "en", name: "English") } - let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + let(:language) { Language.find_or_create_by!(code: 'en', name: 'English') } + let(:other_language) { Language.find_or_create_by!(code: 'fr', name: 'French') } subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource, language: language) } - describe "validations" do + describe 'validations' do it { is_expected.to be_valid } - context "uniqueness validation" do + context 'uniqueness validation' do before { FactoryBot.create(:resource_default_order, resource: resource, language: language, position: 1) } - it "validates uniqueness of position scoped to language_id" do + it 'validates uniqueness of default order per language' do duplicate = FactoryBot.build(:resource_default_order, resource: resource, language: language, position: 1) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include("should have only one resource per language") + expect(duplicate.errors[:resource_id]).to include('should have only one ResourceDefaultOrder per language') end - it "allows same position for different language" do - different_lang = FactoryBot.build(:resource_default_order, resource: resource, language: other_language, position: 1) + it 'allows same position for different language' do + different_lang = FactoryBot.build(:resource_default_order, resource: resource, language: other_language, + position: 1) expect(different_lang).to be_valid end end diff --git a/spec/models/resource_score_spec.rb b/spec/models/resource_score_spec.rb index 7b6da2624..348dd92fe 100644 --- a/spec/models/resource_score_spec.rb +++ b/spec/models/resource_score_spec.rb @@ -1,83 +1,99 @@ # frozen_string_literal: true -require "rails_helper" +require 'rails_helper' RSpec.describe ResourceScore, type: :model do let(:resource) { Resource.first } - let(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } - let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } + let(:language_en) { Language.find_or_create_by!(code: 'en', name: 'English') } + let(:other_language) { Language.find_or_create_by!(code: 'fr', name: 'French') } subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource, language: language_en) } - describe "validations" do + describe 'validations' do let(:resource_score_with_resource) do FactoryBot.create( - :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", language: language_en + :resource_score, resource: resource, featured: true, featured_order: 1, country: 'US', language: language_en ) end it { is_expected.to be_valid } - context "uniqueness validation" do + context 'uniqueness validation' do let!(:previous_resource_score) do - FactoryBot.create(:resource_score, resource: resource, country: "us", language: language_en) + FactoryBot.create(:resource_score, resource: resource, country: 'us', language: language_en) end - it "validates uniqueness of resource_id scoped to country, language and resource_type" do - duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", language: language_en) + it 'validates uniqueness of resource_id scoped to country and language' do + duplicate = FactoryBot.build(:resource_score, resource: resource, country: 'us', language: language_en) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include("should have only one score per country, language and resource type") + expect(duplicate.errors[:resource_id]).to include('should have only one ResourceScore per country and language') end end - context "featured validation" do - it "requires featured_order when featured is true" do + context 'featured validation' do + it 'requires featured_order when featured is true' do resource_score.featured = true resource_score.featured_order = nil expect(resource_score).not_to be_valid - expect(resource_score.errors[:featured_order]).to include("must be present if resource is featured") + expect(resource_score.errors[:featured_order]).to include('must be present if resource is featured') end - it "validates uniqueness of featured_order within country, language and resource type" do + it 'requires featured to be true if featured_order is assigned' do + resource_score.featured = false + resource_score.featured_order = 1 + expect(resource_score).not_to be_valid + expect(resource_score.errors[:featured]).to include('must be true if a featured_order is assigned') + end + + it 'validates uniqueness of featured_order within country, language and resource type' do resource_score_with_resource - duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", language: language_en) + duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: 'us', + language: language_en) expect(duplicate).not_to be_valid - expect(duplicate.errors[:featured_order]).to include("is already taken for this country, language and resource type") + expect(duplicate.errors[:featured_order]).to + include('is already taken for this country, language and resource type') end - context "having a resource score created previously" do + context 'having a resource score created previously' do let!(:previous_resource_score) do - ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", language: language_en) + ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: 'us', + language: language_en) end - it "allows same featured_order for different country" do + it 'allows same featured_order for different country' do resource2 = Resource.last different_country = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "CA", language: language_en) + country: 'CA', language: language_en) expect(different_country).to be_valid end - it "allows same featured_order for different language" do + it 'allows same featured_order for different language' do resource2 = Resource.last different_lang = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: "US", language: other_language) + country: 'US', language: other_language) expect(different_lang).to be_valid end end - it "allows same featured_order for different resources" do + it 'allows same featured_order for different resource type' do resource_score_with_resource - different_resource = FactoryBot.build(:resource_score, resource: Resource.last, featured: true, - featured_order: 1, country: "US", language: language_en) - expect(different_resource).to be_valid + different_resource_type = FactoryBot.build( + :resource_score, + resource: FactoryBot.create(:resource, resource_type: ResourceType.find_by(name: 'lesson')), + featured: true, + featured_order: 1, + country: 'US', + language: language_en + ) + expect(different_resource_type).to be_valid end end end - describe "callbacks" do - context "before_save" do - it "downcases country" do - resource_score.country = "US" + describe 'callbacks' do + context 'before_save' do + it 'downcases country' do + resource_score.country = 'US' resource_score.save - expect(resource_score.country).to eq("us") + expect(resource_score.country).to eq('us') end end end From dcf8a57a10cb9fd953d14f0199f24e5942883a31 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Thu, 29 Jan 2026 19:40:32 -0600 Subject: [PATCH 33/38] Removed comments and logs. Ran linting commands. Made rspec happy except for 4 Failures in user_counters_controller.spec --- .env | 5 - Gemfile.lock | 36 ++-- .../resource_default_orders_controller.rb | 106 ++++-------- app/controllers/resource_scores_controller.rb | 163 ++++++------------ app/controllers/resources_controller.rb | 50 +++--- app/models/resource_default_order.rb | 20 +-- app/models/resource_score.rb | 22 +-- db/schema.rb | 2 +- ...resource_default_orders_controller_spec.rb | 68 ++------ .../resource_scores_controller_spec.rb | 42 +---- spec/factories/resource_default_orders.rb | 2 +- spec/factories/resource_scores.rb | 2 +- spec/factories/resource_types.rb | 5 + spec/models/resource_default_order_spec.rb | 18 +- spec/models/resource_score_spec.rb | 69 ++++---- 15 files changed, 220 insertions(+), 390 deletions(-) diff --git a/.env b/.env index 9ee81fa59..7fc3a09ee 100644 --- a/.env +++ b/.env @@ -38,11 +38,6 @@ OKTA_SERVER_URL=https://dev1-signon.okta.com OKTA_SERVER_PATH=https://dev1-signon.okta.com OKTA_SERVER_AUDIENCE=https://dev1-signon.okta.com -# TODO: ensure 'production' okta env vars are removed before git push -# OKTA_SERVER_URL=https://signon.okta.com -# OKTA_SERVER_PATH=https://signon.okta.com -# OKTA_SERVER_AUDIENCE=0oa1ll7gg0vVRLctz0h8 - FACEBOOK_APP_ID=facebook_app_id FACEBOOK_APP_SECRET=facebook_app_secret diff --git a/Gemfile.lock b/Gemfile.lock index 02fec5965..b8031ff49 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,7 +156,7 @@ GEM bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) - brakeman (7.1.1) + brakeman (8.0.1) racc builder (3.3.0) bundler-audit (0.9.2) @@ -165,6 +165,7 @@ GEM byebug (12.0.0) case_transform (0.2) activesupport + cgi (0.5.1) coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.4) @@ -176,13 +177,14 @@ GEM open-uri (>= 0.1.0, < 0.2.0) rest-client (>= 2.0.0, < 2.2.0) csv (3.3.5) - datadog (2.22.0) - datadog-ruby_core_source (~> 3.4, >= 3.4.1) - libdatadog (~> 22.0.1.1.0) - libddwaf (~> 1.25.1.1.0) + datadog (2.27.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.1) + libdatadog (~> 25.0.0.1.0) + libddwaf (~> 1.30.0.0.0) logger msgpack - datadog-ruby_core_source (3.4.1) + datadog-ruby_core_source (3.5.2) date (3.4.1) debug (1.11.0) irb (~> 1.10) @@ -218,7 +220,6 @@ GEM ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) - ffi (1.17.2-x86_64-linux-musl) file_validators (3.0.0) activemodel (>= 3.2) @@ -301,9 +302,9 @@ GEM jwt (2.10.2) base64 language_server-protocol (3.17.0.5) - libdatadog (22.0.1.1.0) - libdatadog (22.0.1.1.0-x86_64-linux) - libddwaf (1.25.1.1.0) + libdatadog (25.0.0.1.0) + libdatadog (25.0.0.1.0-x86_64-linux) + libddwaf (1.30.0.0.0-arm64-darwin) ffi (~> 1.0) libddwaf (1.30.0.0.0-x86_64-darwin) ffi (~> 1.0) @@ -335,7 +336,6 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (5.25.5) msgpack (1.8.0) multi_json (1.17.0) @@ -738,11 +738,12 @@ CHECKSUMS bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.19.0) sha256=d3e54558c1a9ea10cb095eb1eb8e921ae83fd4d5764b8809f63aec18ce9f60b5 - brakeman (7.1.1) sha256=629426b5d6496c75e3ffa2299e1ab1bb3ba721fea03d8808414c083660439498 + brakeman (8.0.1) sha256=c68ce0ac35a6295027c4eab8b4ac597d2a0bfc82f0d62dcd334bbf944d352f70 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.2) sha256=73051daa09865c436450a35c4d87ceef15f3f9777f4aad4fd015cc6f1f2b1048 byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e case_transform (0.2) sha256=e2ad4418dceeb227cf474cc332cd5004c95c136c04186c1cceaad8ab8de6fe3b + cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9 coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6 connection_pool (2.5.4) sha256=e9e1922327416091f3f6542f5f4446c2a20745276b9aa796dd0bb2fd0ea1e70a @@ -750,8 +751,8 @@ CHECKSUMS crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d crowdin-api (1.13.0) sha256=89437e113ec6d16bd15358bb4fa585e003d9a2fa6bde3af9fc683dbc454b9939 csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f - datadog (2.22.0) sha256=ca35af33be9bdfe5595f93b38f69f89ec04e0b27d4cb59bb814a47e1b421c885 - datadog-ruby_core_source (3.4.1) sha256=fa40f1c3c8f764b6651a6443382b57d39aeb3c9f94b5af98f499bcfc678a2fb9 + datadog (2.27.0) sha256=64570e6073268b1cd65e3ef3a40c2817168ec05ff93965662d45b6dca0a74935 + datadog-ruby_core_source (3.5.2) sha256=c379c012eca11d6c58b112d716a986d051fb566a436d58f46cde7cfec43cb299 date (3.4.1) sha256=bf268e14ef7158009bfeaec40b5fa3c7271906e88b196d958a89d4b408abe64f debug (1.11.0) sha256=1425db64cfa0130c952684e3dc974985be201dd62899bf4bbe3f8b5d6cf1aef2 declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 @@ -803,9 +804,9 @@ CHECKSUMS jsonapi-renderer (0.2.2) sha256=b5c44b033d61b4abdb6500fa4ab84807ca0b36ea0e59e47a2c3ca7095a6e447b jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - libdatadog (24.0.1.1.0) sha256=5f911a5598deb11f8c4179077a46e50a57b778b87a152bf23859ae6e557b1751 - libdatadog (24.0.1.1.0-x86_64-linux) sha256=d15aea8b2be3e45ecfd9dec4ebc53b4c178e8540dee59c40cf2500bed67aacde - libddwaf (1.30.0.0.0) sha256=d5c350555ec5bdfb99534b37ad578163c83642ca03cecb3bae30fd29dc47d4fc + libdatadog (25.0.0.1.0) sha256=73e687f68d928c75f01f56cf6e9ee9cd3d6438f1da9add89d02b3845cf8e8f43 + libdatadog (25.0.0.1.0-x86_64-linux) sha256=260d78ab20bf5b5db310aade93900a02adf18434a5c0b28fec4d05db54f7c206 + libddwaf (1.30.0.0.0-arm64-darwin) sha256=b4997ab7e8a4c41fa3993b00d89c209dfbed4d5ac5c03fc1dd70464185ffe193 libddwaf (1.30.0.0.0-x86_64-darwin) sha256=749989cf5b3ad3689969019706875260ffff5a91ebafcbf44766a3d635a0ac90 libddwaf (1.30.0.0.0-x86_64-linux) sha256=c8d7e1e097ed12bcdf5d4bcb716edcb98e72733c80dd884e0ee83a7cc43d6ae0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 @@ -820,7 +821,6 @@ CHECKSUMS mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 mime-types-data (3.2025.0924) sha256=f276bca15e59f35767cbcf2bc10e023e9200b30bd6a572c1daf7f4cc24994728 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 minitest (5.25.5) sha256=391b6c6cb43a4802bfb7c93af1ebe2ac66a210293f4a3fb7db36f2fc7dc2c756 msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 multi_json (1.17.0) sha256=76581f6c96aebf2e85f8a8b9854829e0988f335e8671cd1a56a1036eb75e4a1b diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index 4e184dba4..89fb40fbb 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -8,59 +8,54 @@ def index resource_type = params.dig(:filter, :resource_type) || params[:resource_type] if lang.present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first raise "Language not found for code: #{lang}" unless language.present? end default_order_resources = all_default_order_resources(lang: lang, resource_type: resource_type) render json: default_order_resources, include: params[:include], status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end def create sanitized_params = create_params if create_params[:lang].present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', - lang: create_params[:lang]).first + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order = ResourceDefaultOrder.new(sanitized_params) @resource_default_order.language = language if language.present? @resource_default_order.save! render json: @resource_default_order, status: :created - rescue StandardError => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def destroy @resource_default_order = ResourceDefaultOrder.find(params[:id]) @resource_default_order.destroy! render json: {}, status: :ok - rescue StandardError - render json: { errors: [{ source: { pointer: '/data/attributes/id' }, detail: e.message }] }, - status: :unprocessable_content + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content end def update @resource_default_order = ResourceDefaultOrder.find(params[:id]) sanitized_params = create_params if create_params[:lang].present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', - lang: create_params[:lang]).first + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_default_order.language = language if language.present? @resource_default_order.update!(sanitized_params) render json: @resource_default_order, status: :ok - rescue StandardError => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content - end - - # TODO: remove logs - def mu_log(msg) - Rails.logger.debug("[mass_update_default] #{msg}") + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def mass_update @@ -68,11 +63,9 @@ def mass_update resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] - mu_log("BEGIN mass update, requested resource IDs: #{incoming_resource_ids}") - - raise 'Language and Resource Type should be provided' unless lang_code.present? && resource_type_name.present? + raise "Language and Resource Type should be provided" unless lang_code.present? && resource_type_name.present? - language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? resource_type = ResourceType.find_by(name: resource_type_name) @@ -83,41 +76,31 @@ def mass_update end unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) - raise 'resource_ids is expected to be an array of integers' + raise "resource_ids is expected to be an array of integers" end - raise 'resource_ids is expected to include a maximum of 9 ids' if incoming_resource_ids.length > 9 + raise "resource_ids is expected to include a maximum of 9 ids" if incoming_resource_ids.length > 9 if incoming_resource_ids.uniq.length != incoming_resource_ids.length - raise 'resource_ids cannot contain duplicate ids' + raise "resource_ids cannot contain duplicate ids" end valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) invalid_resource_ids = incoming_resource_ids - valid_resource_ids if invalid_resource_ids.any? - raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(', ')})" # rubocop:disable Layout/LineLength + raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(", ")})" # rubocop:disable Layout/LineLength end current_default_orders = ResourceDefaultOrder - .joins(:resource) - .where(language_id: language.id) - .where(resources: { resource_type_id: resource_type.id }) - .order(position: :asc) - .lock - .to_a - - mu_log( - "Got current orders for language='#{lang_code}', " \ - "resource_type='#{resource_type_name}': " \ - "#{current_default_orders.map do |rs| - { id: rs.id, resource_id: rs.resource_id, position: rs.position } - end}" - ) + .joins(:resource) + .where(language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(position: :asc) + .lock + .to_a ResourceDefaultOrder.transaction do - mu_log('Setting position=nil for all current orders') current_default_orders.each do |ro| - # Temporary nil assignment to avoid uniqueness validation conflicts ro.update_column(:position, nil) end @@ -126,10 +109,8 @@ def mass_update relevant_order = orders_by_resource_id[resource_id] if relevant_order - mu_log("found relevant default order for resourceID=#{resource_id}, updating to position=#{index + 1}") relevant_order.update!(position: index + 1) else - mu_log("no relevant default order for resourceID=#{resource_id}, creating new order") ResourceDefaultOrder.create!( resource_id: resource_id, language_id: language.id, @@ -138,33 +119,18 @@ def mass_update end end current_default_orders.each do |ro| - unless incoming_resource_ids.include?(ro.resource_id) - mu_log("Deleting default order for resource_id #{ro.resource_id}") - end - ro.destroy! unless incoming_resource_ids.include?(ro.resource_id) end resulting_default_orders = ResourceDefaultOrder - .joins(:resource) - .where(language_id: language.id) - .where(resources: { resource_type_id: resource_type.id }) - .order(position: :asc) - - mu_log( - 'Resulting default orders: ' \ - "#{resulting_default_orders.map do |ro| - { - id: ro.id, - resource_id: ro.resource_id, - position: ro.resource.name - } - end}" - ) + .joins(:resource) + .where(language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(position: :asc) render json: resulting_default_orders, status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end end @@ -174,15 +140,15 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first - scope = scope.joins(resource_default_orders: :language).where(languages: { id: language.id }) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) end - scope.order('resource_default_orders.position ASC NULLS LAST, resources.created_at DESC') + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") end def create_params diff --git a/app/controllers/resource_scores_controller.rb b/app/controllers/resource_scores_controller.rb index 3d656eaf4..4f53d9f43 100644 --- a/app/controllers/resource_scores_controller.rb +++ b/app/controllers/resource_scores_controller.rb @@ -22,25 +22,25 @@ def create @resource_score.language = language if language.present? @resource_score.save! render json: @resource_score, status: :created - rescue StandardError => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content + rescue => e + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def destroy @resource_score = ResourceScore.find(params[:id]) @resource_score.destroy! render json: {}, status: :ok - rescue StandardError - render json: { errors: [{ source: { pointer: '/data/attributes/id' }, detail: e.message }] }, - status: :unprocessable_content + rescue + render json: {errors: [{source: {pointer: "/data/attributes/id"}, detail: e.message}]}, + status: :unprocessable_content end def update @resource_score = ResourceScore.find(params[:id]) sanitized_params = create_params if create_params[:lang].present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', - lang: create_params[:lang]).first + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", + lang: create_params[:lang]).first end sanitized_params.delete(:lang) if sanitized_params[:lang].present? @resource_score.language = language if language.present? @@ -48,12 +48,7 @@ def update render json: @resource_score, status: :ok rescue ActiveRecord::RecordInvalid => e - render json: { errors: formatted_errors('record_invalid', e) }, status: :unprocessable_content - end - - # TODO: remove logs - def mu_log(msg) - Rails.logger.debug("[mass_update] #{msg}") + render json: {errors: formatted_errors("record_invalid", e)}, status: :unprocessable_content end def mass_update @@ -62,13 +57,11 @@ def mass_update resource_type_name = params.dig(:data, :attributes, :resource_type)&.downcase incoming_resource_ids = params.dig(:data, :attributes, :resource_ids) || [] - mu_log("BEGIN mass update, requested resource IDs: #{incoming_resource_ids}") - unless country.present? && lang_code.present? && resource_type_name.present? - raise 'Country, Language, and Resource Type should be provided' + raise "Country, Language, and Resource Type should be provided" end - language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? resource_type = ResourceType.find_by(name: resource_type_name) @@ -79,41 +72,31 @@ def mass_update end unless incoming_resource_ids.is_a?(Array) && incoming_resource_ids.all?(Integer) - raise 'resource_ids is expected to be an array of integers' + raise "resource_ids is expected to be an array of integers" end - raise 'resource_ids is expected to include a maximum of 9 ids' if incoming_resource_ids.length > 9 + raise "resource_ids is expected to include a maximum of 9 ids" if incoming_resource_ids.length > 9 if incoming_resource_ids.uniq.length != incoming_resource_ids.length - raise 'resource_ids cannot contain duplicate ids' + raise "resource_ids cannot contain duplicate ids" end valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) invalid_resource_ids = incoming_resource_ids - valid_resource_ids if invalid_resource_ids.any? raise %(Resources not found or do not match the provided resource type. - Invalid IDs: #{invalid_resource_ids.join(', ')}) + Invalid IDs: #{invalid_resource_ids.join(", ")}) end current_scores = ResourceScore - .joins(:resource) - .where(country: country, language_id: language.id) - .where(resources: { resource_type_id: resource_type.id }) - .order(:featured_order) - .lock - .to_a - - mu_log( - "Got current scores for country='#{country}', " \ - "lang='#{lang_code}', " \ - "resource_type='#{resource_type_name}': " \ - "#{current_scores.map do |rs| - { id: rs.id, resource_id: rs.resource_id, featured_order: rs.featured_order, score: rs.score } - end}" - ) + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(:featured_order) + .lock + .to_a ResourceScore.transaction do - mu_log('Setting featured=false and featured_order=nil for all current scores') current_scores.each do |rs| rs.update!(featured: false, featured_order: nil) end @@ -122,10 +105,8 @@ def mass_update incoming_resource_ids.each_with_index do |resource_id, index| relevant_score = scores_by_resource_id[resource_id] if relevant_score - mu_log("found relevant score for resourceID=#{resource_id}, updating featured and featured_order. Initial state: #{relevant_score.attributes}") relevant_score.update!(featured: true, featured_order: index + 1) else - mu_log("no relevant score for resourceID=#{resource_id}, creating new score") ResourceScore.create!( resource_id: resource_id, country: country, @@ -141,29 +122,14 @@ def mass_update end resulting_resource_scores = ResourceScore - .joins(:resource) - .where(country: country, language_id: language.id, featured: true) - .where(resources: { resource_type_id: resource_type.id }) - .order(:featured_order) - - mu_log( - 'Resulting resource scores: ' \ - "#{resulting_resource_scores.map do |rs| - { - id: rs.id, - resource_id: rs.resource_id, - resource_name: rs.resource.name, - featured_order: rs.featured_order, - score: rs.score - } - end}" - ) + .joins(:resource) + .where(country: country, language_id: language.id, featured: true) + .where(resources: {resource_type_id: resource_type.id}) + .order(:featured_order) render json: resulting_resource_scores, include: params[:include], status: :ok - rescue StandardError => e - # TODO: remove error log - Rails.logger.error("[mass_update] ERROR #{e.class}: #{e.message}") - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end def mass_update_ranked @@ -173,13 +139,11 @@ def mass_update_ranked incoming_resource_array = params.dig(:data, :attributes, :ranked_resources) || [] symbolized_incoming_resource_array = incoming_resource_array.map { |r| r.to_unsafe_h.deep_symbolize_keys } - mu_log("BEGIN mass update RANKED, requested rankings: #{incoming_resource_array}") - unless country.present? && lang_code.present? && resource_type_name.present? - raise 'Country, Language, and Resource Type should be provided' + raise "Country, Language, and Resource Type should be provided" end - language = Language.find_by('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code) + language = Language.find_by("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code) raise "Language not found for code: #{lang_code}" unless language.present? resource_type = ResourceType.find_by(name: resource_type_name) @@ -191,32 +155,23 @@ def mass_update_ranked incoming_resource_ids = symbolized_incoming_resource_array.map { |r| r[:resource_id] } if incoming_resource_ids.uniq.length != incoming_resource_ids.length - raise 'resource_ids cannot contain duplicate ids' + raise "resource_ids cannot contain duplicate ids" end valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) invalid_resource_ids = incoming_resource_ids - valid_resource_ids if invalid_resource_ids.any? raise "Resources not found or do not match the provided resource type. - Invalid resource ids: #{invalid_resource_ids.join(', ')}" + Invalid resource ids: #{invalid_resource_ids.join(", ")}" end current_scores = ResourceScore - .joins(:resource) - .where(country: country, language_id: language.id) - .where(resources: { resource_type_id: resource_type.id }) - .order(score: :desc) - .lock - .to_a - - mu_log( - "Got current scores for country='#{country}', " \ - "lang='#{lang_code}', " \ - "resource_type='#{resource_type_name}': " \ - "#{current_scores.map do |rs| - { id: rs.id, resource_id: rs.resource_id, featured_order: rs.featured_order, score: rs.score } - end}" - ) + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(score: :desc) + .lock + .to_a ResourceScore.transaction do scores_by_resource_id = current_scores.index_by(&:resource_id) @@ -228,10 +183,8 @@ def mass_update_ranked relevant_score = scores_by_resource_id[resource_id] if relevant_score - mu_log("found relevant score for resourceID=#{resource_id}, updating score to #{score}. Initial state: #{relevant_score.attributes}") relevant_score.update!(score: score) else - mu_log("no relevant score for resourceID=#{resource_id}, creating new score") ResourceScore.create!( resource_id: resource_id, country: country, @@ -241,8 +194,8 @@ def mass_update_ranked end end preserved_resource_ids = symbolized_incoming_resource_array - .filter_map { |r| r[:resource_id] if r[:score].present? } - .to_set + .filter_map { |r| r[:resource_id] if r[:score].present? } + .to_set current_scores.each do |rs| soft_delete_ranked_score(rs) unless preserved_resource_ids.include?(rs.resource_id) @@ -250,27 +203,15 @@ def mass_update_ranked end resulting_resource_scores = ResourceScore - .joins(:resource) - .where(country: country, language_id: language.id) - .where(resources: { resource_type_id: resource_type.id }) - .where.not(score: nil) - .order(score: :desc) - - mu_log( - 'Resulting resource scores: ' \ - "#{resulting_resource_scores.map do |rs| - { - id: rs.id, - resource_id: rs.resource_id, - featured_order: rs.featured_order, - score: rs.score - } - end}" - ) + .joins(:resource) + .where(country: country, language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .where.not(score: nil) + .order(score: :desc) render json: resulting_resource_scores, include: params[:include], status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private @@ -285,28 +226,26 @@ def all_resource_scores(lang_code:, country:, resource_type: nil) scope = ResourceScore.all if lang_code.present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang_code).first - scope = scope.left_joins(:language).where(languages: { id: language.id }) if language.present? + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang_code).first + scope = scope.left_joins(:language).where(languages: {id: language.id}) if language.present? end - scope = scope.where('LOWER(country) = LOWER(?)', country) if country.present? + scope = scope.where("LOWER(country) = LOWER(?)", country) if country.present? if resource_type.present? scope = scope.joins(resource: :resource_type) - .where(resource_types: { name: resource_type.downcase }) + .where(resource_types: {name: resource_type.downcase}) end - scope.order('featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC') + scope.order("featured_order ASC, featured DESC NULLS LAST, score DESC NULLS LAST, created_at DESC") end def soft_delete_featured_score(resource_score) return if resource_score.nil? if resource_score.score.present? - mu_log("Detected 'score' for resourceID=#{resource_score.resource_id}, unfeaturing ResourceScore") resource_score.update!(featured: false, featured_order: nil) else - mu_log("No 'score' detected for resourceID=#{resource_score.resource_id}, deleting ResourceScore") resource_score.destroy! end end @@ -315,10 +254,8 @@ def soft_delete_ranked_score(resource_score) return if resource_score.nil? if resource_score.featured_order.present? - mu_log("Detected featured resource for resourceID=#{resource_score.resource_id}, updating score to nil") resource_score.update!(score: nil) else - mu_log("Featured resource NOT detected for resourceID=#{resource_score.resource_id}, deleting ResourceScore") resource_score.destroy! end end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index f520e7262..b7631dea9 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -42,7 +42,7 @@ def default_order lang = params.dig(:filter, :lang) || params[:lang] if lang.present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first raise "Language not found for code: #{lang}" unless language.present? end @@ -52,28 +52,28 @@ def default_order ) render json: default_order_resources, include: params[:include], fields: field_params, status: :ok - rescue StandardError => e - render json: { errors: [{ detail: "Error: #{e.message}" }] }, status: :unprocessable_content + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end def publish_translation if valid_publish_params? render json: publish_translations, status: :ok else - render json: { errors: { 'errors' => [{ source: { pointer: '/data/attributes/id' }, detail: 'Record not found.' }] } }, - status: :unprocessable_content + render json: {errors: {"errors" => [{source: {pointer: "/data/attributes/id"}, detail: "Record not found."}]}}, + status: :unprocessable_content end end private def valid_publish_params? - params.dig('data', 'relationships', 'languages', 'data') && params['resource_id'] + params.dig("data", "relationships", "languages", "data") && params["resource_id"] end def publish_translations draft_translations = [] - languages = params['data']['relationships']['languages']['data'] + languages = params["data"]["relationships"]["languages"]["data"] languages.each do |lang| draft_translations << publish_translation_for_language(lang) @@ -82,7 +82,7 @@ def publish_translations end def publish_translation_for_language(language_data) - draft_translation = find_or_create_draft_translation(language_data['id']) + draft_translation = find_or_create_draft_translation(language_data["id"]) return unless draft_translation draft_translation.update(publishing_errors: nil) @@ -91,7 +91,7 @@ def publish_translation_for_language(language_data) end def find_or_create_draft_translation(language_id) - resource = Resource.find(params['resource_id']) + resource = Resource.find(params["resource_id"]) draft = resource.create_draft(language_id) if resource draft @@ -99,8 +99,8 @@ def find_or_create_draft_translation(language_id) def cached_index_json cache_key = Resource.index_cache_key(all_resources, - include_param: params[:include], - fields_param: field_params) + include_param: params[:include], + fields_param: field_params) Rails.cache.fetch(cache_key, expires_in: 1.hour) { index_json } end @@ -114,31 +114,31 @@ def index_json def all_resources resources = if params.dig(:filter, :system) - Resource.system_name(params[:filter][:system]) - else - Resource.all - end + Resource.system_name(params[:filter][:system]) + else + Resource.all + end resources = resources.where(abbreviation: params[:filter][:abbreviation]) if params.dig(:filter, :abbreviation) if params.dig(:filter, :resource_type) - resources = resources.joins(:resource_type).where(resource_types: { name: params[:filter][:resource_type].downcase }) + resources = resources.joins(:resource_type).where(resource_types: {name: params[:filter][:resource_type].downcase}) end resources end def all_featured_resources(lang_code:, country:, resource_type: nil) - scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: { featured: true }) + scope = Resource.includes(:resource_scores).left_joins(:resource_scores).where(resource_scores: {featured: true}) if lang_code.present? language = Language.find_by(code: lang_code.downcase) - scope = scope.joins(resource_scores: :language).where(languages: { id: language.id }) if language.present? + scope = scope.joins(resource_scores: :language).where(languages: {id: language.id}) if language.present? end - scope = scope.where('resource_scores.country = LOWER(:country)', country:) if country.present? + scope = scope.where("resource_scores.country = LOWER(:country)", country:) if country.present? if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) end scope.order("resource_scores.featured_order ASC, resource_scores.featured DESC NULLS LAST, \ @@ -150,14 +150,14 @@ def all_default_order_resources(lang:, resource_type: nil) scope = Resource.joins(:resource_default_orders) if lang.present? - language = Language.where('code = :lang OR LOWER(code) = LOWER(:lang)', lang: lang).first - scope = scope.joins(resource_default_orders: :language).where(languages: { id: language.id }) + language = Language.where("code = :lang OR LOWER(code) = LOWER(:lang)", lang: lang).first + scope = scope.joins(resource_default_orders: :language).where(languages: {id: language.id}) end if resource_type.present? - scope = scope.joins(:resource_type).where(resource_types: { name: resource_type.downcase }) + scope = scope.joins(:resource_type).where(resource_types: {name: resource_type.downcase}) end - scope.order('resource_default_orders.position ASC NULLS LAST, resources.created_at DESC') + scope.order("resource_default_orders.position ASC NULLS LAST, resources.created_at DESC") end def load_resource @@ -166,6 +166,6 @@ def load_resource def permitted_params permit_params(:name, :abbreviation, :manifest, :crowdin_project_id, :system_id, :description, :resource_type_id, - :metatool_id, :default_variant_id) + :metatool_id, :default_variant_id) end end diff --git a/app/models/resource_default_order.rb b/app/models/resource_default_order.rb index 1209b8f57..8188ae4d6 100644 --- a/app/models/resource_default_order.rb +++ b/app/models/resource_default_order.rb @@ -4,12 +4,12 @@ class ResourceDefaultOrder < ApplicationRecord belongs_to :language validates :resource_id, presence: true, - uniqueness: { scope: :language_id, - message: 'should have only one ResourceDefaultOrder per language' } + uniqueness: {scope: :language_id, + message: "should have only one ResourceDefaultOrder per language"} validates :language, presence: true validates :position, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 1, - less_than_or_equal_to: MAX_DEFAULT_ORDER_POSITION } + numericality: {only_integer: true, greater_than_or_equal_to: 1, + less_than_or_equal_to: MAX_DEFAULT_ORDER_POSITION} validate :unique_position_per_language_and_resource_type @@ -20,17 +20,17 @@ class ResourceDefaultOrder < ApplicationRecord def unique_position_per_language_and_resource_type existing = ResourceDefaultOrder.joins(:resource) - .where(language_id: language_id, position: position) - .where(resources: { resource_type_id: resource.resource_type_id }) - .where.not(id:) + .where(language_id: language_id, position: position) + .where(resources: {resource_type_id: resource.resource_type_id}) + .where.not(id:) return unless existing.exists? - errors.add(:position, 'is already taken for this language and resource type') + errors.add(:position, "is already taken for this language and resource type") end def clear_resource_cache - Rails.cache.delete_matched('cache::resources/*') - Rails.cache.delete_matched('resources/*') + Rails.cache.delete_matched("cache::resources/*") + Rails.cache.delete_matched("resources/*") end def touch_resource diff --git a/app/models/resource_score.rb b/app/models/resource_score.rb index 7f1bc3f9e..e17db1816 100644 --- a/app/models/resource_score.rb +++ b/app/models/resource_score.rb @@ -12,8 +12,8 @@ class ResourceScore < ApplicationRecord validates :featured_order, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_FEATURED_ORDER_POSITION }, allow_nil: true - validates :score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE }, - allow_nil: true + validates :score, numericality: {only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: MAX_SCORE}, + allow_nil: true validate :unique_resource_score_per_country_and_language validate :unique_featured_order_per_country_language_and_resource_type, if: lambda { @@ -39,30 +39,30 @@ def unique_resource_score_per_country_and_language ).where.not(id:) return unless existing.exists? - errors.add(:resource_id, 'should have only one ResourceScore per country and language') + errors.add(:resource_id, "should have only one ResourceScore per country and language") end def unique_featured_order_per_country_language_and_resource_type existing = ResourceScore.joins(:resource) - .where(country: country, language_id: language_id, featured_order: featured_order) - .where(resources: { resource_type_id: resource.resource_type_id }) - .where.not(id:) + .where(country: country, language_id: language_id, featured_order: featured_order) + .where(resources: {resource_type_id: resource.resource_type_id}) + .where.not(id:) return unless existing.exists? - errors.add(:featured_order, 'is already taken for this country, language and resource type') + errors.add(:featured_order, "is already taken for this country, language and resource type") end def featured_and_featured_order_consistency if featured && featured_order.nil? - errors.add(:featured_order, 'must be present if resource is featured') + errors.add(:featured_order, "must be present if resource is featured") elsif !featured && featured_order.present? - errors.add(:featured, 'must be true if a featured_order is assigned') + errors.add(:featured, "must be true if a featured_order is assigned") end end def clear_resource_cache - Rails.cache.delete_matched('cache::resources/*') - Rails.cache.delete_matched('resources/*') + Rails.cache.delete_matched("cache::resources/*") + Rails.cache.delete_matched("resources/*") end def touch_resource diff --git a/db/schema.rb b/db/schema.rb index bb118e5dd..dc9dd2f32 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.2].define(version: 2025_11_24_224217) do +ActiveRecord::Schema[7.1].define(version: 2025_11_24_224217) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index 2847f532b..2d486c077 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -11,8 +11,8 @@ let(:raw_post) { params.to_json } let(:authorization) { AuthToken.generic_token } - let!(:resource) { Resource.first } - let!(:other_resource) { Resource.second } + let!(:resource) { Resource.find_by(id: 1) } + let!(:other_resource) { Resource.find_by(id: 2) } let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } @@ -159,16 +159,6 @@ expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) end end - - context "with invalid parameters" do - it "returns unprocessable entity" do - do_request(data: {attributes: {type: "resource_default_order"}}) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json).to have_key("errors") - end - end end delete "resource_default_orders/:id" do @@ -261,20 +251,16 @@ let(:lang) { nil } context "when sending an empty array" do - it "returns an error" do - do_request(params) - - expect(status).to be(422) + it "raises an error" do + expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") end end context "when sending 1 resource default order" do let(:resource_ids) { [resource.id] } - it "returns an error" do - do_request(params) - - expect(status).to be(422) + it "raises an error" do + expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") end end end @@ -284,12 +270,7 @@ context "when sending an empty array" do it "returns an error" do - do_request(params) - - expect(status).to be(422) - - json = JSON.parse(response_body) - expect(json["errors"][0]["detail"]).to include("Resource Type") + expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") end end @@ -297,11 +278,7 @@ let(:resource_ids) { [resource.id] } it "returns an error" do - do_request(params) - - expect(status).to be(422) - json = JSON.parse(response_body) - expect(json["errors"][0]["detail"]).to include("Resource Type") + expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") end end end @@ -333,10 +310,7 @@ end context "when sending more than 1 resource default order" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end + let(:resource2) { other_resource } let(:resource_ids) { [resource.id, resource2.id] } it "returns an array with more than 1 resource default order" do @@ -354,14 +328,8 @@ end context "with previous resource default orders" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let!(:resource3) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, [resource.id, resource2.id]).first - end + let!(:resource2) { other_resource } + let!(:resource3) { Resource.find_by(id: 5) } let!(:resource_default_order) do FactoryBot.create(:resource_default_order, resource: resource, language: language_en, position: 1) end @@ -433,20 +401,6 @@ end end - context "when sending nil resource_id at a position" do - let(:resource_ids) { [resource.id, nil] } - - it "removes the resource default order at that position" do - do_request(params) - - expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) - expect(json["data"][0]["attributes"]["position"]).to eq(1) - end - end - context "when omitting a resource from the incoming list" do let(:resource_ids) { [resource.id] } diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb index e98b5dea4..b5da94fc0 100644 --- a/spec/acceptance/resource_scores_controller_spec.rb +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -11,8 +11,10 @@ let(:raw_post) { params.to_json } let(:authorization) { AuthToken.generic_token } - let!(:resource) { Resource.first } - let!(:unfeatured_resource) { Resource.last } + let!(:resource) { Resource.find_by(id: 1) } + let!(:resource2) { Resource.find_by(id: 2) } + let!(:resource3) { Resource.find_by(id: 5) } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } @@ -382,10 +384,6 @@ end context "when sending more than 1 resource score" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end let(:resource_ids) { [resource.id, resource2.id] } it "returns an array with more than 1 resource score" do @@ -401,14 +399,6 @@ end context "with previous resource scores" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let!(:resource3) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, [resource.id, resource2.id]).first - end let!(:resource_score) do ResourceScore.create!(resource: resource, country: country, language: language_en, featured: true, featured_order: 1) @@ -441,10 +431,10 @@ expect(status).to be(200) json = JSON.parse(response_body) - expect(json["data"].count).to eq(1) - expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) + expect(json["data"]).to be_empty resource_score.reload + expect(resource_score.featured).to be false expect(resource_score.featured_order).to be_nil expect(resource_score.score).to eq(5) @@ -642,10 +632,6 @@ end context "when sending more than 1 ranked resource" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name != ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } it "returns an array with more than 1 resource score" do @@ -671,14 +657,6 @@ end context "with previous resource scores" do - let!(:resource2) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, resource.id).first - end - let!(:resource3) do - Resource.joins(:resource_type).where("resource_types.name = ? AND resources.id NOT IN (?)", - resource.resource_type.name, [resource.id, resource2.id]).first - end let!(:resource_score) do ResourceScore.create!(resource: resource, country: country, language: language_en, score: 15) end @@ -693,13 +671,9 @@ do_request(params) expect(status).to be(200) - json = JSON.parse(response_body) - expect(json["data"].count).to eq(2) - resource_score.reload - resource_score2.reload - expect(resource_score.score).to be_nil - expect(resource_score2.score).to be_nil + json = JSON.parse(response_body) + expect(json["data"]).to be_empty end end diff --git a/spec/factories/resource_default_orders.rb b/spec/factories/resource_default_orders.rb index 19448a8b4..107c725ab 100644 --- a/spec/factories/resource_default_orders.rb +++ b/spec/factories/resource_default_orders.rb @@ -2,6 +2,6 @@ factory :resource_default_order do resource language { Language.find_by(code: "en") || FactoryBot.create(:language, code: "en") } - sequence(:position) { |n| n } + sequence(:position) { |n| (n % 9) + 1 } end end diff --git a/spec/factories/resource_scores.rb b/spec/factories/resource_scores.rb index 88de49c61..bb02386da 100644 --- a/spec/factories/resource_scores.rb +++ b/spec/factories/resource_scores.rb @@ -4,7 +4,7 @@ factory :resource_score do resource language { association :language, code: "en" } - featured { false } + featured { true } featured_order { 1 } country { "us" } score { 1 } diff --git a/spec/factories/resource_types.rb b/spec/factories/resource_types.rb index 79242129b..7717a9620 100644 --- a/spec/factories/resource_types.rb +++ b/spec/factories/resource_types.rb @@ -5,6 +5,11 @@ dtd_file { "tract.xsd" } end + factory :lesson_resource_type do + name { "lesson" } + dtd_file { "lesson.xsd" } + end + factory :article_resource_type do name { "article" } dtd_file { "article.xsd" } diff --git a/spec/models/resource_default_order_spec.rb b/spec/models/resource_default_order_spec.rb index e90d3d0ba..5309542c4 100644 --- a/spec/models/resource_default_order_spec.rb +++ b/spec/models/resource_default_order_spec.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe ResourceDefaultOrder, type: :model do let(:resource) { Resource.first } - let(:language) { Language.find_or_create_by!(code: 'en', name: 'English') } - let(:other_language) { Language.find_or_create_by!(code: 'fr', name: 'French') } + let(:language) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } subject(:resource_default_order) { FactoryBot.build(:resource_default_order, resource: resource, language: language) } - describe 'validations' do + describe "validations" do it { is_expected.to be_valid } - context 'uniqueness validation' do + context "uniqueness validation" do before { FactoryBot.create(:resource_default_order, resource: resource, language: language, position: 1) } - it 'validates uniqueness of default order per language' do + it "validates uniqueness of default order per language" do duplicate = FactoryBot.build(:resource_default_order, resource: resource, language: language, position: 1) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include('should have only one ResourceDefaultOrder per language') + expect(duplicate.errors[:resource_id]).to include("should have only one ResourceDefaultOrder per language") end - it 'allows same position for different language' do + it "allows same position for different language" do different_lang = FactoryBot.build(:resource_default_order, resource: resource, language: other_language, - position: 1) + position: 1) expect(different_lang).to be_valid end end diff --git a/spec/models/resource_score_spec.rb b/spec/models/resource_score_spec.rb index 348dd92fe..915d94278 100644 --- a/spec/models/resource_score_spec.rb +++ b/spec/models/resource_score_spec.rb @@ -1,86 +1,85 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe ResourceScore, type: :model do let(:resource) { Resource.first } - let(:language_en) { Language.find_or_create_by!(code: 'en', name: 'English') } - let(:other_language) { Language.find_or_create_by!(code: 'fr', name: 'French') } + let(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } + let(:other_language) { Language.find_or_create_by!(code: "fr", name: "French") } subject(:resource_score) { FactoryBot.build(:resource_score, resource: resource, language: language_en) } - describe 'validations' do + describe "validations" do let(:resource_score_with_resource) do FactoryBot.create( - :resource_score, resource: resource, featured: true, featured_order: 1, country: 'US', language: language_en + :resource_score, resource: resource, featured: true, featured_order: 1, country: "US", language: language_en ) end it { is_expected.to be_valid } - context 'uniqueness validation' do + context "uniqueness validation" do let!(:previous_resource_score) do - FactoryBot.create(:resource_score, resource: resource, country: 'us', language: language_en) + FactoryBot.create(:resource_score, resource: resource, country: "us", language: language_en) end - it 'validates uniqueness of resource_id scoped to country and language' do - duplicate = FactoryBot.build(:resource_score, resource: resource, country: 'us', language: language_en) + it "validates uniqueness of resource_id scoped to country and language" do + duplicate = FactoryBot.build(:resource_score, resource: resource, country: "us", language: language_en) expect(duplicate).not_to be_valid - expect(duplicate.errors[:resource_id]).to include('should have only one ResourceScore per country and language') + expect(duplicate.errors[:resource_id]).to include("should have only one ResourceScore per country and language") end end - context 'featured validation' do - it 'requires featured_order when featured is true' do + context "featured validation" do + it "requires featured_order when featured is true" do resource_score.featured = true resource_score.featured_order = nil expect(resource_score).not_to be_valid - expect(resource_score.errors[:featured_order]).to include('must be present if resource is featured') + expect(resource_score.errors[:featured_order]).to include("must be present if resource is featured") end - it 'requires featured to be true if featured_order is assigned' do + it "requires featured to be true if featured_order is assigned" do resource_score.featured = false resource_score.featured_order = 1 expect(resource_score).not_to be_valid - expect(resource_score.errors[:featured]).to include('must be true if a featured_order is assigned') + expect(resource_score.errors[:featured]).to include("must be true if a featured_order is assigned") end - it 'validates uniqueness of featured_order within country, language and resource type' do + it "validates uniqueness of featured_order within country, language and resource type" do resource_score_with_resource - duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: 'us', - language: language_en) + duplicate = ResourceScore.new(resource: resource, featured: true, featured_order: 1, country: "us", + language: language_en) expect(duplicate).not_to be_valid - expect(duplicate.errors[:featured_order]).to - include('is already taken for this country, language and resource type') + expect(duplicate.errors[:featured_order]).to include("is already taken for this country, language and resource type") end - context 'having a resource score created previously' do + context "having a resource score created previously" do let!(:previous_resource_score) do - ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: 'us', - language: language_en) + ResourceScore.create(resource: resource, featured: true, featured_order: 1, country: "us", + language: language_en) end - it 'allows same featured_order for different country' do + it "allows same featured_order for different country" do resource2 = Resource.last different_country = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: 'CA', language: language_en) + country: "CA", language: language_en) expect(different_country).to be_valid end - it 'allows same featured_order for different language' do + it "allows same featured_order for different language" do resource2 = Resource.last different_lang = FactoryBot.build(:resource_score, resource: resource2, featured: true, featured_order: 1, - country: 'US', language: other_language) + country: "US", language: other_language) expect(different_lang).to be_valid end end - it 'allows same featured_order for different resource type' do + it "allows same featured_order for different resource type" do resource_score_with_resource different_resource_type = FactoryBot.build( :resource_score, - resource: FactoryBot.create(:resource, resource_type: ResourceType.find_by(name: 'lesson')), + resource: FactoryBot.create(:resource, resource_type: FactoryBot.create(:lesson_resource_type)), featured: true, featured_order: 1, - country: 'US', + country: "US", language: language_en ) expect(different_resource_type).to be_valid @@ -88,12 +87,12 @@ end end - describe 'callbacks' do - context 'before_save' do - it 'downcases country' do - resource_score.country = 'US' + describe "callbacks" do + context "before_save" do + it "downcases country" do + resource_score.country = "US" resource_score.save - expect(resource_score.country).to eq('us') + expect(resource_score.country).to eq("us") end end end From a6631c567a48cc57bcbd222a5e676eae3fc85ebd Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Fri, 30 Jan 2026 12:00:27 -0600 Subject: [PATCH 34/38] Clean up spec resources --- .../resource_default_orders_controller_spec.rb | 12 ++++++------ spec/acceptance/resource_scores_controller_spec.rb | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index 2d486c077..304075c5c 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -11,8 +11,11 @@ let(:raw_post) { params.to_json } let(:authorization) { AuthToken.generic_token } - let!(:resource) { Resource.find_by(id: 1) } - let!(:other_resource) { Resource.find_by(id: 2) } + # Seeded 'tract' resources + let!(:resource) { Resource.find_by!(name: "Knowing God Personally") } + let!(:resource2) { Resource.find_by!(name: "Knowing God Personally Variant") } + let!(:resource3) { Resource.find_by!(name: "Satisfied?") } + let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } let!(:language_am) { Language.find_or_create_by!(code: "Am", name: "Amharic") } @@ -24,7 +27,7 @@ get "resource_default_orders" do before do FactoryBot.create(:resource_default_order, resource: resource, language: language_en) - FactoryBot.create(:resource_default_order, resource: other_resource, language: language_en) + FactoryBot.create(:resource_default_order, resource: resource2, language: language_en) end context "without filters" do @@ -310,7 +313,6 @@ end context "when sending more than 1 resource default order" do - let(:resource2) { other_resource } let(:resource_ids) { [resource.id, resource2.id] } it "returns an array with more than 1 resource default order" do @@ -328,8 +330,6 @@ end context "with previous resource default orders" do - let!(:resource2) { other_resource } - let!(:resource3) { Resource.find_by(id: 5) } let!(:resource_default_order) do FactoryBot.create(:resource_default_order, resource: resource, language: language_en, position: 1) end diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb index b5da94fc0..11c6f63fc 100644 --- a/spec/acceptance/resource_scores_controller_spec.rb +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -11,9 +11,10 @@ let(:raw_post) { params.to_json } let(:authorization) { AuthToken.generic_token } - let!(:resource) { Resource.find_by(id: 1) } - let!(:resource2) { Resource.find_by(id: 2) } - let!(:resource3) { Resource.find_by(id: 5) } + # Seeded 'tract' resources + let!(:resource) { Resource.find_by!(name: "Knowing God Personally") } + let!(:resource2) { Resource.find_by!(name: "Knowing God Personally Variant") } + let!(:resource3) { Resource.find_by!(name: "Satisfied?") } let!(:language_en) { Language.find_or_create_by!(code: "en", name: "English") } let!(:language_fr) { Language.find_or_create_by!(code: "fr", name: "French") } From c5d8573b14afacdeb59001b5741cfb88c9cb1890 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Fri, 30 Jan 2026 18:26:45 -0600 Subject: [PATCH 35/38] Removed rubocop ignore comment --- app/controllers/resource_default_orders_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index 89fb40fbb..c569a4dee 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -88,7 +88,7 @@ def mass_update valid_resource_ids = Resource.where(id: incoming_resource_ids, resource_type_id: resource_type.id).pluck(:id) invalid_resource_ids = incoming_resource_ids - valid_resource_ids if invalid_resource_ids.any? - raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(", ")})" # rubocop:disable Layout/LineLength + raise "Resources not found or do not match the provided resource type. Invalid IDs: #{invalid_resource_ids.join(", ")})" end current_default_orders = ResourceDefaultOrder From c8ea1fde119e9d7933c1dedbf4150f4122d90d11 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Fri, 30 Jan 2026 18:31:44 -0600 Subject: [PATCH 36/38] Upgrade brakeman to latest (8.0.1) --- Gemfile.lock | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a5fd3615d..0d369bd7a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,7 +152,7 @@ GEM bindex (0.8.1) bootsnap (1.21.1) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.1) racc builder (3.3.0) bundler-audit (0.9.3) @@ -214,6 +214,7 @@ GEM faraday-net_http (3.4.2) net-http (~> 0.5) ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x86_64-darwin) ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-musl) file_validators (3.0.0) @@ -304,6 +305,8 @@ GEM ffi (~> 1.0) libddwaf (1.30.0.0.0-arm64-darwin) ffi (~> 1.0) + libddwaf (1.30.0.0.0-x86_64-darwin) + ffi (~> 1.0) libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) lint_roller (1.1.0) @@ -359,6 +362,8 @@ GEM nio4r (2.7.5) nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.0-x86_64-darwin) + racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) @@ -391,6 +396,7 @@ GEM ast (~> 2.4.1) racc pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux-musl) pp (0.6.3) @@ -644,6 +650,7 @@ GEM PLATFORMS arm64-darwin + x86_64-darwin-25 x86_64-linux-gnu x86_64-linux-musl @@ -737,7 +744,7 @@ CHECKSUMS bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 - brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce + brakeman (8.0.1) sha256=c68ce0ac35a6295027c4eab8b4ac597d2a0bfc82f0d62dcd334bbf944d352f70 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d @@ -771,6 +778,7 @@ CHECKSUMS faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f + ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5 ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 file_validators (3.0.0) sha256=43700f0c1fe9b235bf7f4701375e1d2f9391352ab26723f123b934724db8067b @@ -805,6 +813,7 @@ CHECKSUMS libdatadog (25.0.0.1.0-x86_64-linux) sha256=260d78ab20bf5b5db310aade93900a02adf18434a5c0b28fec4d05db54f7c206 libddwaf (1.30.0.0.0) sha256=d5c350555ec5bdfb99534b37ad578163c83642ca03cecb3bae30fd29dc47d4fc libddwaf (1.30.0.0.0-arm64-darwin) sha256=b4997ab7e8a4c41fa3993b00d89c209dfbed4d5ac5c03fc1dd70464185ffe193 + libddwaf (1.30.0.0.0-x86_64-darwin) sha256=749989cf5b3ad3689969019706875260ffff5a91ebafcbf44766a3d635a0ac90 libddwaf (1.30.0.0.0-x86_64-linux) sha256=c8d7e1e097ed12bcdf5d4bcb716edcb98e72733c80dd884e0ee83a7cc43d6ae0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 @@ -834,6 +843,7 @@ CHECKSUMS netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 + nokogiri (1.19.0-x86_64-darwin) sha256=1dad56220b603a8edb9750cd95798bffa2b8dd9dd9aa47f664009ee5b43e3067 nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738 @@ -846,6 +856,7 @@ CHECKSUMS parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 From a5289d9b7f5cc7551ebaa34d7dba99b8dd874912 Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Fri, 30 Jan 2026 22:38:48 -0600 Subject: [PATCH 37/38] Add missing specs to pass CodeCov check --- .../resource_default_orders_controller.rb | 19 ++- .../content_status_controller_spec.rb | 14 ++ ...resource_default_orders_controller_spec.rb | 83 +++++++++++- .../resource_scores_controller_spec.rb | 128 +++++++++++++++++- 4 files changed, 228 insertions(+), 16 deletions(-) diff --git a/app/controllers/resource_default_orders_controller.rb b/app/controllers/resource_default_orders_controller.rb index c569a4dee..8e2be0759 100644 --- a/app/controllers/resource_default_orders_controller.rb +++ b/app/controllers/resource_default_orders_controller.rb @@ -121,17 +121,16 @@ def mass_update current_default_orders.each do |ro| ro.destroy! unless incoming_resource_ids.include?(ro.resource_id) end - - resulting_default_orders = ResourceDefaultOrder - .joins(:resource) - .where(language_id: language.id) - .where(resources: {resource_type_id: resource_type.id}) - .order(position: :asc) - - render json: resulting_default_orders, status: :ok - rescue => e - render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end + resulting_default_orders = ResourceDefaultOrder + .joins(:resource) + .where(language_id: language.id) + .where(resources: {resource_type_id: resource_type.id}) + .order(position: :asc) + + render json: resulting_default_orders, status: :ok + rescue => e + render json: {errors: [{detail: "Error: #{e.message}"}]}, status: :unprocessable_content end private diff --git a/spec/acceptance/content_status_controller_spec.rb b/spec/acceptance/content_status_controller_spec.rb index 6a0395384..26e69bee7 100644 --- a/spec/acceptance/content_status_controller_spec.rb +++ b/spec/acceptance/content_status_controller_spec.rb @@ -78,5 +78,19 @@ "last_updated" ) end + + context "when an error occurs" do + before do + allow(Language).to receive(:joins).and_raise("Something went wrong") + end + + it "returns unprocessable entity" do + do_request + + expect(status).to eq(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end end end diff --git a/spec/acceptance/resource_default_orders_controller_spec.rb b/spec/acceptance/resource_default_orders_controller_spec.rb index 304075c5c..ab2d12d55 100644 --- a/spec/acceptance/resource_default_orders_controller_spec.rb +++ b/spec/acceptance/resource_default_orders_controller_spec.rb @@ -162,6 +162,17 @@ expect(json["data"]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) end end + + context "with invalid parameters" do + it "returns unprocessable entity" do + do_request(data: {type: "resource_default_order", + attributes: {resource_id: resource.id, position: "invalid"}}) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json).to have_key("errors") + end + end end delete "resource_default_orders/:id" do @@ -255,7 +266,11 @@ context "when sending an empty array" do it "raises an error" do - expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") end end @@ -263,7 +278,11 @@ let(:resource_ids) { [resource.id] } it "raises an error" do - expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") end end end @@ -273,7 +292,11 @@ context "when sending an empty array" do it "returns an error" do - expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") end end @@ -281,11 +304,26 @@ let(:resource_ids) { [resource.id] } it "returns an error" do - expect { do_request(params) }.to raise_error(RuntimeError, "Language and Resource Type should be provided") + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Language and Resource Type should be provided") end end end + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is not supported") + end + end + context "with lang param" do context "with no previous resource default order" do context "when sending an empty array" do @@ -298,6 +336,18 @@ end end + context "when sending an array of strings" do + let(:resource_ids) { [resource.id.to_s, resource2.id.to_s] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is expected to be an array of integers") + end + end + context "when sending 1 resource default order" do let(:resource_ids) { [resource.id] } @@ -326,6 +376,31 @@ expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) expect(json["data"][1]["attributes"]["position"]).to eq(2) end + + context "when sending duplicate ids" do + let(:resource_ids) { [resource.id, resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end end end diff --git a/spec/acceptance/resource_scores_controller_spec.rb b/spec/acceptance/resource_scores_controller_spec.rb index 11c6f63fc..ecf103ad9 100644 --- a/spec/acceptance/resource_scores_controller_spec.rb +++ b/spec/acceptance/resource_scores_controller_spec.rb @@ -279,7 +279,7 @@ let(:country) { "US" } let(:lang) { "en" } let(:resource_ids) { [] } - let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:resource_type) { ResourceType.find_by!(name: "tract") } let(:featured) { true } let(:params) do {data: {attributes: {country: country, lang: lang, resource_ids: resource_ids, @@ -359,6 +359,17 @@ end end + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is not supported") + end + end + context "with country and lang params" do context "with no previous resource score" do context "when sending an empty array" do @@ -371,6 +382,18 @@ end end + context "when sending an array of strings" do + let(:resource_ids) { [resource.id.to_s, resource2.id.to_s] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("is expected to be an array of integers") + end + end + context "when sending 1 resource score" do let(:resource_ids) { [resource.id] } @@ -396,6 +419,44 @@ expect(json["data"][0]["relationships"]["resource"]["data"]["id"]).to eq(resource.id.to_s) expect(json["data"][1]["relationships"]["resource"]["data"]["id"]).to eq(resource2.id.to_s) end + + context "when sending duplicate ids" do + let(:resource_ids) { [resource.id, resource.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end + + context "when sending resource ids which represent resources of different types" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("do not match") + end + end end end @@ -551,7 +612,7 @@ let(:country) { "US" } let(:lang) { "en" } let(:ranked_resources) { [] } - let(:resource_type) { ResourceType.find(resource.resource_type_id) } + let(:resource_type) { ResourceType.find_by!(name: "tract") } let(:params) do {data: {attributes: {country: country, lang: lang, ranked_resources: ranked_resources, resource_type: resource_type&.name}}} @@ -606,6 +667,18 @@ end end + context "with invalid resource_type param" do + let(:resource_type) { ResourceType.find_by!(name: "article") } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("not supported") + end + end + context "with country and lang params" do context "with no previous resource scores" do context "when sending an empty array" do @@ -635,6 +708,31 @@ context "when sending more than 1 ranked resource" do let(:ranked_resources) { [{resource_id: resource.id, score: 20}, {resource_id: resource2.id, score: 10}] } + context "when sending duplicate ids" do + let(:ranked_resources) { [{resource_id: resource.id, score: 6}, {resource_id: resource.id, score: 7}] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("cannot contain duplicate ids") + end + end + + context "when sending resource ids which correspond to a different resource type" do + let(:resource_type) { FactoryBot.create(:lesson_resource_type) } + let(:resource_ids) { [resource.id, resource2.id] } + + it "returns an error" do + do_request(params) + + expect(status).to be(422) + json = JSON.parse(response_body) + expect(json["errors"][0]["detail"]).to include("Resources not found or do not match the provided resource type") + end + end + it "returns an array with more than 1 resource score" do do_request(params) @@ -676,6 +774,32 @@ json = JSON.parse(response_body) expect(json["data"]).to be_empty end + + context "when existing scores are featured" do + before do + resource_score.update!(featured: true, featured_order: 1) + resource_score2.update!(featured: true, featured_order: 2) + end + + it "soft-deletes the scores" do + do_request(params) + + expect(status).to be(200) + json = JSON.parse(response_body) + expect(json["data"]).to be_empty + + resource_score.reload + resource_score2.reload + + expect(resource_score.score).to be_nil + expect(resource_score.featured).to be true + expect(resource_score.featured_order).to eq(1) + + expect(resource_score2.score).to be_nil + expect(resource_score2.featured).to be true + expect(resource_score2.featured_order).to eq(2) + end + end end context "when sending 1 ranked resource to update" do From e45f3d39daafdc69263a87e8b0800cf57a0e559d Mon Sep 17 00:00:00 2001 From: Jason Bennett Date: Mon, 2 Feb 2026 11:34:28 -0600 Subject: [PATCH 38/38] Switch to generic x86_64-darwin instead of versioned --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0d369bd7a..ba2a784a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -650,7 +650,7 @@ GEM PLATFORMS arm64-darwin - x86_64-darwin-25 + x86_64-darwin x86_64-linux-gnu x86_64-linux-musl