Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/controllers/games_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def index
end

def show
prepare_training_plan_proposals
end

def new
Expand Down Expand Up @@ -179,6 +180,26 @@ def prepare_coaches
@coaches = User.not_merged.where(coach: true).order(:name)
end

# Правки плана видны всем, кто открыл тренировку, а предлагать их может тот,
# кто выйдет на корт: состав, тренеры и организатор.
def prepare_training_plan_proposals
@training_plan_proposals = TrainingPlanProposal.none
@proposal_training_blocks = TrainingBlock.none
@can_propose_training_plan = false
return unless @game.training?

@training_plan_proposals = @game.training_plan_proposals.open.includes(:user).recent_first
return unless current_user && (current_user.admin? || @game.team_member_ids.include?(current_user.id))

@can_propose_training_plan = true
# Блок из личной библиотеки автора правки виден остальным в её списке —
# иначе организатор потерял бы его, сохраняя ту же правку.
@proposal_training_blocks = TrainingBlock.available_for(
([ current_user.id, @game.user_id ] + @game.assigned_coach_ids).compact.uniq,
@game.training_block_ids + @training_plan_proposals.flat_map(&:training_block_ids)
).ordered
end

# В форме показываем свою библиотеку, библиотеки тренеров этой тренировки и
# общие блоки GetCourt: остальные чужие блоки организатору ни к чему.
def prepare_training_blocks
Expand Down
121 changes: 121 additions & 0 deletions app/controllers/training_plan_proposals_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Правки плана тренировки: предлагает участник, судьбу решает организатор, а
# при голосовании — состав игры.
class TrainingPlanProposalsController < ApplicationController
before_action :set_game
before_action :set_proposal, except: :create
before_action :authorize_team_member!, only: :create
before_action :authorize_game_manager!, only: %i[update approve reject]

def create
@proposal = @game.training_plan_proposals.new(proposal_attributes.merge(user: current_user))

if @proposal.save
# Организатору спрашивать разрешения не у кого — его правка идёт сразу.
can_manage?(@game) ? approve_and_notify : TrainingPlanProposalNotifier.approval_requested(@proposal)
redirect_to @game, notice: t("games.training_plan_proposals.#{created_notice_key}")
else
redirect_to @game, alert: @proposal.errors.full_messages.to_sentence
end
end

# Организатор может поправить предложенное, прежде чем пускать его дальше.
def update
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.pending?

if @proposal.update(proposal_attributes)
redirect_to @game, notice: t("games.training_plan_proposals.updated")
else
redirect_to @game, alert: @proposal.errors.full_messages.to_sentence
end
end

def approve
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.pending?

approve_and_notify
redirect_to @game, notice: t("games.training_plan_proposals.#{@proposal.applied? ? "applied" : "vote_started"}")
end

def reject
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.open?

@proposal.reject!
TrainingPlanProposalNotifier.settled(@proposal)
redirect_to @game, notice: t("games.training_plan_proposals.rejected")
end

def vote
unless @proposal.vote!(current_user, ActiveModel::Type::Boolean.new.cast(params[:in_favor]))
return redirect_to @game, alert: t("games.training_plan_proposals.vote_not_allowed")
end

TrainingPlanProposalNotifier.settled(@proposal) unless @proposal.open?
redirect_to @game, notice: t("games.training_plan_proposals.vote_counted")
end

# Автор забирает правку назад, организатор — убирает лишнее из списка.
def destroy
unless @proposal.user_id == current_user.id || can_manage?(@game)
return head :forbidden
end

@proposal.destroy
redirect_to @game, notice: t("games.training_plan_proposals.removed")
end

private

def set_game
@game = Game.find(params[:game_id])
end

def set_proposal
@proposal = @game.training_plan_proposals.find(params[:id])
end

def authorize_team_member!
head :forbidden unless team_member?
end

def authorize_game_manager!
head :forbidden unless can_manage?(@game)
end

def team_member?
current_user.admin? || @game.team_member_ids.include?(current_user.id)
end

def approve_and_notify
@proposal.approve!

if @proposal.voting?
TrainingPlanProposalNotifier.vote_started(@proposal)
else
TrainingPlanProposalNotifier.settled(@proposal)
end
end

def created_notice_key
return "created" unless can_manage?(@game)

@proposal.applied? ? "applied" : "vote_started"
end

def proposal_attributes
permitted = params.require(:training_plan_proposal).permit(:comment, :mode, training_block_ids: [])

permitted.to_h.merge("training_block_ids" => allowed_block_ids(permitted[:training_block_ids]))
end

# Блок из чужой библиотеки в план не попадает, даже если его id прислали в форме.
# Своё предложение — исключение: организатор правит его целиком, не теряя блок,
# который принёс из личной библиотеки автор правки.
def allowed_block_ids(ids)
ids = Array(ids).map(&:to_i).uniq.reject(&:zero?)
owner_ids = ([ current_user.id, @game.user_id ] + @game.assigned_coach_ids).compact.uniq
known_ids = @game.training_block_ids + Array(@proposal&.training_block_ids)
allowed = TrainingBlock.available_for(owner_ids, known_ids).where(id: ids).pluck(:id)

ids.select { |id| allowed.include?(id) }
end
end
40 changes: 21 additions & 19 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,26 +101,28 @@ def clear_city
redirect_to profile_account_path, notice: "City cleared"
end

# Расписание тренера — отдельный раздел кабинета: игроку он не нужен вовсе,
# а тренеру мешал искать свои игры в общем списке.
def coach_schedule
return redirect_to edit_account_path unless current_user.coach?

# Тренер может стоять и вторым — расписание собираем по обоим слотам.
accepted_games = Game.where(coach_id: current_user.id, coach_invitation_status: "accepted")
.or(Game.where(second_coach_id: current_user.id, second_coach_invitation_status: "accepted"))

recurring_dates = current_user.coach_prebookings
.where(date: Date.current.., game: accepted_games)
.includes(game: [ :court, { participations: :user } ])
.map { |booking| { game: booking.game, date: booking.date } }
one_off_dates = accepted_games
.where(recurring: false, date: Date.current..)
.includes(:court, participations: :user)
.map { |game| { game: game, date: game.date } }

@coach_schedule = (recurring_dates + one_off_dates).sort_by { |entry| [ entry[:date], entry[:game].time.to_s ] }
end

def games
@coach_schedule =
if current_user.coach?
# Тренер может стоять и вторым — расписание собираем по обоим слотам.
accepted_games = Game.where(coach_id: current_user.id, coach_invitation_status: "accepted")
.or(Game.where(second_coach_id: current_user.id, second_coach_invitation_status: "accepted"))

recurring_dates = current_user.coach_prebookings
.where(date: Date.current.., game: accepted_games)
.includes(game: [ :court, { participations: :user } ])
.map { |booking| { game: booking.game, date: booking.date } }
one_off_dates = accepted_games
.where(recurring: false, date: Date.current..)
.includes(:court, participations: :user)
.map { |game| { game: game, date: game.date } }

(recurring_dates + one_off_dates).sort_by { |entry| [ entry[:date], entry[:game].time.to_s ] }
else
[]
end
order_sql =
if Game.column_names.include?("next_date")
"COALESCE(games.next_date, games.date) DESC NULLS LAST, games.time DESC"
Expand Down
9 changes: 9 additions & 0 deletions app/models/game.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class Game < ApplicationRecord
# План тренировки — блоки из библиотеки тренера в выбранном порядке.
has_many :game_training_blocks, -> { ordered }, dependent: :destroy, inverse_of: :game
has_many :training_blocks, through: :game_training_blocks
# Правки плана, которые предложили участники тренировки.
has_many :training_plan_proposals, dependent: :destroy

SURFACES = Court::SURFACES
KINDS = %w[game training].freeze
Expand Down Expand Up @@ -59,6 +61,13 @@ def coaches
[ coach, second_coach ].compact
end

# Кто выходит на корт: состав, принятые тренеры и организатор. Они же решают,
# как пройдёт занятие — предлагают правки плана и голосуют за них.
def team_member_ids
ids = participations.approved.where.not(user_id: nil).pluck(:user_id)
(ids + accepted_coaches.map(&:id) + [ user_id ]).compact.uniq
end

# Порядок блоков задаёт сам список: он и есть план занятия.
def replace_training_plan!(block_ids)
block_ids = Array(block_ids).map(&:to_i).uniq.reject(&:zero?)
Expand Down
121 changes: 121 additions & 0 deletions app/models/training_plan_proposal.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Правка плана тренировки, которую предложил участник игры.
#
# Хозяин занятия — организатор: он решает, пускать ли правку. Дальше всё
# зависит от того, что выбрал автор: применить сразу или спросить остальных.
class TrainingPlanProposal < ApplicationRecord
belongs_to :game
belongs_to :user
has_many :training_plan_votes, dependent: :destroy

STATUSES = %w[pending voting applied rejected].freeze
MODES = %w[vote direct].freeze

before_validation :normalize_training_block_ids

validates :status, inclusion: { in: STATUSES }
validates :mode, inclusion: { in: MODES }
validates :comment, length: { maximum: 500 }, allow_blank: true
validate :plan_has_blocks
validate :game_is_a_training

scope :open, -> { where(status: %w[pending voting]) }
scope :recent_first, -> { order(created_at: :desc) }

STATUSES.each do |value|
define_method("#{value}?") { status == value }
end

def open?
pending? || voting?
end

def direct?
mode == "direct"
end

# Блоки в том порядке, в каком их выстроил автор правки.
def blocks
by_id = TrainingBlock.where(id: training_block_ids).index_by(&:id)
training_block_ids.filter_map { |id| by_id[id] }
end

# Без «да» организатора правка не идёт ни на голосование, ни в план.
def approve!
return false unless pending?

if direct?
apply!
else
update!(status: "voting")
# Голосовать может быть некому: тренировка на одного — тоже тренировка.
settle!
end
true
end

def reject!
return false unless open?

update!(status: "rejected")
end

def apply!
transaction do
game.replace_training_plan!(training_block_ids)
update!(status: "applied")
Comment on lines +62 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle training blocks deleted while proposals are open

If the proposal author deletes one of their library blocks while the proposal is pending or voting, its ID remains in this JSON array and applying the proposal attempts to create a game_training_blocks row for a nonexistent block. The foreign key then raises, returning a 500 from approval or the decisive vote and leaving the proposal unable to settle; filter or invalidate missing blocks before replacing the plan.

Useful? React with 👍 / 👎.

end
end

# Голосуют те, кто выйдет на корт. Автор правки уже «за» — бюллетень ему не нужен.
def voter_ids
@voter_ids ||= game.team_member_ids - [ user_id ]
end

def voted?(voter)
training_plan_votes.exists?(user_id: voter&.id)
end

def vote!(voter, in_favor)
return false unless voting? && voter_ids.include?(voter&.id)

training_plan_votes.find_or_initialize_by(user_id: voter.id).update!(in_favor: in_favor)
settle!
true
end

def votes_in_favor
training_plan_votes.where(in_favor: true).count + 1
end

def votes_against
training_plan_votes.where(in_favor: false).count
end

def votes_expected
voter_ids.size + 1
Comment on lines +92 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude departed members' ballots from vote totals

If a participant votes and then leaves the game while the proposal remains open, votes_expected recalculates from the current team but these counters still include that former participant's stored ballot. For example, a departed yes vote can combine with the author's implicit vote to apply the plan even when the remaining members have not produced a majority. Either snapshot the electorate when voting starts or restrict all ballot counts to the same current voter IDs used by votes_expected.

Useful? React with 👍 / 👎.

end

private

# Ждать последний голос незачем: как только одна сторона взяла большинство,
# остальные бюллетени ничего не меняют.
def settle!
if votes_in_favor * 2 > votes_expected
apply!
elsif votes_against * 2 >= votes_expected
update!(status: "rejected")
end
end

def normalize_training_block_ids
self.training_block_ids = Array(training_block_ids).map(&:to_i).uniq.reject(&:zero?)
end

def plan_has_blocks
errors.add(:training_block_ids, :blank) if training_block_ids.empty?
end

def game_is_a_training
errors.add(:game, :invalid) unless game&.training?
end
end
6 changes: 6 additions & 0 deletions app/models/training_plan_vote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class TrainingPlanVote < ApplicationRecord
belongs_to :training_plan_proposal
belongs_to :user

validates :user_id, uniqueness: { scope: :training_plan_proposal_id }
end
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ def skill_level_display_for(sport)
has_many :coach_prebookings, foreign_key: :coach_id, dependent: :destroy, inverse_of: :coach
# Библиотека блоков тренировок принадлежит тренеру и уходит вместе с ним.
has_many :training_blocks, dependent: :destroy
has_many :training_plan_proposals, dependent: :destroy
has_many :training_plan_votes, dependent: :destroy
has_many :participations
has_many :favorite_court_links, class_name: "FavoriteCourt", dependent: :destroy
has_many :court_suggestions, dependent: :destroy
Expand Down
Loading