Recommended articles list (#20383)
* Add recommended articles list * Adjust tests * Adjust tests
This commit is contained in:
parent
df0b134ec4
commit
347fd6d112
11 changed files with 329 additions and 1 deletions
|
|
@ -0,0 +1,54 @@
|
|||
module Api
|
||||
module V1
|
||||
class RecommendedArticlesListsController < ApiController
|
||||
before_action :authenticate_with_api_key!
|
||||
before_action :require_admin
|
||||
|
||||
rescue_from ArgumentError, with: :error_unprocessable_entity
|
||||
|
||||
def index
|
||||
@recommended_articles_lists = RecommendedArticlesList.order(id: :desc).page(params[:page]).per(50)
|
||||
if params[:search].present?
|
||||
@recommended_articles_lists = @recommended_articles_lists.search(params[:search])
|
||||
end
|
||||
render json: @recommended_articles_lists
|
||||
end
|
||||
|
||||
def show
|
||||
@recommended_articles_list = RecommendedArticlesList.find(params[:id])
|
||||
render json: @recommended_articles_list
|
||||
end
|
||||
|
||||
def create
|
||||
# Actually uspert than a pure create
|
||||
# We upsert by user_id and placement_area
|
||||
# In the future we may want to allow multiple lists per user and placement_area,
|
||||
# and we will use a special param for that.
|
||||
@recommended_articles_list = RecommendedArticlesList.where(
|
||||
user_id: permitted_params[:user_id],
|
||||
placement_area: permitted_params[:placement_area],
|
||||
).first_or_initialize
|
||||
@recommended_articles_list.assign_attributes(permitted_params)
|
||||
@recommended_articles_list.save!
|
||||
render json: @recommended_articles_list, status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
@recommended_articles_list = RecommendedArticlesList.find(params[:id])
|
||||
@recommended_articles_list.update!(permitted_params)
|
||||
render json: @recommended_articles_list
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_admin
|
||||
authorize RecommendedArticlesList, :access?, policy_class: InternalPolicy
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit :name, :placement_area, :expires_at, :user_id,
|
||||
:article_ids, article_ids: []
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -355,6 +355,7 @@ module Articles
|
|||
user_required: false,
|
||||
select_fragment: "articles.score",
|
||||
group_by_fragment: "articles.score")
|
||||
|
||||
relevancy_lever(:language_match,
|
||||
label: "Weight to give based on whether the language matches any of the user's languages",
|
||||
range: "[0..1]", # 0 for no match, 1 for match
|
||||
|
|
@ -367,7 +368,26 @@ module Articles
|
|||
joins_fragments: ["LEFT OUTER JOIN user_languages
|
||||
ON user_languages.user_id = :user_id"],
|
||||
group_by_fragment: "articles.language")
|
||||
|
||||
relevancy_lever(:recommended_articles_match,
|
||||
label: "Weight to give based on whether the article is in the first non-expired recommendations",
|
||||
range: "[0..1]", # 0 for no match, 1 for match
|
||||
user_required: true,
|
||||
select_fragment: "CASE
|
||||
WHEN COUNT(first_matching_list.id) = 0 THEN 0
|
||||
WHEN articles.id = ANY(array_agg(first_matching_list.article_ids)) THEN 1
|
||||
ELSE 0
|
||||
END",
|
||||
joins_fragments: ["LEFT OUTER JOIN
|
||||
(SELECT * FROM recommended_articles_lists
|
||||
WHERE expires_at > CURRENT_TIMESTAMP
|
||||
AND placement_area = 0
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1) AS first_matching_list
|
||||
ON first_matching_list.user_id = :user_id"],
|
||||
group_by_fragment: "articles.id")
|
||||
end
|
||||
|
||||
private_constant :LEVER_CATALOG
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
end
|
||||
|
|
|
|||
22
app/models/recommended_articles_list.rb
Normal file
22
app/models/recommended_articles_list.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class RecommendedArticlesList < ApplicationRecord
|
||||
belongs_to :user
|
||||
validates :name, presence: true, length: { maximum: 120 }
|
||||
|
||||
enum placement_area: { main_feed: 0 } # Only main feed for now, could be used in Digest, trending, etc.
|
||||
|
||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||
|
||||
before_save :set_default_values
|
||||
|
||||
def set_default_values
|
||||
self.expires_at = 1.day.from_now if expires_at.nil?
|
||||
end
|
||||
|
||||
# exclude_article_ids is an integer array, web inputs are comma-separated strings
|
||||
# ActiveRecord normalizes these in a bad way, so we are intervening
|
||||
def article_ids=(input)
|
||||
adjusted_input = input.is_a?(String) ? input.split(",") : input
|
||||
adjusted_input = adjusted_input&.filter_map { |value| value.presence&.to_i }
|
||||
write_attribute :article_ids, (adjusted_input || [])
|
||||
end
|
||||
end
|
||||
|
|
@ -100,6 +100,7 @@ class User < ApplicationRecord
|
|||
has_many :profile_pins, as: :profile, inverse_of: :profile, dependent: :delete_all
|
||||
has_many :segmented_users, dependent: :destroy
|
||||
has_many :audience_segments, through: :segmented_users
|
||||
has_many :recommended_articles_lists, dependent: :destroy
|
||||
|
||||
# we keep rating votes as they belong to the article, not to the user who viewed it
|
||||
has_many :rating_votes, dependent: :nullify
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ Rails.application.routes.draw do
|
|||
post "/reactions", to: "reactions#create"
|
||||
post "/reactions/toggle", to: "reactions#toggle"
|
||||
|
||||
resources :recommended_articles_lists, only: %i[index show create update]
|
||||
|
||||
resources :billboards, only: %i[index show create update] do
|
||||
put "unpublish", on: :member
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
class CreateRecommendedArticlesLists < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :recommended_articles_lists do |t|
|
||||
t.string :name
|
||||
t.integer :article_ids, array: true, default: []
|
||||
t.integer :placement_area, default: 0
|
||||
t.datetime :expires_at
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
#add_column :display_ads, :exclude_article_ids, :integer, array: true, default: []
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
class AddIndexesForRecommendedArticlesList < ActiveRecord::Migration[7.0]
|
||||
disable_ddl_transaction!
|
||||
def change
|
||||
add_index :recommended_articles_lists, :article_ids, using: :gin, algorithm: :concurrently
|
||||
add_index :recommended_articles_lists, :placement_area, algorithm: :concurrently
|
||||
add_index :recommended_articles_lists, :expires_at, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
17
db/schema.rb
17
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_11_14_154619) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2023_11_21_140731) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "citext"
|
||||
enable_extension "ltree"
|
||||
|
|
@ -998,6 +998,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_154619) do
|
|||
t.index ["user_id", "reactable_id", "reactable_type", "category"], name: "index_reactions_on_user_id_reactable_id_reactable_type_category", unique: true
|
||||
end
|
||||
|
||||
create_table "recommended_articles_lists", force: :cascade do |t|
|
||||
t.integer "article_ids", default: [], array: true
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at"
|
||||
t.string "name"
|
||||
t.integer "placement_area", default: 0
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.index ["article_ids"], name: "index_recommended_articles_lists_on_article_ids", using: :gin
|
||||
t.index ["expires_at"], name: "index_recommended_articles_lists_on_expires_at"
|
||||
t.index ["placement_area"], name: "index_recommended_articles_lists_on_placement_area"
|
||||
t.index ["user_id"], name: "index_recommended_articles_lists_on_user_id"
|
||||
end
|
||||
|
||||
create_table "response_templates", force: :cascade do |t|
|
||||
t.text "content", null: false
|
||||
t.string "content_type", null: false
|
||||
|
|
@ -1465,6 +1479,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_154619) do
|
|||
add_foreign_key "rating_votes", "articles", on_delete: :cascade
|
||||
add_foreign_key "rating_votes", "users", on_delete: :nullify
|
||||
add_foreign_key "reactions", "users", on_delete: :cascade
|
||||
add_foreign_key "recommended_articles_lists", "users"
|
||||
add_foreign_key "response_templates", "users"
|
||||
add_foreign_key "segmented_users", "audience_segments"
|
||||
add_foreign_key "segmented_users", "users"
|
||||
|
|
|
|||
9
spec/factories/recommended_articles_lists.rb
Normal file
9
spec/factories/recommended_articles_lists.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
FactoryBot.define do
|
||||
factory :recommended_articles_list do
|
||||
sequence(:name) { |n| "#{Faker::Lorem.sentence}#{n}" }
|
||||
sequence(:article_ids) { |n| [n, n + 1, n + 2] }
|
||||
expires_at { 1.day.from_now }
|
||||
placement_area { "main_feed" }
|
||||
user
|
||||
end
|
||||
end
|
||||
69
spec/models/recommended_articles_list_spec.rb
Normal file
69
spec/models/recommended_articles_list_spec.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe RecommendedArticlesList do
|
||||
# Test associations
|
||||
describe "associations" do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
# Test validations
|
||||
describe "validations" do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(120) }
|
||||
end
|
||||
|
||||
# Test scopes
|
||||
describe ".active" do
|
||||
let!(:active_list) { create(:recommended_articles_list, expires_at: 2.days.from_now) }
|
||||
let!(:inactive_list) { create(:recommended_articles_list, expires_at: 2.days.ago) }
|
||||
|
||||
it "includes lists that have not expired" do
|
||||
expect(described_class.active).to include(active_list)
|
||||
end
|
||||
|
||||
it "excludes lists that have expired" do
|
||||
expect(described_class.active).not_to include(inactive_list)
|
||||
end
|
||||
end
|
||||
|
||||
# Test callbacks
|
||||
describe "before_save" do
|
||||
context "when expires_at is not set" do
|
||||
let(:user) { create(:user) }
|
||||
let(:list) { build(:recommended_articles_list, expires_at: nil, user: user) }
|
||||
|
||||
it "sets expires_at to one day from now" do
|
||||
Timecop.freeze(Time.current) do
|
||||
list.save!
|
||||
expect(list.reload.expires_at).to be_within(5.seconds).of(1.day.from_now)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Test custom methods
|
||||
describe "#article_ids=" do
|
||||
let(:list) { build(:recommended_articles_list) }
|
||||
|
||||
context "when input is a comma-separated string" do
|
||||
it "converts it to an array of integers" do
|
||||
list.article_ids = "1,2,3"
|
||||
expect(list.article_ids).to eq([1, 2, 3])
|
||||
end
|
||||
end
|
||||
|
||||
context "when input is an array" do
|
||||
it "keeps it as an array of integers" do
|
||||
list.article_ids = [1, 2, 3]
|
||||
expect(list.article_ids).to eq([1, 2, 3])
|
||||
end
|
||||
end
|
||||
|
||||
context "when input includes nil or empty values" do
|
||||
it "filters out invalid entries" do
|
||||
list.article_ids = [1, "", nil, 3]
|
||||
expect(list.article_ids).to eq([1, 3])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
115
spec/requests/api/v1/recommended_articles_lists_spec.rb
Normal file
115
spec/requests/api/v1/recommended_articles_lists_spec.rb
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Api::V1::RecommendedArticlesLists" do
|
||||
let!(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } }
|
||||
|
||||
let(:list_params) do
|
||||
{
|
||||
name: "Sample List",
|
||||
placement_area: "main_feed",
|
||||
expires_at: 1.week.from_now,
|
||||
user_id: user.id,
|
||||
article_ids: [article1.id, article2.id]
|
||||
}
|
||||
end
|
||||
|
||||
let(:api_secret) { create(:api_secret) }
|
||||
let(:user) { api_secret.user }
|
||||
let(:article1) { create(:article) }
|
||||
let(:article2) { create(:article) }
|
||||
let(:auth_header) { v1_headers.merge({ "api-key" => api_secret.secret }) }
|
||||
|
||||
shared_context "when user is authorized" do
|
||||
before { user.add_role(:admin) }
|
||||
end
|
||||
|
||||
describe "GET /api/v1/recommended_articles_lists" do
|
||||
context "when authenticated and authorized" do
|
||||
include_context "when user is authorized"
|
||||
|
||||
it "returns json response with all recommended article lists" do
|
||||
get api_recommended_articles_lists_path, headers: auth_header
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.media_type).to eq("application/json")
|
||||
# Expectations regarding the content of the response
|
||||
end
|
||||
end
|
||||
|
||||
context "when unauthenticated" do
|
||||
it "returns unauthorized" do
|
||||
get api_recommended_articles_lists_path, headers: v1_headers
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/v1/recommended_articles_lists/:id" do
|
||||
let(:existing_list) { create(:recommended_articles_list, user: user) }
|
||||
|
||||
context "when authenticated and authorized" do
|
||||
include_context "when user is authorized"
|
||||
|
||||
it "returns json response with the specified recommended article list" do
|
||||
get api_recommended_articles_list_path(existing_list.id), headers: auth_header
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.media_type).to eq("application/json")
|
||||
end
|
||||
end
|
||||
|
||||
context "when unauthenticated" do
|
||||
it "returns unauthorized" do
|
||||
get api_recommended_articles_list_path(existing_list.id), headers: v1_headers
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/recommended_articles_lists" do
|
||||
context "when authenticated and authorized" do
|
||||
include_context "when user is authorized"
|
||||
|
||||
it "creates a new recommended articles list" do
|
||||
expect do
|
||||
post api_recommended_articles_lists_path, params: list_params.to_json, headers: auth_header
|
||||
end.to change(RecommendedArticlesList, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(response.media_type).to eq("application/json")
|
||||
end
|
||||
end
|
||||
|
||||
context "when unauthenticated" do
|
||||
it "returns unauthorized" do
|
||||
post api_recommended_articles_lists_path, params: list_params.to_json, headers: v1_headers
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /api/v1/recommended_articles_lists/:id" do
|
||||
let(:existing_list) { create(:recommended_articles_list, user: user) }
|
||||
|
||||
context "when authenticated and authorized" do
|
||||
include_context "when user is authorized"
|
||||
|
||||
it "updates an existing recommended articles list" do
|
||||
user.add_role(:admin)
|
||||
put api_recommended_articles_list_path(existing_list.id), params: list_params
|
||||
.merge(name: "Updated List").to_json, headers: auth_header
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.media_type).to eq("application/json")
|
||||
# Assertions for the updated attributes
|
||||
end
|
||||
end
|
||||
|
||||
context "when unauthenticated" do
|
||||
it "returns unauthorized" do
|
||||
put api_recommended_articles_list_path(existing_list.id), params: list_params.to_json, headers: v1_headers
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue