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:
@@ -6,6 +6,7 @@ class Club < ApplicationRecord
|
||||
has_many :club_memberships, dependent: :destroy
|
||||
has_many :users, through: :club_memberships
|
||||
has_many :teams, dependent: :destroy
|
||||
has_many :tournaments, dependent: :restrict_with_error
|
||||
has_one :youtube_credential, dependent: :destroy
|
||||
has_one :subscription, dependent: :destroy
|
||||
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
|
||||
|
||||
@@ -2,7 +2,14 @@ class Match < ApplicationRecord
|
||||
include Coverable
|
||||
|
||||
belongs_to :team
|
||||
belongs_to :tournament, optional: true
|
||||
belongs_to :home_participant, class_name: "TournamentParticipant", optional: true
|
||||
belongs_to :away_participant, class_name: "TournamentParticipant", optional: true
|
||||
belongs_to :tournament_group, optional: true
|
||||
belongs_to :tournament_round, optional: true
|
||||
belongs_to :winner_participant, class_name: "TournamentParticipant", optional: true
|
||||
has_many :stream_sessions, dependent: :destroy
|
||||
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
|
||||
|
||||
has_one_attached :opponent_logo_file
|
||||
|
||||
@@ -20,6 +27,8 @@ class Match < ApplicationRecord
|
||||
|
||||
before_validation :normalize_sport_key
|
||||
before_validation :inherit_sport_from_team, on: :create
|
||||
before_validation :sync_tournament_display_names
|
||||
after_commit :sync_tournament_broadcast_assignments, on: %i[create update]
|
||||
|
||||
scope :scheduled_for_live, -> {
|
||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
|
||||
@@ -116,6 +125,30 @@ class Match < ApplicationRecord
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
|
||||
def tournament_match?
|
||||
has_attribute?(:tournament_id) && tournament_id.present?
|
||||
end
|
||||
|
||||
def home_display_name
|
||||
home_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : team.name)
|
||||
end
|
||||
|
||||
def away_display_name
|
||||
away_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : opponent_name.presence)
|
||||
end
|
||||
|
||||
def matchup_label
|
||||
"#{home_display_name} vs #{away_display_name}"
|
||||
end
|
||||
|
||||
def played?
|
||||
result_status == "played"
|
||||
end
|
||||
|
||||
def court_or_location
|
||||
court.presence || location
|
||||
end
|
||||
|
||||
# Alias legacy API/spec (colonna rinominata in sport_key).
|
||||
def sport
|
||||
sport_key
|
||||
@@ -132,12 +165,34 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def inherit_sport_from_team
|
||||
if has_attribute?(:tournament_id) && tournament.present?
|
||||
self.sport_key = tournament.sport_key if new_record? || sport_key.blank?
|
||||
return
|
||||
end
|
||||
|
||||
return unless team.present?
|
||||
|
||||
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
|
||||
self.sport_key = team.sport_key if new_record? || sport_key.blank?
|
||||
end
|
||||
|
||||
def sync_tournament_display_names
|
||||
return unless has_attribute?(:tournament_id) && tournament_id.present?
|
||||
|
||||
self.opponent_name = away_display_name if away_participant.present? || opponent_name.blank?
|
||||
venue_court = [tournament&.venue, court].compact_blank
|
||||
self.location = venue_court.join(" — ") if venue_court.any?
|
||||
|
||||
self.category = tournament.name if category.blank? && tournament.present?
|
||||
end
|
||||
|
||||
def sync_tournament_broadcast_assignments
|
||||
return unless has_attribute?(:tournament_id) && tournament_id.present?
|
||||
return unless saved_change_to_court? || saved_change_to_scheduled_at? || previously_new_record?
|
||||
|
||||
Tournaments::SyncAssignments.sync_match!(self)
|
||||
end
|
||||
|
||||
def sport_key_known
|
||||
return if sport_key.blank?
|
||||
return if Sports::Catalog.find_optional(sport_key)
|
||||
|
||||
@@ -2,6 +2,8 @@ class Team < ApplicationRecord
|
||||
include Brandable
|
||||
include Coverable
|
||||
|
||||
INTERNAL_KINDS = %w[tournament_broadcast].freeze
|
||||
|
||||
belongs_to :club
|
||||
has_many :user_teams, dependent: :destroy
|
||||
has_many :users, through: :user_teams
|
||||
@@ -9,6 +11,10 @@ class Team < ApplicationRecord
|
||||
has_many :recordings, dependent: :destroy
|
||||
has_many :team_invitations, dependent: :destroy
|
||||
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
|
||||
has_one :broadcast_tournament, class_name: "Tournament", foreign_key: :broadcast_team_id, inverse_of: :broadcast_team
|
||||
|
||||
scope :visible, -> { where("internal_kind IS NULL OR internal_kind = ''") }
|
||||
scope :tournament_broadcast, -> { where(internal_kind: Tournament::INTERNAL_TEAM_KIND) }
|
||||
|
||||
has_one_attached :photo_file
|
||||
|
||||
@@ -61,6 +67,10 @@ class Team < ApplicationRecord
|
||||
Rails.application.routes.url_helpers.public_team_page_path(slug)
|
||||
end
|
||||
|
||||
def tournament_broadcast?
|
||||
internal_kind == Tournament::INTERNAL_TEAM_KIND
|
||||
end
|
||||
|
||||
def sport_label
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
class Tournament < ApplicationRecord
|
||||
include Coverable
|
||||
|
||||
FORMAT_KINDS = %w[groups knockout mixed free].freeze
|
||||
STATUSES = %w[draft published live archived].freeze
|
||||
INTERNAL_TEAM_KIND = "tournament_broadcast"
|
||||
|
||||
belongs_to :club
|
||||
belongs_to :broadcast_team, class_name: "Team", optional: true
|
||||
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
|
||||
has_many :matches, dependent: :destroy
|
||||
has_many :broadcast_invitations, class_name: "TournamentBroadcastInvitation", dependent: :destroy
|
||||
has_many :participants, class_name: "TournamentParticipant", dependent: :destroy
|
||||
has_many :rounds, class_name: "TournamentRound", dependent: :destroy
|
||||
has_many :groups, class_name: "TournamentGroup", dependent: :destroy
|
||||
has_many_attached :invite_email_images
|
||||
has_one_attached :logo_file
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: true,
|
||||
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
|
||||
validates :sport_key, presence: true
|
||||
validates :starts_on, :ends_on, presence: true
|
||||
validates :format_kind, inclusion: { in: FORMAT_KINDS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :invite_draft_note, length: { maximum: 2000 }, allow_blank: true
|
||||
validate :sport_key_known
|
||||
validate :ends_on_not_before_starts_on
|
||||
validate :logo_file_type, if: -> { logo_file.attached? }
|
||||
|
||||
before_validation :normalize_sport_key
|
||||
before_validation :assign_slug, on: :create
|
||||
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
|
||||
before_validation :normalize_courts
|
||||
|
||||
scope :visible_to_public, -> { where(status: %w[published live archived]) }
|
||||
scope :listed_on_live, -> { where(status: %w[published live]) }
|
||||
scope :search_public, lambda { |query|
|
||||
q = query.to_s.strip
|
||||
return all if q.blank?
|
||||
|
||||
term = "%#{sanitize_sql_like(q)}%"
|
||||
left_joins(:club).where(
|
||||
"tournaments.name ILIKE :term OR clubs.name ILIKE :term OR tournaments.venue ILIKE :term",
|
||||
term: term
|
||||
)
|
||||
}
|
||||
|
||||
def published?
|
||||
status.in?(%w[published live archived])
|
||||
end
|
||||
|
||||
def archived?
|
||||
status == "archived"
|
||||
end
|
||||
|
||||
def writable?
|
||||
!archived?
|
||||
end
|
||||
|
||||
def uses_groups?
|
||||
format_kind.in?(%w[groups mixed])
|
||||
end
|
||||
|
||||
def uses_knockout?
|
||||
format_kind.in?(%w[knockout mixed])
|
||||
end
|
||||
|
||||
def court_list
|
||||
Array(courts).map { |c| c.to_s.strip }.reject(&:blank?)
|
||||
end
|
||||
|
||||
def days
|
||||
(starts_on..ends_on).to_a
|
||||
end
|
||||
|
||||
def sport_label
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
|
||||
def public_page_path
|
||||
Rails.application.routes.url_helpers.public_tournament_page_path(slug)
|
||||
end
|
||||
|
||||
def effective_primary_color
|
||||
club.effective_primary_color
|
||||
end
|
||||
|
||||
def effective_secondary_color
|
||||
club.effective_secondary_color
|
||||
end
|
||||
|
||||
def effective_logo_url
|
||||
return unless logo_file.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
|
||||
end
|
||||
|
||||
def concurrent_limit
|
||||
club.subscription&.plan&.concurrent_streams_limit
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_sport_key
|
||||
self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
|
||||
end
|
||||
|
||||
def sport_key_known
|
||||
return if sport_key.blank?
|
||||
return if Sports::Catalog.find_optional(sport_key)
|
||||
|
||||
errors.add(:sport_key, "non valido")
|
||||
end
|
||||
|
||||
def ends_on_not_before_starts_on
|
||||
return if starts_on.blank? || ends_on.blank?
|
||||
return if ends_on >= starts_on
|
||||
|
||||
errors.add(:ends_on, "non può precedere la data di inizio")
|
||||
end
|
||||
|
||||
def assign_slug
|
||||
self.slug = Tournaments::GenerateSlug.call(self) if slug.blank?
|
||||
end
|
||||
|
||||
def normalize_slug
|
||||
self.slug = slug.to_s.parameterize
|
||||
end
|
||||
|
||||
def normalize_courts
|
||||
self.courts = court_list
|
||||
self.courts = ["Campo 1"] if courts.blank?
|
||||
end
|
||||
|
||||
def cover_parent
|
||||
club
|
||||
end
|
||||
|
||||
def logo_file_type
|
||||
return if logo_file.content_type.in?(Coverable::COVER_IMAGE_TYPES)
|
||||
|
||||
errors.add(:logo_file, I18n.t("coverable.errors.invalid_type"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class TournamentBroadcastAssignment < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
belongs_to :match
|
||||
belongs_to :user
|
||||
belongs_to :invitation, class_name: "TournamentBroadcastInvitation", optional: true
|
||||
|
||||
validates :user_id, uniqueness: { scope: :match_id }
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
class TournamentBroadcastInvitation < ApplicationRecord
|
||||
SCOPE_KINDS = %w[matches court_day].freeze
|
||||
|
||||
belongs_to :tournament
|
||||
belongs_to :accepted_by, class_name: "User", optional: true
|
||||
has_many :assignments, class_name: "TournamentBroadcastAssignment",
|
||||
foreign_key: :invitation_id, dependent: :destroy
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :scope_kind, inclusion: { in: SCOPE_KINDS }
|
||||
validates :court, presence: true, if: -> { scope_kind == "court_day" }
|
||||
validates :on_date, presence: true, if: -> { scope_kind == "court_day" }
|
||||
validates :note, length: { maximum: 2000 }, allow_blank: true
|
||||
|
||||
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
|
||||
scope :accepted, -> { where.not(accepted_at: nil) }
|
||||
|
||||
def self.generate_token
|
||||
SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.past?
|
||||
end
|
||||
|
||||
def covers_match?(match)
|
||||
return false if match.blank? || match.tournament_id != tournament_id
|
||||
|
||||
case scope_kind
|
||||
when "court_day"
|
||||
return false if court.blank? || on_date.blank? || match.scheduled_at.blank?
|
||||
|
||||
court.to_s == match.court.to_s && on_date == match.scheduled_at.in_time_zone.to_date
|
||||
else
|
||||
Array(match_ids).map(&:to_s).include?(match.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def accept!(user)
|
||||
transaction do
|
||||
update!(accepted_at: Time.current, accepted_by: user)
|
||||
Tournaments::GrantBroadcastAccess.call(invitation: self, user: user)
|
||||
Tournaments::SyncAssignments.call(invitation: self)
|
||||
end
|
||||
end
|
||||
|
||||
def scope_label
|
||||
if scope_kind == "court_day"
|
||||
"#{court} · #{I18n.l(on_date)}"
|
||||
else
|
||||
"#{Array(match_ids).size} partite"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class TournamentGroup < ApplicationRecord
|
||||
belongs_to :tournament
|
||||
has_many :participants, class_name: "TournamentParticipant", foreign_key: :group_id, dependent: :nullify
|
||||
has_many :matches, dependent: :nullify
|
||||
|
||||
validates :name, presence: true
|
||||
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
class TournamentParticipant < ApplicationRecord
|
||||
include Brandable
|
||||
|
||||
belongs_to :tournament
|
||||
belongs_to :group, class_name: "TournamentGroup", optional: true
|
||||
belongs_to :source_team, class_name: "Team", optional: true
|
||||
has_many :home_matches, class_name: "Match", foreign_key: :home_participant_id, dependent: :nullify
|
||||
has_many :away_matches, class_name: "Match", foreign_key: :away_participant_id, dependent: :nullify
|
||||
|
||||
validates :name, presence: true
|
||||
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
|
||||
after_update :sync_related_match_names, if: :saved_change_to_name?
|
||||
|
||||
def branding_parent
|
||||
source_team || tournament
|
||||
end
|
||||
|
||||
def effective_logo_url
|
||||
if logo_file.attached?
|
||||
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
|
||||
elsif logo_url.present?
|
||||
logo_url
|
||||
else
|
||||
source_team&.effective_logo_url
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_related_match_names
|
||||
Match.where(home_participant_id: id).or(Match.where(away_participant_id: id)).find_each(&:save!)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class TournamentRound < ApplicationRecord
|
||||
KINDS = %w[round_of_16 quarterfinal semifinal final third_place].freeze
|
||||
|
||||
belongs_to :tournament
|
||||
has_many :matches, dependent: :nullify
|
||||
|
||||
validates :kind, inclusion: { in: KINDS }
|
||||
validates :name, presence: true
|
||||
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
end
|
||||
@@ -12,16 +12,19 @@ class User < ApplicationRecord
|
||||
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
||||
has_many :stream_sessions, dependent: :nullify
|
||||
has_many :stream_concurrency_violations, dependent: :nullify
|
||||
has_many :tournament_broadcast_assignments, dependent: :destroy
|
||||
|
||||
def manageable_teams
|
||||
staff_ids = teams.select(:id)
|
||||
owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id)
|
||||
owner_ids = Team.visible.where(club_id: owned_clubs.select(:id)).select(:id)
|
||||
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
|
||||
end
|
||||
|
||||
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
|
||||
def streamable_teams
|
||||
manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
|
||||
listed = manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
|
||||
owned_broadcast = Team.tournament_broadcast.where(club_id: owned_clubs.select(:id)).includes(:club)
|
||||
(listed + owned_broadcast.to_a).uniq
|
||||
end
|
||||
|
||||
def club_admin?(club)
|
||||
@@ -29,6 +32,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def can_schedule_for?(team)
|
||||
return false if team.tournament_broadcast?
|
||||
return true if team.club&.owned_by?(self)
|
||||
|
||||
membership = user_teams.find_by(team: team)
|
||||
@@ -38,7 +42,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def schedulable_teams_for(club)
|
||||
teams = club.teams.order(:name).to_a
|
||||
teams = club.teams.visible.order(:name).to_a
|
||||
return teams if club.owned_by?(self)
|
||||
|
||||
teams.select { |team| can_schedule_for?(team) }
|
||||
@@ -55,6 +59,14 @@ class User < ApplicationRecord
|
||||
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
|
||||
end
|
||||
|
||||
def can_broadcast_match?(match)
|
||||
return true if match.team.club&.owned_by?(self)
|
||||
return false unless can_stream_for?(match.team)
|
||||
return true unless match.tournament_match?
|
||||
|
||||
tournament_broadcast_assignments.exists?(match_id: match.id)
|
||||
end
|
||||
|
||||
def staff_role_for(team)
|
||||
return "owner" if team.club&.owned_by?(self)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user