Merge origin/master into follower sync

This commit is contained in:
Daniel Schädler 2026-04-16 15:54:32 +02:00
commit 8789c2cb2e
15 changed files with 551 additions and 30 deletions

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
class BeamersController < ApplicationController
before_action :set_tournament
before_action :set_beamer, only: %i[show update destroy]
before_action :authenticate_user!, only: %i[create update destroy]
before_action -> { require_owner! @tournament.owner }, only: %i[create update destroy]
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_error
# GET /tournaments/:tournament_id/beamers
def index
render json: @tournament.beamers, each_serializer: BeamerSerializer
end
# GET /tournaments/:tournament_id/beamers/:id
def show
render json: @beamer
end
# GET /tournaments/:tournament_id/beamers/by_name?name=beamername
def by_name
beamer = @tournament.beamers.find_by(name: params[:name])
beamer ||= @tournament.beamers.find_by(name: Beamer::DEFAULT_NAME)
if beamer
render json: beamer
else
render json: { error: 'No beamer found' }, status: :not_found
end
end
# POST /tournaments/:tournament_id/beamers
def create
beamer = @tournament.beamers.new(beamer_params)
if beamer.save
render json: beamer, status: :created
else
render json: beamer.errors, status: :unprocessable_entity
end
end
# PATCH /tournaments/:tournament_id/beamers/:id
def update
if @beamer.update(beamer_params)
render json: @beamer
else
render json: @beamer.errors, status: :unprocessable_entity
end
end
# DELETE /tournaments/:tournament_id/beamers/:id
def destroy
@beamer.destroy
head :no_content
end
private
def set_tournament
@tournament = Tournament.find(params[:tournament_id])
end
def set_beamer
@beamer = @tournament.beamers.find(params[:id])
end
def beamer_params
params.permit(:name, :display_state, :qr_code_url, :logo_url)
end
end

View File

@ -127,7 +127,7 @@ class TournamentsController < ApplicationController
# GET /tournaments/:id/timer_end # GET /tournaments/:id/timer_end
def timer_end def timer_end
render json: { timer_end: @tournament.timer_end } render json: { timer_end: serialize_timer_end(@tournament.timer_end) }
end end
# PATCH /tournaments/:id/set_timer_end # PATCH /tournaments/:id/set_timer_end
@ -154,6 +154,10 @@ class TournamentsController < ApplicationController
{ timer_end: params[:timer_end] } { timer_end: params[:timer_end] }
end end
def serialize_timer_end(timer_end)
timer_end&.iso8601
end
def organize_teams_in_groups(teams) def organize_teams_in_groups(teams)
# each team gets put into an array of teams depending on the group specified in team[:group] # each team gets put into an array of teams depending on the group specified in team[:group]
teams.group_by { |team| team['group'] }.values.map do |group| teams.group_by { |team| team['group'] }.values.map do |group|
@ -260,7 +264,6 @@ class TournamentsController < ApplicationController
error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match' error: 'playoff_teams_amount, instant_finalists_amount and intermediate_round_participants_amount don\'t match'
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
def sync_state_params def sync_state_params
params.require(:snapshot).permit!.to_h params.require(:snapshot).permit!.to_h
end end
@ -331,6 +334,8 @@ def validate_set_timer_end_params
elsif !parsed_time.future? elsif !parsed_time.future?
return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity return render json: { error: 'Timer end must be in the future' }, status: :unprocessable_entity
end end
params[:timer_end] = parsed_time
rescue ArgumentError rescue ArgumentError
return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity return render json: { error: 'Invalid datetime format' }, status: :unprocessable_entity
end end
@ -338,3 +343,4 @@ def validate_set_timer_end_params
return render json: { error: 'Timer end is required' }, status: :unprocessable_entity return render json: { error: 'Timer end is required' }, status: :unprocessable_entity
end end
end end
end

16
app/models/beamer.rb Normal file
View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Beamer < ApplicationRecord
DEFAULT_NAME = 'default'
DISPLAY_STATES = %w[
matches_all matches_in_progress matches_upcoming matches_not_started
matches_finished groups between_games during_game
].freeze
belongs_to :tournament
validates :name, presence: true, uniqueness: { scope: :tournament_id }
validates :display_state, presence: true, inclusion: { in: DISPLAY_STATES }
delegate :owner, to: :tournament
end

View File

@ -6,6 +6,7 @@ class Tournament < ApplicationRecord
belongs_to :user belongs_to :user
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy has_many :stages, dependent: :destroy
has_many :beamers, dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true
@ -17,6 +18,7 @@ class Tournament < ApplicationRecord
alias_attribute :owner, :user alias_attribute :owner, :user
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer!
before_validation :clear_follower_sync_token_when_disabling_read_only_mode before_validation :clear_follower_sync_token_when_disabling_read_only_mode
def matches def matches
@ -69,6 +71,20 @@ class Tournament < ApplicationRecord
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together') errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end end
def ensure_default_beamer!
beamers.find_or_create_by!(name: Beamer::DEFAULT_NAME) do |beamer|
beamer.display_state = 'matches_all'
end
end
def sync_configuration_is_complete
return if sync_target_url.blank? && sync_auth_token.blank?
return if sync_target_url.present? && sync_auth_token.present?
return if read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
errors.add(:base, 'sync_target_url and sync_auth_token must be configured together')
end
def clear_follower_sync_token_when_disabling_read_only_mode def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode? return if read_only_mode?
return if sync_target_url.present? return if sync_target_url.present?

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class BeamerSerializer < ApplicationSerializer
attributes :name, :display_state, :qr_code_url, :logo_url
end

View File

@ -2,9 +2,13 @@
class TournamentSerializer < SimpleTournamentSerializer class TournamentSerializer < SimpleTournamentSerializer
attributes :description, :playoff_teams_amount, attributes :description, :playoff_teams_amount,
:instant_finalists_amount, :intermediate_round_participants_amount, :timer_end :instant_finalists_amount, :intermediate_round_participants_amount
has_many :stages has_many :stages
attribute :timer_end do
object.timer_end&.iso8601
end
attribute :owner_username do attribute :owner_username do
object.owner.username object.owner.username
end end

View File

@ -17,6 +17,11 @@ Rails.application.routes.draw do
resources :tournaments do resources :tournaments do
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index]
resources :beamers, only: %i[index show create update destroy] do
collection do
get :by_name
end
end
member do member do
get :timer_end get :timer_end
patch :set_timer_end patch :set_timer_end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateBeamers < ActiveRecord::Migration[7.0]
def change
create_table :beamers do |t|
t.string :name, null: false
t.string :display_state, null: false, default: 'matches_all'
t.boolean :is_default, default: false
t.string :qr_code_url
t.string :logo_url
t.belongs_to :tournament, index: true, foreign_key: { on_delete: :cascade }, null: false
t.timestamps
end
add_index :beamers, %i[tournament_id name], unique: true
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
class BackfillDefaultBeamersByName < ActiveRecord::Migration[7.0]
DEFAULT_BEAMER_NAME = 'default'
class MigrationTournament < ApplicationRecord
self.table_name = 'tournaments'
end
class MigrationBeamer < ApplicationRecord
self.table_name = 'beamers'
end
def up
MigrationTournament.find_each do |tournament|
beamers = MigrationBeamer.where(tournament_id: tournament.id)
next if beamers.exists?(name: DEFAULT_BEAMER_NAME)
legacy_default = beamers.find_by(is_default: true)
if legacy_default
legacy_default.update_columns(name: DEFAULT_BEAMER_NAME)
else
MigrationBeamer.create!(
tournament_id: tournament.id,
name: DEFAULT_BEAMER_NAME,
display_state: 'matches_all'
)
end
end
end
def down
MigrationBeamer.where(name: DEFAULT_BEAMER_NAME).where.not(tournament_id: nil).find_each do |beamer|
next unless MigrationBeamer.where(tournament_id: beamer.tournament_id).count == 1
beamer.delete
end
end
end

View File

@ -0,0 +1,194 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe BeamersController, type: :controller do
before do
@tournament = create(:tournament)
@owner = @tournament.owner
@default_beamer = @tournament.beamers.find_by!(name: Beamer::DEFAULT_NAME)
@beamer = create(:beamer, tournament: @tournament)
end
describe 'GET #index' do
it 'returns all beamers for the tournament' do
get :index, params: { tournament_id: @tournament.to_param }
expect(response).to be_successful
body = deserialize_response response
expect(body.length).to eq(2)
end
end
describe 'GET #show' do
it 'returns the beamer' do
get :show, params: { tournament_id: @tournament.to_param, id: @beamer.to_param }
expect(response).to be_successful
body = deserialize_response response
expect(body[:name]).to eq(@beamer.name)
expect(body[:display_state]).to eq(@beamer.display_state)
end
it 'returns 404 for nonexistent beamer' do
get :show, params: { tournament_id: @tournament.to_param, id: 999_999 }
expect(response).to have_http_status(:not_found)
end
end
describe 'GET #by_name' do
it 'returns beamer by name' do
get :by_name, params: { tournament_id: @tournament.to_param, name: @beamer.name }
expect(response).to be_successful
body = deserialize_response response
expect(body[:name]).to eq(@beamer.name)
end
it 'falls back to default beamer when name not found' do
get :by_name, params: { tournament_id: @tournament.to_param, name: 'nonexistent' }
expect(response).to be_successful
body = deserialize_response response
expect(body[:name]).to eq(@default_beamer.name)
end
it 'falls back to default beamer when name param is missing' do
get :by_name, params: { tournament_id: @tournament.to_param }
expect(response).to be_successful
body = deserialize_response response
expect(body[:name]).to eq(Beamer::DEFAULT_NAME)
end
it 'returns 404 when all beamers were deleted' do
empty_tournament = create(:tournament)
empty_tournament.beamers.destroy_all
get :by_name, params: { tournament_id: empty_tournament.to_param, name: 'anything' }
expect(response).to have_http_status(:not_found)
end
end
describe 'POST #create' do
let(:valid_params) do
{ name: 'new-beamer', display_state: 'groups' }
end
context 'as owner' do
before(:each) { apply_authentication_headers_for @owner }
it 'creates a new beamer' do
expect {
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
}.to change(@tournament.beamers, :count).by(1)
expect(response).to have_http_status(:created)
end
it 'returns the created beamer' do
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
body = deserialize_response response
expect(body[:name]).to eq('new-beamer')
expect(body[:display_state]).to eq('groups')
end
it 'rejects invalid display_state' do
post :create, params: {
tournament_id: @tournament.to_param, name: 'bad', display_state: 'invalid'
}
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects duplicate name in same tournament' do
post :create, params: {
tournament_id: @tournament.to_param, name: @beamer.name, display_state: 'matches_all'
}
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'as another user' do
before(:each) { apply_authentication_headers_for create(:user) }
it 'returns forbidden' do
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
expect(response).to have_http_status(:forbidden)
end
end
context 'unauthenticated' do
it 'returns unauthorized' do
post :create, params: { tournament_id: @tournament.to_param }.merge(valid_params)
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PATCH #update' do
context 'as owner' do
before(:each) { apply_authentication_headers_for @owner }
it 'updates the display_state' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'during_game'
}
expect(response).to be_successful
body = deserialize_response response
expect(body[:display_state]).to eq('during_game')
expect(@beamer.reload.display_state).to eq('during_game')
end
it 'updates optional fields' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
qr_code_url: 'https://example.com/qr', logo_url: '/static/logo.png'
}
expect(response).to be_successful
@beamer.reload
expect(@beamer.qr_code_url).to eq('https://example.com/qr')
expect(@beamer.logo_url).to eq('/static/logo.png')
end
it 'rejects invalid display_state' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'bogus'
}
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'as another user' do
before(:each) { apply_authentication_headers_for create(:user) }
it 'returns forbidden' do
patch :update, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param,
display_state: 'groups'
}
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'DELETE #destroy' do
context 'as owner' do
before(:each) { apply_authentication_headers_for @owner }
it 'deletes the beamer' do
expect {
delete :destroy, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param
}
}.to change(@tournament.beamers, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
end
context 'as another user' do
before(:each) { apply_authentication_headers_for create(:user) }
it 'returns forbidden' do
delete :destroy, params: {
tournament_id: @tournament.to_param, id: @beamer.to_param
}
expect(response).to have_http_status(:forbidden)
end
end
end
end

View File

@ -162,6 +162,34 @@ RSpec.describe TournamentsController, type: :controller do
end end
end end
describe 'GET #timer_end' do
it 'returns timer_end in ISO 8601 format' do
timer_end = Time.utc(2026, 4, 13, 18, 0, 0)
@tournament.update!(timer_end: timer_end)
get :timer_end, params: { id: @tournament.to_param }
expect(response).to be_successful
expect(deserialize_response(response)[:timer_end]).to eq(timer_end.iso8601)
end
end
describe 'PATCH #set_timer_end' do
before(:each) do
apply_authentication_headers_for @user
end
it 'stores timezone-aware timer_end values without shifting them' do
timer_end = '2030-04-13T18:00:00+02:00'
patch :set_timer_end, params: { id: @tournament.to_param, timer_end: timer_end }
expect(response).to be_successful
expect(@tournament.reload.timer_end.iso8601).to eq(Time.iso8601(timer_end).utc.iso8601)
expect(deserialize_response(response)[:timer_end]).to eq(@tournament.timer_end.iso8601)
end
end
describe 'POST #create' do describe 'POST #create' do
let(:create_playoff_tournament_data) do let(:create_playoff_tournament_data) do
{ {
@ -250,6 +278,14 @@ RSpec.describe TournamentsController, type: :controller do
expect(included_teams).to match_array(@teams) expect(included_teams).to match_array(@teams)
end end
it 'creates a default beamer automatically' do
post :create, params: create_playoff_tournament_data
body = deserialize_response response
tournament = Tournament.find(body[:id])
expect(tournament.beamers.pluck(:name)).to include(Beamer::DEFAULT_NAME)
end
context 'with parameter group_stage=true' do context 'with parameter group_stage=true' do
before do before do
post :create, params: create_group_tournament_data post :create, params: create_group_tournament_data

17
spec/factories/beamers.rb Normal file
View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
FactoryBot.define do
factory :beamer do
sequence(:name) { |n| "beamer-#{n}" }
display_state { 'matches_all' }
tournament
factory :default_beamer do
name { Beamer::DEFAULT_NAME }
initialize_with do
tournament.beamers.find_or_initialize_by(name: name)
end
end
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Beamer, type: :model do
describe 'validation' do
it { should validate_presence_of :name }
it { should validate_presence_of :display_state }
it 'validates uniqueness of name scoped to tournament' do
beamer = create(:beamer)
duplicate = build(:beamer, name: beamer.name, tournament: beamer.tournament)
expect(duplicate).not_to be_valid
end
it 'allows same name in different tournaments' do
beamer = create(:beamer, name: 'projector')
other = build(:beamer, name: 'projector', tournament: create(:tournament))
expect(other).to be_valid
end
it 'validates display_state inclusion' do
beamer = build(:beamer, display_state: 'invalid_state')
expect(beamer).not_to be_valid
end
Beamer::DISPLAY_STATES.each do |state|
it "accepts display_state '#{state}'" do
beamer = build(:beamer, display_state: state)
expect(beamer).to be_valid
end
end
end
describe 'association' do
it { should belong_to :tournament }
end
describe '#owner' do
it 'delegates to tournament' do
beamer = create(:beamer)
expect(beamer.owner).to eq(beamer.tournament.owner)
end
end
end

View File

@ -29,6 +29,15 @@ RSpec.describe Tournament, type: :model do
it { should belong_to :user } it { should belong_to :user }
it { should have_many :teams } it { should have_many :teams }
it { should have_many :stages } it { should have_many :stages }
it { should have_many :beamers }
end
describe 'default beamer' do
it 'creates a default beamer automatically' do
tournament = create(:tournament)
expect(tournament.beamers.pluck(:name)).to include(Beamer::DEFAULT_NAME)
end
end end
describe '#matches' do describe '#matches' do

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe BeamersController, type: :routing do
describe 'routing' do
it 'routes to #index' do
expect(get: '/tournaments/1/beamers').to route_to(
'beamers#index', tournament_id: '1'
)
end
it 'routes to #show' do
expect(get: '/tournaments/1/beamers/2').to route_to(
'beamers#show', tournament_id: '1', id: '2'
)
end
it 'routes to #by_name' do
expect(get: '/tournaments/1/beamers/by_name').to route_to(
'beamers#by_name', tournament_id: '1'
)
end
it 'routes to #create' do
expect(post: '/tournaments/1/beamers').to route_to(
'beamers#create', tournament_id: '1'
)
end
it 'routes to #update via PATCH' do
expect(patch: '/tournaments/1/beamers/2').to route_to(
'beamers#update', tournament_id: '1', id: '2'
)
end
it 'routes to #destroy' do
expect(delete: '/tournaments/1/beamers/2').to route_to(
'beamers#destroy', tournament_id: '1', id: '2'
)
end
end
end