diff --git a/app/assets/stylesheets/leagues.sass b/app/assets/stylesheets/leagues.sass
index 63a1d5d8d..386f0d0ee 100644
--- a/app/assets/stylesheets/leagues.sass
+++ b/app/assets/stylesheets/leagues.sass
@@ -56,16 +56,19 @@ table.matches-table
font-size: 0.8rem
&.home-team
- width: 45%
+ width: 25%
text-align: right
&.vs
width: 2%
&.away-team
- width: 45%
+ width: 25%
text-align: left
+ &.date
+ width: 20%
+
&.match-page
width: 4%
font-size: 0.8rem
diff --git a/app/controllers/concerns/leagues/matches/schedule_edit_permissions.rb b/app/controllers/concerns/leagues/matches/schedule_edit_permissions.rb
new file mode 100644
index 000000000..b986ddb10
--- /dev/null
+++ b/app/controllers/concerns/leagues/matches/schedule_edit_permissions.rb
@@ -0,0 +1,24 @@
+module Leagues
+ module Matches
+ module ScheduleEditPermissions
+ extend ActiveSupport::Concern
+ include ::Leagues::MatchPermissions
+
+ def user_can_create_schedule_edit?
+ return true if user_can_edit_league?
+ return false unless @league.allow_rescheduling
+ return false unless @match.status == 'pending'
+ user_can_home_team? || user_can_away_team?
+ end
+
+ def user_can_decide_schedule_edit?(edit: nil)
+ edit ||= @edit
+
+ return false unless edit.pending?
+ return true if user_can_edit_league?
+ return false unless @match.status == 'pending'
+ edit.home_team? ? user_can_home_team? : user_can_away_team?
+ end
+ end
+ end
+end
diff --git a/app/controllers/leagues/matches/schedule_edits_controller.rb b/app/controllers/leagues/matches/schedule_edits_controller.rb
new file mode 100644
index 000000000..489033880
--- /dev/null
+++ b/app/controllers/leagues/matches/schedule_edits_controller.rb
@@ -0,0 +1,55 @@
+module Leagues
+ module Matches
+ class ScheduleEditsController < ApplicationController
+ include ScheduleEditPermissions
+
+ before_action only: :create do
+ @match = League::Match.find(params[:match_id])
+ @league = @match.league
+ end
+ before_action only: [:approve, :deny] do
+ @edit = League::Match::ScheduleEdit.find(params[:id])
+ @match = @edit.match
+ @league = @match.league
+ end
+ before_action :require_can_create, only: :create
+ before_action :require_can_decide, only: [:approve, :deny]
+
+ def create
+ @edit = ScheduleEdits::CreationService.call(@match, current_user, edit_params)
+
+ redirect_to match_path(@match)
+ end
+
+ def approve
+ ScheduleEdits::ApprovalService.call(@edit, current_user)
+
+ redirect_to match_path(@match)
+ end
+
+ def deny
+ ScheduleEdits::DenialService.call(@edit, current_user)
+
+ redirect_to match_path(@match)
+ end
+
+ private
+
+ def edit_params
+ params.require(:schedule_edit).permit(:scheduled_at)
+ end
+
+ def redirect_to_match
+ redirect_back(fallback_location: match_path(@match))
+ end
+
+ def require_can_create
+ redirect_to_match unless user_can_create_schedule_edit?
+ end
+
+ def require_can_decide
+ redirect_to_match unless user_can_decide_schedule_edit?
+ end
+ end
+ end
+end
diff --git a/app/controllers/leagues/matches_controller.rb b/app/controllers/leagues/matches_controller.rb
index 4c439ea20..f7b78e96e 100644
--- a/app/controllers/leagues/matches_controller.rb
+++ b/app/controllers/leagues/matches_controller.rb
@@ -74,6 +74,7 @@ def show
match_show_includes
@comm = League::Match::Comm.new(match: @match)
+ @edit = League::Match::ScheduleEdit.new(match: @match)
end
def edit
@@ -153,14 +154,14 @@ def report_scores_by_team_params
def match_params
params.require(:match).permit(:home_team_id, :away_team_id, :has_winner, :allow_round_draws,
- :round_name, :round_number, :notice,
+ :round_name, :round_number, :notice, :scheduled_at,
pick_bans_attributes: [:id, :_destroy, :kind, :team, :deferrable],
rounds_attributes: [:id, :_destroy, :map_id])
end
def create_round_params
params.require(:match).permit(:division_id, :generate_kind, :has_winner, :allow_round_draws,
- :round_name, :round_number, :notice,
+ :round_name, :round_number, :notice, :scheduled_at,
pick_bans_attributes: [:id, :_destroy, :kind, :team, :deferrable],
rounds_attributes: [:id, :_destroy, :map_id])
end
diff --git a/app/controllers/leagues_controller.rb b/app/controllers/leagues_controller.rb
index 36c1a6081..248f01113 100644
--- a/app/controllers/leagues_controller.rb
+++ b/app/controllers/leagues_controller.rb
@@ -82,7 +82,8 @@ def destroy
LEAGUE_PARAMS = [
:name, :description, :format_id, :category,
- :signuppable, :hide_rosters, :roster_locked, :matches_submittable, :transfers_require_approval, :allow_disbanding,
+ :signuppable, :hide_rosters, :roster_locked, :matches_submittable, :transfers_require_approval,
+ :allow_disbanding, :allow_rescheduling,
:forfeit_all_matches_when_roster_disbands,
:min_players, :max_players,
:schedule_locked, :schedule,
diff --git a/app/helpers/leagues/matches_helper.rb b/app/helpers/leagues/matches_helper.rb
index 4bbe1a6ed..54ec2c914 100644
--- a/app/helpers/leagues/matches_helper.rb
+++ b/app/helpers/leagues/matches_helper.rb
@@ -2,6 +2,7 @@ module Leagues
module MatchesHelper
include MatchPermissions
include Matches::PickBanPermissions
+ include Matches::ScheduleEditPermissions
def user_on_either_teams?(match = nil)
match ||= @match
diff --git a/app/models/league.rb b/app/models/league.rb
index 0024a6589..64c9fd272 100644
--- a/app/models/league.rb
+++ b/app/models/league.rb
@@ -37,6 +37,7 @@ class League < ApplicationRecord
validates :schedule_locked, inclusion: { in: [true, false] }
validates :forfeit_all_matches_when_roster_disbands, inclusion: { in: [true, false] }
validates :hide_rosters, inclusion: { in: [true, false] }
+ validates :allow_rescheduling, inclusion: { in: [true, false] }
validates :min_players, presence: true, numericality: { greater_than: 0 }
validates :max_players, presence: true, numericality: { greater_than_or_equal_to: 0 }
diff --git a/app/models/league/match.rb b/app/models/league/match.rb
index 59e4c7f88..5ebb194d8 100644
--- a/app/models/league/match.rb
+++ b/app/models/league/match.rb
@@ -16,6 +16,8 @@ class Match < ApplicationRecord
has_many :comms, class_name: 'Match::Comm', dependent: :destroy
+ has_many :schedule_edits, class_name: 'Match::ScheduleEdit', dependent: :destroy
+
delegate :division, :league, to: :home_team, allow_nil: true
validates :rounds, associated: true # Make *really* sure all rounds are valid
@@ -71,6 +73,12 @@ def bye?
!away_team_id
end
+ def users
+ users = home_team.users
+ users.union(away_team.users) unless bye?
+ users
+ end
+
def picking_completed?
pick_bans.pending.empty?
end
diff --git a/app/models/league/match/schedule_edit.rb b/app/models/league/match/schedule_edit.rb
new file mode 100644
index 000000000..8af52787b
--- /dev/null
+++ b/app/models/league/match/schedule_edit.rb
@@ -0,0 +1,55 @@
+class League
+ class Match
+ class ScheduleEdit < ApplicationRecord
+ belongs_to :match
+ belongs_to :created_by, class_name: 'User'
+ belongs_to :decided_by, class_name: 'User', optional: true
+ enum deciding_team: [:home_team, :away_team]
+
+ validate :validate_approved_iff_decided_by
+
+ scope :ordered, -> { order(:updated_at) }
+ scope :pending, -> { where(approved: nil) }
+
+ def approve(user)
+ transaction do
+ match.update!(scheduled_at: scheduled_at)
+ update!(decided_by: user, approved: true)
+ end
+ end
+
+ def deny(user)
+ update!(decided_by: user, approved: false)
+ end
+
+ def deciding_roster
+ match.send(team_decide.first)
+ end
+
+ def requesting_roster
+ match.send(team_decide.last)
+ end
+
+ def pending?
+ approved.nil?
+ end
+
+ private
+
+ def team_decide
+ if home_team?
+ [:home_team, :away_team]
+ else
+ [:away_team, :home_team]
+ end
+ end
+
+ def validate_approved_iff_decided_by
+ errors.add(:decided_by, 'must have decided_by when decided') \
+ if decided_by.nil? && !approved.nil?
+ errors.add(:approved, 'must be decided when decided_by is set') \
+ if approved.nil? && !decided_by.nil?
+ end
+ end
+ end
+end
diff --git a/app/presenters/league/match/schedule_edit_presenter.rb b/app/presenters/league/match/schedule_edit_presenter.rb
new file mode 100644
index 000000000..647703455
--- /dev/null
+++ b/app/presenters/league/match/schedule_edit_presenter.rb
@@ -0,0 +1,35 @@
+class League
+ class Match
+ class ScheduleEditPresenter < BasePresenter
+ presents :edit
+
+ # rubocop:disable Rails/OutputSafety
+ def to_s
+ if edit.approved.nil?
+ safe_join([created_by.link, raw(' requested reschedule to '), scheduled_at,
+ raw('')])
+ else
+ safe_join([decided_by.link, ' ', status, raw(' reschedule to '), scheduled_at,
+ raw(' as requested by '), created_by.link])
+ end
+ end
+ # rubocop:enable Rails/OutputSafety
+
+ def created_by
+ @created_by ||= present(edit.created_by)
+ end
+
+ def decided_by
+ @decided_by ||= present(edit.decided_by)
+ end
+
+ def status
+ edit.approved? ? 'approved' : 'denied'
+ end
+
+ def scheduled_at
+ edit.scheduled_at.strftime('%c')
+ end
+ end
+ end
+end
diff --git a/app/presenters/league/match_presenter.rb b/app/presenters/league/match_presenter.rb
index 0724b69c5..bab1ac90e 100644
--- a/app/presenters/league/match_presenter.rb
+++ b/app/presenters/league/match_presenter.rb
@@ -74,6 +74,10 @@ def bracket_data
result
end
+ def scheduled_at
+ match.scheduled_at.strftime('%c')
+ end
+
private
def score_results
diff --git a/app/serializers/api/v1/leagues/match_serializer.rb b/app/serializers/api/v1/leagues/match_serializer.rb
index 499e788ff..20b8bfe05 100644
--- a/app/serializers/api/v1/leagues/match_serializer.rb
+++ b/app/serializers/api/v1/leagues/match_serializer.rb
@@ -5,7 +5,7 @@ class MatchSerializer < ActiveModel::Serializer
type :match
attributes :id, :forfeit_by, :status, :round_name, :round_number, :notice
- attributes :created_at
+ attributes :created_at, :scheduled_at
belongs_to :league
has_one :home_team, serializer: RosterSerializer
diff --git a/app/services/leagues/matches/creation_service.rb b/app/services/leagues/matches/creation_service.rb
index f2e98d96c..f3cb20563 100644
--- a/app/services/leagues/matches/creation_service.rb
+++ b/app/services/leagues/matches/creation_service.rb
@@ -37,11 +37,17 @@ def users_to_notify(match)
end
def notify_message(match)
- if match.bye?
- "You have a match BYE for #{match.home_team.name}."
- else
- "You have an upcoming match: #{match.home_team.name} vs #{match.away_team.name}."
- end
+ msg = if match.bye?
+ "You have a match BYE for #{match.home_team.name}"
+ else
+ "You have an upcoming match: #{match.home_team.name} vs #{match.away_team.name}"
+ end
+
+ msg << if match.scheduled_at
+ " scheduled for #{match.scheduled_at.strftime('%c')}."
+ else
+ '.'
+ end
end
end
end
diff --git a/app/services/leagues/matches/schedule_edits/approval_service.rb b/app/services/leagues/matches/schedule_edits/approval_service.rb
new file mode 100644
index 000000000..9aa77add3
--- /dev/null
+++ b/app/services/leagues/matches/schedule_edits/approval_service.rb
@@ -0,0 +1,24 @@
+module Leagues
+ module Matches
+ module ScheduleEdits
+ module ApprovalService
+ include BaseService
+ extend ScheduleEditServicer
+
+ def call(edit, user)
+ edit.transaction do
+ # Deny other proposed match dates
+ edit.match.schedule_edits
+ .pending
+ .where.not(id: edit.id)
+ .each { |e| e.deny(user) }
+ edit.approve(user)
+
+ notify(edit.match.users, reschedule_msg(user, 'approved', edit),
+ match_path(edit.match))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/leagues/matches/schedule_edits/creation_service.rb b/app/services/leagues/matches/schedule_edits/creation_service.rb
new file mode 100644
index 000000000..450204bfa
--- /dev/null
+++ b/app/services/leagues/matches/schedule_edits/creation_service.rb
@@ -0,0 +1,35 @@
+module Leagues
+ module Matches
+ module ScheduleEdits
+ module CreationService
+ include BaseService
+ extend ScheduleEditServicer
+
+ # rubocop:disable Metrics/MethodLength
+ def call(match, user, params)
+ team = if match.bye? || user.can?(:edit, match.away_team.team)
+ :home_team
+ else
+ :away_team
+ end
+
+ edit_params = params.merge(created_by: user, deciding_team: team)
+ edit = match.schedule_edits.new(edit_params)
+ edit.transaction do
+ # Cancel any existing proposed match dates
+ match.schedule_edits
+ .pending
+ .where(created_by: user)
+ .where.not(id: edit.id)
+ .each { |e| e.deny(user) }
+ edit.save!
+ notify_captains(edit, edit.deciding_roster, reschedule_msg(user, 'requested', edit))
+ end
+
+ edit
+ end
+ # rubocop:enable Metrics/MethodLength
+ end
+ end
+ end
+end
diff --git a/app/services/leagues/matches/schedule_edits/denial_service.rb b/app/services/leagues/matches/schedule_edits/denial_service.rb
new file mode 100644
index 000000000..e301364ca
--- /dev/null
+++ b/app/services/leagues/matches/schedule_edits/denial_service.rb
@@ -0,0 +1,17 @@
+module Leagues
+ module Matches
+ module ScheduleEdits
+ module DenialService
+ include BaseService
+ extend ScheduleEditServicer
+
+ def call(edit, user)
+ edit.transaction do
+ edit.deny(user)
+ notify_captains(edit, edit.requesting_roster, reschedule_msg(user, 'denied', edit))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/leagues/matches/schedule_edits/schedule_edit_servicer.rb b/app/services/leagues/matches/schedule_edits/schedule_edit_servicer.rb
new file mode 100644
index 000000000..6436e94bf
--- /dev/null
+++ b/app/services/leagues/matches/schedule_edits/schedule_edit_servicer.rb
@@ -0,0 +1,33 @@
+module Leagues
+ module Matches
+ module ScheduleEdits
+ module ScheduleEditServicer
+ include BaseService
+
+ def reschedule_msg(user, action, edit)
+ match_name = if edit.match.bye?
+ "BYE for '#{edit.match.home_team.name}'"
+ else
+ "'#{edit.match.home_team.name}' vs '#{edit.match.away_team.name}'"
+ end
+ "'#{user.name}' #{action} rescheduling #{match_name} to "\
+ "#{edit.scheduled_at.strftime('%c')}"
+ end
+
+ def notify(users, msg, link)
+ users.each do |user|
+ Users::NotificationService.call(user, message: msg, link: link)
+ end
+ end
+
+ def notify_captains(edit, roster, msg)
+ captains = User.which_can(:edit, roster.team)
+ link = match_path(edit.match)
+ notify(captains, msg, link)
+ Users::NotificationService.call(edit.created_by, message: msg, link: link) \
+ unless captains.include?(edit.created_by)
+ end
+ end
+ end
+ end
+end
diff --git a/app/views/leagues/_league_form.html.haml b/app/views/leagues/_league_form.html.haml
index 585504165..484a79fa5 100644
--- a/app/views/leagues/_league_form.html.haml
+++ b/app/views/leagues/_league_form.html.haml
@@ -24,6 +24,8 @@
= f.check_box :transfers_require_approval, label_class: 'pl-1'
= f.check_box :allow_disbanding, label_class: 'pl-1'
= f.check_box :forfeit_all_matches_when_roster_disbands, label_class: 'pl-1'
+ = f.check_box :allow_rescheduling, lable: 'Allow rescheduling matches',
+ label_class: 'pl-1'
.card.mb-3
.card-header Points
diff --git a/app/views/leagues/matches/_match_form.html.haml b/app/views/leagues/matches/_match_form.html.haml
index d9e2a5747..78f928d57 100644
--- a/app/views/leagues/matches/_match_form.html.haml
+++ b/app/views/leagues/matches/_match_form.html.haml
@@ -1,4 +1,5 @@
- no_team_fields = false if local_assigns[:no_team_fields].nil?
+- editing = false if local_assigns[:editing].nil?
.card.mb-3
.card-header Match Settings
@@ -20,6 +21,10 @@
= f.check_box :has_winner, label: 'Has a Distinct Winner', label_class: 'pl-1'
= f.check_box :allow_round_draws, label: 'Allow set draws', label_class: 'pl-1'
+ - options = { label: 'Match date', include_blank: true, start_year: 2016,
+ end_year: Time.zone.today.year + 1, minute_step: 5 }
+ - options[:selected] = Time.zone.now if div.league.allow_rescheduling && !editing
+ = f.datetime_select :scheduled_at, options
= f.number_field :round_number, label: 'Round Number (Used for ordering)'
= f.text_field :round_name, label: 'Round Name (Display Name)'
= f.markdown_editor :notice, id: dom_id(div), rows: 6, no_escape: true
diff --git a/app/views/leagues/matches/_match_scores.html.haml b/app/views/leagues/matches/_match_scores.html.haml
index 81a692510..d6fbe3409 100644
--- a/app/views/leagues/matches/_match_scores.html.haml
+++ b/app/views/leagues/matches/_match_scores.html.haml
@@ -5,7 +5,7 @@
- if match.picking_completed? || user_can_edit_league?
= render 'leagues/matches/match_scores_form', match: match
-- if !match.pending? && !match.confirmed? && !user_can_edit_league?
+- if !match.pending? && !match.confirmed?
%table.table.mb-0
- present_collection(match.rounds).each do |round_p|
%tr
diff --git a/app/views/leagues/matches/_matches_table.html.haml b/app/views/leagues/matches/_matches_table.html.haml
index 592fbd13b..69d1af3ed 100644
--- a/app/views/leagues/matches/_matches_table.html.haml
+++ b/app/views/leagues/matches/_matches_table.html.haml
@@ -11,6 +11,7 @@
%th.home-team Home Team
%th.vs vs
%th.away-team Away Team
+ %th Date
%th
%tbody
@@ -38,6 +39,9 @@
%td.away-team
= match_p.away_team.link
+ %td.date
+ = match_p.scheduled_at if match.scheduled_at
+
%td.match-page
= match_p.link('Match Page')
diff --git a/app/views/leagues/matches/_schedule_edits.html.haml b/app/views/leagues/matches/_schedule_edits.html.haml
new file mode 100644
index 000000000..0a1ffafd6
--- /dev/null
+++ b/app/views/leagues/matches/_schedule_edits.html.haml
@@ -0,0 +1,24 @@
+.card.mb-3
+ .card-header Rescheduling
+
+ - match.schedule_edits.ordered.each do |e|
+ %li.list-group-item.d-flex.align-items-center
+ %span.mr-2
+ = present(e)
+ - if user_can_decide_schedule_edit?(edit: e)
+ = button_to approve_schedule_edit_path(e), method: :patch,
+ class: 'btn btn-success mr-2' do
+ Approve
+ = button_to deny_schedule_edit_path(e), method: :patch,
+ class: 'btn btn-warning' do
+ Deny
+
+ - if user_can_create_schedule_edit?
+ %li.list-group-item
+ = bootstrap_form_for edit, as: :schedule_edit, url: match_schedule_edit_index_path(match),
+ layout: :inline do |f|
+ = f.datetime_select :scheduled_at, label: 'Reschedule match',
+ start_year: Time.zone.today.year, end_year: Time.zone.today.year + 1,
+ minute_step: 5, selected: match.scheduled_at || Time.zone.now
+
+ .pull-right.ml-2= f.submit 'Request'
diff --git a/app/views/leagues/matches/edit.html.haml b/app/views/leagues/matches/edit.html.haml
index 1d0206511..f682581c1 100644
--- a/app/views/leagues/matches/edit.html.haml
+++ b/app/views/leagues/matches/edit.html.haml
@@ -8,7 +8,7 @@
.mb-5
= bootstrap_form_for @match, as: :match, url: match_path(@match) do |f|
= f.errors_on :base
- = render 'match_form', f: f, div: @match.division
+ = render 'match_form', f: f, div: @match.division, editing: true
.d-flex.justify-content-end
= link_to 'Cancel', match_path(@match), class: 'btn btn-link mr-2'
diff --git a/app/views/leagues/matches/show.html.haml b/app/views/leagues/matches/show.html.haml
index 7e8a9d53c..c740afdc6 100644
--- a/app/views/leagues/matches/show.html.haml
+++ b/app/views/leagues/matches/show.html.haml
@@ -12,6 +12,8 @@
- if @league.divisions.size > 1
#{@match.division.name} -
= match_p.round_s
+ - if @match.scheduled_at.present?
+ \- #{match_p.scheduled_at}
- if @match.bye?
%br
@@ -60,4 +62,7 @@
- if @match.home_team.schedule_data && @match.away_team.schedule_data
= render 'leagues/matches/schedule', match: @match, league: @league
+ - if @league.allow_rescheduling || !@match.schedule_edits.empty?
+ = render 'leagues/matches/schedule_edits', match: @match, edit: @edit
+
= render 'leagues/matches/match_comms', league: @league, match: @match, comm: @comm, comms: @comms
diff --git a/config/routes.rb b/config/routes.rb
index febadeee5..b435237dc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -71,6 +71,14 @@
patch 'defer'
end
end
+
+ resources :schedule_edit, controller: 'leagues/matches/schedule_edits',
+ only: [:create] do
+ member do
+ patch 'approve'
+ patch 'deny'
+ end
+ end
end
end
diff --git a/db/migrate/20240522191744_add_scheduled_at_to_league_matches.rb b/db/migrate/20240522191744_add_scheduled_at_to_league_matches.rb
new file mode 100644
index 000000000..c60d8a298
--- /dev/null
+++ b/db/migrate/20240522191744_add_scheduled_at_to_league_matches.rb
@@ -0,0 +1,5 @@
+class AddScheduledAtToLeagueMatches < ActiveRecord::Migration[5.2]
+ def change
+ add_column :league_matches, :scheduled_at, :datetime
+ end
+end
diff --git a/db/migrate/20240522191745_create_league_match_schedule_edits.rb b/db/migrate/20240522191745_create_league_match_schedule_edits.rb
new file mode 100644
index 000000000..36804c16d
--- /dev/null
+++ b/db/migrate/20240522191745_create_league_match_schedule_edits.rb
@@ -0,0 +1,21 @@
+class CreateLeagueMatchScheduleEdits < ActiveRecord::Migration[5.2]
+ def change
+ add_column :leagues, :allow_rescheduling, :boolean, null: false, default: false
+ change_column_default :leagues, :allow_rescheduling, from: false, to: true
+
+ create_table :league_match_schedule_edits do |t|
+ t.integer :match_id, null: false, index: true
+ t.integer :created_by_id, null: false, index: true
+ t.integer :decided_by_id, index: true
+ t.datetime :created_at, null: false
+ t.datetime :updated_at, null: false, index: true
+ t.datetime :scheduled_at, null: false
+ t.integer :deciding_team, null: false, limit: 1
+ t.boolean :approved
+ end
+
+ add_foreign_key :league_match_schedule_edits, :league_matches, column: :match_id
+ add_foreign_key :league_match_schedule_edits, :users, column: :created_by_id
+ add_foreign_key :league_match_schedule_edits, :users, column: :decided_by_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 86928e136..f31a125c2 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.define(version: 2020_07_19_142550) do
+ActiveRecord::Schema.define(version: 2024_05_22_191745) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -308,6 +308,21 @@
t.index ["winner_id"], name: "index_league_match_rounds_on_winner_id"
end
+ create_table "league_match_schedule_edits", force: :cascade do |t|
+ t.integer "match_id", null: false
+ t.integer "created_by_id", null: false
+ t.integer "decided_by_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "scheduled_at", null: false
+ t.integer "deciding_team", limit: 2, null: false
+ t.boolean "approved"
+ t.index ["created_by_id"], name: "index_league_match_schedule_edits_on_created_by_id"
+ t.index ["decided_by_id"], name: "index_league_match_schedule_edits_on_decided_by_id"
+ t.index ["match_id"], name: "index_league_match_schedule_edits_on_match_id"
+ t.index ["updated_at"], name: "index_league_match_schedule_edits_on_updated_at"
+ end
+
create_table "league_matches", id: :serial, force: :cascade do |t|
t.integer "home_team_id"
t.integer "away_team_id"
@@ -330,6 +345,7 @@
t.integer "total_home_team_round_wins", default: 0, null: false
t.integer "total_away_team_round_wins", default: 0, null: false
t.integer "total_round_draws", default: 0, null: false
+ t.datetime "scheduled_at"
t.index ["away_team_id"], name: "index_league_matches_on_away_team_id"
t.index ["home_team_id"], name: "index_league_matches_on_home_team_id"
t.index ["loser_id"], name: "index_league_matches_on_loser_id"
@@ -487,6 +503,7 @@
t.decimal "points_per_forfeit_loss", default: "0.0", null: false
t.boolean "forfeit_all_matches_when_roster_disbands", default: true, null: false
t.boolean "hide_rosters", default: false, null: false
+ t.boolean "allow_rescheduling", default: true, null: false
t.index ["format_id"], name: "index_leagues_on_format_id"
t.index ["query_name_cache"], name: "index_leagues_on_query_name_change", opclass: :gist_trgm_ops, using: :gist
end
@@ -691,6 +708,9 @@
add_foreign_key "league_match_rounds", "league_rosters", column: "loser_id"
add_foreign_key "league_match_rounds", "league_rosters", column: "winner_id"
add_foreign_key "league_match_rounds", "maps"
+ add_foreign_key "league_match_schedule_edits", "league_matches", column: "match_id"
+ add_foreign_key "league_match_schedule_edits", "users", column: "created_by_id"
+ add_foreign_key "league_match_schedule_edits", "users", column: "decided_by_id"
add_foreign_key "league_matches", "league_rosters", column: "away_team_id"
add_foreign_key "league_matches", "league_rosters", column: "home_team_id"
add_foreign_key "league_matches", "league_rosters", column: "loser_id"
diff --git a/spec/controllers/leagues/matches/schedule_edits_controller_spec.rb b/spec/controllers/leagues/matches/schedule_edits_controller_spec.rb
new file mode 100644
index 000000000..a35d9ac60
--- /dev/null
+++ b/spec/controllers/leagues/matches/schedule_edits_controller_spec.rb
@@ -0,0 +1,200 @@
+require 'rails_helper'
+
+describe Leagues::Matches::ScheduleEditsController do
+ let(:user) { create(:user) }
+ let(:edit) { create(:league_match_schedule_edit, created_by: user) }
+ let(:match) { edit.match }
+ let(:scheduled_at) { edit.scheduled_at }
+
+ shared_examples 'redirects' do
+ it 'redirects to the match page' do
+ request
+ expect(response).to redirect_to(match_path(match))
+ end
+ end
+
+ # Submitted by home/away team omitted to speed up tests
+ shared_examples 'pending/submitted' do |pending, submitted|
+ context 'when match.status is pending' do
+ include_examples pending
+ end
+
+ context 'when match.status is confirmed' do
+ before { match.update(status: :confirmed) }
+
+ include_examples submitted
+ end
+ end
+
+ describe 'POST #create' do
+ service = Leagues::Matches::ScheduleEdits::CreationService
+ before { allow(service).to receive(:call).and_return(edit) }
+
+ def request
+ post :create, params: {
+ match_id: match.id,
+ schedule_edit: { scheduled_at: scheduled_at },
+ }
+ end
+
+ shared_examples 'succeeds' do
+ let(:edit_params) do
+ ActionController::Parameters.new(scheduled_at: scheduled_at.to_s)
+ .permit(:scheduled_at)
+ end
+
+ include_examples 'redirects'
+
+ it 'calls CreationService' do
+ expect(service).to receive(:call).with(match, user, edit_params)
+ request
+ end
+ end
+
+ shared_examples 'fails' do
+ include_examples 'redirects'
+
+ it "doesn't call CreationService" do
+ expect(service).not_to receive(:call)
+ request
+ end
+ end
+
+ shared_examples 'user' do |result|
+ context 'when user is admin' do
+ before do
+ user.grant(:edit, match.league)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', 'succeeds', 'succeeds'
+ end
+
+ context 'when user can edit home_team' do
+ before do
+ user.grant(:edit, match.home_team.team)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', result, 'fails'
+ end
+
+ context 'when user can edit away_team' do
+ before do
+ user.grant(:edit, match.away_team.team)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', result, 'fails'
+ end
+
+ context "when user isn't logged in" do
+ include_examples 'pending/submitted', 'fails', 'fails'
+ end
+ end
+
+ context "when league doesn't allow rescheduling" do
+ before { match.league.update(allow_rescheduling: false) }
+
+ include_examples 'user', 'fails'
+ end
+
+ context 'when league allows rescheduling' do
+ include_examples 'user', 'succeeds'
+ end
+ end
+
+ shared_examples 'decides' do |service|
+ before { allow(service).to receive(:call).and_return(true) }
+
+ def request
+ patch endpoint, params: { id: edit.id }
+ end
+
+ shared_examples 'succeeds' do
+ include_examples 'redirects'
+
+ it "calls #{service.name.split(':').last}" do
+ expect(service).to receive(:call).with(edit, user)
+ request
+ end
+ end
+
+ shared_examples 'fails' do
+ include_examples 'redirects'
+
+ it "doesn't call #{service.name.split(':').last}" do
+ expect(service).not_to receive(:call)
+ request
+ end
+ end
+
+ shared_examples 'home/away' do |home, away|
+ context 'when user can edit home_team' do
+ before do
+ user.grant(:edit, match.home_team.team)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', home, 'fails'
+ end
+
+ context 'when user can edit away_team' do
+ before do
+ user.grant(:edit, match.away_team.team)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', away, 'fails'
+ end
+ end
+
+ shared_examples 'user' do |result|
+ context 'when user is admin' do
+ before do
+ user.grant(:edit, match.league)
+ sign_in user
+ end
+
+ include_examples 'pending/submitted', result, result
+ end
+
+ context 'when deciding team is home' do
+ include_examples 'home/away', result, 'fails'
+ end
+
+ context 'when deciding team is away' do
+ before { edit.update(deciding_team: :away_team) }
+
+ include_examples 'home/away', 'fails', result
+ end
+
+ context "when user isn't logged in" do
+ include_examples 'pending/submitted', 'fails', 'fails'
+ end
+ end
+
+ context 'when edit is pending' do
+ include_examples 'user', 'succeeds'
+ end
+
+ # denied ommitted to speed up tests
+ context 'when edit is decided' do
+ before { edit.update(approved: true, decided_by: user) }
+
+ include_examples 'user', 'fails'
+ end
+ end
+
+ describe 'PATCH #approve' do
+ let(:endpoint) { :approve }
+
+ include_examples 'decides', Leagues::Matches::ScheduleEdits::ApprovalService
+ end
+
+ describe 'PATCH #deny' do
+ let(:endpoint) { :deny }
+
+ include_examples 'decides', Leagues::Matches::ScheduleEdits::DenialService
+ end
+end
diff --git a/spec/factories/league/match/schedule_edit.rb b/spec/factories/league/match/schedule_edit.rb
new file mode 100644
index 000000000..4f86ab27c
--- /dev/null
+++ b/spec/factories/league/match/schedule_edit.rb
@@ -0,0 +1,10 @@
+FactoryBot.define do
+ factory :league_match_schedule_edit, class: 'League::Match::ScheduleEdit' do
+ association :match, factory: :league_match
+ created_by {}
+ decided_by { nil }
+ deciding_team { :home_team }
+ scheduled_at { Time.now.utc }
+ approved { nil }
+ end
+end
diff --git a/spec/models/league/match/schedule_edit.rb b/spec/models/league/match/schedule_edit.rb
new file mode 100644
index 000000000..ba6ec0134
--- /dev/null
+++ b/spec/models/league/match/schedule_edit.rb
@@ -0,0 +1,57 @@
+require 'rails_helper'
+
+describe League::Match::ScheduleEdit do
+ let(:creator) { create(:user) }
+ let(:decider) { create(:user) }
+ let(:edit) { create(:league_match_schedule_edit, created_by: creator) }
+
+ it { is_expected.to belong_to :match }
+ it { is_expected.to belong_to :created_by }
+ it { is_expected.to belong_to(:decided_by).optional }
+ it { is_expected.to define_enum_for(:deciding_team).with_values([:home_team, :away_team]) }
+
+ it { expect(edit).to be_valid }
+ it 'is valid when decided' do
+ edit.decided_by = decider
+ edit.approved = true
+ expect(edit).to be_valid
+ end
+
+ describe '#decided_by' do
+ it 'is required when approved' do
+ edit.approved = true
+ edit.valid?
+ expect(edit.errors[:decided_by].size).to eq 1
+ end
+ end
+
+ describe '#approved' do
+ it 'is required when decided_by' do
+ edit.decided_by = decider
+ edit.valid?
+ expect(edit.errors[:approved].size).to eq 1
+ end
+ end
+
+ it 'approves a request', :aggregate_failures do
+ edit = create(:league_match_schedule_edit, created_by: creator)
+
+ expect(edit.approve(decider)).to be true
+ edit.reload
+ edit.match.reload
+ expect(edit.match.scheduled_at).to eq edit.scheduled_at
+ expect(edit.approved).to be true
+ expect(edit.decided_by).to eq decider
+ end
+
+ it 'denies a request', :aggregate_failures do
+ edit = create(:league_match_schedule_edit, created_by: creator)
+
+ expect(edit.deny(decider)).to be true
+ edit.reload
+ edit.match.reload
+ expect(edit.match.scheduled_at).to be nil
+ expect(edit.approved).to be false
+ expect(edit.decided_by).to eq decider
+ end
+end
diff --git a/spec/models/league/match_spec.rb b/spec/models/league/match_spec.rb
index 07bd93e98..e2149e204 100644
--- a/spec/models/league/match_spec.rb
+++ b/spec/models/league/match_spec.rb
@@ -19,6 +19,8 @@
it { should have_many(:comms).class_name('Match::Comm').dependent(:destroy) }
+ it { should have_many(:schedule_edits).class_name('Match::ScheduleEdit').dependent(:destroy) }
+
it { should allow_value('').for(:round_name) }
it { should validate_length_of(:round_name).is_at_least(0) }
diff --git a/spec/requests/api/v1/matches_spec.rb b/spec/requests/api/v1/matches_spec.rb
index 5f7f29d06..b3bf4bc88 100644
--- a/spec/requests/api/v1/matches_spec.rb
+++ b/spec/requests/api/v1/matches_spec.rb
@@ -17,6 +17,7 @@
expect(match_h).to_not be_nil
expect(match_h['forfeit_by']).to eq(match.forfeit_by)
expect(match_h['status']).to eq(match.status)
+ expect(match_h['scheduled_at']).to eq(match.scheduled_at)
expect(match_h['league']['name']).to eq(match.league.name)
expect(match_h['home_team']['name']).to eq(match.home_team.name)
expect(match_h['away_team']['name']).to eq(match.away_team.name)
diff --git a/spec/services/leagues/matches/schedule_edits/approval_service_spec.rb b/spec/services/leagues/matches/schedule_edits/approval_service_spec.rb
new file mode 100644
index 000000000..7509e242f
--- /dev/null
+++ b/spec/services/leagues/matches/schedule_edits/approval_service_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+require_relative './shared'
+
+describe Leagues::Matches::ScheduleEdits::ApprovalService do
+ include_context 'with captains'
+
+ it 'approves a reschedule request', :aggregate_failures do
+ edit = create(:league_match_schedule_edit, created_by: user, match: match)
+
+ subject.call(edit, away_captain)
+ edit.reload
+ expect(edit).to be_valid
+ expect(edit.approved).to be true
+ expect(edit.decided_by).to eq away_captain
+ match.reload
+ expect(match.scheduled_at).to eq edit.scheduled_at
+
+ expect(user.notifications).to be_empty
+ expect(home_captain.notifications).to be_empty
+ expect(away_captain.notifications).to be_empty
+ expect(match.users.map(&:notifications)).not_to include(be_empty)
+ end
+
+ context 'with other edits' do
+ include_context 'with schedule edits'
+
+ it 'denies other pending requests', :aggregate_failures do
+ edit = create(:league_match_schedule_edit, created_by: away_captain, match: match)
+ subject.call(edit, user)
+ other_edit.reload
+ pending_edit.reload
+ approved_edit.reload
+
+ expect(edit.approved).to be true
+ expect(edit.decided_by).to eq user
+ expect(other_edit.approved).to be false
+ expect(other_edit.decided_by).to eq user
+ expect(pending_edit.approved).to be false
+ expect(pending_edit.decided_by).to eq user
+ expect(approved_edit.approved).to be true
+ expect(approved_edit.decided_by).to eq away_captain
+ end
+ end
+end
diff --git a/spec/services/leagues/matches/schedule_edits/creation_service_spec.rb b/spec/services/leagues/matches/schedule_edits/creation_service_spec.rb
new file mode 100644
index 000000000..870f87484
--- /dev/null
+++ b/spec/services/leagues/matches/schedule_edits/creation_service_spec.rb
@@ -0,0 +1,54 @@
+require 'rails_helper'
+require_relative './shared'
+
+describe Leagues::Matches::ScheduleEdits::CreationService do
+ include_context 'with captains'
+
+ it 'creates a reschedule request', :aggregate_failures do
+ scheduled_at = Time.now.utc
+ edit = subject.call(match, user, scheduled_at: scheduled_at)
+
+ expect(edit).to be_valid
+ expect(edit.created_by).to be(user)
+ expect(edit.scheduled_at).to eq(scheduled_at)
+ expect(edit.away_team?).to be(true)
+ expect(edit.pending?).to be(true)
+
+ expect(match.schedule_edits).not_to be_empty
+ expect(match.scheduled_at).to be_nil
+
+ expect(user.notifications).not_to be_empty
+ expect(home_captain.notifications).to be_empty
+ expect(away_captain.notifications).not_to be_empty
+ expect(match.users.map(&:notifications)).to all(be_empty)
+ end
+
+ it 'sets deciding_team to the other team' do
+ expect(subject.call(match, user, scheduled_at: Time.now.utc).deciding_team).to eq 'away_team'
+ end
+
+ it 'sets deciding_team to :home_team for BYEs' do
+ expect(subject.call(create(:bye_league_match), user, scheduled_at: Time.now.utc)
+ .deciding_team).to eq 'home_team'
+ end
+
+ context 'with other edits' do
+ include_context 'with schedule edits'
+
+ it 'cancels non-pending requests from the same user', :aggregate_failures do
+ edit = subject.call(match, user, scheduled_at: Time.now.utc)
+ other_edit.reload
+ pending_edit.reload
+ approved_edit.reload
+
+ expect(edit.approved).to be nil
+ expect(edit.decided_by).to be nil
+ expect(other_edit.approved).to be nil
+ expect(other_edit.decided_by).to be nil
+ expect(pending_edit.approved).to be false
+ expect(pending_edit.decided_by).to eq user
+ expect(approved_edit.approved).to be true
+ expect(approved_edit.decided_by).to eq away_captain
+ end
+ end
+end
diff --git a/spec/services/leagues/matches/schedule_edits/denial_service_spec.rb b/spec/services/leagues/matches/schedule_edits/denial_service_spec.rb
new file mode 100644
index 000000000..00c3e1ccf
--- /dev/null
+++ b/spec/services/leagues/matches/schedule_edits/denial_service_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+require_relative './shared'
+
+describe Leagues::Matches::ScheduleEdits::DenialService do
+ include_context 'with captains'
+
+ it 'denies a reschedule request', :aggregate_failures do
+ edit = create(:league_match_schedule_edit, created_by: user, match: match,
+ deciding_team: :away_team)
+
+ subject.call(edit, away_captain)
+ edit.reload
+ expect(edit).to be_valid
+ expect(edit.approved).to be(false)
+ expect(edit.decided_by).to eq(away_captain)
+ match.reload
+ expect(match.scheduled_at).to be_nil
+
+ expect(user.notifications).not_to be_empty
+ expect(home_captain.notifications).not_to be_empty
+ expect(away_captain.notifications).to be_empty
+ expect(match.users.map(&:notifications)).to all(be_empty)
+ end
+end
diff --git a/spec/services/leagues/matches/schedule_edits/shared.rb b/spec/services/leagues/matches/schedule_edits/shared.rb
new file mode 100644
index 000000000..1f13a574d
--- /dev/null
+++ b/spec/services/leagues/matches/schedule_edits/shared.rb
@@ -0,0 +1,21 @@
+shared_context 'with captains' do
+ let(:match) { create(:league_match) }
+ let(:user) { create(:user) }
+ let(:home_captain) { create(:user) }
+ let(:away_captain) { create(:user) }
+
+ before do
+ user.grant(:edit, match.home_team.team)
+ home_captain.grant(:edit, match.home_team.team)
+ away_captain.grant(:edit, match.away_team.team)
+ ActionMailer::Base.deliveries.clear
+ end
+end
+
+shared_context 'with schedule edits' do
+ let!(:other_edit) { create(:league_match_schedule_edit, created_by: home_captain, match: match) }
+ let!(:pending_edit) { create(:league_match_schedule_edit, created_by: user, match: match) }
+ let!(:approved_edit) { create(:league_match_schedule_edit, created_by: user, match: match) }
+
+ before { approved_edit.approve(away_captain) }
+end
diff --git a/spec/views/leagues/matches/index.html.haml_spec.rb b/spec/views/leagues/matches/index.html.haml_spec.rb
index edb00c261..b76df1803 100644
--- a/spec/views/leagues/matches/index.html.haml_spec.rb
+++ b/spec/views/leagues/matches/index.html.haml_spec.rb
@@ -8,7 +8,8 @@
before do
@matches = []
@matches << build_stubbed(:league_match, home_team: home_team,
- away_team: away_team, status: 'confirmed')
+ away_team: away_team, status: 'confirmed',
+ scheduled_at: Time.zone.now)
@matches << build_stubbed(:league_match, home_team: home_team, away_team: away_team,
status: 'pending', round_name: 'Finals')
@matches << build_stubbed(:bye_league_match, home_team: home_team, status: 'confirmed')
@@ -26,7 +27,7 @@
end
end
- it 'displays matches' do
+ it 'displays matches', :aggregate_failures do
allow(view).to receive(:user_can_edit_league?).and_return(true)
assign(:league, div.league)
assign(:divisions, [div])
@@ -37,6 +38,7 @@
@matches.each do |match|
expect(rendered).to include(match.home_team.name)
expect(rendered).to include(match.away_team.name) if match.away_team
+ expect(rendered).to include(match.scheduled_at.strftime('%c')) if match.scheduled_at
end
end
end
diff --git a/spec/views/leagues/matches/show.html.haml_spec.rb b/spec/views/leagues/matches/show.html.haml_spec.rb
index 46de7db00..b78bae5db 100644
--- a/spec/views/leagues/matches/show.html.haml_spec.rb
+++ b/spec/views/leagues/matches/show.html.haml_spec.rb
@@ -33,6 +33,7 @@
assign(:rounds, rounds)
assign(:comm, League::Match::Comm.new(match: match))
assign(:comms, comms)
+ assign(:edit, League::Match::ScheduleEdit.new(match: match))
(home_team.users + away_team.users + comms.map(&:created_by)).each do |user|
allow(user).to receive(:can?).and_return(true)
@@ -245,4 +246,58 @@
include_examples 'displays matches'
end
+
+ context 'with match date' do
+ before { match.scheduled_at = Time.zone.now }
+
+ include_examples 'displays matches'
+ end
+
+ shared_examples 'without rescheduling' do
+ context 'without rescheduling' do
+ before { league.allow_rescheduling }
+
+ include_examples 'displays matches'
+ end
+ end
+
+ include_examples 'without rescheduling'
+
+ shared_context 'mock edits' do
+ before do
+ schedule_edits = double('ScheduleEdits', empty?: false, ordered: edits)
+ allow(match).to receive(:schedule_edits).and_return(schedule_edits)
+ end
+ end
+
+ context 'with pending schedule edits' do
+ let(:edits) { [build_stubbed(:league_match_schedule_edit, created_by: user, match: match)] }
+
+ include_context 'mock edits'
+
+ context 'match pending' do
+ include_examples 'displays matches'
+ include_examples 'without rescheduling'
+ end
+
+ context 'match confirmed' do
+ before { match.status = :confirmed }
+
+ include_examples 'displays matches'
+ end
+ end
+
+ context 'with approved schedule edits' do
+ let(:edits) do
+ [
+ build_stubbed(:league_match_schedule_edit, created_by: user, match: match, decided_by: user,
+ approved: false),
+ build_stubbed(:league_match_schedule_edit, created_by: user, match: match, decided_by: user,
+ approved: true),
+ ]
+ end
+
+ include_context 'mock edits'
+ include_examples 'displays matches'
+ end
end
diff --git a/spec/views/leagues/show.html.haml_spec.rb b/spec/views/leagues/show.html.haml_spec.rb
index be692fc24..b9bcc9e55 100644
--- a/spec/views/leagues/show.html.haml_spec.rb
+++ b/spec/views/leagues/show.html.haml_spec.rb
@@ -6,7 +6,7 @@
let(:roster) { build_stubbed(:league_roster) }
let(:matches) do
[
- build_stubbed(:league_match, status: :confirmed),
+ build_stubbed(:league_match, status: :confirmed, scheduled_at: Time.zone.now),
build_stubbed(:league_match, status: :confirmed, forfeit_by: :home_team_forfeit),
build_stubbed(:league_match, status: :confirmed, forfeit_by: :away_team_forfeit),
build_stubbed(:league_match, status: :confirmed, forfeit_by: :mutual_forfeit),
diff --git a/spec/views/teams/show.html.haml_spec.rb b/spec/views/teams/show.html.haml_spec.rb
index 6a5982184..844d0031b 100644
--- a/spec/views/teams/show.html.haml_spec.rb
+++ b/spec/views/teams/show.html.haml_spec.rb
@@ -27,7 +27,7 @@
@matches = []
@matches << build_stubbed(:league_match, home_team: roster, status: 'confirmed')
@matches << build_stubbed(:league_match, home_team: roster, status: 'pending',
- round_name: 'Finals')
+ round_name: 'Finals', scheduled_at: Time.zone.now)
@matches << build_stubbed(:bye_league_match, home_team: roster, status: 'confirmed')
League::Match.forfeit_bies.each_key do |ff|
@matches << build_stubbed(:league_match, home_team: roster, forfeit_by: ff,
@@ -98,6 +98,7 @@
@matches.each do |match|
expect(rendered).to include(match.home_team.name)
expect(rendered).to include(match.away_team.name) if match.away_team
+ expect(rendered).to include(match.scheduled_at.strftime('%c')) if match.scheduled_at
end
@users.each_value do |user|