From 366158b698d8a4b5b3208f35f6fa6f6dc98421d7 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Tue, 20 Jan 2026 19:54:14 -0800 Subject: [PATCH] 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. --- Gemfile | 1 + Gemfile.lock | 5 + .../stylesheets/users/_top_contributors.sass | 10 +- app/controllers/users_controller.rb | 5 +- app/models/user.rb | 45 +++ app/views/users/top_contributors.html.haml | 11 +- config/locales/en-MEEP.yml | 5 + config/locales/en.yml | 5 + config/locales/es.yml | 5 + config/locales/pt.yml | 5 + ...to_contributions_user_id_and_created_at.rb | 6 + db/openneo_id_schema.rb | 32 +- db/schema.rb | 205 ++++++------ db/seeds/top_contributors_sample_data.rb | 72 +++++ spec/controllers/users_controller_spec.rb | 119 +++++++ spec/models/user_spec.rb | 293 ++++++++++++++++++ .../cache/rails-controller-testing-1.0.5.gem | Bin 0 -> 39936 bytes 17 files changed, 703 insertions(+), 121 deletions(-) create mode 100644 db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb create mode 100644 db/seeds/top_contributors_sample_data.rb create mode 100644 spec/controllers/users_controller_spec.rb create mode 100644 spec/models/user_spec.rb create mode 100644 vendor/cache/rails-controller-testing-1.0.5.gem diff --git a/Gemfile b/Gemfile index cf82fc06..0560cc29 100644 --- a/Gemfile +++ b/Gemfile @@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1" # For automated tests. gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test] +gem 'rails-controller-testing', group: [:test] gem "webmock", "~> 3.24", group: [:test] diff --git a/Gemfile.lock b/Gemfile.lock index e31c4707..bb90cac4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -341,6 +341,10 @@ GEM activesupport (= 8.1.2) bundler (>= 1.15.0) 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) activesupport (>= 5.0.0) minitest @@ -505,6 +509,7 @@ DEPENDENCIES rack-attack (~> 6.7) rack-mini-profiler (~> 4.0, >= 4.0.1) rails (~> 8.0, >= 8.0.1) + rails-controller-testing rails-i18n (~> 8.0, >= 8.0.1) rdiscount (~> 2.2, >= 2.2.7.1) rspec-rails (~> 8.0, >= 8.0.2) diff --git a/app/assets/stylesheets/users/_top_contributors.sass b/app/assets/stylesheets/users/_top_contributors.sass index 336b8d22..f6e6faea 100644 --- a/app/assets/stylesheets/users/_top_contributors.sass +++ b/app/assets/stylesheets/users/_top_contributors.sass @@ -2,7 +2,15 @@ body.users-top_contributors text-align: center - + + .timeframe-nav + margin: 1em 0 + display: flex + justify-content: center + gap: 1em + list-style: none + padding: 0 + #top-contributors border: spacing: 0 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c5e636ee..ad05b940 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,7 +14,10 @@ class UsersController < ApplicationController end 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 def edit diff --git a/app/models/user.rb b/app/models/user.rb index a70a2d81..4960f99e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,51 @@ class User < ApplicationRecord 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 :log_trade_activity, if: -> user { (user.saved_change_to_owned_closet_hangers_visibility? && diff --git a/app/views/users/top_contributors.html.haml b/app/views/users/top_contributors.html.haml index c4bcb33f..8c32ca80 100644 --- a/app/views/users/top_contributors.html.haml +++ b/app/views/users/top_contributors.html.haml @@ -1,4 +1,13 @@ - 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 %table#top-contributors %thead @@ -11,5 +20,5 @@ %tr %th{:scope => 'row'}= @users.offset + rank + 1 %td= link_to user.name, user_contributions_path(user) - %td= user.points + %td= user.period_points = will_paginate @users diff --git a/config/locales/en-MEEP.yml b/config/locales/en-MEEP.yml index 8f09ff72..aa3b8f04 100644 --- a/config/locales/en-MEEP.yml +++ b/config/locales/en-MEEP.yml @@ -640,6 +640,11 @@ en-MEEP: rank: Reep user: Meepit points: Peeps + timeframes: + all_time: All Meep + this_year: Meeps Year + this_month: Meeps Month + this_week: Meeps Week update: success: Settings successfully meeped. diff --git a/config/locales/en.yml b/config/locales/en.yml index a96038df..8e71dcf5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -783,6 +783,11 @@ en: rank: Rank user: User points: Points + timeframes: + all_time: All Time + this_year: This Year + this_month: This Month + this_week: This Week update: success: Settings successfully saved. diff --git a/config/locales/es.yml b/config/locales/es.yml index e655dbd2..eb175919 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -505,6 +505,11 @@ es: rank: Puesto user: Usuario points: Puntos + timeframes: + all_time: Todo el Tiempo + this_year: Este Año + this_month: Este Mes + this_week: Esta Semana update: success: Ajustes guardados correctamente. invalid: "No hemos podido guardar los ajustes: %{errors}" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 90b5331b..8dffb0f0 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -499,6 +499,11 @@ pt: rank: Rank user: Usuário points: Pontos + timeframes: + all_time: Todo o Tempo + this_year: Este Ano + this_month: Este Mês + this_week: Esta Semana update: success: Configurações salvas com sucesso invalid: "Não foi possível salvar as configurações: %{errors}" diff --git a/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb b/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb new file mode 100644 index 00000000..7b26ea7c --- /dev/null +++ b/db/migrate/20260121031001_add_index_to_contributions_user_id_and_created_at.rb @@ -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 diff --git a/db/openneo_id_schema.rb b/db/openneo_id_schema.rb index 250d7a4d..3b0af5b4 100644 --- a/db/openneo_id_schema.rb +++ b/db/openneo_id_schema.rb @@ -10,28 +10,28 @@ # # 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| - t.string "name", limit: 30, null: false - t.string "encrypted_password", limit: 64 + t.datetime "created_at", precision: nil + t.datetime "current_sign_in_at", precision: nil + t.string "current_sign_in_ip" 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 "provider" + t.datetime "remember_created_at" + t.datetime "reset_password_sent_at", precision: nil t.string "reset_password_token" 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 "neopass_email" + t.string "unlock_token" + t.datetime "updated_at", precision: nil 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 ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/db/schema.rb b/db/schema.rb index 809c71ff..0b6d3206 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,50 +10,50 @@ # # 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| - t.integer "species_id", null: false - t.integer "color_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 "updated_at", precision: nil, null: false - t.string "series_name" - t.string "thumbnail_url", null: false 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 ["species_id"], name: "index_alt_styles_on_species_id" end 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 "icon", size: :long, null: false + t.string "name", limit: 40, null: false t.string "secret", limit: 64, null: false + t.string "short_name", limit: 10, null: false end 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.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, 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.string "purpose", default: "our hosting costs this year", null: false - t.string "theme_id", default: "hug", null: false - t.text "thanks", size: :long + t.integer "goal", null: false 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 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 "updated_at", precision: nil - t.boolean "owned", default: true, null: false + t.integer "item_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 ["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" @@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end 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.text "description", size: :long + t.boolean "hangers_owned", null: false + t.string "name" t.datetime "updated_at", precision: nil + t.integer "user_id" t.integer "visibility", default: 1, null: false t.index ["user_id"], name: "index_closet_lists_on_user_id" end create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.boolean "basic" - t.boolean "standard" t.string "name", null: false t.string "pb_item_name" t.string "pb_item_thumbnail_url" + t.boolean "standard" end 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 "user_id", null: false + t.string "contributed_type", limit: 8, 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 ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at" t.index ["user_id"], name: "index_contributions_on_user_id" end 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 "outfit_id" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false end create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "amount", null: false + t.integer "campaign_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 "secret" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "donor_email" - t.integer "campaign_id", null: false + t.integer "user_id" end 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 "outfit_id" - t.boolean "is_worn" - t.datetime "created_at", precision: nil t.datetime "updated_at", precision: nil 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" end 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 "thumbnail_url", size: :long, null: false + t.text "cached_compatible_body_ids", default: "" + t.string "cached_occupied_zone_ids", default: "" + t.boolean "cached_predicted_fully_modeled", default: false, null: false 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 "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 "is_manually_nc", default: false, null: false t.integer "manual_special_color_id" t.column "modeling_status_hint", "enum('done','glitchy')" - t.boolean "is_manually_nc", default: false, 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.integer "dyeworks_base_item_id" - t.string "cached_occupied_zone_ids", default: "" - t.text "cached_compatible_body_ids", default: "" - t.boolean "cached_predicted_fully_modeled", default: false, null: false + t.integer "rarity_index", limit: 2 + t.text "species_support_ids", size: :long + t.text "thumbnail_url", size: :long, 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 ["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" @@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end 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 "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"], name: "login_cookies_user_id" end @@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do end create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| - t.integer "item_id", null: false - t.integer "price", null: false - t.integer "discount_price" + t.datetime "created_at", null: false t.datetime "discount_begins_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.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true end 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.string "neopets_username" t.datetime "updated_at", precision: nil, null: false + t.integer "user_id" end 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.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 ["pet_state_id"], name: "index_outfits_on_pet_state_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| t.integer "parent_id", null: false - t.integer "swf_asset_id", 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", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id" end 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.datetime "created_at", precision: nil, null: false + t.string "pet_name", limit: 20, null: false end 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.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.index ["pet_type_id"], name: "pet_states_pet_type_id" end 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.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"], name: "pet_types_body_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 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.datetime "reported_broken_at", precision: nil + t.string "type", limit: 7, null: false t.text "url", size: :long, null: false t.integer "zone_id", 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 ["type", "remote_id"], name: "swf_assets_type_and_id" t.index ["zone_id"], name: "idx_swf_assets_zone_id" end 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 "remote_id", null: false - t.integer "points", default: 0, 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.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 "support_staff", default: false, null: false + t.integer "wanted_closet_hangers_visibility", default: 1, null: false end create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "depth" - t.integer "type_id" t.string "label", null: false t.string "plain_label", null: false + t.integer "type_id" end add_foreign_key "alt_styles", "colors" diff --git a/db/seeds/top_contributors_sample_data.rb b/db/seeds/top_contributors_sample_data.rb new file mode 100644 index 00000000..6721846a --- /dev/null +++ b/db/seeds/top_contributors_sample_data.rb @@ -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" diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 00000000..de0c42f7 --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 00000000..3d30994c --- /dev/null +++ b/spec/models/user_spec.rb @@ -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 diff --git a/vendor/cache/rails-controller-testing-1.0.5.gem b/vendor/cache/rails-controller-testing-1.0.5.gem new file mode 100644 index 0000000000000000000000000000000000000000..870b8085cd53f24c3b7f64e0d72bc3f5e808fe11 GIT binary patch literal 39936 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 M4*YT8-{rvH03ybwTmS$7 literal 0 HcmV?d00001