Skip to content

Commit 86662b8

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/court-street-in-picker
# Conflicts: # db/schema.rb
2 parents a52cfc1 + cbe7e14 commit 86662b8

26 files changed

Lines changed: 1182 additions & 9 deletions

app/controllers/games_controller.rb

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class GamesController < ApplicationController
55
before_action :set_game, only: %i[show edit update destroy toggle_urgent_player_search accept_coach_invitation decline_coach_invitation]
66
before_action :authorize_manage_game!, only: %i[edit update destroy toggle_urgent_player_search]
77
before_action :prepare_coaches, only: %i[new create edit update]
8+
before_action :prepare_training_blocks, only: %i[new create edit update]
89
skip_before_action :authenticate_user!, only: %i[index show]
910

1011
helper_method :display_date, :display_time, :game_badges
@@ -73,12 +74,16 @@ def new
7374
def create
7475
gp = sanitized_game_params
7576
@game = Game.new(gp.merge(user: current_user))
77+
@new_training_blocks = build_inline_training_blocks
7678

77-
if @game.save
79+
if inline_training_blocks_valid? && @game.save
80+
save_inline_training_blocks
7881
@game.ensure_prebookings_for_next_weeks if @game.prebooking_enabled?
82+
apply_training_plan
7983
coaches_awaiting_invitation.each { |coach| deliver_coach_invitation(coach) }
8084
redirect_to @game, notice: "Game was successfully created."
8185
else
86+
@game.validate
8287
Rails.logger.warn "Game save failed: #{ @game.errors.full_messages.join('; ') }"
8388
render :new, status: :unprocessable_entity
8489
end
@@ -88,14 +93,19 @@ def edit
8893
end
8994

9095
def update
91-
gp = sanitized_game_params
92-
if @game.update(gp)
96+
@game.assign_attributes(sanitized_game_params)
97+
@new_training_blocks = build_inline_training_blocks
98+
99+
if inline_training_blocks_valid? && @game.save
100+
save_inline_training_blocks
93101
@game.ensure_prebookings_for_next_weeks if @game.prebooking_enabled?
102+
apply_training_plan
94103
coaches_awaiting_invitation.each { |coach| deliver_coach_invitation(coach) }
95104
# The web form saves every field at once, so one message covers the whole edit.
96105
GameChangeNotifier.notify(game: @game, actor: current_user, changes: @game.saved_changes)
97106
redirect_to @game, notice: "Game was successfully updated."
98107
else
108+
@game.validate
99109
Rails.logger.warn "Game update failed: #{ @game.errors.full_messages.join('; ') }"
100110
render :edit, status: :unprocessable_entity
101111
end
@@ -128,6 +138,18 @@ def toggle_urgent_player_search
128138
redirect_to @game, notice: "Players search #{state}."
129139
end
130140

141+
# GET /games/training_plan_fragment
142+
# Библиотека зависит от выбранных тренеров, а выбирают их прямо в форме,
143+
# поэтому список блоков перезагружаем без перезагрузки страницы.
144+
def training_plan_fragment
145+
owner_ids = ([ current_user.id ] + Array(params[:coach_ids]).map(&:to_i)).reject(&:zero?).uniq
146+
147+
render partial: "games/training_plan_library", locals: {
148+
blocks: TrainingBlock.where(user_id: owner_ids).ordered,
149+
selected_ids: Array(params[:training_block_ids]).map(&:to_i)
150+
}
151+
end
152+
131153
# GET /games/prebooking_fragment
132154
def prebooking_fragment
133155
if params[:game_id].present?
@@ -153,6 +175,66 @@ def prepare_coaches
153175
@coaches = User.not_merged.where(coach: true).order(:name)
154176
end
155177

178+
# В форме показываем свою библиотеку и библиотеки тренеров этой тренировки:
179+
# чужие блоки организатору ни к чему.
180+
def prepare_training_blocks
181+
@training_blocks = TrainingBlock.where(user_id: training_library_owner_ids).ordered
182+
end
183+
184+
# Тренеров берём и из формы: на создании игры @game ещё нет, а библиотека
185+
# выбранного тренера нужна уже там.
186+
def training_library_owner_ids
187+
submitted = [ params.dig(:game, :coach_id), params.dig(:game, :second_coach_id) ]
188+
189+
([ current_user&.id ] + Array(@game&.assigned_coach_ids) + submitted).map(&:to_i).reject(&:zero?).uniq
190+
end
191+
192+
def apply_training_plan
193+
# План приходит только из формы игры, поэтому пустой запрос его не стирает.
194+
return unless params[:game].respond_to?(:key?) && params[:game].key?(:training_block_ids)
195+
return unless @game.training?
196+
197+
ids = training_plan_params[:ids] + @new_training_blocks.map(&:id)
198+
# Блок из чужой библиотеки в план не попадает, даже если его id прислали в форме.
199+
allowed_ids = TrainingBlock.where(id: ids, user_id: training_plan_owner_ids).pluck(:id)
200+
201+
@game.replace_training_plan!(ids.uniq.select { |id| allowed_ids.include?(id) })
202+
end
203+
204+
def training_plan_params
205+
raw = params.fetch(:game, ActionController::Parameters.new)
206+
submitted = raw[:new_training_blocks]
207+
submitted = submitted.values if submitted.respond_to?(:values)
208+
209+
@training_plan_params ||= {
210+
ids: Array(raw[:training_block_ids]).map(&:to_i),
211+
new_blocks: Array(submitted).filter_map { |block| block.permit(:title, :description, :duration_minutes) if block.respond_to?(:permit) }
212+
}
213+
end
214+
215+
# Блоки собираем до сохранения игры: иначе игра сохранится, а блок с опечаткой
216+
# молча потеряется вместе со своим местом в плане.
217+
def build_inline_training_blocks
218+
return [] unless @game.training? || @game.with_coach?
219+
220+
training_plan_params[:new_blocks]
221+
.reject { |attrs| attrs.values.all?(&:blank?) }
222+
.uniq { |attrs| attrs[:title].to_s.strip.downcase }
223+
.map { |attrs| TrainingBlock.build_for(current_user, attrs) }
224+
end
225+
226+
def inline_training_blocks_valid?
227+
@new_training_blocks.map(&:valid?).all?
228+
end
229+
230+
def save_inline_training_blocks
231+
@new_training_blocks.each(&:save!)
232+
end
233+
234+
def training_plan_owner_ids
235+
([ current_user.id ] + @game.assigned_coach_ids).uniq
236+
end
237+
156238
# Приглашение уходит на смену слота, а не набора тренеров: если тренеров
157239
# поменять местами, модель сбросит оба статуса в pending, и по одному только
158240
# набору id никто бы приглашения не получил.

app/controllers/player_statistics_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def create_for_game
6666
saved_any = true
6767
end
6868

69-
matches_input = matches_params
69+
# На тренировке счёт не ведут, поэтому матчи из формы туда не попадают.
70+
matches_input = game.training? ? [] : matches_params
7071
unbalanced_matches = 0
7172
matches_input.each do |m|
7273
team_a_ids = sanitize_team_ids(m[:team_a_user_ids], game)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
class TrainingBlocksController < ApplicationController
2+
before_action :set_training_block, only: %i[update destroy]
3+
4+
def index
5+
@training_block = TrainingBlock.new
6+
@training_blocks = current_user.training_blocks.ordered
7+
end
8+
9+
def create
10+
@training_block = current_user.training_blocks.new(training_block_params)
11+
12+
if @training_block.save
13+
redirect_to training_blocks_path, notice: t("training_blocks.created")
14+
else
15+
@training_blocks = current_user.training_blocks.ordered
16+
render :index, status: :unprocessable_entity
17+
end
18+
end
19+
20+
def update
21+
if @training_block.update(training_block_params)
22+
redirect_to training_blocks_path, notice: t("training_blocks.updated")
23+
else
24+
redirect_to training_blocks_path, alert: @training_block.errors.full_messages.to_sentence
25+
end
26+
end
27+
28+
def destroy
29+
@training_block.destroy
30+
redirect_to training_blocks_path, notice: t("training_blocks.destroyed")
31+
end
32+
33+
private
34+
35+
# Правку и удаление пускаем только по своей библиотеке.
36+
def set_training_block
37+
@training_block = current_user.training_blocks.find(params[:id])
38+
end
39+
40+
def training_block_params
41+
params.require(:training_block).permit(:title, :description, :duration_minutes)
42+
end
43+
end
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Конструктор тренировки: отмеченные блоки держим сверху в порядке занятия —
4+
// именно этот порядок форма и отправляет. Новые блоки дозаписываются здесь же
5+
// и попадают в библиотеку вместе с сохранением игры.
6+
export default class extends Controller {
7+
static targets = ["container", "template", "library", "row", "checkbox", "position", "moveControls", "coachSelect"]
8+
static values = { nextIndex: Number, libraryUrl: String }
9+
10+
connect() {
11+
this.renumber()
12+
}
13+
14+
add(event) {
15+
event.preventDefault()
16+
const html = this.templateTarget.innerHTML.replaceAll("__INDEX__", this.nextIndexValue)
17+
this.containerTarget.insertAdjacentHTML("beforeend", html)
18+
this.nextIndexValue++
19+
}
20+
21+
remove(event) {
22+
event.preventDefault()
23+
const row = event.target.closest("[data-training-plan-row]")
24+
if (row) row.remove()
25+
}
26+
27+
reorder(event) {
28+
const row = event.target.closest("[data-training-plan-block-row]")
29+
if (row) this.moveToPlanEdge(row, event.target.checked)
30+
this.renumber()
31+
}
32+
33+
moveUp(event) {
34+
event.preventDefault()
35+
this.swap(event.target.closest("[data-training-plan-block-row]"), -1)
36+
}
37+
38+
moveDown(event) {
39+
event.preventDefault()
40+
this.swap(event.target.closest("[data-training-plan-block-row]"), 1)
41+
}
42+
43+
async reloadLibrary() {
44+
if (!this.hasLibraryTarget || !this.libraryUrlValue) return
45+
46+
const params = new URLSearchParams()
47+
this.coachSelectTargets.forEach((select) => { if (select.value) params.append("coach_ids[]", select.value) })
48+
this.checkedRows().forEach((row) => params.append("training_block_ids[]", this.checkboxIn(row).value))
49+
50+
const response = await fetch(`${this.libraryUrlValue}?${params}`, {
51+
headers: { Accept: "text/html" },
52+
credentials: "same-origin"
53+
})
54+
if (!response.ok) return
55+
56+
this.libraryTarget.innerHTML = await response.text()
57+
this.renumber()
58+
}
59+
60+
// Отмеченный блок встаёт в конец плана, снятый — сразу под ним.
61+
moveToPlanEdge(row, checked) {
62+
const chosen = this.checkedRows().filter((candidate) => candidate !== row)
63+
const last = chosen[chosen.length - 1]
64+
65+
if (checked && last) {
66+
last.after(row)
67+
} else if (checked) {
68+
row.parentNode.prepend(row)
69+
} else if (last) {
70+
last.after(row)
71+
}
72+
}
73+
74+
swap(row, direction) {
75+
if (!row) return
76+
77+
const chosen = this.checkedRows()
78+
const index = chosen.indexOf(row)
79+
const neighbour = chosen[index + direction]
80+
if (index < 0 || !neighbour) return
81+
82+
if (direction < 0) {
83+
neighbour.before(row)
84+
} else {
85+
neighbour.after(row)
86+
}
87+
88+
this.renumber()
89+
}
90+
91+
renumber() {
92+
const chosen = this.checkedRows()
93+
94+
this.rowTargets.forEach((row) => {
95+
const position = row.querySelector("[data-training-plan-target='position']")
96+
const controls = row.querySelector("[data-training-plan-target='moveControls']")
97+
const index = chosen.indexOf(row)
98+
99+
if (position) position.textContent = index < 0 ? "" : `${index + 1}.`
100+
if (controls) controls.classList.toggle("invisible", index < 0)
101+
})
102+
}
103+
104+
checkedRows() {
105+
return this.rowTargets.filter((row) => this.checkboxIn(row)?.checked)
106+
}
107+
108+
checkboxIn(row) {
109+
return row.querySelector("input[type=checkbox]")
110+
}
111+
}

app/models/game.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
class Game < ApplicationRecord
22
after_commit :schedule_post_game_stats_reminder, on: %i[create update]
33
after_commit :enqueue_urgent_player_search_notification, on: %i[create update]
4+
after_update :drop_training_plan_from_plain_game, if: -> { saved_change_to_kind? && !training? }
45
after_update :remove_stale_coach_prebookings,
56
if: -> { saved_change_to_coach_id? || saved_change_to_second_coach_id? || saved_change_to_date? || saved_change_to_recurring? }
67

@@ -19,6 +20,9 @@ class Game < ApplicationRecord
1920
has_many :featured_matches, dependent: :nullify
2021
has_many :player_statistic_entries, dependent: :nullify
2122
has_many :game_media, class_name: "GameMedium", dependent: :destroy
23+
# План тренировки — блоки из библиотеки тренера в выбранном порядке.
24+
has_many :game_training_blocks, -> { ordered }, dependent: :destroy, inverse_of: :game
25+
has_many :training_blocks, through: :game_training_blocks
2226

2327
SURFACES = Court::SURFACES
2428
KINDS = %w[game training].freeze
@@ -41,6 +45,7 @@ class Game < ApplicationRecord
4145
validates :coach_invitation_status, inclusion: { in: COACH_INVITATION_STATUSES }, allow_nil: true
4246
validates :second_coach_invitation_status, inclusion: { in: COACH_INVITATION_STATUSES }, allow_nil: true
4347
validate :selected_coaches_are_coaches
48+
validate :training_cannot_hide_recorded_scores, if: -> { persisted? && training? && kind_changed? }
4449
validate :prebooking_requires_recurring
4550
validate :surface_available_at_court
4651
validate :environment_available_at_court
@@ -54,6 +59,23 @@ def coaches
5459
[ coach, second_coach ].compact
5560
end
5661

62+
# Порядок блоков задаёт сам список: он и есть план занятия.
63+
def replace_training_plan!(block_ids)
64+
block_ids = Array(block_ids).map(&:to_i).uniq.reject(&:zero?)
65+
66+
transaction do
67+
game_training_blocks.where.not(training_block_id: block_ids).destroy_all
68+
block_ids.each_with_index do |block_id, index|
69+
entry = game_training_blocks.find_or_initialize_by(training_block_id: block_id)
70+
entry.position = index
71+
entry.save!
72+
end
73+
end
74+
75+
game_training_blocks.reset
76+
training_blocks.reset
77+
end
78+
5779
def assigned_coach_ids
5880
[ coach_id, second_coach_id ].compact
5981
end
@@ -523,6 +545,17 @@ def normalize_invitation_status(slot)
523545
end
524546
end
525547

548+
def drop_training_plan_from_plain_game
549+
# У обычной игры плана занятия не бывает, поэтому он уходит вместе с типом.
550+
game_training_blocks.destroy_all
551+
end
552+
553+
# Счёт у тренировки не показать и не исправить, поэтому игру с уже записанными
554+
# матчами в тренировку не превращаем — иначе счёт остался бы висеть в статистике.
555+
def training_cannot_hide_recorded_scores
556+
errors.add(:kind, "cannot switch to training while the game has recorded scores") if matches.exists?
557+
end
558+
526559
def selected_coaches_are_coaches
527560
coaches.each do |candidate|
528561
errors.add(:coach, "must be a coach") unless candidate.coach?

app/models/game_training_block.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class GameTrainingBlock < ApplicationRecord
2+
belongs_to :game
3+
belongs_to :training_block
4+
5+
validates :training_block_id, uniqueness: { scope: :game_id }
6+
7+
scope :ordered, -> { order(:position, :id) }
8+
end

0 commit comments

Comments
 (0)