Recommended articles list (#20383)

* Add recommended articles list

* Adjust tests

* Adjust tests
This commit is contained in:
Ben Halpern 2023-11-24 12:57:55 -05:00 committed by GitHub
parent df0b134ec4
commit 347fd6d112
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 329 additions and 1 deletions

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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: []

View file

@ -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

View file

@ -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"

View 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

View 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

View 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