Skip to content

Commit b527735

Browse files
authored
Merge pull request #167 from denis1011101/feature/training-plan-proposals
Feature/training plan proposals
2 parents cf88904 + f493928 commit b527735

38 files changed

Lines changed: 1493 additions & 80 deletions

app/controllers/games_controller.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def index
6565
end
6666

6767
def show
68+
prepare_training_plan_proposals
6869
end
6970

7071
def new
@@ -179,6 +180,26 @@ def prepare_coaches
179180
@coaches = User.not_merged.where(coach: true).order(:name)
180181
end
181182

183+
# Правки плана видны всем, кто открыл тренировку, а предлагать их может тот,
184+
# кто выйдет на корт: состав, тренеры и организатор.
185+
def prepare_training_plan_proposals
186+
@training_plan_proposals = TrainingPlanProposal.none
187+
@proposal_training_blocks = TrainingBlock.none
188+
@can_propose_training_plan = false
189+
return unless @game.training?
190+
191+
@training_plan_proposals = @game.training_plan_proposals.open.includes(:user).recent_first
192+
return unless current_user && (current_user.admin? || @game.team_member_ids.include?(current_user.id))
193+
194+
@can_propose_training_plan = true
195+
# Блок из личной библиотеки автора правки виден остальным в её списке —
196+
# иначе организатор потерял бы его, сохраняя ту же правку.
197+
@proposal_training_blocks = TrainingBlock.available_for(
198+
([ current_user.id, @game.user_id ] + @game.assigned_coach_ids).compact.uniq,
199+
@game.training_block_ids + @training_plan_proposals.flat_map(&:training_block_ids)
200+
).ordered
201+
end
202+
182203
# В форме показываем свою библиотеку, библиотеки тренеров этой тренировки и
183204
# общие блоки GetCourt: остальные чужие блоки организатору ни к чему.
184205
def prepare_training_blocks
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Правки плана тренировки: предлагает участник, судьбу решает организатор, а
2+
# при голосовании — состав игры.
3+
class TrainingPlanProposalsController < ApplicationController
4+
before_action :set_game
5+
before_action :set_proposal, except: :create
6+
before_action :authorize_team_member!, only: :create
7+
before_action :authorize_game_manager!, only: %i[update approve reject]
8+
9+
def create
10+
@proposal = @game.training_plan_proposals.new(proposal_attributes.merge(user: current_user))
11+
12+
if @proposal.save
13+
# Организатору спрашивать разрешения не у кого — его правка идёт сразу.
14+
can_manage?(@game) ? approve_and_notify : TrainingPlanProposalNotifier.approval_requested(@proposal)
15+
redirect_to @game, notice: t("games.training_plan_proposals.#{created_notice_key}")
16+
else
17+
redirect_to @game, alert: @proposal.errors.full_messages.to_sentence
18+
end
19+
end
20+
21+
# Организатор может поправить предложенное, прежде чем пускать его дальше.
22+
def update
23+
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.pending?
24+
25+
if @proposal.update(proposal_attributes)
26+
redirect_to @game, notice: t("games.training_plan_proposals.updated")
27+
else
28+
redirect_to @game, alert: @proposal.errors.full_messages.to_sentence
29+
end
30+
end
31+
32+
def approve
33+
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.pending?
34+
35+
approve_and_notify
36+
redirect_to @game, notice: t("games.training_plan_proposals.#{@proposal.applied? ? "applied" : "vote_started"}")
37+
end
38+
39+
def reject
40+
return redirect_to @game, alert: t("games.training_plan_proposals.closed") unless @proposal.open?
41+
42+
@proposal.reject!
43+
TrainingPlanProposalNotifier.settled(@proposal)
44+
redirect_to @game, notice: t("games.training_plan_proposals.rejected")
45+
end
46+
47+
def vote
48+
unless @proposal.vote!(current_user, ActiveModel::Type::Boolean.new.cast(params[:in_favor]))
49+
return redirect_to @game, alert: t("games.training_plan_proposals.vote_not_allowed")
50+
end
51+
52+
TrainingPlanProposalNotifier.settled(@proposal) unless @proposal.open?
53+
redirect_to @game, notice: t("games.training_plan_proposals.vote_counted")
54+
end
55+
56+
# Автор забирает правку назад, организатор — убирает лишнее из списка.
57+
def destroy
58+
unless @proposal.user_id == current_user.id || can_manage?(@game)
59+
return head :forbidden
60+
end
61+
62+
@proposal.destroy
63+
redirect_to @game, notice: t("games.training_plan_proposals.removed")
64+
end
65+
66+
private
67+
68+
def set_game
69+
@game = Game.find(params[:game_id])
70+
end
71+
72+
def set_proposal
73+
@proposal = @game.training_plan_proposals.find(params[:id])
74+
end
75+
76+
def authorize_team_member!
77+
head :forbidden unless team_member?
78+
end
79+
80+
def authorize_game_manager!
81+
head :forbidden unless can_manage?(@game)
82+
end
83+
84+
def team_member?
85+
current_user.admin? || @game.team_member_ids.include?(current_user.id)
86+
end
87+
88+
def approve_and_notify
89+
@proposal.approve!
90+
91+
if @proposal.voting?
92+
TrainingPlanProposalNotifier.vote_started(@proposal)
93+
else
94+
TrainingPlanProposalNotifier.settled(@proposal)
95+
end
96+
end
97+
98+
def created_notice_key
99+
return "created" unless can_manage?(@game)
100+
101+
@proposal.applied? ? "applied" : "vote_started"
102+
end
103+
104+
def proposal_attributes
105+
permitted = params.require(:training_plan_proposal).permit(:comment, :mode, training_block_ids: [])
106+
107+
permitted.to_h.merge("training_block_ids" => allowed_block_ids(permitted[:training_block_ids]))
108+
end
109+
110+
# Блок из чужой библиотеки в план не попадает, даже если его id прислали в форме.
111+
# Своё предложение — исключение: организатор правит его целиком, не теряя блок,
112+
# который принёс из личной библиотеки автор правки.
113+
def allowed_block_ids(ids)
114+
ids = Array(ids).map(&:to_i).uniq.reject(&:zero?)
115+
owner_ids = ([ current_user.id, @game.user_id ] + @game.assigned_coach_ids).compact.uniq
116+
known_ids = @game.training_block_ids + Array(@proposal&.training_block_ids)
117+
allowed = TrainingBlock.available_for(owner_ids, known_ids).where(id: ids).pluck(:id)
118+
119+
ids.select { |id| allowed.include?(id) }
120+
end
121+
end

app/controllers/users_controller.rb

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,26 +101,28 @@ def clear_city
101101
redirect_to profile_account_path, notice: "City cleared"
102102
end
103103

104+
# Расписание тренера — отдельный раздел кабинета: игроку он не нужен вовсе,
105+
# а тренеру мешал искать свои игры в общем списке.
106+
def coach_schedule
107+
return redirect_to edit_account_path unless current_user.coach?
108+
109+
# Тренер может стоять и вторым — расписание собираем по обоим слотам.
110+
accepted_games = Game.where(coach_id: current_user.id, coach_invitation_status: "accepted")
111+
.or(Game.where(second_coach_id: current_user.id, second_coach_invitation_status: "accepted"))
112+
113+
recurring_dates = current_user.coach_prebookings
114+
.where(date: Date.current.., game: accepted_games)
115+
.includes(game: [ :court, { participations: :user } ])
116+
.map { |booking| { game: booking.game, date: booking.date } }
117+
one_off_dates = accepted_games
118+
.where(recurring: false, date: Date.current..)
119+
.includes(:court, participations: :user)
120+
.map { |game| { game: game, date: game.date } }
121+
122+
@coach_schedule = (recurring_dates + one_off_dates).sort_by { |entry| [ entry[:date], entry[:game].time.to_s ] }
123+
end
124+
104125
def games
105-
@coach_schedule =
106-
if current_user.coach?
107-
# Тренер может стоять и вторым — расписание собираем по обоим слотам.
108-
accepted_games = Game.where(coach_id: current_user.id, coach_invitation_status: "accepted")
109-
.or(Game.where(second_coach_id: current_user.id, second_coach_invitation_status: "accepted"))
110-
111-
recurring_dates = current_user.coach_prebookings
112-
.where(date: Date.current.., game: accepted_games)
113-
.includes(game: [ :court, { participations: :user } ])
114-
.map { |booking| { game: booking.game, date: booking.date } }
115-
one_off_dates = accepted_games
116-
.where(recurring: false, date: Date.current..)
117-
.includes(:court, participations: :user)
118-
.map { |game| { game: game, date: game.date } }
119-
120-
(recurring_dates + one_off_dates).sort_by { |entry| [ entry[:date], entry[:game].time.to_s ] }
121-
else
122-
[]
123-
end
124126
order_sql =
125127
if Game.column_names.include?("next_date")
126128
"COALESCE(games.next_date, games.date) DESC NULLS LAST, games.time DESC"

app/models/game.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class Game < ApplicationRecord
2323
# План тренировки — блоки из библиотеки тренера в выбранном порядке.
2424
has_many :game_training_blocks, -> { ordered }, dependent: :destroy, inverse_of: :game
2525
has_many :training_blocks, through: :game_training_blocks
26+
# Правки плана, которые предложили участники тренировки.
27+
has_many :training_plan_proposals, dependent: :destroy
2628

2729
SURFACES = Court::SURFACES
2830
KINDS = %w[game training].freeze
@@ -59,6 +61,13 @@ def coaches
5961
[ coach, second_coach ].compact
6062
end
6163

64+
# Кто выходит на корт: состав, принятые тренеры и организатор. Они же решают,
65+
# как пройдёт занятие — предлагают правки плана и голосуют за них.
66+
def team_member_ids
67+
ids = participations.approved.where.not(user_id: nil).pluck(:user_id)
68+
(ids + accepted_coaches.map(&:id) + [ user_id ]).compact.uniq
69+
end
70+
6271
# Порядок блоков задаёт сам список: он и есть план занятия.
6372
def replace_training_plan!(block_ids)
6473
block_ids = Array(block_ids).map(&:to_i).uniq.reject(&:zero?)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Правка плана тренировки, которую предложил участник игры.
2+
#
3+
# Хозяин занятия — организатор: он решает, пускать ли правку. Дальше всё
4+
# зависит от того, что выбрал автор: применить сразу или спросить остальных.
5+
class TrainingPlanProposal < ApplicationRecord
6+
belongs_to :game
7+
belongs_to :user
8+
has_many :training_plan_votes, dependent: :destroy
9+
10+
STATUSES = %w[pending voting applied rejected].freeze
11+
MODES = %w[vote direct].freeze
12+
13+
before_validation :normalize_training_block_ids
14+
15+
validates :status, inclusion: { in: STATUSES }
16+
validates :mode, inclusion: { in: MODES }
17+
validates :comment, length: { maximum: 500 }, allow_blank: true
18+
validate :plan_has_blocks
19+
validate :game_is_a_training
20+
21+
scope :open, -> { where(status: %w[pending voting]) }
22+
scope :recent_first, -> { order(created_at: :desc) }
23+
24+
STATUSES.each do |value|
25+
define_method("#{value}?") { status == value }
26+
end
27+
28+
def open?
29+
pending? || voting?
30+
end
31+
32+
def direct?
33+
mode == "direct"
34+
end
35+
36+
# Блоки в том порядке, в каком их выстроил автор правки.
37+
def blocks
38+
by_id = TrainingBlock.where(id: training_block_ids).index_by(&:id)
39+
training_block_ids.filter_map { |id| by_id[id] }
40+
end
41+
42+
# Без «да» организатора правка не идёт ни на голосование, ни в план.
43+
def approve!
44+
return false unless pending?
45+
46+
if direct?
47+
apply!
48+
else
49+
update!(status: "voting")
50+
# Голосовать может быть некому: тренировка на одного — тоже тренировка.
51+
settle!
52+
end
53+
true
54+
end
55+
56+
def reject!
57+
return false unless open?
58+
59+
update!(status: "rejected")
60+
end
61+
62+
def apply!
63+
# Блок могли удалить из библиотеки, пока правка ждала своей очереди: в план
64+
# ставим то, что уцелело, а если не уцелело ничего — применять нечего.
65+
ids = blocks.map(&:id)
66+
return update!(status: "rejected") if ids.empty?
67+
68+
transaction do
69+
game.replace_training_plan!(ids)
70+
update!(status: "applied")
71+
end
72+
end
73+
74+
# Голосуют те, кто выйдет на корт. Автор правки уже «за» — бюллетень ему не нужен.
75+
def voter_ids
76+
@voter_ids ||= game.team_member_ids - [ user_id ]
77+
end
78+
79+
def voted?(voter)
80+
training_plan_votes.exists?(user_id: voter&.id)
81+
end
82+
83+
def vote!(voter, in_favor)
84+
return false unless voting? && voter_ids.include?(voter&.id)
85+
86+
training_plan_votes.find_or_initialize_by(user_id: voter.id).update!(in_favor: in_favor)
87+
@ballots = nil
88+
settle!
89+
true
90+
end
91+
92+
def votes_in_favor
93+
ballots.count(&:in_favor?) + 1
94+
end
95+
96+
def votes_against
97+
ballots.count { |ballot| !ballot.in_favor? }
98+
end
99+
100+
def votes_expected
101+
voter_ids.size + 1
102+
end
103+
104+
private
105+
106+
# Голос того, кто уже вышел из игры, не считаем: иначе бюллетень ушедшего
107+
# решает за тех, кто на корт всё-таки выйдет.
108+
def ballots
109+
@ballots ||= training_plan_votes.where(user_id: voter_ids).to_a
110+
end
111+
112+
# Ждать последний голос незачем: как только одна сторона взяла большинство,
113+
# остальные бюллетени ничего не меняют.
114+
def settle!
115+
if votes_in_favor * 2 > votes_expected
116+
apply!
117+
elsif votes_against * 2 >= votes_expected
118+
update!(status: "rejected")
119+
end
120+
end
121+
122+
def normalize_training_block_ids
123+
self.training_block_ids = Array(training_block_ids).map(&:to_i).uniq.reject(&:zero?)
124+
end
125+
126+
def plan_has_blocks
127+
errors.add(:training_block_ids, :blank) if training_block_ids.empty?
128+
end
129+
130+
def game_is_a_training
131+
errors.add(:game, :invalid) unless game&.training?
132+
end
133+
end

app/models/training_plan_vote.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class TrainingPlanVote < ApplicationRecord
2+
belongs_to :training_plan_proposal
3+
belongs_to :user
4+
5+
validates :user_id, uniqueness: { scope: :training_plan_proposal_id }
6+
end

app/models/user.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ def skill_level_display_for(sport)
9292
has_many :coach_prebookings, foreign_key: :coach_id, dependent: :destroy, inverse_of: :coach
9393
# Библиотека блоков тренировок принадлежит тренеру и уходит вместе с ним.
9494
has_many :training_blocks, dependent: :destroy
95+
has_many :training_plan_proposals, dependent: :destroy
96+
has_many :training_plan_votes, dependent: :destroy
9597
has_many :participations
9698
has_many :favorite_court_links, class_name: "FavoriteCourt", dependent: :destroy
9799
has_many :court_suggestions, dependent: :destroy

0 commit comments

Comments
 (0)