diff --git a/app/controllers/api/v1/live/live_controller.rb b/app/controllers/api/v1/live/live_controller.rb index 2a7a0c9e9ee..8c7af16ee04 100644 --- a/app/controllers/api/v1/live/live_controller.rb +++ b/app/controllers/api/v1/live/live_controller.rb @@ -32,19 +32,33 @@ def podiums end def open_round - competition_id = params.require(:competition_id) + competition = Competition.find(params.require(:competition_id)) wcif_id = params.require(:round_id) - round = Round.find_by_wcif_id!(wcif_id, competition_id) + round = Round.find_by_wcif_id!(wcif_id, competition.id) # TODO: Move these to actual error codes at one point + return render json: { status: "unauthorized" }, status: :unauthorized unless @current_user.can_manage_competition?(competition) # Also think about if we should auto open all round ones at competition day start and not have this check return render json: { status: "previous round has empty results" }, status: :bad_request unless round.number == 1 || round.previous_round.score_taking_done? return render json: { status: "round already open" }, status: :bad_request if round.live_results.any? - result = round.init_round + result = round.open_and_lock_previous(@current_user) + + render json: { status: "ok", locked_rows: result } + end + + def quit_competitor + competition = Competition.find(params.require(:competition_id)) + registration_id = params.require(:registration_id) + + return render json: { status: "unauthorized" }, status: :unauthorized unless @current_user.can_manage_competition?(competition) + + round = Round.find_by_wcif_id!(wcif_id, competition.id) + + quit_count = round.quit_from_round!(registration_id, @current_user) - render json: { status: "ok", added_rows: result.affected_rows } + render json: { status: "ok", quit: quit_count } end end diff --git a/app/models/live_result.rb b/app/models/live_result.rb index 096e7df3739..85e74fbe78b 100644 --- a/app/models/live_result.rb +++ b/app/models/live_result.rb @@ -14,8 +14,14 @@ class LiveResult < ApplicationRecord belongs_to :round + belongs_to :quit_by, class_name: 'User', optional: true + + belongs_to :locked_by, class_name: 'User', optional: true + scope :not_empty, -> { where.not(best: 0) } + scope :without_quitters, -> { where(quit_by_id: nil).or(where.not(best: 0)) } + alias_attribute :result_id, :id has_one :event, through: :round @@ -47,6 +53,10 @@ def best_possible_solve_times end end + def mark_as_quit(current_user) + update(quit_by_id: current_user.id, advancing: false, advancing_questionable: false) + end + def self.compute_average_and_best(attempts, round) r = Result.new( event_id: round.event.id, @@ -84,6 +94,6 @@ def notify_users end def trigger_recompute_columns - round.recompute_live_columns + round.recompute_live_columns(skip_advancing: locked_by.present?) end end diff --git a/app/models/round.rb b/app/models/round.rb index b216da9a142..7f73c8d2029 100644 --- a/app/models/round.rb +++ b/app/models/round.rb @@ -44,7 +44,10 @@ def format has_many :wcif_extensions, as: :extendable, dependent: :delete_all has_many :live_results, -> { order(:global_pos) } - has_many :live_competitors, through: :live_results, source: :registration + has_many :live_results_without_quitters, + -> { without_quitters.order(:global_pos) }, + class_name: "LiveResult" + has_many :live_competitors, through: :live_results_without_quitters, source: :registration has_many :results has_many :scrambles @@ -182,6 +185,13 @@ def advancing_registrations end end + def open_and_lock_previous(current_user) + init_round + return 0 if number == 1 || linked_round.present? + + previous_round.lock_results(current_user) + end + def init_round empty_results = advancing_registrations.map do |r| { registration_id: r.id, round_id: id, average: 0, best: 0, last_attempt_entered_at: current_time_from_proper_timezone } @@ -193,18 +203,18 @@ def total_competitors live_competitors.count end - def recompute_live_columns + def recompute_live_columns(skip_advancing: false) recompute_local_pos recompute_global_pos - recompute_advancing + recompute_advancing unless skip_advancing end def recompute_advancing has_linked_round = linked_round.present? advancement_determining_results = has_linked_round ? linked_round.live_results : live_results - # Only ranked results can be considered for advancing. - round_results = advancement_determining_results.where.not(global_pos: nil) + # Only ranked results that are not locked can be considered for advancing. + round_results = advancement_determining_results.where.not(global_pos: nil).where(locked_by_id: nil) round_results.update_all(advancing: false, advancing_questionable: false) missing_attempts = total_competitors - round_results.count @@ -353,6 +363,25 @@ def self.wcif_to_round_attributes(event, wcif, round_number, total_rounds) } end + def lock_results(locking_user) + results_to_lock = linked_round.present? ? linked_round.live_results : live_results + + results_to_lock.update_all(locked_by_id: locking_user.id) + end + + def quit_from_round!(registration_id, quitting_user) + result = live_results.find_by!(registration_id: registration_id) + + is_quit = result.mark_as_quit(quitting_user) + + return is_quit ? 1 : 0 if number == 1 || linked_round.present? + + # We need to also quit the result from the previous round so advancement can be correctly shown + previous_round_results = previous_round.linked_round.present? ? previous_round.linked_round.live_results : previous_round.live_results + + previous_round_results.where(registration_id: registration_id).map { it.mark_as_quit(quitting_user) }.count { it == true } + end + def wcif_id "#{self.event_id}-r#{self.number}" end diff --git a/config/routes.rb b/config/routes.rb index 5dac7495f52..18da6d37e55 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -375,6 +375,7 @@ namespace :live do get '/rounds/:round_id' => 'live#round_results', as: :live_round_results put '/rounds/:round_id/open' => "live#open_round", as: :live_round_open + put '/rounds/:round_id/:registration_id' => 'live#quit_competitor', as: :quit_competitor_from_round get '/podiums' => 'live#podiums', as: :live_podiums get '/registrations/:registration_id' => 'live#by_person', as: :get_live_by_person end diff --git a/db/migrate/20260128194213_add_locked_by_and_quit_by.rb b/db/migrate/20260128194213_add_locked_by_and_quit_by.rb new file mode 100644 index 00000000000..28576c403f0 --- /dev/null +++ b/db/migrate/20260128194213_add_locked_by_and_quit_by.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddLockedByAndQuitBy < ActiveRecord::Migration[8.1] + def change + change_table :live_results, bulk: true do |t| + t.references :quit_by, type: :integer, foreign_key: { to_table: :users } + t.references :locked_by, type: :integer, foreign_key: { to_table: :users } + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b9ece185fe9..86533c6762c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_26_101113) do +ActiveRecord::Schema[8.1].define(version: 2026_01_28_194213) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", precision: nil, null: false @@ -779,10 +779,14 @@ t.integer "global_pos" t.datetime "last_attempt_entered_at", null: false t.integer "local_pos" + t.integer "locked_by_id" + t.integer "quit_by_id" t.bigint "registration_id", null: false t.bigint "round_id", null: false t.string "single_record_tag", limit: 255 t.datetime "updated_at", null: false + t.index ["locked_by_id"], name: "index_live_results_on_locked_by_id" + t.index ["quit_by_id"], name: "index_live_results_on_quit_by_id" t.index ["registration_id", "round_id"], name: "index_live_results_on_registration_id_and_round_id", unique: true t.index ["registration_id"], name: "index_live_results_on_registration_id" t.index ["round_id"], name: "index_live_results_on_round_id" @@ -1579,6 +1583,8 @@ add_foreign_key "inbox_scrambles", "inbox_scramble_sets" add_foreign_key "inbox_scrambles", "inbox_scramble_sets", column: "matched_scramble_set_id" add_foreign_key "live_attempt_history_entries", "live_attempts" + add_foreign_key "live_results", "users", column: "locked_by_id" + add_foreign_key "live_results", "users", column: "quit_by_id" add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", on_delete: :cascade add_foreign_key "payment_intents", "users", column: "initiated_by_id" add_foreign_key "paypal_records", "paypal_records", column: "parent_record_id" diff --git a/spec/factories/live_results.rb b/spec/factories/live_results.rb index 8a3b8956b92..f7dea25ae82 100644 --- a/spec/factories/live_results.rb +++ b/spec/factories/live_results.rb @@ -9,6 +9,9 @@ average { 5000 } last_attempt_entered_at { Time.now.utc } + locked_by_id { nil } + quit_by_id { nil } + transient do attempts_count { 5 } end diff --git a/spec/requests/live_recompute_advancing_spec.rb b/spec/requests/live_recompute_advancing_spec.rb index 255f9531178..40c3893d2c9 100644 --- a/spec/requests/live_recompute_advancing_spec.rb +++ b/spec/requests/live_recompute_advancing_spec.rb @@ -111,5 +111,68 @@ def attempt_result_condition expect(round.live_results.pluck(:advancing)).to eq([true, true, false, false, false]) end end + + context "with locked results" do + it "doesn't change advancing of locked results" do + round = create(:round, number: 1, total_number_of_rounds: 2, event_id: "333", competition: competition, advancement_condition: attempt_result_condition) + + 5.times do |i| + create(:live_result, registration: registrations[i], round: round, average: (i + 1) * 100) + end + + expect(round.total_competitors).to eq 5 + expect(round.competitors_live_results_entered).to eq 5 + + round.lock_results(User.first) + # Update best/average after locking + round.live_results.last.update(average: 50) + + # Advancing is not updated, but ranking is + expect(round.live_results.pluck(:global_pos, :advancing)).to eq([[1, false], [2, true], [3, true], [4, false], [5, false]]) + end + end + + context "with quit results" do + it "quit from first round excludes from competitors" do + round = create(:round, number: 1, total_number_of_rounds: 2, event_id: "333", competition: competition, advancement_condition: attempt_result_condition) + registration_1 = registrations.first + + # Open Round + round.open_and_lock_previous(User.first) + + # Quit Competitor + round.quit_from_round!(registration_1.id, User.first) + + # Quit users is not part of the rounds competitors + expect(round.live_competitors.count).to eq 4 + expect(round.live_competitors.pluck(:registration_id)).not_to include registrations.first.id + end + + it "quit from next round marks as no advancing in previous round" do + round = create(:round, number: 1, total_number_of_rounds: 2, event_id: "333", competition: competition, advancement_condition: attempt_result_condition) + final = create(:round, number: 2, total_number_of_rounds: 2, event_id: "333", competition: competition) + + 5.times do |i| + create(:live_result, registration: registrations[i], round: round, average: (i + 1) * 100) + end + + expect(round.total_competitors).to eq 5 + expect(round.competitors_live_results_entered).to eq 5 + + # Open next round and quit first result from it + final.open_and_lock_previous(User.first) + final.quit_from_round!(registrations.first.id, User.first) + + # Quit user is marked as not advancing + expect(round.live_results.reload.pluck(:global_pos, :advancing)).to eq([[1, false], [2, true], [3, false], [4, false], [5, false]]) + + # Quit users is not part of the final round competitors + expect(final.live_competitors.count).to eq 1 + expect(final.live_competitors.first.id).to eq registrations.second.id + + # But still part of the first round competitors + expect(round.live_competitors.count).to eq 5 + end + end end end