-
-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/training plan proposals #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
4b93152
e3358c4
bcaab35
f493928
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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") | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a participant votes and then leaves the game while the proposal remains open, 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 | ||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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_blocksrow 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 👍 / 👎.