feat: track team action list completion
This commit is contained in:
parent
164b7df625
commit
7d65e4df82
|
|
@ -65,9 +65,12 @@ class StagesController < ApplicationController
|
|||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
return unless AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
||||
|
||||
TeamActionListService.ensure_group_stage_survivor_playoff_token_list!(@stage.tournament)
|
||||
return
|
||||
|
||||
render json: { error: 'Generating group stage failed' }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionItemsController < ApplicationController
|
||||
before_action :set_team_action_item, only: %i[update]
|
||||
before_action :authenticate_user!, only: %i[update]
|
||||
before_action -> { require_owner! @team_action_item.owner }, only: %i[update]
|
||||
before_action -> { require_writable_tournament!(@team_action_item.tournament) }, only: %i[update]
|
||||
|
||||
# PATCH /team_action_items/1
|
||||
def update
|
||||
if @team_action_item.update(team_action_item_params)
|
||||
render json: @team_action_item
|
||||
else
|
||||
render json: @team_action_item.errors, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team_action_item
|
||||
@team_action_item = TeamActionItem.find(params[:id])
|
||||
end
|
||||
|
||||
def team_action_item_params
|
||||
params.slice(:completed).permit(:completed)
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,7 @@ class Team < ApplicationRecord
|
|||
has_many :group_scores, dependent: :destroy
|
||||
has_many :match_scores, dependent: :destroy
|
||||
has_many :bets, dependent: :destroy
|
||||
has_many :team_action_items, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionItem < ApplicationRecord
|
||||
belongs_to :team_action_list
|
||||
belongs_to :team
|
||||
|
||||
validates :team_id, uniqueness: { scope: :team_action_list_id }
|
||||
|
||||
delegate :tournament, :owner, to: :team_action_list
|
||||
|
||||
before_validation :normalize_completed_at
|
||||
|
||||
private
|
||||
|
||||
def normalize_completed_at
|
||||
self.completed_at = completed? ? (completed_at || Time.current) : nil
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionList < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
has_many :team_action_items, dependent: :destroy
|
||||
|
||||
validates :name, :key, :action_name, :source, presence: true
|
||||
validates :key, uniqueness: { scope: :tournament_id }
|
||||
|
||||
delegate :owner, to: :tournament
|
||||
|
||||
def sync_teams!(teams)
|
||||
desired_team_ids = teams.map(&:id).uniq
|
||||
|
||||
transaction do
|
||||
team_action_items.where.not(team_id: desired_team_ids).destroy_all
|
||||
|
||||
desired_team_ids.each do |team_id|
|
||||
team_action_items.find_or_create_by!(team_id: team_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,6 +7,7 @@ class Tournament < ApplicationRecord
|
|||
has_many :teams, dependent: :destroy
|
||||
has_many :stages, dependent: :destroy
|
||||
has_many :beamers, dependent: :destroy
|
||||
has_many :team_action_lists, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :code, presence: true, uniqueness: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionItemSerializer < ApplicationSerializer
|
||||
attributes :completed, :completed_at, :team
|
||||
|
||||
attribute :completed_at do
|
||||
object.completed_at&.iso8601
|
||||
end
|
||||
|
||||
attribute :team do
|
||||
{
|
||||
id: object.team.id,
|
||||
name: object.team.name
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionListSerializer < ApplicationSerializer
|
||||
attributes :name, :key, :action_name, :source
|
||||
|
||||
has_many :team_action_items
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ class TournamentSerializer < SimpleTournamentSerializer
|
|||
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
|
||||
|
||||
has_many :stages
|
||||
has_many :team_action_lists
|
||||
|
||||
attribute :timer_end do
|
||||
object.timer_end&.iso8601
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TeamActionListService
|
||||
GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY = 'group_stage_survivor_playoff_tokens'
|
||||
|
||||
class << self
|
||||
def ensure_group_stage_survivor_playoff_token_list!(tournament)
|
||||
group_stage = tournament.group_stage
|
||||
return nil unless group_stage&.finished?
|
||||
|
||||
teams = tournament.teams_advancing_from_group_stage
|
||||
return nil if teams.empty?
|
||||
|
||||
ensure_list!(
|
||||
tournament: tournament,
|
||||
key: GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY,
|
||||
name: 'Playoff token collection',
|
||||
action_name: 'Collect playoff token',
|
||||
source: 'group_stage_survivors',
|
||||
teams: teams
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_list!(tournament:, key:, name:, action_name:, source:, teams:)
|
||||
list = tournament.team_action_lists.find_or_initialize_by(key: key)
|
||||
list.name = name
|
||||
list.action_name = action_name
|
||||
list.source = source
|
||||
list.save!
|
||||
list.sync_teams!(teams)
|
||||
list
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,8 @@ class TournamentSnapshotBuilder
|
|||
MATCH_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(Match).map(&:to_sym).freeze
|
||||
MATCH_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(MatchScore).map(&:to_sym).freeze
|
||||
GROUP_SCORE_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(GroupScore).map(&:to_sym).freeze
|
||||
TEAM_ACTION_LIST_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionList).map(&:to_sym).freeze
|
||||
TEAM_ACTION_ITEM_ATTRIBUTES = TournamentSyncSchema.synced_columns_for(TeamActionItem).map(&:to_sym).freeze
|
||||
|
||||
def self.build(tournament)
|
||||
new(tournament).build
|
||||
|
|
@ -29,6 +31,8 @@ class TournamentSnapshotBuilder
|
|||
payload[:matches] = matches_payload
|
||||
payload[:match_scores] = match_scores_payload
|
||||
payload[:group_scores] = group_scores_payload
|
||||
payload[:team_action_lists] = team_action_lists_payload
|
||||
payload[:team_action_items] = team_action_items_payload
|
||||
payload
|
||||
end
|
||||
|
||||
|
|
@ -78,6 +82,20 @@ class TournamentSnapshotBuilder
|
|||
end
|
||||
end
|
||||
|
||||
def team_action_lists_payload
|
||||
tournament.team_action_lists.order(:id).map do |team_action_list|
|
||||
team_action_list.attributes.symbolize_keys.slice(*TEAM_ACTION_LIST_ATTRIBUTES)
|
||||
end
|
||||
end
|
||||
|
||||
def team_action_items_payload
|
||||
tournament.team_action_lists.flat_map(&:team_action_items).sort_by(&:id).map do |team_action_item|
|
||||
attrs = team_action_item.attributes.symbolize_keys.slice(*TEAM_ACTION_ITEM_ATTRIBUTES)
|
||||
attrs[:completed_at] = team_action_item.completed_at&.iso8601
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
def teams_for_snapshot
|
||||
(
|
||||
tournament.teams.to_a +
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ class TournamentSnapshotImporter
|
|||
existing_matches = tournament.matches
|
||||
existing_match_scores = existing_matches.flat_map(&:match_scores)
|
||||
existing_group_scores = existing_groups.flat_map(&:group_scores)
|
||||
existing_team_action_lists = tournament.team_action_lists.to_a
|
||||
existing_team_action_items = existing_team_action_lists.flat_map(&:team_action_items)
|
||||
|
||||
update_tournament!
|
||||
teams_by_source_id = sync_teams!
|
||||
|
|
@ -41,7 +43,11 @@ class TournamentSnapshotImporter
|
|||
matches_by_source_id = sync_matches!(stages_by_source_id, groups_by_source_id)
|
||||
sync_match_scores!(matches_by_source_id, teams_by_source_id)
|
||||
sync_group_scores!(groups_by_source_id, teams_by_source_id)
|
||||
team_action_lists_by_source_id = sync_team_action_lists!
|
||||
sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
|
||||
|
||||
destroy_stale_records(existing_team_action_items, snapshot.fetch(:team_action_items, []))
|
||||
destroy_stale_records(existing_team_action_lists, snapshot.fetch(:team_action_lists, []))
|
||||
destroy_stale_records(existing_beamers, snapshot.fetch(:beamers, []))
|
||||
destroy_stale_records(existing_group_scores, snapshot.fetch(:group_scores, []))
|
||||
destroy_stale_records(existing_match_scores, snapshot.fetch(:match_scores, []))
|
||||
|
|
@ -162,6 +168,33 @@ class TournamentSnapshotImporter
|
|||
end
|
||||
end
|
||||
|
||||
def sync_team_action_lists!
|
||||
sync_records(
|
||||
model_class: TeamActionList,
|
||||
existing_records: tournament.team_action_lists.to_a,
|
||||
payload: snapshot.fetch(:team_action_lists, [])
|
||||
) do |team_action_list, attrs|
|
||||
team_action_list.tournament = tournament
|
||||
team_action_list.name = attrs.fetch(:name)
|
||||
team_action_list.key = attrs.fetch(:key)
|
||||
team_action_list.action_name = attrs.fetch(:action_name)
|
||||
team_action_list.source = attrs.fetch(:source)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_team_action_items!(team_action_lists_by_source_id, teams_by_source_id)
|
||||
sync_records(
|
||||
model_class: TeamActionItem,
|
||||
existing_records: tournament.team_action_lists.flat_map(&:team_action_items),
|
||||
payload: snapshot.fetch(:team_action_items, [])
|
||||
) do |team_action_item, attrs|
|
||||
team_action_item.team_action_list = team_action_lists_by_source_id.fetch(attrs.fetch(:team_action_list_id))
|
||||
team_action_item.team = teams_by_source_id.fetch(attrs.fetch(:team_id))
|
||||
team_action_item.completed = attrs.fetch(:completed)
|
||||
team_action_item.completed_at = parse_time(attrs[:completed_at])
|
||||
end
|
||||
end
|
||||
|
||||
def sync_records(model_class:, existing_records:, payload:)
|
||||
existing_by_source_id = existing_records.index_by(&:sync_source_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ class TournamentSyncSchema
|
|||
GroupScore => {
|
||||
synced: %w[id group_points scored_points received_points team_id group_id position].freeze,
|
||||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
TeamActionList => {
|
||||
synced: %w[id name key action_name source].freeze,
|
||||
ignored: %w[tournament_id created_at updated_at sync_source_id].freeze
|
||||
}.freeze,
|
||||
TeamActionItem => {
|
||||
synced: %w[id team_action_list_id team_id completed completed_at].freeze,
|
||||
ignored: %w[created_at updated_at sync_source_id].freeze
|
||||
}.freeze
|
||||
}.freeze
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
resources :stages, only: %i[show update]
|
||||
resources :teams, only: %i[show update]
|
||||
resources :team_action_items, only: %i[update]
|
||||
resources :tournaments do
|
||||
resources :statistics, only: %i[index]
|
||||
resources :matches, only: %i[index]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateTeamActionLists < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :team_action_lists do |t|
|
||||
t.references :tournament, null: false, foreign_key: { on_delete: :cascade }, index: true
|
||||
t.string :name, null: false
|
||||
t.string :key, null: false
|
||||
t.string :action_name, null: false
|
||||
t.string :source, null: false
|
||||
t.bigint :sync_source_id, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :team_action_lists, [:tournament_id, :key], unique: true
|
||||
|
||||
create_table :team_action_items do |t|
|
||||
t.references :team_action_list, null: false, foreign_key: { on_delete: :cascade }, index: true
|
||||
t.references :team, null: false, foreign_key: { on_delete: :cascade }, index: true
|
||||
t.boolean :completed, null: false, default: false
|
||||
t.datetime :completed_at
|
||||
t.bigint :sync_source_id, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :team_action_items, [:team_action_list_id, :team_id], unique: true
|
||||
end
|
||||
end
|
||||
|
|
@ -91,6 +91,16 @@ RSpec.describe StagesController, type: :controller do
|
|||
expect(running_group_stage.tournament.reload.stages.max_by(&:level).teams)
|
||||
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
|
||||
end
|
||||
|
||||
it 'creates playoff token collection list for advancing teams' do
|
||||
tournament = running_group_stage.tournament.reload
|
||||
list = tournament.team_action_lists.find_by!(key: TeamActionListService::GROUP_STAGE_SURVIVOR_PLAYOFF_TOKEN_KEY)
|
||||
|
||||
expect(list.name).to eq('Playoff token collection')
|
||||
expect(list.team_action_items.includes(:team).map(&:team))
|
||||
.to match_array(GroupStageService.get_advancing_teams(running_group_stage))
|
||||
expect(list.team_action_items.pluck(:completed).uniq).to eq([false])
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TeamActionItemsController, type: :controller do
|
||||
let(:team_action_item) { create(:team_action_item) }
|
||||
|
||||
describe 'PATCH #update' do
|
||||
context 'as owner' do
|
||||
before do
|
||||
apply_authentication_headers_for team_action_item.owner
|
||||
end
|
||||
|
||||
it 'ticks item off' do
|
||||
patch :update, params: { id: team_action_item.to_param, completed: true }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(team_action_item.reload.completed).to eq(true)
|
||||
expect(team_action_item.completed_at).not_to be_nil
|
||||
end
|
||||
|
||||
it 'can untick item' do
|
||||
team_action_item.update!(completed: true, completed_at: Time.zone.parse('2026-04-22T09:00:00Z'))
|
||||
|
||||
patch :update, params: { id: team_action_item.to_param, completed: false }
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(team_action_item.reload.completed).to eq(false)
|
||||
expect(team_action_item.completed_at).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'as another user' do
|
||||
before do
|
||||
apply_authentication_headers_for create(:user)
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
patch :update, params: { id: team_action_item.to_param, completed: true }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
expected_keys = %i[
|
||||
id name code public description playoff_teams_amount
|
||||
instant_finalists_amount intermediate_round_participants_amount
|
||||
timer_end owner_username stages teams
|
||||
timer_end owner_username stages teams team_action_lists
|
||||
]
|
||||
expect(json.keys).to match_array(expected_keys)
|
||||
expect(json[:name]).to eq(@tournament.name)
|
||||
|
|
@ -204,6 +204,19 @@ RSpec.describe TournamentsController, type: :controller do
|
|||
advancing_teams = advancing_teams.map { |team| Team.find(team[:id]) }
|
||||
expect(advancing_teams).to match_array(expected_advancing_teams)
|
||||
end
|
||||
|
||||
it 'includes team action lists and item status for all viewers' do
|
||||
list = create(:team_action_list, tournament: @group_stage_tournament)
|
||||
create(:team_action_item, team_action_list: list, team: @group_stage_tournament.teams.first, completed: true)
|
||||
allow(GroupStageService).to receive(:get_advancing_teams).and_return([])
|
||||
|
||||
get :show, params: { id: @group_stage_tournament.to_param }
|
||||
body = deserialize_response response
|
||||
|
||||
expect(body[:team_action_lists].size).to eq(1)
|
||||
expect(body[:team_action_lists].first[:key]).to eq('group_stage_survivor_playoff_tokens')
|
||||
expect(body[:team_action_lists].first[:team_action_items].first[:completed]).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :team_action_item do
|
||||
team_action_list
|
||||
team { create(:team, tournament: team_action_list.tournament) }
|
||||
completed { false }
|
||||
completed_at { nil }
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :team_action_list do
|
||||
tournament
|
||||
name { 'Playoff token collection' }
|
||||
key { 'group_stage_survivor_playoff_tokens' }
|
||||
action_name { 'Collect playoff token' }
|
||||
source { 'group_stage_survivors' }
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TeamActionItemsController, type: :routing do
|
||||
describe 'routing' do
|
||||
it 'routes to #update via PATCH' do
|
||||
expect(patch: '/team_action_items/1').to route_to('team_action_items#update', id: '1')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,6 +7,8 @@ RSpec.describe TournamentSnapshotImporter do
|
|||
it 'replaces follower tournament graph using source ids as stable mapping keys' do
|
||||
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
|
||||
source.beamers.create!(name: 'stream-a', display_state: 'during_game', qr_code_url: 'https://example.com/qr')
|
||||
list = create(:team_action_list, tournament: source)
|
||||
create(:team_action_item, team_action_list: list, team: source.teams.first, completed: true)
|
||||
follower = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||
|
||||
snapshot = TournamentSnapshotBuilder.build(source)
|
||||
|
|
@ -18,6 +20,8 @@ RSpec.describe TournamentSnapshotImporter do
|
|||
expect(follower.name).to eq(source.name)
|
||||
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
|
||||
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count)
|
||||
expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count)
|
||||
expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count)
|
||||
expect(follower.stages.count).to eq(source.stages.count)
|
||||
expect(follower.matches.count).to eq(source.matches.count)
|
||||
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
|
||||
|
|
@ -180,6 +184,21 @@ RSpec.describe TournamentSnapshotImporter do
|
|||
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
|
||||
timer_end: tournament.timer_end&.iso8601,
|
||||
teams: tournament.teams.order(:name).map { |team| team.name },
|
||||
team_action_lists: tournament.team_action_lists.order(:key).map do |list|
|
||||
{
|
||||
key: list.key,
|
||||
name: list.name,
|
||||
action_name: list.action_name,
|
||||
source: list.source,
|
||||
items: list.team_action_items.includes(:team).sort_by { |item| item.team.name }.map do |item|
|
||||
{
|
||||
team_name: item.team.name,
|
||||
completed: item.completed,
|
||||
completed_at: item.completed_at&.iso8601
|
||||
}
|
||||
end
|
||||
}
|
||||
end,
|
||||
beamers: tournament.beamers.order(:name).map do |beamer|
|
||||
beamer.slice(:name, :display_state, :is_default, :qr_code_url, :logo_url).symbolize_keys
|
||||
end,
|
||||
|
|
|
|||
Loading…
Reference in New Issue