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|