Add time frames to the Top Contributors list
Note that these queries are a bit slow. I don't think these new subpages will be accessed anywhere near often enough for their ~2sec query time to be a big deal. But if we start getting into trouble with it (e.g. someone starts slamming us for fun), we can look into how how cache these values over time.
This commit is contained in:
parent
04ed182cef
commit
366158b698
17 changed files with 703 additions and 121 deletions
1
Gemfile
1
Gemfile
|
|
@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1"
|
||||||
|
|
||||||
# For automated tests.
|
# For automated tests.
|
||||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||||
|
gem 'rails-controller-testing', group: [:test]
|
||||||
gem "webmock", "~> 3.24", group: [:test]
|
gem "webmock", "~> 3.24", group: [:test]
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,10 @@ GEM
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.2)
|
railties (= 8.1.2)
|
||||||
|
rails-controller-testing (1.0.5)
|
||||||
|
actionpack (>= 5.0.1.rc1)
|
||||||
|
actionview (>= 5.0.1.rc1)
|
||||||
|
activesupport (>= 5.0.1.rc1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -505,6 +509,7 @@ DEPENDENCIES
|
||||||
rack-attack (~> 6.7)
|
rack-attack (~> 6.7)
|
||||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||||
rails (~> 8.0, >= 8.0.1)
|
rails (~> 8.0, >= 8.0.1)
|
||||||
|
rails-controller-testing
|
||||||
rails-i18n (~> 8.0, >= 8.0.1)
|
rails-i18n (~> 8.0, >= 8.0.1)
|
||||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||||
rspec-rails (~> 8.0, >= 8.0.2)
|
rspec-rails (~> 8.0, >= 8.0.2)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@
|
||||||
body.users-top_contributors
|
body.users-top_contributors
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
|
.timeframe-nav
|
||||||
|
margin: 1em 0
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
gap: 1em
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
|
||||||
#top-contributors
|
#top-contributors
|
||||||
border:
|
border:
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ class UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def top_contributors
|
def top_contributors
|
||||||
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
|
valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
|
||||||
|
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
|
||||||
|
@users = User.top_contributors_for(@timeframe.to_sym)
|
||||||
|
.paginate(page: params[:page], per_page: 20)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,51 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
||||||
|
|
||||||
|
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
|
||||||
|
|
||||||
|
scope :top_contributors_for, ->(timeframe = :all_time) {
|
||||||
|
case timeframe.to_sym
|
||||||
|
when :all_time
|
||||||
|
top_contributors # Use existing efficient scope
|
||||||
|
else
|
||||||
|
top_contributors_by_period(timeframe)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.top_contributors_by_period(timeframe)
|
||||||
|
start_date = case timeframe.to_sym
|
||||||
|
when :this_week then 1.week.ago
|
||||||
|
when :this_month then 1.month.ago
|
||||||
|
when :this_year then 1.year.ago
|
||||||
|
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build the CASE statement dynamically from Contribution::POINT_VALUES
|
||||||
|
point_case = Contribution::POINT_VALUES.map { |type, points|
|
||||||
|
"WHEN #{connection.quote(type)} THEN #{points}"
|
||||||
|
}.join("\n ")
|
||||||
|
|
||||||
|
select(
|
||||||
|
'users.*',
|
||||||
|
"COALESCE(SUM(
|
||||||
|
CASE contributions.contributed_type
|
||||||
|
#{point_case}
|
||||||
|
END
|
||||||
|
), 0) AS period_points"
|
||||||
|
)
|
||||||
|
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
|
||||||
|
.where('contributions.created_at >= ?', start_date)
|
||||||
|
.group('users.id')
|
||||||
|
.having('period_points > 0')
|
||||||
|
.order('period_points DESC, users.id ASC')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Virtual attribute reader for dynamically calculated points (from time-period queries).
|
||||||
|
# Falls back to the denormalized `points` column when not calculated.
|
||||||
|
def period_points
|
||||||
|
attributes['period_points'] || points
|
||||||
|
end
|
||||||
|
|
||||||
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
||||||
after_update :log_trade_activity, if: -> user {
|
after_update :log_trade_activity, if: -> user {
|
||||||
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
- title t('.title')
|
- title t('.title')
|
||||||
|
|
||||||
|
%ul.timeframe-nav
|
||||||
|
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
|
||||||
|
%li
|
||||||
|
- if @timeframe == tf
|
||||||
|
%strong= t(".timeframes.#{tf}")
|
||||||
|
- else
|
||||||
|
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
|
||||||
|
|
||||||
= will_paginate @users
|
= will_paginate @users
|
||||||
%table#top-contributors
|
%table#top-contributors
|
||||||
%thead
|
%thead
|
||||||
|
|
@ -11,5 +20,5 @@
|
||||||
%tr
|
%tr
|
||||||
%th{:scope => 'row'}= @users.offset + rank + 1
|
%th{:scope => 'row'}= @users.offset + rank + 1
|
||||||
%td= link_to user.name, user_contributions_path(user)
|
%td= link_to user.name, user_contributions_path(user)
|
||||||
%td= user.points
|
%td= user.period_points
|
||||||
= will_paginate @users
|
= will_paginate @users
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,11 @@ en-MEEP:
|
||||||
rank: Reep
|
rank: Reep
|
||||||
user: Meepit
|
user: Meepit
|
||||||
points: Peeps
|
points: Peeps
|
||||||
|
timeframes:
|
||||||
|
all_time: All Meep
|
||||||
|
this_year: Meeps Year
|
||||||
|
this_month: Meeps Month
|
||||||
|
this_week: Meeps Week
|
||||||
|
|
||||||
update:
|
update:
|
||||||
success: Settings successfully meeped.
|
success: Settings successfully meeped.
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,11 @@ en:
|
||||||
rank: Rank
|
rank: Rank
|
||||||
user: User
|
user: User
|
||||||
points: Points
|
points: Points
|
||||||
|
timeframes:
|
||||||
|
all_time: All Time
|
||||||
|
this_year: This Year
|
||||||
|
this_month: This Month
|
||||||
|
this_week: This Week
|
||||||
|
|
||||||
update:
|
update:
|
||||||
success: Settings successfully saved.
|
success: Settings successfully saved.
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,11 @@ es:
|
||||||
rank: Puesto
|
rank: Puesto
|
||||||
user: Usuario
|
user: Usuario
|
||||||
points: Puntos
|
points: Puntos
|
||||||
|
timeframes:
|
||||||
|
all_time: Todo el Tiempo
|
||||||
|
this_year: Este Año
|
||||||
|
this_month: Este Mes
|
||||||
|
this_week: Esta Semana
|
||||||
update:
|
update:
|
||||||
success: Ajustes guardados correctamente.
|
success: Ajustes guardados correctamente.
|
||||||
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,11 @@ pt:
|
||||||
rank: Rank
|
rank: Rank
|
||||||
user: Usuário
|
user: Usuário
|
||||||
points: Pontos
|
points: Pontos
|
||||||
|
timeframes:
|
||||||
|
all_time: Todo o Tempo
|
||||||
|
this_year: Este Ano
|
||||||
|
this_month: Este Mês
|
||||||
|
this_week: Esta Semana
|
||||||
update:
|
update:
|
||||||
success: Configurações salvas com sucesso
|
success: Configurações salvas com sucesso
|
||||||
invalid: "Não foi possível salvar as configurações: %{errors}"
|
invalid: "Não foi possível salvar as configurações: %{errors}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_index :contributions, [:user_id, :created_at],
|
||||||
|
name: 'index_contributions_on_user_id_and_created_at'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,28 +10,28 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do
|
ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
|
||||||
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||||
t.string "name", limit: 30, null: false
|
t.datetime "created_at", precision: nil
|
||||||
t.string "encrypted_password", limit: 64
|
t.datetime "current_sign_in_at", precision: nil
|
||||||
|
t.string "current_sign_in_ip"
|
||||||
t.string "email", limit: 50
|
t.string "email", limit: 50
|
||||||
|
t.string "encrypted_password", limit: 64
|
||||||
|
t.integer "failed_attempts", default: 0
|
||||||
|
t.datetime "last_sign_in_at", precision: nil
|
||||||
|
t.string "last_sign_in_ip"
|
||||||
|
t.datetime "locked_at", precision: nil
|
||||||
|
t.string "name", limit: 30, null: false
|
||||||
|
t.string "neopass_email"
|
||||||
t.string "password_salt", limit: 32
|
t.string "password_salt", limit: 32
|
||||||
|
t.string "provider"
|
||||||
|
t.datetime "remember_created_at"
|
||||||
|
t.datetime "reset_password_sent_at", precision: nil
|
||||||
t.string "reset_password_token"
|
t.string "reset_password_token"
|
||||||
t.integer "sign_in_count", default: 0
|
t.integer "sign_in_count", default: 0
|
||||||
t.datetime "current_sign_in_at", precision: nil
|
|
||||||
t.datetime "last_sign_in_at", precision: nil
|
|
||||||
t.string "current_sign_in_ip"
|
|
||||||
t.string "last_sign_in_ip"
|
|
||||||
t.integer "failed_attempts", default: 0
|
|
||||||
t.string "unlock_token"
|
|
||||||
t.datetime "locked_at", precision: nil
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
|
||||||
t.datetime "reset_password_sent_at", precision: nil
|
|
||||||
t.datetime "remember_created_at"
|
|
||||||
t.string "provider"
|
|
||||||
t.string "uid"
|
t.string "uid"
|
||||||
t.string "neopass_email"
|
t.string "unlock_token"
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
|
|
||||||
205
db/schema.rb
205
db/schema.rb
|
|
@ -10,50 +10,50 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
||||||
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "species_id", null: false
|
|
||||||
t.integer "color_id", null: false
|
|
||||||
t.integer "body_id", null: false
|
t.integer "body_id", null: false
|
||||||
|
t.integer "color_id", null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
|
||||||
t.string "series_name"
|
|
||||||
t.string "thumbnail_url", null: false
|
|
||||||
t.string "full_name"
|
t.string "full_name"
|
||||||
|
t.string "series_name"
|
||||||
|
t.integer "species_id", null: false
|
||||||
|
t.string "thumbnail_url", null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
||||||
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "short_name", limit: 10, null: false
|
|
||||||
t.string "name", limit: 40, null: false
|
|
||||||
t.text "icon", size: :long, null: false
|
|
||||||
t.text "gateway", size: :long, null: false
|
t.text "gateway", size: :long, null: false
|
||||||
|
t.text "icon", size: :long, null: false
|
||||||
|
t.string "name", limit: 40, null: false
|
||||||
t.string "secret", limit: 64, null: false
|
t.string "secret", limit: 64, null: false
|
||||||
|
t.string "short_name", limit: 10, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "progress", default: 0, null: false
|
|
||||||
t.integer "goal", null: false
|
|
||||||
t.boolean "active", null: false
|
t.boolean "active", null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
|
||||||
t.boolean "advertised", default: true, null: false
|
t.boolean "advertised", default: true, null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.text "description", size: :long, null: false
|
t.text "description", size: :long, null: false
|
||||||
t.string "purpose", default: "our hosting costs this year", null: false
|
t.integer "goal", null: false
|
||||||
t.string "theme_id", default: "hug", null: false
|
|
||||||
t.text "thanks", size: :long
|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
|
t.integer "progress", default: 0, null: false
|
||||||
|
t.string "purpose", default: "our hosting costs this year", null: false
|
||||||
|
t.text "thanks", size: :long
|
||||||
|
t.string "theme_id", default: "hug", null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "item_id"
|
|
||||||
t.integer "user_id"
|
|
||||||
t.integer "quantity"
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
t.datetime "updated_at", precision: nil
|
t.integer "item_id"
|
||||||
t.boolean "owned", default: true, null: false
|
|
||||||
t.integer "list_id"
|
t.integer "list_id"
|
||||||
|
t.boolean "owned", default: true, null: false
|
||||||
|
t.integer "quantity"
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
||||||
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
||||||
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
||||||
|
|
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "name"
|
|
||||||
t.text "description", size: :long
|
|
||||||
t.integer "user_id"
|
|
||||||
t.boolean "hangers_owned", null: false
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
|
t.text "description", size: :long
|
||||||
|
t.boolean "hangers_owned", null: false
|
||||||
|
t.string "name"
|
||||||
t.datetime "updated_at", precision: nil
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.integer "visibility", default: 1, null: false
|
t.integer "visibility", default: 1, null: false
|
||||||
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.boolean "basic"
|
t.boolean "basic"
|
||||||
t.boolean "standard"
|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "pb_item_name"
|
t.string "pb_item_name"
|
||||||
t.string "pb_item_thumbnail_url"
|
t.string "pb_item_thumbnail_url"
|
||||||
|
t.boolean "standard"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "contributed_type", limit: 8, null: false
|
|
||||||
t.integer "contributed_id", null: false
|
t.integer "contributed_id", null: false
|
||||||
t.integer "user_id", null: false
|
t.string "contributed_type", limit: 8, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
||||||
|
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
|
||||||
t.index ["user_id"], name: "index_contributions_on_user_id"
|
t.index ["user_id"], name: "index_contributions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.integer "donation_id", null: false
|
t.integer "donation_id", null: false
|
||||||
t.integer "outfit_id"
|
t.integer "outfit_id"
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "amount", null: false
|
t.integer "amount", null: false
|
||||||
|
t.integer "campaign_id", null: false
|
||||||
t.string "charge_id", null: false
|
t.string "charge_id", null: false
|
||||||
t.integer "user_id"
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "donor_email"
|
||||||
t.string "donor_name"
|
t.string "donor_name"
|
||||||
t.string "secret"
|
t.string "secret"
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.string "donor_email"
|
t.integer "user_id"
|
||||||
t.integer "campaign_id", null: false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", precision: nil
|
||||||
|
t.boolean "is_worn"
|
||||||
t.integer "item_id"
|
t.integer "item_id"
|
||||||
t.integer "outfit_id"
|
t.integer "outfit_id"
|
||||||
t.boolean "is_worn"
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
t.datetime "updated_at", precision: nil
|
||||||
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
||||||
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.text "zones_restrict", size: :medium, null: false
|
t.text "cached_compatible_body_ids", default: ""
|
||||||
t.text "thumbnail_url", size: :long, null: false
|
t.string "cached_occupied_zone_ids", default: ""
|
||||||
|
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||||
t.string "category", limit: 50
|
t.string "category", limit: 50
|
||||||
t.string "type", limit: 50
|
|
||||||
t.integer "rarity_index", limit: 2
|
|
||||||
t.integer "price", limit: 3, null: false
|
|
||||||
t.integer "weight_lbs", limit: 2
|
|
||||||
t.text "species_support_ids", size: :long
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
t.datetime "updated_at", precision: nil
|
t.text "description", size: :medium, null: false
|
||||||
|
t.integer "dyeworks_base_item_id"
|
||||||
t.boolean "explicitly_body_specific", default: false, null: false
|
t.boolean "explicitly_body_specific", default: false, null: false
|
||||||
|
t.boolean "is_manually_nc", default: false, null: false
|
||||||
t.integer "manual_special_color_id"
|
t.integer "manual_special_color_id"
|
||||||
t.column "modeling_status_hint", "enum('done','glitchy')"
|
t.column "modeling_status_hint", "enum('done','glitchy')"
|
||||||
t.boolean "is_manually_nc", default: false, null: false
|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "description", size: :medium, null: false
|
t.integer "price", limit: 3, null: false
|
||||||
t.string "rarity", default: "", null: false
|
t.string "rarity", default: "", null: false
|
||||||
t.integer "dyeworks_base_item_id"
|
t.integer "rarity_index", limit: 2
|
||||||
t.string "cached_occupied_zone_ids", default: ""
|
t.text "species_support_ids", size: :long
|
||||||
t.text "cached_compatible_body_ids", default: ""
|
t.text "thumbnail_url", size: :long, null: false
|
||||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
t.string "type", limit: 50
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "weight_lbs", limit: 2
|
||||||
|
t.text "zones_restrict", size: :medium, null: false
|
||||||
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
||||||
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
||||||
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
||||||
|
|
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "user_id", null: false
|
|
||||||
t.integer "series", null: false
|
t.integer "series", null: false
|
||||||
t.integer "token", null: false
|
t.integer "token", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
||||||
t.index ["user_id"], name: "login_cookies_user_id"
|
t.index ["user_id"], name: "login_cookies_user_id"
|
||||||
end
|
end
|
||||||
|
|
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
||||||
t.integer "item_id", null: false
|
t.datetime "created_at", null: false
|
||||||
t.integer "price", null: false
|
|
||||||
t.integer "discount_price"
|
|
||||||
t.datetime "discount_begins_at"
|
t.datetime "discount_begins_at"
|
||||||
t.datetime "discount_ends_at"
|
t.datetime "discount_ends_at"
|
||||||
t.datetime "created_at", null: false
|
t.integer "discount_price"
|
||||||
|
t.integer "item_id", null: false
|
||||||
|
t.integer "price", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "user_id"
|
|
||||||
t.string "neopets_username"
|
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "neopets_username"
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "pet_state_id"
|
|
||||||
t.integer "user_id"
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
|
||||||
t.string "name"
|
|
||||||
t.boolean "starred", default: false, null: false
|
|
||||||
t.string "image"
|
|
||||||
t.string "image_layers_hash"
|
|
||||||
t.boolean "image_enqueued", default: false, null: false
|
|
||||||
t.bigint "alt_style_id"
|
t.bigint "alt_style_id"
|
||||||
|
t.datetime "created_at", precision: nil
|
||||||
|
t.string "image"
|
||||||
|
t.boolean "image_enqueued", default: false, null: false
|
||||||
|
t.string "image_layers_hash"
|
||||||
|
t.string "name"
|
||||||
|
t.integer "pet_state_id"
|
||||||
|
t.boolean "starred", default: false, null: false
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
||||||
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
||||||
t.index ["user_id"], name: "index_outfits_on_user_id"
|
t.index ["user_id"], name: "index_outfits_on_user_id"
|
||||||
|
|
@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
|
|
||||||
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "parent_id", null: false
|
t.integer "parent_id", null: false
|
||||||
t.integer "swf_asset_id", null: false
|
|
||||||
t.string "parent_type", limit: 8, null: false
|
t.string "parent_type", limit: 8, null: false
|
||||||
|
t.integer "swf_asset_id", null: false
|
||||||
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
||||||
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
||||||
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "pet_name", limit: 20, null: false
|
|
||||||
t.text "amf", size: :long, null: false
|
t.text "amf", size: :long, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "pet_name", limit: 20, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "pet_type_id", null: false
|
|
||||||
t.text "swf_asset_ids", size: :medium, null: false
|
|
||||||
t.boolean "female"
|
|
||||||
t.integer "mood_id"
|
|
||||||
t.boolean "unconverted"
|
|
||||||
t.boolean "labeled", default: false, null: false
|
|
||||||
t.boolean "glitched", default: false, null: false
|
|
||||||
t.string "artist_neopets_username"
|
t.string "artist_neopets_username"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
|
t.boolean "female"
|
||||||
|
t.boolean "glitched", default: false, null: false
|
||||||
|
t.boolean "labeled", default: false, null: false
|
||||||
|
t.integer "mood_id"
|
||||||
|
t.integer "pet_type_id", null: false
|
||||||
|
t.text "swf_asset_ids", size: :medium, null: false
|
||||||
|
t.boolean "unconverted"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "color_id", null: false
|
|
||||||
t.integer "species_id", null: false
|
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.integer "body_id", limit: 2, null: false
|
|
||||||
t.string "image_hash", limit: 8
|
|
||||||
t.string "basic_image_hash"
|
t.string "basic_image_hash"
|
||||||
|
t.integer "body_id", limit: 2, null: false
|
||||||
|
t.integer "color_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "image_hash", limit: 8
|
||||||
|
t.integer "species_id", null: false
|
||||||
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
||||||
t.index ["body_id"], name: "pet_types_body_id"
|
t.index ["body_id"], name: "pet_types_body_id"
|
||||||
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
||||||
|
|
@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "type", limit: 7, null: false
|
t.integer "body_id", limit: 2, null: false
|
||||||
|
t.datetime "converted_at", precision: nil
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.boolean "has_image", default: false, null: false
|
||||||
|
t.boolean "image_manual", default: false, null: false
|
||||||
|
t.boolean "image_requested", default: false, null: false
|
||||||
|
t.string "known_glitches", limit: 128, default: ""
|
||||||
|
t.text "manifest", size: :long
|
||||||
|
t.timestamp "manifest_cached_at"
|
||||||
|
t.datetime "manifest_loaded_at"
|
||||||
|
t.integer "manifest_status_code"
|
||||||
|
t.string "manifest_url"
|
||||||
t.integer "remote_id", limit: 3, null: false
|
t.integer "remote_id", limit: 3, null: false
|
||||||
|
t.datetime "reported_broken_at", precision: nil
|
||||||
|
t.string "type", limit: 7, null: false
|
||||||
t.text "url", size: :long, null: false
|
t.text "url", size: :long, null: false
|
||||||
t.integer "zone_id", null: false
|
t.integer "zone_id", null: false
|
||||||
t.text "zones_restrict", size: :medium, null: false
|
t.text "zones_restrict", size: :medium, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.integer "body_id", limit: 2, null: false
|
|
||||||
t.boolean "has_image", default: false, null: false
|
|
||||||
t.boolean "image_requested", default: false, null: false
|
|
||||||
t.datetime "reported_broken_at", precision: nil
|
|
||||||
t.datetime "converted_at", precision: nil
|
|
||||||
t.boolean "image_manual", default: false, null: false
|
|
||||||
t.text "manifest", size: :long
|
|
||||||
t.timestamp "manifest_cached_at"
|
|
||||||
t.string "known_glitches", limit: 128, default: ""
|
|
||||||
t.string "manifest_url"
|
|
||||||
t.datetime "manifest_loaded_at"
|
|
||||||
t.integer "manifest_status_code"
|
|
||||||
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
||||||
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
||||||
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "name", limit: 30, null: false
|
|
||||||
t.integer "auth_server_id", limit: 1, null: false
|
t.integer "auth_server_id", limit: 1, null: false
|
||||||
t.integer "remote_id", null: false
|
|
||||||
t.integer "points", default: 0, null: false
|
|
||||||
t.boolean "beta", default: false, null: false
|
t.boolean "beta", default: false, null: false
|
||||||
t.string "remember_token"
|
|
||||||
t.datetime "remember_created_at", precision: nil
|
|
||||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
|
||||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
|
||||||
t.integer "contact_neopets_connection_id"
|
t.integer "contact_neopets_connection_id"
|
||||||
t.timestamp "last_trade_activity_at"
|
t.timestamp "last_trade_activity_at"
|
||||||
t.boolean "support_staff", default: false, null: false
|
t.string "name", limit: 30, null: false
|
||||||
|
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||||
|
t.integer "points", default: 0, null: false
|
||||||
|
t.datetime "remember_created_at", precision: nil
|
||||||
|
t.string "remember_token"
|
||||||
|
t.integer "remote_id", null: false
|
||||||
t.boolean "shadowbanned", default: false, null: false
|
t.boolean "shadowbanned", default: false, null: false
|
||||||
|
t.boolean "support_staff", default: false, null: false
|
||||||
|
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "depth"
|
t.integer "depth"
|
||||||
t.integer "type_id"
|
|
||||||
t.string "label", null: false
|
t.string "label", null: false
|
||||||
t.string "plain_label", null: false
|
t.string "plain_label", null: false
|
||||||
|
t.integer "type_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key "alt_styles", "colors"
|
add_foreign_key "alt_styles", "colors"
|
||||||
|
|
|
||||||
72
db/seeds/top_contributors_sample_data.rb
Normal file
72
db/seeds/top_contributors_sample_data.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Sample contributions for testing Top Contributors feature
|
||||||
|
# Run with: rails runner db/seeds/top_contributors_sample_data.rb
|
||||||
|
|
||||||
|
puts "Creating sample contributions for Top Contributors testing..."
|
||||||
|
|
||||||
|
# Find or create test users
|
||||||
|
users = []
|
||||||
|
5.times do |i|
|
||||||
|
name = "TestContributor#{i + 1}"
|
||||||
|
user = User.find_or_create_by!(name: name) do |u|
|
||||||
|
# Create a corresponding auth_user record
|
||||||
|
auth_user = AuthUser.create!(
|
||||||
|
name: name,
|
||||||
|
email: "test#{i + 1}@example.com",
|
||||||
|
password: 'password123',
|
||||||
|
)
|
||||||
|
u.remote_id = auth_user.id
|
||||||
|
u.auth_server_id = 1
|
||||||
|
end
|
||||||
|
users << user
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get some existing items/pet types to contribute
|
||||||
|
items = Item.limit(10).to_a
|
||||||
|
pet_types = PetType.limit(5).to_a
|
||||||
|
swf_assets = SwfAsset.limit(5).to_a
|
||||||
|
|
||||||
|
if items.empty? || pet_types.empty?
|
||||||
|
puts "WARNING: No items or pet types found. Create some first or contributions will be limited."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create contributions with different time periods
|
||||||
|
# User 1: Heavy contributor this week
|
||||||
|
if items.any?
|
||||||
|
3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) }
|
||||||
|
5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# User 2: Heavy contributor this month, but not this week
|
||||||
|
if items.any? && pet_types.any?
|
||||||
|
2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) }
|
||||||
|
1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# User 3: Heavy contributor this year, but not this month
|
||||||
|
if pet_types.any?
|
||||||
|
3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# User 4: Old contributor (only in all-time)
|
||||||
|
if items.any?
|
||||||
|
users[3].update!(points: 500) # Set points directly for all-time view
|
||||||
|
2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# User 5: Mixed contributions across all periods
|
||||||
|
if items.any? && pet_types.any?
|
||||||
|
Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago)
|
||||||
|
Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago)
|
||||||
|
Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago)
|
||||||
|
end
|
||||||
|
if swf_assets.any?
|
||||||
|
Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Created sample contributions:"
|
||||||
|
puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)"
|
||||||
|
puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)"
|
||||||
|
puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)"
|
||||||
|
puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)"
|
||||||
|
puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)"
|
||||||
|
puts "\nTest the feature at: http://localhost:3000/users/top-contributors"
|
||||||
119
spec/controllers/users_controller_spec.rb
Normal file
119
spec/controllers/users_controller_spec.rb
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe UsersController, type: :controller do
|
||||||
|
include Devise::Test::ControllerHelpers
|
||||||
|
|
||||||
|
describe 'GET #top_contributors' do
|
||||||
|
let!(:user1) { create_user('Alice', 100) }
|
||||||
|
let!(:user2) { create_user('Bob', 50) }
|
||||||
|
let!(:user3) { create_user('Charlie', 0) }
|
||||||
|
|
||||||
|
context 'without timeframe parameter' do
|
||||||
|
it 'defaults to all_time timeframe' do
|
||||||
|
get :top_contributors
|
||||||
|
expect(assigns(:timeframe)).to eq('all_time')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns users ordered by points' do
|
||||||
|
get :top_contributors
|
||||||
|
users = assigns(:users)
|
||||||
|
expect(users.to_a.map(&:id)).to eq([user1.id, user2.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'paginates results' do
|
||||||
|
get :top_contributors, params: { page: 1 }
|
||||||
|
users = assigns(:users)
|
||||||
|
expect(users).to respond_to(:total_pages)
|
||||||
|
expect(users).to respond_to(:current_page)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with valid timeframe parameter' do
|
||||||
|
it 'accepts all_time' do
|
||||||
|
get :top_contributors, params: { timeframe: 'all_time' }
|
||||||
|
expect(assigns(:timeframe)).to eq('all_time')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts this_year' do
|
||||||
|
get :top_contributors, params: { timeframe: 'this_year' }
|
||||||
|
expect(assigns(:timeframe)).to eq('this_year')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts this_month' do
|
||||||
|
get :top_contributors, params: { timeframe: 'this_month' }
|
||||||
|
expect(assigns(:timeframe)).to eq('this_month')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts this_week' do
|
||||||
|
get :top_contributors, params: { timeframe: 'this_week' }
|
||||||
|
expect(assigns(:timeframe)).to eq('this_week')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls User.top_contributors_for with the timeframe' do
|
||||||
|
expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original
|
||||||
|
get :top_contributors, params: { timeframe: 'this_week' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid timeframe parameter' do
|
||||||
|
it 'defaults to all_time' do
|
||||||
|
get :top_contributors, params: { timeframe: 'invalid' }
|
||||||
|
expect(assigns(:timeframe)).to eq('all_time')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not raise an error' do
|
||||||
|
expect {
|
||||||
|
get :top_contributors, params: { timeframe: 'invalid' }
|
||||||
|
}.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with pagination' do
|
||||||
|
before do
|
||||||
|
# Create 25 users to test pagination (per_page is 20)
|
||||||
|
25.times do |i|
|
||||||
|
create_user("User#{i}", 100 - i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'paginates with 20 users per page' do
|
||||||
|
get :top_contributors
|
||||||
|
expect(assigns(:users).size).to eq(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'supports page parameter' do
|
||||||
|
get :top_contributors, params: { page: 2 }
|
||||||
|
expect(assigns(:users).current_page).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'works with timeframe and pagination together' do
|
||||||
|
get :top_contributors, params: { timeframe: 'all_time', page: 2 }
|
||||||
|
expect(assigns(:timeframe)).to eq('all_time')
|
||||||
|
expect(assigns(:users).current_page).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'renders the correct template' do
|
||||||
|
it 'renders the top_contributors template' do
|
||||||
|
get :top_contributors
|
||||||
|
expect(response).to render_template('top_contributors')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns HTTP success' do
|
||||||
|
get :top_contributors
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def create_user(name, points = 0)
|
||||||
|
auth_user = AuthUser.create!(
|
||||||
|
name: name,
|
||||||
|
email: "#{name.downcase}@example.com",
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'password123'
|
||||||
|
)
|
||||||
|
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points)
|
||||||
|
end
|
||||||
|
end
|
||||||
293
spec/models/user_spec.rb
Normal file
293
spec/models/user_spec.rb
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe User do
|
||||||
|
describe '.top_contributors_for' do
|
||||||
|
let!(:user1) { create_user('Alice') }
|
||||||
|
let!(:user2) { create_user('Bob') }
|
||||||
|
let!(:user3) { create_user('Charlie') }
|
||||||
|
|
||||||
|
context 'with all_time timeframe' do
|
||||||
|
it 'uses the denormalized points column' do
|
||||||
|
user1.update!(points: 100)
|
||||||
|
user2.update!(points: 50)
|
||||||
|
user3.update!(points: 0)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:all_time)
|
||||||
|
expect(results.map(&:id)).to eq([user1.id, user2.id])
|
||||||
|
expect(results.first.points).to eq(100)
|
||||||
|
expect(results.second.points).to eq(50)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes users with zero points' do
|
||||||
|
user1.update!(points: 100)
|
||||||
|
user2.update!(points: 0)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:all_time)
|
||||||
|
expect(results).not_to include(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'orders by points descending' do
|
||||||
|
user1.update!(points: 50)
|
||||||
|
user2.update!(points: 100)
|
||||||
|
user3.update!(points: 75)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:all_time)
|
||||||
|
expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with this_week timeframe' do
|
||||||
|
let(:item) { create_item }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create contributions from this week
|
||||||
|
create_contribution(user1, item, 3.days.ago) # 3 points
|
||||||
|
create_contribution(user1, item, 2.days.ago) # 3 points
|
||||||
|
|
||||||
|
# Create contributions from last month (should be excluded)
|
||||||
|
create_contribution(user2, item, 1.month.ago) # 3 points (excluded)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates points from contributions in the last week' do
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.first).to eq(user1)
|
||||||
|
expect(results.first.period_points).to eq(6)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes users with no recent contributions' do
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results).not_to include(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes contributions older than one week' do
|
||||||
|
create_contribution(user3, item, 8.days.ago)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results).not_to include(user3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with this_month timeframe' do
|
||||||
|
let(:item) { create_item }
|
||||||
|
let(:pet_type) { create_pet_type }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# User 1: contributions from this month
|
||||||
|
create_contribution(user1, item, 15.days.ago) # 3 points
|
||||||
|
create_contribution(user1, pet_type, 20.days.ago) # 15 points
|
||||||
|
|
||||||
|
# User 2: contributions older than one month
|
||||||
|
create_contribution(user2, item, 35.days.ago) # 3 points (excluded)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates points from contributions in the last month' do
|
||||||
|
results = User.top_contributors_for(:this_month)
|
||||||
|
expect(results.first).to eq(user1)
|
||||||
|
expect(results.first.period_points).to eq(18)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes contributions older than one month' do
|
||||||
|
results = User.top_contributors_for(:this_month)
|
||||||
|
expect(results).not_to include(user2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with this_year timeframe' do
|
||||||
|
let(:item) { create_item }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# User 1: contributions from this year
|
||||||
|
create_contribution(user1, item, 3.months.ago) # 3 points
|
||||||
|
create_contribution(user1, item, 6.months.ago) # 3 points
|
||||||
|
|
||||||
|
# User 2: contributions older than one year
|
||||||
|
create_contribution(user2, item, 13.months.ago) # 3 points (excluded)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates points from contributions in the last year' do
|
||||||
|
results = User.top_contributors_for(:this_year)
|
||||||
|
expect(results.first).to eq(user1)
|
||||||
|
expect(results.first.period_points).to eq(6)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes contributions older than one year' do
|
||||||
|
results = User.top_contributors_for(:this_year)
|
||||||
|
expect(results).not_to include(user2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'point value calculations' do
|
||||||
|
let(:item) { create_item }
|
||||||
|
let(:pet_type) { create_pet_type }
|
||||||
|
let(:alt_style) { create_alt_style }
|
||||||
|
|
||||||
|
it 'assigns 3 points for Item contributions' do
|
||||||
|
create_contribution(user1, item, 1.day.ago)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.first.period_points).to eq(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'assigns 15 points for PetType contributions' do
|
||||||
|
create_contribution(user1, pet_type, 1.day.ago)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.first.period_points).to eq(15)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'assigns 30 points for AltStyle contributions' do
|
||||||
|
create_contribution(user1, alt_style, 1.day.ago)
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.first.period_points).to eq(30)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sums multiple contribution types correctly' do
|
||||||
|
create_contribution(user1, item, 1.day.ago) # 3 points
|
||||||
|
create_contribution(user1, pet_type, 2.days.ago) # 15 points
|
||||||
|
create_contribution(user1, alt_style, 3.days.ago) # 30 points
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.first.period_points).to eq(48)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'ordering and filtering' do
|
||||||
|
let(:item) { create_item }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create various contributions
|
||||||
|
3.times { create_contribution(user1, item, 1.day.ago) } # 9 points
|
||||||
|
5.times { create_contribution(user2, item, 2.days.ago) } # 15 points
|
||||||
|
2.times { create_contribution(user3, item, 3.days.ago) } # 6 points
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'orders by period_points descending' do
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses user.id as secondary sort for tied scores' do
|
||||||
|
# Create two users with same points
|
||||||
|
user4 = create_user('Dave')
|
||||||
|
user5 = create_user('Eve')
|
||||||
|
|
||||||
|
create_contribution(user4, item, 1.day.ago) # 3 points
|
||||||
|
create_contribution(user5, item, 1.day.ago) # 3 points
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id])
|
||||||
|
# Should be ordered by user.id ASC when points are tied
|
||||||
|
expect(results.first.id).to be < results.second.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes users with zero contributions in period' do
|
||||||
|
# user3 has no contributions this week
|
||||||
|
user4 = create_user('Dave')
|
||||||
|
|
||||||
|
results = User.top_contributors_for(:this_week)
|
||||||
|
expect(results).not_to include(user4)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid timeframe' do
|
||||||
|
it 'raises ArgumentError' do
|
||||||
|
expect { User.top_contributors_by_period(:invalid) }.
|
||||||
|
to raise_error(ArgumentError, /Invalid timeframe/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#period_points' do
|
||||||
|
let(:user) { create_user('Alice') }
|
||||||
|
|
||||||
|
context 'when period_points attribute is set' do
|
||||||
|
it 'returns the calculated period_points' do
|
||||||
|
# Simulate a query that sets period_points
|
||||||
|
user_with_period = User.select('users.*, 42 AS period_points').find(user.id)
|
||||||
|
expect(user_with_period.period_points).to eq(42)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when period_points attribute is not set' do
|
||||||
|
it 'falls back to denormalized points column' do
|
||||||
|
user.update!(points: 100)
|
||||||
|
expect(user.period_points).to eq(100)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper methods
|
||||||
|
def create_user(name)
|
||||||
|
auth_user = AuthUser.create!(
|
||||||
|
name: name,
|
||||||
|
email: "#{name.downcase}@example.com",
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'password123'
|
||||||
|
)
|
||||||
|
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_contribution(user, contributed, created_at)
|
||||||
|
Contribution.create!(
|
||||||
|
user: user,
|
||||||
|
contributed: contributed,
|
||||||
|
created_at: created_at
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_item
|
||||||
|
# Create a minimal item for testing
|
||||||
|
Item.create!(
|
||||||
|
name: "Test Item #{SecureRandom.hex(4)}",
|
||||||
|
description: "Test item",
|
||||||
|
thumbnail_url: "http://example.com/thumb.png",
|
||||||
|
rarity: "",
|
||||||
|
price: 0,
|
||||||
|
zones_restrict: ""
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_swf_asset
|
||||||
|
# Create a minimal swf_asset for testing
|
||||||
|
zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1)
|
||||||
|
SwfAsset.create!(
|
||||||
|
type: 'object',
|
||||||
|
remote_id: SecureRandom.random_number(100000),
|
||||||
|
url: "http://example.com/test.swf",
|
||||||
|
zone_id: zone.id,
|
||||||
|
body_id: 0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_pet_type
|
||||||
|
# Use find_or_create_by to avoid duplicate key errors
|
||||||
|
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||||
|
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||||
|
PetType.create!(
|
||||||
|
species_id: species.id,
|
||||||
|
color_id: color.id,
|
||||||
|
body_id: 0
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_pet_state
|
||||||
|
pet_type = create_pet_type
|
||||||
|
PetState.create!(
|
||||||
|
pet_type: pet_type,
|
||||||
|
swf_asset_ids: []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_alt_style
|
||||||
|
# Use find_or_create_by to avoid duplicate key errors
|
||||||
|
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||||
|
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||||
|
AltStyle.create!(
|
||||||
|
species_id: species.id,
|
||||||
|
color_id: color.id,
|
||||||
|
body_id: 0,
|
||||||
|
series_name: "Test Series",
|
||||||
|
thumbnail_url: "http://example.com/thumb.png"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
BIN
vendor/cache/rails-controller-testing-1.0.5.gem
vendored
Normal file
BIN
vendor/cache/rails-controller-testing-1.0.5.gem
vendored
Normal file
Binary file not shown.
Loading…
Reference in a new issue