Skip to content
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

Give organizer access to "bulk auto accept" #10628

Draft
wants to merge 41 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
dc1a8c5
added test to start the PR
dunkOnIT Dec 7, 2024
6dab06f
started adding db fields
dunkOnIT Dec 7, 2024
7a90640
added competition fields and model tests
dunkOnIT Dec 8, 2024
fac6289
made auto accept fields available to competition form
dunkOnIT Dec 8, 2024
6c24925
added auto_accept fields to competition form
dunkOnIT Dec 8, 2024
4fa9ddc
rubocop
dunkOnIT Dec 8, 2024
8f205ab
very basic auto-accept added
dunkOnIT Dec 8, 2024
9f6155c
added a few more tests
dunkOnIT Dec 9, 2024
39a032c
more auto accept enforcement tests
dunkOnIT Dec 10, 2024
735f1d1
added comp full validation and auto_accept tests
dunkOnIT Dec 10, 2024
c6dce04
rubocop
dunkOnIT Dec 10, 2024
f4b4e6b
merged main
dunkOnIT Dec 10, 2024
16db8bf
added test for not bypassing series reg limit
dunkOnIT Dec 10, 2024
a09d1bb
added check for competing_status = pending
dunkOnIT Dec 10, 2024
8c3cdaf
refactored tests
dunkOnIT Dec 10, 2024
557a176
fixing test failures
dunkOnIT Dec 10, 2024
cb2e396
added integration test
dunkOnIT Dec 10, 2024
b8fd76e
fixed tests
dunkOnIT Dec 11, 2024
9e62947
removed fields from frontend
dunkOnIT Dec 11, 2024
5276b09
creating a diff
dunkOnIT Dec 11, 2024
d1e8d7e
Merge branch 'v3/auto-accept' into v3/auto-accept-2
dunkOnIT Dec 11, 2024
aa59c22
removed unnecessary comment
dunkOnIT Dec 11, 2024
0bc1dd2
added auto_accept from waiting list
dunkOnIT Dec 12, 2024
15ee692
added auto-accept onto waiting list
dunkOnIT Dec 13, 2024
4b7156e
started adding bulk auto accept tests
dunkOnIT Dec 13, 2024
3204be3
bulk accept tests before auto_accept method changes
dunkOnIT Dec 17, 2024
a81ec1d
many changes - waiting list, log validation errors
dunkOnIT Dec 19, 2024
f81102d
bulk auto accept w/ competitor limit tests
dunkOnIT Dec 19, 2024
9e7f7cb
bulk auto-accept tests finished
dunkOnIT Dec 19, 2024
0e1f481
rubocop
dunkOnIT Dec 19, 2024
30ece91
removed unnecessary safe accessor
dunkOnIT Dec 19, 2024
a695518
removed unnecessary comments from migration
dunkOnIT Dec 19, 2024
3f24c5b
re-enabled random test order
dunkOnIT Dec 19, 2024
e1b1c2a
waiting list fix
dunkOnIT Dec 19, 2024
32a5f4c
adding buttons and routes
dunkOnIT Jan 12, 2025
9b46a44
added buttons and played around on local
dunkOnIT Jan 13, 2025
8d3f145
basic auto accept and enable checks
dunkOnIT Jan 14, 2025
c8bd84c
added disable route
dunkOnIT Jan 14, 2025
d2cf7f9
got disable auto accept button working
dunkOnIT Jan 14, 2025
6bfb721
added mutation for disable auto accept
dunkOnIT Jan 15, 2025
4a0b8ca
added responsive functionality to button
dunkOnIT Jan 15, 2025
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
9 changes: 9 additions & 0 deletions app/controllers/api/v0/competitions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ def update_wcif
}
end

def disable_auto_accept
competition = competition_from_params
require_can_manage!(competition)

competition.update!(auto_accept_registrations: false)
render json: {status: 'success'}
end


private def competition_from_params(associations: {})
id = params[:competition_id] || params[:id]
competition = Competition.includes(associations).find_by_id(id)
Expand Down
11 changes: 8 additions & 3 deletions app/controllers/api/v1/registrations/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class Api::V1::Registrations::RegistrationsController < Api::V1::ApiController
# before_actions are triggered in the order they are defined
before_action :validate_create_request, only: [:create]
before_action :validate_show_registration, only: [:show]
before_action :validate_list_admin, only: [:list_admin]
before_action :validate_admin_action, only: [:list_admin, :bulk_auto_accept]
before_action :validate_update_request, only: [:update]
before_action :validate_bulk_update_request, only: [:bulk_update]
before_action :validate_payment_ticket_request, only: [:payment_ticket]
Expand All @@ -27,6 +27,7 @@ class Api::V1::Registrations::RegistrationsController < Api::V1::ApiController
render_error(e.status, e.errors)
end


def validate_show_registration
@user_id, @competition_id = show_params
@competition = Competition.find(@competition_id)
Expand Down Expand Up @@ -94,9 +95,8 @@ def list
end

# To list Registrations in the admin view you need to be able to administer the competition
def validate_list_admin
def validate_admin_action
competition_id = list_params
# TODO: Do we set this as an instance variable here so we can use it below?
@competition = Competition.find(competition_id)
unless @current_user.can_manage_competition?(@competition)
render_error(:unauthorized, ErrorCodes::USER_INSUFFICIENT_PERMISSIONS)
Expand All @@ -116,6 +116,11 @@ def list_admin
).map { |r| r.to_v2_json(admin: true, history: true, pii: true) }
end

def bulk_auto_accept
Registration.bulk_auto_accept(@competition)
render json: { result: "success" }
end

def validate_payment_ticket_request
competition_id = params[:competition_id]
@competition = Competition.find(competition_id)
Expand Down
40 changes: 39 additions & 1 deletion app/models/competition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ class Competition < ApplicationRecord
event_change_deadline_date
competition_series_id
registration_version
auto_accept_registrations
auto_accept_disable_threshold
).freeze
VALID_NAME_RE = /\A([-&.:' [:alnum:]]+) (\d{4})\z/
VALID_ID_RE = /\A[a-zA-Z0-9]+\Z/
Expand Down Expand Up @@ -353,6 +355,25 @@ def advancement_condition_must_be_present_for_all_non_final_rounds
end
end

validate :auto_accept_validations
private def auto_accept_validations
errors.add(:auto_accept_registrations, I18n.t('competitions.errors.must_use_wca_registration')) if
auto_accept_registrations && !use_wca_registration

errors.add(:auto_accept_registrations, I18n.t('competitions.errors.auto_accept_limit')) if
auto_accept_disable_threshold > 0 &&
competitor_limit.present? &&
auto_accept_disable_threshold >= competitor_limit

errors.add(:auto_accept_registrations, I18n.t('competitions.errors.auto_accept_not_negative')) if auto_accept_disable_threshold < 0

if auto_accept_registrations_changed?
errors.add(:auto_accept_registrations, I18n.t('competitions.errors.auto_accept_accept_paid_pending')) if registrations.pending.with_payments.count > 0
errors.add(:auto_accept_registrations, I18n.t('competitions.errors.auto_accept_accept_waitlisted')) if
registrations.waitlisted.count > 0 && !accepted_full?
end
end

def has_any_round_per_event?
competition_events.map(&:rounds).none?(&:empty?)
end
Expand Down Expand Up @@ -448,11 +469,16 @@ def confirmed_or_visible?
self.confirmed? || self.showAtAll
end

# TODO: Consider refactoring this once auto-accept is implemented
def registration_full?
competitor_count = registrations.accepted_and_paid_pending_count
competitor_limit_enabled? && competitor_count >= competitor_limit
end

def accepted_full?
competitor_limit_enabled? && registrations.competing_status_accepted.count >= competitor_limit
end

def number_of_bookmarks
bookmarked_users.count
end
Expand Down Expand Up @@ -1850,7 +1876,7 @@ def to_competition_info
base_entry_fee_lowest_denomination currency_code allow_registration_edits allow_registration_self_delete_after_acceptance
allow_registration_without_qualification refund_policy_percent use_wca_registration guests_per_registration_limit venue contact
force_comment_in_registration use_wca_registration external_registration_page guests_entry_fee_lowest_denomination guest_entry_status
information events_per_registration_limit guests_enabled],
information events_per_registration_limit guests_enabled auto_accept_registrations auto_accept_disable_threshold],
methods: %w[url website short_name city venue_address venue_details latitude_degrees longitude_degrees country_iso2 event_ids registration_currently_open?
main_event_id number_of_bookmarks using_payment_integrations? uses_qualification? uses_cutoff? competition_series_ids registration_full?
part_of_competition_series?],
Expand Down Expand Up @@ -2400,6 +2426,8 @@ def to_form_data
"admin" => {
"isConfirmed" => confirmed?,
"isVisible" => showAtAll?,
"autoAcceptEnabled" => auto_accept_registrations,
"autoAcceptDisableThreshold" => auto_accept_disable_threshold,
},
"cloning" => {
"fromId" => being_cloned_from_id,
Expand Down Expand Up @@ -2501,6 +2529,8 @@ def form_errors
"admin" => {
"isConfirmed" => errors[:confirmed_at],
"isVisible" => errors[:showAtAll],
"autoAcceptEnabled" => errors[:auto_accept_registrations],
"autoAcceptDisableThreshold" => errors[:auto_accept_disable_threshold],
},
"cloning" => {
"fromId" => errors[:being_cloned_from_id],
Expand Down Expand Up @@ -2606,6 +2636,8 @@ def self.form_data_to_attributes(form_data)
showAtAll: form_data.dig('admin', 'isVisible'),
being_cloned_from_id: form_data.dig('cloning', 'fromId'),
clone_tabs: form_data.dig('cloning', 'cloneTabs'),
auto_accept_registrations: form_data.dig('admin', 'autoAcceptEnabled'),
auto_accept_disable_threshold: form_data.dig('admin', 'autoAcceptDisableThreshold'),
}
end

Expand Down Expand Up @@ -2789,6 +2821,8 @@ def self.form_data_json_schema
"guestsPerRegistration" => { "type" => ["integer", "null"] },
"extraRequirements" => { "type" => ["string", "null"] },
"forceComment" => { "type" => ["boolean", "null"] },
"autoAcceptEnabled" => { "type" => ["boolean", "null"] },
"autoAcceptDisableThreshold" => { "type" => ["integer", "null"] },
},
},
"eventRestrictions" => {
Expand Down Expand Up @@ -2845,4 +2879,8 @@ def self.form_data_json_schema
},
}
end

def auto_accept_threshold_reached?
auto_accept_disable_threshold > 0 && auto_accept_disable_threshold <= registrations.competing_status_accepted.count
end
end
83 changes: 81 additions & 2 deletions app/models/registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ class Registration < ApplicationRecord

after_save :mark_registration_processing_as_done

after_update :waiting_list_auto_accept_check

private def mark_registration_processing_as_done
Rails.cache.delete(CacheAccess.registration_processing_cache_key(competition_id, user_id))
end

def update_lanes!(params, acting_user)
Registrations::Lanes::Competing.update!(params, self.competition, acting_user.id)
def update_lanes!(params, acting_user_id)
Registrations::Lanes::Competing.update!(params, self.competition, acting_user_id)
end

def guest_limit
Expand Down Expand Up @@ -335,6 +337,14 @@ def self.accepted_and_paid_pending_count
end
end

validate :does_not_exceed_competitor_limit
private def does_not_exceed_competitor_limit
return unless competition&.competitor_limit.present?
return unless competing_status == Registrations::Helper::STATUS_ACCEPTED
errors.add(:competitor_limit, I18n.t('registrations.errors.competitor_limit_reached')) if
competition.registrations.competing_status_accepted.count >= competition.competitor_limit
end

# TODO: V3-REG cleanup. All these Validations can be used instead of the registration_checker checks
validate :cannot_be_undeleted_when_banned, if: :competing_status_changed?
private def cannot_be_undeleted_when_banned
Expand Down Expand Up @@ -428,4 +438,73 @@ def series_registration_info
def serializable_hash(options = nil)
super(DEFAULT_SERIALIZE_OPTIONS.merge(options || {}))
end

def self.bulk_auto_accept(competition)

if competition.waiting_list.present?
competition.waiting_list.entries.each do |r_id|
Registration.find(r_id).auto_accept
# It isnt clear to me why this is needed - but without it, the auto-accepted items getting removed from the waiting list reappear
# on the waiting list in the test body, even when calling waiting_list.reload there
# I have also tried assigning competition.waiting_list.entries to a variable and iterating over that, but it didnt help
competition.waiting_list.reload
end
end

sorted_pending_registrations = competition
.registrations
.competing_status_pending
.with_payments
.sort_by { |registration| registration.last_positive_payment.updated_at }

sorted_pending_registrations.each { |r| r.auto_accept }
end

def auto_accept
return log_error('Auto-accept is not enabled for this competition.') unless competition.auto_accept_registrations
return log_error('Can only auto-accept pending registrations or first position on waiting list') unless
competing_status_pending? || (competing_status_waiting_list? && waiting_list_position == 1)
return log_error("Competition has reached auto_accept_disable_threshold of #{competition.auto_accept_disable_threshold} registrations") if
competition.auto_accept_threshold_reached?
return log_error('Competitor still has outstanding registration fees') if outstanding_entry_fees > 0
return log_error('Cant auto-accept while registration is not open') if !competition.registration_currently_open?

if competition.accepted_full? && competing_status_pending?
update_lanes!(
{ user_id: user_id, competing: { status: Registrations::Helper::STATUS_WAITING_LIST } }.with_indifferent_access,
'Auto-accept',
)
else
update_lanes!(
{ user_id: user_id, competing: { status: Registrations::Helper::STATUS_ACCEPTED } }.with_indifferent_access,
'Auto-accept',
)
end
rescue ActiveRecord::RecordInvalid => e
log_error("Auto accept error for registration #{id}: #{e}")
end

private def log_error(error)
Rails.logger.error(error)
false
end

private def waiting_list_auto_accept_check
changed_from_accepted = saved_change_to_competing_status? && saved_change_to_competing_status.first == Registrations::Helper::STATUS_ACCEPTED
return unless changed_from_accepted

waiting_list_leader_id = competition.waiting_list&.entries.first

if changed_from_accepted && waiting_list_leader_id.present?
Registration.find(waiting_list_leader_id).auto_accept
end
end

def last_positive_payment
registration_payments
.where('amount_lowest_denomination > 0')
.order(updated_at: :desc)
.limit(1)
.first
end
end
6 changes: 6 additions & 0 deletions app/models/registration_payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class RegistrationPayment < ApplicationRecord
belongs_to :refunded_registration_payment, class_name: 'RegistrationPayment', optional: true
has_many :refunding_registration_payments, class_name: 'RegistrationPayment', inverse_of: :refunded_registration_payment, foreign_key: :refunded_registration_payment_id, dependent: :destroy

after_create :attempt_auto_accept

monetize :amount_lowest_denomination,
as: "amount",
allow_nil: true,
Expand All @@ -26,4 +28,8 @@ def payment_status
receipt.determine_wca_status
end
end

private def attempt_auto_accept
registration.auto_accept
end
end
2 changes: 2 additions & 0 deletions app/models/waiting_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ def remove(entry_id)
end

def add(entry_id)
return if entries.include?(entry_id)

if entries.nil?
update_column :entries, [entry_id]
else
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import React from 'react';
import { InputBoolean } from '../../wca/FormBuilder/input/FormInputs';
import { InputBoolean, InputBooleanSelect, InputNumber } from '../../wca/FormBuilder/input/FormInputs';
import ConditionalSection from './ConditionalSection';
import { useStore } from '../../../lib/providers/StoreProvider';
import SubSection from '../../wca/FormBuilder/SubSection';
import { useFormObject } from '../../wca/FormBuilder/provider/FormObjectProvider';

export default function Admin() {
const { admin } = useFormObject();
const { isAdminView, isPersisted } = useStore();
if (!isPersisted || !isAdminView) return null;

return (
<SubSection section="admin">
<InputBoolean id="isConfirmed" />
<InputBoolean id="isVisible" />
<InputBooleanSelect id="autoAcceptEnabled" required />
<ConditionalSection showIf={admin.autoAcceptEnabled}>
<InputNumber id="autoAcceptDisableThreshold" />
</ConditionalSection>
</SubSection>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Icon } from 'semantic-ui-react';
import React, {
useMemo, useReducer, useRef,
} from 'react';
Expand All @@ -14,6 +15,7 @@ import { useDispatch } from '../../../lib/providers/StoreProvider';
import I18n from '../../../lib/i18n';
import Loading from '../../Requests/Loading';
import { bulkUpdateRegistrations } from '../api/registration/patch/update_registration';
import { disableAutoAccept } from '../api/registration/patch/auto_accept';
import RegistrationAdministrationTable from './RegistrationsAdministrationTable';
import useCheckboxState from '../../../lib/hooks/useCheckboxState';
import { countries } from '../../../lib/wca-data.js.erb';
Expand Down Expand Up @@ -114,6 +116,7 @@ const columnReducer = (state, action) => {
return state;
};


export default function RegistrationAdministrationList({ competitionInfo }) {
const [expandedColumns, dispatchColumns] = useReducer(
columnReducer,
Expand Down Expand Up @@ -160,6 +163,21 @@ export default function RegistrationAdministrationList({ competitionInfo }) {
},
});


const { mutate: disableAutoAcceptMutation, isPending: isUpdating } = useMutation({
mutationFn: disableAutoAccept,
onError: (data) => {
const { error } = data.json;
dispatchStore(setMessage(
`competitions.registration_v2.auto_accept.cant_disable`,
'negative',
));
},
onSuccess: () => {
dispatchStore(setMessage('competitions.registration_v2.auto_accept.disabled', 'positive'));
},
});

const { mutate: updateRegistrationMutation, isPending: isMutating } = useMutation({
mutationFn: bulkUpdateRegistrations,
onError: (data) => {
Expand Down Expand Up @@ -308,6 +326,23 @@ export default function RegistrationAdministrationList({ competitionInfo }) {
<Loading />
) : (
<Segment loading={isMutating} style={{ overflowX: 'scroll' }}>

{ competitionInfo.auto_accept_registrations && (
<>
<Button
disabled={isUpdating}
color="red"
onClick={() => disableAutoAcceptMutation(competitionInfo.id)}
>
<Icon name="ban" />
{' '}
{I18n.t('competitions.registration_v2.auto_accept.disable')}
</Button>
</>
)}



<Form>
<Form.Group widths="equal">
{Object.entries(expandableColumns).map(([id, name]) => (
Expand Down Expand Up @@ -344,6 +379,7 @@ export default function RegistrationAdministrationList({ competitionInfo }) {
{pending.length}
)
</Header>

<RegistrationAdministrationTable
columnsExpanded={expandedColumns}
registrations={pending}
Expand Down
Loading
Loading