Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
module Tournaments
|
||||
class CaptureStreamResult
|
||||
def self.call(session)
|
||||
new(session).call
|
||||
end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
match = @session.match
|
||||
return unless match&.tournament_match?
|
||||
|
||||
score = @session.score_state
|
||||
return unless score
|
||||
|
||||
board = match.effective_board_type
|
||||
home, away, extra = case board
|
||||
when "basket", "timed"
|
||||
[score.basket_home_score, score.basket_away_score, { "board" => board }]
|
||||
else
|
||||
[score.home_sets, score.away_sets, { "board" => board, "partials" => score.set_partials }]
|
||||
end
|
||||
|
||||
Tournaments::RecordResult.call(
|
||||
match: match,
|
||||
home_score: home,
|
||||
away_score: away,
|
||||
source: "stream",
|
||||
result_data: extra
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Tournaments
|
||||
class ComposeInviteEmail
|
||||
LINK_TOKEN = "{{link_invito}}"
|
||||
EXPIRY_TOKEN = "{{scadenza}}"
|
||||
MAX_HTML_BYTES = 100_000
|
||||
ALLOWED_TAGS = %w[p br strong b em i u ul ol li a img h2 h3 h4 span div blockquote].freeze
|
||||
ALLOWED_ATTR = %w[href src alt width height style target rel data-invite-note].freeze
|
||||
ALLOWED_STYLE = %w[
|
||||
width height max-width min-width margin margin-top margin-bottom margin-left margin-right
|
||||
padding padding-top padding-bottom padding-left padding-right
|
||||
float display text-align color background background-color
|
||||
font-weight font-size line-height text-decoration border-radius border
|
||||
].freeze
|
||||
|
||||
def self.call(html:, invite_url:, expires_on: nil)
|
||||
new(html: html, invite_url: invite_url, expires_on: expires_on).call
|
||||
end
|
||||
|
||||
def self.sanitize_html(html)
|
||||
new(html: html.to_s, invite_url: "").sanitize(html.to_s)
|
||||
end
|
||||
|
||||
def self.default_html(tournament:, invited_by:)
|
||||
intro = I18n.t(
|
||||
"mailers.tournament_invite.body_html",
|
||||
inviter: CGI.escapeHTML(invited_by.name.to_s),
|
||||
tournament: CGI.escapeHTML(tournament.name.to_s),
|
||||
club: CGI.escapeHTML(tournament.club.name.to_s),
|
||||
assignment: I18n.t("tournaments.hub.invite_email_scope_generic")
|
||||
)
|
||||
cta = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
|
||||
<<~HTML
|
||||
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}</p>
|
||||
<p>#{intro}</p>
|
||||
<p><a href="#{LINK_TOKEN}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{cta}</a></p>
|
||||
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}<br>#{LINK_TOKEN}</p>
|
||||
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}</p>
|
||||
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}</p>
|
||||
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}</p>
|
||||
HTML
|
||||
end
|
||||
|
||||
def initialize(html:, invite_url:, expires_on: nil)
|
||||
@html = html.to_s
|
||||
@invite_url = invite_url.to_s
|
||||
@expires_on = expires_on.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
html = @html.dup
|
||||
html.gsub!(LINK_TOKEN, @invite_url)
|
||||
html.gsub!(EXPIRY_TOKEN, @expires_on) if @expires_on.present?
|
||||
sanitized = sanitize(html)
|
||||
sanitized += fallback_cta if @invite_url.present? && !sanitized.include?(@invite_url)
|
||||
sanitized
|
||||
end
|
||||
|
||||
def sanitize(html)
|
||||
html = html.bytesize > MAX_HTML_BYTES ? html.byteslice(0, MAX_HTML_BYTES) : html
|
||||
fragment = Loofah.fragment(html.to_s)
|
||||
fragment.css("[data-invite-note]").each do |el|
|
||||
el.remove if el.text.to_s.strip.blank?
|
||||
end
|
||||
scrubber = Rails::HTML::PermitScrubber.new
|
||||
scrubber.tags = ALLOWED_TAGS
|
||||
scrubber.attributes = ALLOWED_ATTR
|
||||
fragment.scrub!(scrubber)
|
||||
fragment.css("a").each { |node| scrub_url!(node, "href") }
|
||||
fragment.css("img").each do |node|
|
||||
scrub_url!(node, "src")
|
||||
end
|
||||
fragment.css("[style]").each { |node| scrub_style!(node) }
|
||||
fragment.css("a[target='_blank']").each do |node|
|
||||
node["rel"] = "noopener noreferrer"
|
||||
end
|
||||
fragment.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scrub_url!(node, attr)
|
||||
url = node[attr].to_s.strip
|
||||
return if url == LINK_TOKEN || url.include?(LINK_TOKEN)
|
||||
return if url.match?(/\Ahttps?:\/\//i) || url.start_with?("/rails/active_storage")
|
||||
|
||||
if attr == "src"
|
||||
node.remove
|
||||
else
|
||||
node.remove_attribute(attr)
|
||||
end
|
||||
end
|
||||
|
||||
def scrub_style!(node)
|
||||
decls = node["style"].to_s.split(";").map(&:strip).reject(&:blank?)
|
||||
kept = decls.select do |decl|
|
||||
prop, value = decl.split(":", 2).map { |part| part.to_s.strip }
|
||||
next false if prop.blank? || value.blank?
|
||||
next false unless ALLOWED_STYLE.include?(prop.downcase)
|
||||
next false if value.match?(/expression|javascript|url\s*\(/i)
|
||||
|
||||
true
|
||||
end
|
||||
if kept.any?
|
||||
node["style"] = kept.join("; ")
|
||||
else
|
||||
node.remove_attribute("style")
|
||||
end
|
||||
end
|
||||
|
||||
def fallback_cta
|
||||
label = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
|
||||
url = CGI.escapeHTML(@invite_url)
|
||||
%(<p><a href="#{url}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{label}</a></p><p>#{url}</p>)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
module Tournaments
|
||||
class Create
|
||||
KNOCKOUT_ROUNDS = {
|
||||
16 => %w[round_of_16 quarterfinal semifinal final],
|
||||
8 => %w[quarterfinal semifinal final],
|
||||
4 => %w[semifinal final],
|
||||
2 => %w[final]
|
||||
}.freeze
|
||||
|
||||
ROUND_LABELS = {
|
||||
"round_of_16" => "Ottavi",
|
||||
"quarterfinal" => "Quarti",
|
||||
"semifinal" => "Semifinali",
|
||||
"final" => "Finale",
|
||||
"third_place" => "Finale 3° posto"
|
||||
}.freeze
|
||||
|
||||
def self.call(club:, attrs:)
|
||||
new(club: club, attrs: attrs).call
|
||||
end
|
||||
|
||||
def initialize(club:, attrs:)
|
||||
@club = club
|
||||
@attrs = attrs
|
||||
end
|
||||
|
||||
def call
|
||||
Tournaments::Entitlements.new(@club).assert_creatable!
|
||||
|
||||
tournament = @club.tournaments.build(filtered_attrs)
|
||||
tournament.sport_key = Sports::Catalog.normalize_key(tournament.sport_key.presence || @club.sport)
|
||||
Tournament.transaction do
|
||||
tournament.save!
|
||||
attach_logo!(tournament)
|
||||
Tournaments::EnsureBroadcastTeam.call(tournament)
|
||||
create_default_groups!(tournament)
|
||||
create_knockout_rounds!(tournament)
|
||||
end
|
||||
tournament
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_attrs
|
||||
@attrs.to_h.symbolize_keys.slice(
|
||||
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
|
||||
:description, :knockout_size, :courts
|
||||
).tap do |h|
|
||||
h[:courts] = parse_courts(h[:courts]) if h.key?(:courts)
|
||||
h[:knockout_size] = h[:knockout_size].to_i if h[:knockout_size].present?
|
||||
h[:knockout_size] = nil if h[:knockout_size].to_i <= 0
|
||||
end
|
||||
end
|
||||
|
||||
def parse_courts(value)
|
||||
case value
|
||||
when Array then value
|
||||
else value.to_s.split(/[\n,]/)
|
||||
end
|
||||
end
|
||||
|
||||
def attach_logo!(tournament)
|
||||
file = @attrs.to_h.symbolize_keys[:logo_file]
|
||||
tournament.logo_file.attach(file) if file.present?
|
||||
end
|
||||
|
||||
def create_default_groups!(tournament)
|
||||
return unless tournament.uses_groups?
|
||||
return if tournament.groups.exists?
|
||||
|
||||
["Girone A", "Girone B"].each_with_index do |name, idx|
|
||||
tournament.groups.create!(name: name, position: idx)
|
||||
end
|
||||
end
|
||||
|
||||
def create_knockout_rounds!(tournament)
|
||||
return unless tournament.uses_knockout?
|
||||
return if tournament.rounds.exists?
|
||||
|
||||
size = tournament.knockout_size.presence || 4
|
||||
kinds = KNOCKOUT_ROUNDS[size] || KNOCKOUT_ROUNDS[4]
|
||||
kinds.each_with_index do |kind, idx|
|
||||
tournament.rounds.create!(kind: kind, name: ROUND_LABELS[kind], position: idx)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
module Tournaments
|
||||
class Destroy
|
||||
class LiveBroadcastError < StandardError; end
|
||||
|
||||
def self.call(tournament:)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
raise LiveBroadcastError, I18n.t("flash.tournaments.delete_blocked_live") if live_broadcast?
|
||||
|
||||
team = @tournament.broadcast_team
|
||||
Tournament.transaction do
|
||||
purge_recordings!
|
||||
@tournament.update_column(:broadcast_team_id, nil) if team
|
||||
@tournament.destroy!
|
||||
destroy_orphan_broadcast_team!(team)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def live_broadcast?
|
||||
StreamSession.where(
|
||||
match_id: @tournament.matches.select(:id),
|
||||
status: %w[connecting live reconnecting paused]
|
||||
).exists?
|
||||
end
|
||||
|
||||
def purge_recordings!
|
||||
session_ids = StreamSession.where(match_id: @tournament.matches.select(:id)).select(:id)
|
||||
Recording.where(stream_session_id: session_ids).find_each(&:destroy!)
|
||||
end
|
||||
|
||||
def destroy_orphan_broadcast_team!(team)
|
||||
return unless team&.tournament_broadcast?
|
||||
return if Tournament.exists?(broadcast_team_id: team.id)
|
||||
|
||||
team.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Tournaments
|
||||
class EnsureBroadcastTeam
|
||||
def self.call(tournament)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
return @tournament.broadcast_team if @tournament.broadcast_team.present?
|
||||
|
||||
team = @tournament.club.teams.create!(
|
||||
name: @tournament.name,
|
||||
sport_key: @tournament.sport_key,
|
||||
internal_kind: Tournament::INTERNAL_TEAM_KIND
|
||||
)
|
||||
@tournament.update!(broadcast_team: team)
|
||||
team
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
module Tournaments
|
||||
class EntitlementError < StandardError
|
||||
attr_reader :code, :billing_url
|
||||
|
||||
def initialize(message, code:, billing_url: nil)
|
||||
super(message)
|
||||
@code = code
|
||||
@billing_url = billing_url
|
||||
end
|
||||
end
|
||||
|
||||
class Entitlements
|
||||
def initialize(club)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def subscription
|
||||
@subscription ||= @club.subscription
|
||||
end
|
||||
|
||||
def premium_full?
|
||||
subscription&.premium_full? == true
|
||||
end
|
||||
|
||||
def billing_url
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
|
||||
end
|
||||
|
||||
def assert_creatable!
|
||||
assert_writable!
|
||||
end
|
||||
|
||||
def assert_writable!
|
||||
unless premium_full?
|
||||
raise EntitlementError.new(
|
||||
"I tornei sono disponibili con il piano Premium Full.",
|
||||
code: "premium_full_required",
|
||||
billing_url: billing_url
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
module Tournaments
|
||||
class FillKnockoutSources
|
||||
def self.call(tournament)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
@tournament.matches.find_each do |match|
|
||||
next if match.home_source_kind.blank? && match.away_source_kind.blank?
|
||||
home = resolve(match, :home)
|
||||
away = resolve(match, :away)
|
||||
attrs = {}
|
||||
attrs[:home_participant] = home if home && match.home_participant_id.blank?
|
||||
attrs[:away_participant] = away if away && match.away_participant_id.blank?
|
||||
match.update!(attrs) if attrs.any?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve(match, side)
|
||||
kind = match.public_send("#{side}_source_kind")
|
||||
case kind
|
||||
when "winner_match"
|
||||
source = Match.find_by(id: match.public_send("#{side}_source_match_id"))
|
||||
source&.winner_participant
|
||||
when "group_rank"
|
||||
group = TournamentGroup.find_by(id: match.public_send("#{side}_source_group_id"))
|
||||
rank = match.public_send("#{side}_source_rank").to_i
|
||||
return nil unless group && rank.positive?
|
||||
|
||||
Tournaments::Standings.call(group)[rank - 1]&.participant
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
module Tournaments
|
||||
class GenerateGroupMatches
|
||||
def self.call(tournament:, start_at: nil)
|
||||
new(tournament, start_at: start_at).call
|
||||
end
|
||||
|
||||
def initialize(tournament, start_at: nil)
|
||||
@tournament = tournament
|
||||
@start_at = start_at
|
||||
end
|
||||
|
||||
def call
|
||||
Tournaments::Entitlements.new(@tournament.club).assert_writable!
|
||||
created = []
|
||||
cursor = @start_at || Time.zone.local(@tournament.starts_on.year, @tournament.starts_on.month, @tournament.starts_on.day, 9, 0, 0)
|
||||
courts = @tournament.court_list
|
||||
court_idx = 0
|
||||
|
||||
@tournament.groups.includes(:participants).order(:position).each do |group|
|
||||
pairs = round_robin(group.participants.to_a)
|
||||
pairs.each do |home, away|
|
||||
next if home.blank? || away.blank?
|
||||
|
||||
match = Tournaments::ScheduleMatch.call(
|
||||
tournament: @tournament,
|
||||
attrs: {
|
||||
home_participant_id: home.id,
|
||||
away_participant_id: away.id,
|
||||
tournament_group_id: group.id,
|
||||
court: courts[court_idx % courts.size],
|
||||
scheduled_at: cursor
|
||||
}
|
||||
)
|
||||
created << match
|
||||
court_idx += 1
|
||||
if (court_idx % courts.size).zero?
|
||||
cursor += 1.hour
|
||||
cursor = next_day_morning(cursor) if cursor.to_date > @tournament.ends_on
|
||||
end
|
||||
end
|
||||
end
|
||||
created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def round_robin(participants)
|
||||
list = participants.dup
|
||||
return [] if list.size < 2
|
||||
|
||||
list << nil if list.size.odd?
|
||||
n = list.size
|
||||
rounds = n - 1
|
||||
pairs = []
|
||||
rounds.times do
|
||||
(n / 2).times do |i|
|
||||
a = list[i]
|
||||
b = list[n - 1 - i]
|
||||
pairs << [a, b] if a && b
|
||||
end
|
||||
list = [list[0]] + [list[-1]] + list[1..-2]
|
||||
end
|
||||
pairs
|
||||
end
|
||||
|
||||
def next_day_morning(time)
|
||||
nxt = time.to_date + 1.day
|
||||
nxt = @tournament.starts_on if nxt > @tournament.ends_on
|
||||
Time.zone.local(nxt.year, nxt.month, nxt.day, 9, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module Tournaments
|
||||
class GenerateSlug
|
||||
def self.call(tournament)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
base = @tournament.name.to_s.parameterize.presence || "torneo"
|
||||
slug = base
|
||||
n = 2
|
||||
while conflict?(slug)
|
||||
slug = "#{base}-#{n}"
|
||||
n += 1
|
||||
end
|
||||
slug
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conflict?(slug)
|
||||
scope = Tournament.where(slug: slug)
|
||||
scope = scope.where.not(id: @tournament.id) if @tournament.persisted?
|
||||
scope.exists?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
module Tournaments
|
||||
class GrantBroadcastAccess
|
||||
def self.call(invitation:, user:)
|
||||
new(invitation: invitation, user: user).call
|
||||
end
|
||||
|
||||
def initialize(invitation:, user:)
|
||||
@invitation = invitation
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
team = Tournaments::EnsureBroadcastTeam.call(@invitation.tournament)
|
||||
membership = UserTeam.find_or_initialize_by(user: @user, team: team)
|
||||
membership.role = "member" if membership.new_record?
|
||||
membership.staff_kind = "transmission"
|
||||
membership.save!
|
||||
membership
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module Tournaments
|
||||
class Invite
|
||||
def self.call(tournament:, email:, scope_kind:, match_ids: [], court: nil, on_date: nil, invited_by:, note: nil)
|
||||
new(
|
||||
tournament: tournament,
|
||||
email: email,
|
||||
scope_kind: scope_kind,
|
||||
match_ids: match_ids,
|
||||
court: court,
|
||||
on_date: on_date,
|
||||
invited_by: invited_by,
|
||||
note: note
|
||||
).call
|
||||
end
|
||||
|
||||
def initialize(tournament:, email:, scope_kind:, match_ids:, court:, on_date:, invited_by:, note: nil)
|
||||
@tournament = tournament
|
||||
@email = email.to_s.downcase.strip
|
||||
@scope_kind = scope_kind.to_s
|
||||
@match_ids = Array(match_ids).reject(&:blank?)
|
||||
@court = court
|
||||
@on_date = on_date
|
||||
@invited_by = invited_by
|
||||
@note = note.to_s.strip.presence
|
||||
end
|
||||
|
||||
def call
|
||||
Tournaments::Entitlements.new(@tournament.club).assert_writable!
|
||||
raise ArgumentError, "Email non valida" if @email.blank?
|
||||
|
||||
token = TournamentBroadcastInvitation.generate_token
|
||||
invitation = @tournament.broadcast_invitations.create!(
|
||||
email: @email,
|
||||
token_digest: Digest::SHA256.hexdigest(token),
|
||||
scope_kind: @scope_kind,
|
||||
match_ids: @match_ids,
|
||||
court: @court,
|
||||
on_date: @on_date,
|
||||
note: @note,
|
||||
expires_at: 7.days.from_now
|
||||
)
|
||||
[invitation, token]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
module Tournaments
|
||||
class ProposeKnockout
|
||||
def self.call(tournament)
|
||||
new(tournament).call
|
||||
end
|
||||
|
||||
def initialize(tournament)
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def call
|
||||
Tournaments::Entitlements.new(@tournament.club).assert_writable!
|
||||
first_round = @tournament.rounds.order(:position).first
|
||||
return [] unless first_round
|
||||
|
||||
pairs = pair_qualified
|
||||
slots = first_round.matches.order(:scheduled_at, :created_at).to_a
|
||||
slots = create_slots!(first_round, [pairs.size, 1].max) if slots.empty?
|
||||
|
||||
updated = []
|
||||
slots.each_with_index do |match, idx|
|
||||
home, away = pairs[idx]
|
||||
next if home.blank? && away.blank?
|
||||
|
||||
match.update!(home_participant: home, away_participant: away)
|
||||
updated << match
|
||||
end
|
||||
updated.concat(seed_later_rounds!(slots))
|
||||
updated
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pair_qualified
|
||||
groups = @tournament.groups.order(:position).to_a
|
||||
ranked = groups.map { |group| Tournaments::Standings.call(group).map(&:participant) }
|
||||
return ranked.flatten.each_slice(2).to_a if groups.size < 2
|
||||
|
||||
pairs = []
|
||||
first = ranked[0] || []
|
||||
second = ranked[1] || []
|
||||
pairs << [first[0], second[1]] if first[0] || second[1]
|
||||
pairs << [second[0], first[1]] if second[0] || first[1]
|
||||
leftover = (first.drop(2) + second.drop(2) + ranked.drop(2).flatten)
|
||||
pairs.concat(leftover.each_slice(2).to_a)
|
||||
pairs
|
||||
end
|
||||
|
||||
def seed_later_rounds!(first_round_matches)
|
||||
prev = first_round_matches
|
||||
created = []
|
||||
@tournament.rounds.order(:position).offset(1).each do |round|
|
||||
needed = [prev.size / 2, 1].max
|
||||
slots = round.matches.order(:scheduled_at, :created_at).to_a
|
||||
last_at = prev.map(&:scheduled_at).compact.max
|
||||
start = last_at&.+(1.hour)
|
||||
slots = create_slots!(round, needed, start: start) if slots.empty?
|
||||
|
||||
slots.each_with_index do |match, idx|
|
||||
home_src = prev[idx * 2]
|
||||
away_src = prev[idx * 2 + 1]
|
||||
attrs = {}
|
||||
if home_src && match.home_source_match_id.blank?
|
||||
attrs[:home_source_kind] = "winner_match"
|
||||
attrs[:home_source_match_id] = home_src.id
|
||||
end
|
||||
if away_src && match.away_source_match_id.blank?
|
||||
attrs[:away_source_kind] = "winner_match"
|
||||
attrs[:away_source_match_id] = away_src.id
|
||||
end
|
||||
match.update!(attrs) if attrs.any?
|
||||
created << match
|
||||
end
|
||||
prev = slots
|
||||
end
|
||||
created
|
||||
end
|
||||
|
||||
def create_slots!(round, n, start: nil)
|
||||
count = [n, 1].max
|
||||
day = @tournament.ends_on
|
||||
start ||= Time.zone.local(day.year, day.month, day.day, 18, 0, 0)
|
||||
Array.new(count) do |i|
|
||||
Tournaments::ScheduleMatch.call(
|
||||
tournament: @tournament,
|
||||
attrs: {
|
||||
tournament_round_id: round.id,
|
||||
court: @tournament.court_list.first,
|
||||
scheduled_at: start + i.hours
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
module Tournaments
|
||||
class RecordResult
|
||||
def self.call(match:, home_score:, away_score:, source: "manual", result_data: {}, walkover: nil)
|
||||
new(
|
||||
match: match,
|
||||
home_score: home_score,
|
||||
away_score: away_score,
|
||||
source: source,
|
||||
result_data: result_data,
|
||||
walkover: walkover
|
||||
).call
|
||||
end
|
||||
|
||||
def initialize(match:, home_score:, away_score:, source:, result_data:, walkover:)
|
||||
@match = match
|
||||
@home_score = home_score
|
||||
@away_score = away_score
|
||||
@source = source
|
||||
@result_data = result_data || {}
|
||||
@walkover = walkover
|
||||
end
|
||||
|
||||
def call
|
||||
return @match unless @match.tournament_match?
|
||||
|
||||
status = "played"
|
||||
winner_id = nil
|
||||
if @walkover == "home"
|
||||
status = "walkover_home"
|
||||
winner_id = @match.home_participant_id
|
||||
@home_score = @home_score.presence || 1
|
||||
@away_score = 0
|
||||
elsif @walkover == "away"
|
||||
status = "walkover_away"
|
||||
winner_id = @match.away_participant_id
|
||||
@away_score = @away_score.presence || 1
|
||||
@home_score = 0
|
||||
else
|
||||
winner_id = if @home_score.to_i > @away_score.to_i
|
||||
@match.home_participant_id
|
||||
elsif @away_score.to_i > @home_score.to_i
|
||||
@match.away_participant_id
|
||||
end
|
||||
end
|
||||
|
||||
@match.update!(
|
||||
home_score: @home_score,
|
||||
away_score: @away_score,
|
||||
result_status: status,
|
||||
result_source: @source,
|
||||
result_data: @result_data,
|
||||
winner_participant_id: winner_id
|
||||
)
|
||||
Tournaments::FillKnockoutSources.call(@match.tournament)
|
||||
@match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Tournaments
|
||||
class RevokeAssignment
|
||||
def self.call(assignment:)
|
||||
new(assignment).call
|
||||
end
|
||||
|
||||
def initialize(assignment)
|
||||
@assignment = assignment
|
||||
end
|
||||
|
||||
def call
|
||||
invitation = @assignment.invitation
|
||||
@assignment.destroy!
|
||||
return unless invitation
|
||||
|
||||
leftover = invitation.assignments.reload.pluck(:match_id).map(&:to_s)
|
||||
invitation.update!(scope_kind: "matches", match_ids: leftover, court: nil, on_date: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
module Tournaments
|
||||
class ScheduleMatch
|
||||
def self.call(tournament:, attrs:)
|
||||
new(tournament: tournament, attrs: attrs).call
|
||||
end
|
||||
|
||||
def initialize(tournament:, attrs:)
|
||||
@tournament = tournament
|
||||
@attrs = attrs.to_h.symbolize_keys
|
||||
end
|
||||
|
||||
def call
|
||||
Tournaments::Entitlements.new(@tournament.club).assert_writable!
|
||||
raise Tournaments::EntitlementError.new("Torneo archiviato", code: "archived") unless @tournament.writable?
|
||||
|
||||
team = Tournaments::EnsureBroadcastTeam.call(@tournament)
|
||||
match = team.matches.build(
|
||||
tournament: @tournament,
|
||||
sport_key: @tournament.sport_key,
|
||||
home_participant_id: @attrs[:home_participant_id].presence,
|
||||
away_participant_id: @attrs[:away_participant_id].presence,
|
||||
tournament_group_id: @attrs[:tournament_group_id].presence,
|
||||
tournament_round_id: @attrs[:tournament_round_id].presence,
|
||||
court: @attrs[:court].presence,
|
||||
location: @attrs[:location].presence,
|
||||
scheduled_at: @attrs[:scheduled_at],
|
||||
opponent_name: "TBD",
|
||||
sets_to_win: @attrs[:sets_to_win].presence || 3
|
||||
)
|
||||
match.save!
|
||||
match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
module Tournaments
|
||||
class Standings
|
||||
Row = Struct.new(
|
||||
:participant, :played, :won, :lost, :drawn, :points,
|
||||
:sets_for, :sets_against, :score_for, :score_against,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def self.call(group)
|
||||
new(group).call
|
||||
end
|
||||
|
||||
def initialize(group)
|
||||
@group = group
|
||||
@tournament = group.tournament
|
||||
end
|
||||
|
||||
def call
|
||||
rows = @group.participants.with_attached_logo_file.map { |p| blank_row(p) }.index_by { |r| r.participant.id }
|
||||
matches = @group.matches.where(result_status: %w[played walkover_home walkover_away])
|
||||
matches.find_each do |match|
|
||||
apply_match!(rows, match)
|
||||
end
|
||||
rows.values.sort_by { |row| [-row.points, -set_diff(row), -score_diff(row), row.participant.name] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blank_row(participant)
|
||||
Row.new(
|
||||
participant: participant, played: 0, won: 0, lost: 0, drawn: 0, points: 0,
|
||||
sets_for: 0, sets_against: 0, score_for: 0, score_against: 0
|
||||
)
|
||||
end
|
||||
|
||||
def apply_match!(rows, match)
|
||||
home = rows[match.home_participant_id]
|
||||
away = rows[match.away_participant_id]
|
||||
return unless home && away
|
||||
|
||||
hs = match.home_score.to_i
|
||||
as = match.away_score.to_i
|
||||
home.played += 1
|
||||
away.played += 1
|
||||
home.sets_for += hs
|
||||
home.sets_against += as
|
||||
away.sets_for += as
|
||||
away.sets_against += hs
|
||||
home.score_for += hs
|
||||
home.score_against += as
|
||||
away.score_for += as
|
||||
away.score_against += hs
|
||||
|
||||
if hs == as
|
||||
home.drawn += 1
|
||||
away.drawn += 1
|
||||
home.points += split_draw_points
|
||||
away.points += split_draw_points
|
||||
return
|
||||
end
|
||||
|
||||
if hs > as
|
||||
home.won += 1
|
||||
away.lost += 1
|
||||
home.points += win_points(hs, as)
|
||||
away.points += loss_points(hs, as)
|
||||
else
|
||||
away.won += 1
|
||||
home.lost += 1
|
||||
away.points += win_points(as, hs)
|
||||
home.points += loss_points(as, hs)
|
||||
end
|
||||
end
|
||||
|
||||
def settings
|
||||
@settings ||= (@tournament.scoring_settings || {}).stringify_keys
|
||||
end
|
||||
|
||||
def split_sets?
|
||||
ActiveModel::Type::Boolean.new.cast(settings["split_sets"])
|
||||
end
|
||||
|
||||
def win_points(winner_sets, loser_sets)
|
||||
return settings.fetch("win_points", 3).to_i unless split_sets?
|
||||
|
||||
if winner_sets - loser_sets >= 2
|
||||
settings.fetch("win_3_0_or_3_1", 3).to_i
|
||||
else
|
||||
settings.fetch("win_3_2", 2).to_i
|
||||
end
|
||||
end
|
||||
|
||||
def loss_points(winner_sets, loser_sets)
|
||||
return settings.fetch("loss_points", 0).to_i unless split_sets?
|
||||
|
||||
if winner_sets - loser_sets >= 2
|
||||
settings.fetch("loss_0_3_or_1_3", 0).to_i
|
||||
else
|
||||
settings.fetch("loss_2_3", 1).to_i
|
||||
end
|
||||
end
|
||||
|
||||
def split_draw_points
|
||||
settings.fetch("draw_points", 1).to_i
|
||||
end
|
||||
|
||||
def set_diff(row)
|
||||
row.sets_for - row.sets_against
|
||||
end
|
||||
|
||||
def score_diff(row)
|
||||
row.score_for - row.score_against
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
module Tournaments
|
||||
class SwapSides
|
||||
class LiveBroadcastError < StandardError; end
|
||||
|
||||
def self.call(match:)
|
||||
new(match).call
|
||||
end
|
||||
|
||||
def initialize(match)
|
||||
@match = match
|
||||
end
|
||||
|
||||
def call
|
||||
tournament = @match.tournament
|
||||
raise ArgumentError, "not a tournament match" unless tournament
|
||||
|
||||
Tournaments::Entitlements.new(tournament.club).assert_writable!
|
||||
unless tournament.writable?
|
||||
raise Tournaments::EntitlementError.new(
|
||||
I18n.t("flash.tournaments.archived_locked"),
|
||||
code: "archived"
|
||||
)
|
||||
end
|
||||
raise LiveBroadcastError, I18n.t("flash.matches.close_live_before_delete") unless @match.deletable?
|
||||
|
||||
home_id = @match.home_participant_id
|
||||
away_id = @match.away_participant_id
|
||||
home_score = @match.home_score
|
||||
away_score = @match.away_score
|
||||
home_source_kind = @match.home_source_kind
|
||||
away_source_kind = @match.away_source_kind
|
||||
home_source_match_id = @match.home_source_match_id
|
||||
away_source_match_id = @match.away_source_match_id
|
||||
home_source_group_id = @match.home_source_group_id
|
||||
away_source_group_id = @match.away_source_group_id
|
||||
home_source_rank = @match.home_source_rank
|
||||
away_source_rank = @match.away_source_rank
|
||||
|
||||
@match.update!(
|
||||
home_participant_id: away_id,
|
||||
away_participant_id: home_id,
|
||||
home_score: away_score,
|
||||
away_score: home_score,
|
||||
home_source_kind: away_source_kind,
|
||||
away_source_kind: home_source_kind,
|
||||
home_source_match_id: away_source_match_id,
|
||||
away_source_match_id: home_source_match_id,
|
||||
home_source_group_id: away_source_group_id,
|
||||
away_source_group_id: home_source_group_id,
|
||||
home_source_rank: away_source_rank,
|
||||
away_source_rank: home_source_rank,
|
||||
result_status: swapped_result_status
|
||||
)
|
||||
@match
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def swapped_result_status
|
||||
case @match.result_status
|
||||
when "walkover_home" then "walkover_away"
|
||||
when "walkover_away" then "walkover_home"
|
||||
else @match.result_status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
module Tournaments
|
||||
class SyncAssignments
|
||||
def self.call(invitation:)
|
||||
new(invitation).sync_invitation!
|
||||
end
|
||||
|
||||
def self.sync_match!(match)
|
||||
return unless match.tournament_id.present?
|
||||
|
||||
match.tournament.broadcast_invitations.accepted.find_each do |invitation|
|
||||
new(invitation).sync_invitation!
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(invitation)
|
||||
@invitation = invitation
|
||||
end
|
||||
|
||||
def sync_invitation!
|
||||
return unless @invitation.accepted_at.present?
|
||||
return unless @invitation.accepted_by_id.present?
|
||||
|
||||
desired_ids = resolve_match_ids
|
||||
existing = @invitation.assignments.index_by(&:match_id)
|
||||
|
||||
desired_ids.each do |match_id|
|
||||
next if existing[match_id]
|
||||
|
||||
TournamentBroadcastAssignment.find_or_create_by!(
|
||||
tournament_id: @invitation.tournament_id,
|
||||
match_id: match_id,
|
||||
user_id: @invitation.accepted_by_id
|
||||
) do |row|
|
||||
row.invitation = @invitation
|
||||
end
|
||||
end
|
||||
|
||||
stale = existing.keys - desired_ids
|
||||
@invitation.assignments.where(match_id: stale).delete_all if stale.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_match_ids
|
||||
matches = @invitation.tournament.matches
|
||||
case @invitation.scope_kind
|
||||
when "court_day"
|
||||
matches
|
||||
.where(court: @invitation.court)
|
||||
.where("scheduled_at >= ? AND scheduled_at < ?", @invitation.on_date.beginning_of_day, @invitation.on_date.end_of_day)
|
||||
.pluck(:id)
|
||||
else
|
||||
Array(@invitation.match_ids).map(&:to_s).intersection(matches.pluck(:id).map(&:to_s))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user