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 00000000..870b8085 Binary files /dev/null and b/vendor/cache/rails-controller-testing-1.0.5.gem differ