diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index db8b9855e5c..010f91ad671 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -33,10 +33,6 @@ def new_results @results_validator.validate(@competition.id) end - def check_results - with_results_validator - end - def check_competition_results with_results_validator do @competition = competition_from_params @@ -72,12 +68,6 @@ def with_results_validator yield if block_given? end - def do_check_results - running_validators do - render :check_results - end - end - def do_check_competition_results running_validators do uniq_id = @result_validation.competitions.first diff --git a/app/controllers/panel_controller.rb b/app/controllers/panel_controller.rb index 92ed2271cbf..fcc69b95147 100644 --- a/app/controllers/panel_controller.rb +++ b/app/controllers/panel_controller.rb @@ -50,6 +50,45 @@ def generate_db_token } end + private def validators_for_competition_ids(competition_ids) + validators = params.require(:selectedValidators).split(',').map(&:constantize) + apply_fix_when_possible = params.require(:applyFixWhenPossible) + + results_validator = ResultsValidators::CompetitionsResultsValidator.new( + validators, + check_real_results: true, + apply_fixes: apply_fix_when_possible, + ) + results_validator.validate(competition_ids) + render json: { + has_results: results_validator.has_results?, + validators: results_validator.validators, + infos: results_validator.infos, + errors: results_validator.errors, + warnings: results_validator.warnings, + } + end + + private def competition_ids_in_range(range) + start_date = range[:startDate] + end_date = range[:endDate] + ResultValidationForm.competitions_between(start_date, end_date) + .order(:start_date) + .ids + end + + def validators_for_competition_list + competition_ids = params.require(:competitionIds).split(',') + validators_for_competition_ids(competition_ids) + end + + def validators_for_competitions_in_range + range = JSON.parse(params.require(:competitionRange)).transform_keys(&:to_sym) + competition_ids = competition_ids_in_range(range) + + validators_for_competition_ids(competition_ids) + end + def panel_page panel_page_id = params.require(:id) panel_with_panel_page = current_user.panels_with_access&.find { |panel| User.panel_list[panel][:pages].include?(panel_page_id) } diff --git a/app/views/admin/_validator_form.html.erb b/app/views/admin/_validator_form.html.erb deleted file mode 100644 index 97e247f925b..00000000000 --- a/app/views/admin/_validator_form.html.erb +++ /dev/null @@ -1,152 +0,0 @@ -<% backend_url ||= nil %> -<% lock_selection = defined? @competition %> -
-
Configure validations to run
-
- <%= simple_form_for @result_validation, url: backend_url do |f| %> - <%= f.input :validator_classes, as: :string, label: false, hint: false, input_html: { id: "validators" } %> - <%= f.input :apply_fixes, as: :boolean, label: "Apply fix when possible", hint: "List of validators with automated fix: #{ResultValidationForm::VALIDATOR_WITH_FIX_NAMES.join(",")}." %> - <% unless lock_selection %> - <%= f.input :competition_selection, collection: ResultValidationForm::COMP_VALIDATION_MODES, as: :radio_buttons, label: "Competition selection", hint: "WARNING: Running multiple validations on all competitions can take a long time." %> - <% end %> -
- <%= f.input :competition_ids, as: :competition_id, hint: false, label: "Competition ID(s)", input_html: { class: lock_selection ? "wca-autocomplete-input_lock" : "" } %> -
- <% unless lock_selection %> -
- <%= f.input :competition_start_date, as: :date_picker, hint: false, label: "Start date for checking" %> - <%= f.input :competition_end_date, as: :date_picker, hint: "Please pick dates above to update this label with information on the selected competitions.", label: "End date for checking" %> - - -
- <% end %> -
- <%= f.button :submit, value: "Run validators", class: "btn-primary" %> - <%= ui_icon('info circle', 'data-toggle': "collapse", 'data-target': "#collapse-validator-desc") %> -
-
- <%= wca_table do %> - - - Validator - Description - - - - - <% ResultsValidators::Utils::ALL_VALIDATORS.each do |validator| %> - - <%= validator.class_name %> - <%= validator.description %> - - - <% end %> - - <% end %> -
- <% end %> -
-
- - - diff --git a/app/views/admin/check_competition_results.html.erb b/app/views/admin/check_competition_results.html.erb index 3bfc67f2d06..0b0761d79f1 100644 --- a/app/views/admin/check_competition_results.html.erb +++ b/app/views/admin/check_competition_results.html.erb @@ -5,8 +5,9 @@ Check existing results for the competition.

- <%= render "validator_form", backend_url: competition_admin_run_validators_path %> - <%= render "results_submission/check_results_panel", results_validator: @results_validator %> + <%= react_component("Panel/pages/RunValidatorsPage/RunValidatorsForm", { + competitionIds: [@competition.id], + }) %> <%= render "results_submission/results_preview_panel", results_validator: @results_validator %> <% end %> diff --git a/app/views/admin/check_results.html.erb b/app/views/admin/check_results.html.erb deleted file mode 100644 index 4a2b9ca2c6a..00000000000 --- a/app/views/admin/check_results.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<% provide(:title, "Check results") %> -
-

Run validators

-

- Check existing results. -

- - <%= render "validator_form", backend_url: admin_check_results_path %> - <%= render "results_submission/check_results_panel", results_validator: @results_validator, display_competition: @result_validation.competitions.length > 1 %> -
diff --git a/app/webpacker/components/Panel/PanelPages.jsx b/app/webpacker/components/Panel/PanelPages.jsx index 369264676ff..ee29d9992b8 100644 --- a/app/webpacker/components/Panel/PanelPages.jsx +++ b/app/webpacker/components/Panel/PanelPages.jsx @@ -5,7 +5,6 @@ import { subordinateUpcomingCompetitionsUrl, generateDbTokenUrl, serverStatusPageUrl, - runValidatorsUrl, createNewComersUrl, checkRecordsUrl, computeAuxiliaryDataUrl, @@ -39,6 +38,7 @@ import DownloadVoters from './pages/DownloadVoters'; import ApprovePictures from './pages/ApprovePictures'; import EditPersonRequestsPage from './pages/EditPersonRequestsPage'; import AnonymizationScriptPage from './pages/AnonymizationScriptPage'; +import RunValidatorsForm from './pages/RunValidatorsPage/RunValidatorsForm'; const DELEGATE_HANDBOOK_LINK = 'https://documents.worldcubeassociation.org/edudoc/delegate-handbook/delegate-handbook.pdf'; @@ -165,7 +165,7 @@ export default { }, [PANEL_PAGES.runValidators]: { name: 'Run Validators', - link: runValidatorsUrl, + component: RunValidatorsForm, }, [PANEL_PAGES.createNewComers]: { name: 'Create New Comers', diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/CompetitionRangeSelector.jsx b/app/webpacker/components/Panel/pages/RunValidatorsPage/CompetitionRangeSelector.jsx new file mode 100644 index 00000000000..faaab9d477b --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/CompetitionRangeSelector.jsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from 'react'; +import { Form } from 'semantic-ui-react'; +import { QueryClient, useQuery } from '@tanstack/react-query'; +import UtcDatePicker from '../../../wca/UtcDatePicker'; +import getCompetitionList from './api/getCompetitionList'; +import Loading from '../../../Requests/Loading'; +import Errored from '../../../Requests/Errored'; + +const RUN_VALIDATORS_QUERY_CLIENT = new QueryClient(); +const MAX_COMPETITIONS_PER_QUERY = 50; + +export default function CompetitionRangeSelector({ range, setRange }) { + const [startDate, setStartDate] = useState(range?.startDate); + const [endDate, setEndDate] = useState(range?.endDate); + + const enableCompetitionListFetch = Boolean(startDate && endDate); + + const { + data: competitionList, isLoading, isError, refetch, + } = useQuery({ + queryKey: ['competitionCountInRange'], + queryFn: () => getCompetitionList(startDate, endDate, MAX_COMPETITIONS_PER_QUERY), + enabled: enableCompetitionListFetch, + }, RUN_VALIDATORS_QUERY_CLIENT); + + useEffect(() => { + setRange({ startDate, endDate }); + if (enableCompetitionListFetch) { + refetch(); + } + }, [startDate, endDate, setRange, enableCompetitionListFetch, refetch]); + + return ( + <> + + + {enableCompetitionListFetch && ( + <> + {isLoading && ()} + {isError && } + {!isLoading && !isError && ( +
+ {`The checks will run for ${competitionList.length}${ + competitionList.length >= MAX_COMPETITIONS_PER_QUERY ? '+' : '' + } competitions`} +
+ )} + + )} + + ); +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/RunValidatorsForm.jsx b/app/webpacker/components/Panel/pages/RunValidatorsPage/RunValidatorsForm.jsx new file mode 100644 index 00000000000..0723ba79ba4 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/RunValidatorsForm.jsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { + Form, FormField, FormGroup, Header, Radio, +} from 'semantic-ui-react'; +import { QueryClient, useQuery } from '@tanstack/react-query'; +import useInputState from '../../../../lib/hooks/useInputState'; +import { ALL_VALIDATORS, VALIDATORS_WITH_FIX } from '../../../../lib/wca-data.js.erb'; +import { IdWcaSearch } from '../../../SearchWidget/WcaSearch'; +import SEARCH_MODELS from '../../../SearchWidget/SearchModel'; +import CompetitionRangeSelector from './CompetitionRangeSelector'; +import useCheckboxState from '../../../../lib/hooks/useCheckboxState'; +import runValidatorsForCompetitionList from './api/runValidatorsForCompetitionList'; +import runValidatorsForCompetitionsInRange from './api/runValidatorsForCompetitionsInRange'; +import ValidationOutput from './ValidationOutput'; + +const validatorNameReadable = (validatorName) => validatorName.split('::')[1]; + +const VALIDATOR_OPTIONS = ALL_VALIDATORS.map((validator) => ({ + key: validator, + text: validatorNameReadable(validator), + value: validator, +})); + +const COMPETITION_SELECTION_OPTIONS_MAP = { + manual: { + key: 'manual', + text: 'Pick competition(s) manually', + }, + range: { + key: 'range', + text: 'Competition between dates', + }, +}; + +const COMPETITION_SELECTION_OPTIONS = [ + COMPETITION_SELECTION_OPTIONS_MAP.manual.key, + COMPETITION_SELECTION_OPTIONS_MAP.range.key, +]; + +const RUN_VALIDATORS_QUERY_CLIENT = new QueryClient(); + +export default function RunValidatorsForm({ competitionIds }) { + const [ + selectedCompetitionSelectionOption, + setSelectedCompetitionSelectionOption, + ] = useInputState(COMPETITION_SELECTION_OPTIONS_MAP.manual.key); + + const [selectedCompetitionIds, setSelectedCompetitionIds] = useInputState(competitionIds || []); + const [selectedCompetitionRange, setSelectedCompetitionRange] = useState(); + + const [selectedValidators, setSelectedValidators] = useInputState(ALL_VALIDATORS); + const [applyFixWhenPossible, setApplyFixWhenPossible] = useCheckboxState(false); + + const runValidatorsForCompetitions = () => { + if (selectedCompetitionSelectionOption === COMPETITION_SELECTION_OPTIONS_MAP.manual.key) { + return runValidatorsForCompetitionList( + selectedCompetitionIds, + selectedValidators, + applyFixWhenPossible, + ); + } + return runValidatorsForCompetitionsInRange( + selectedCompetitionRange, + selectedValidators, + applyFixWhenPossible, + ); + }; + + const { + data: validationOutput, isFetching, isError, refetch: runValidators, + } = useQuery({ + queryKey: ['competitionCountInRange'], + queryFn: runValidatorsForCompetitions, + enabled: false, + }, RUN_VALIDATORS_QUERY_CLIENT); + + // enableCompetitionEditor says whether competition list editor should be enabled or not. If the + // list of competitions is passed as parameter, then the editor need not be shown. + const enableCompetitionEditor = !competitionIds; + + // Competition name needs to be shown on output only when the script is not ran just for a single + // competition. + const showCompetitionNameOnOutput = !( + selectedCompetitionSelectionOption === COMPETITION_SELECTION_OPTIONS_MAP.manual.key + && selectedCompetitionIds.length === 1 + ); + + return ( + <> +
+ {enableCompetitionEditor && ( + <> +
Competition Selector
+ + {COMPETITION_SELECTION_OPTIONS.map((option) => ( + + + + ))} + + {( + selectedCompetitionSelectionOption === COMPETITION_SELECTION_OPTIONS_MAP.manual.key + ) && ( + + )} + {( + selectedCompetitionSelectionOption === COMPETITION_SELECTION_OPTIONS_MAP.range.key + ) && ( + + )} + + )} +
Run Validators
+ + + Run Validators + + + + ); +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationListView.jsx b/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationListView.jsx new file mode 100644 index 00000000000..287be4757b8 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationListView.jsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import _ from 'lodash'; +import { Header, List, ListItem } from 'semantic-ui-react'; +import I18n from '../../../../lib/i18n'; +import { competitionUrl } from '../../../../lib/requests/routes.js.erb'; + +const headingPrefixForType = (type) => { + switch (type) { + case 'info': return 'Information for'; + case 'warning': return 'Warnings detected in'; + case 'error': return 'Errors detected in'; + default: return 'Unknown detected in'; + } +}; + +export default function ValidationListView({ validations, showCompetitionNameOnOutput, type }) { + const listByGroup = useMemo(() => _.groupBy(validations, 'kind'), [validations]); + + return ( + <> + {Object.entries(listByGroup).map(([group, list]) => ( + <> +
{`${headingPrefixForType(type)} ${group}`}
+ + {list.map((validationData) => ( + + + + ))} + + + ))} + + ); +} + +function ValidationText({ validationData, group, showCompetitionNameOnOutput }) { + return ( + <> + {showCompetitionNameOnOutput && ( + <> + [ + + {validationData.competition_id} + + {'] '} + + )} + <>{I18n.t(`validators.${group}.${validationData.id}`, validationData.args)} + + ); +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationOutput.jsx b/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationOutput.jsx new file mode 100644 index 00000000000..b28ca4a6f03 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/ValidationOutput.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Header, Message } from 'semantic-ui-react'; +import Loading from '../../../Requests/Loading'; +import Errored from '../../../Requests/Errored'; +import ValidationListView from './ValidationListView'; + +export default function ValidationOutput({ + validationOutput, isFetching, isError, showCompetitionNameOnOutput, +}) { + if (isFetching) return ; + if (isError) return ; + + if (!isFetching && !isError && !validationOutput) { + return ( + Please run the validators to see the output. + ); + } + + return ( + <> +
Validation Output
+ {validationOutput.infos.length > 0 && ( + <> +
Infos
+ + + )} +
Errors
+ +
Warnings
+ + + ); +} + +function ValidationErrorOutput({ validationOutput, showCompetitionNameOnOutput }) { + const hasResults = validationOutput.has_results; + const hasErrors = validationOutput.errors.length > 0; + + if (!hasErrors) { + if (hasResults) { + return

No error detected in the results.

; + } + return

No results for the competition yet.

; + } + + return ( + <> +

Please fix the errors below:

+ + + ); +} + +function ValidationWarningOutput({ validationOutput, showCompetitionNameOnOutput }) { + const hasResults = validationOutput.has_results; + const hasWarning = validationOutput.warnings.length > 0; + + if (!hasWarning) { + if (hasResults) { + return

No warning detected in the results.

; + } + return

No results for the competition yet.

; + } + + return ( + <> +

Please pay attention to the warnings below:

+ + + ); +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/api/getCompetitionList.js b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/getCompetitionList.js new file mode 100644 index 00000000000..f417a2da4b0 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/getCompetitionList.js @@ -0,0 +1,9 @@ +import { fetchJsonOrError } from '../../../../../lib/requests/fetchWithAuthenticityToken'; +import { apiV0Urls } from '../../../../../lib/requests/routes.js.erb'; + +export default async function getCompetitionList(startDate, endDate, maxLimit) { + const { data } = await fetchJsonOrError( + `${apiV0Urls.competitions.listIndex}?start=${startDate}&end=${endDate}&per_page=${maxLimit}`, + ); + return data; +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionList.js b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionList.js new file mode 100644 index 00000000000..d840fc16075 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionList.js @@ -0,0 +1,15 @@ +import { fetchJsonOrError } from '../../../../../lib/requests/fetchWithAuthenticityToken'; +import { actionUrls } from '../../../../../lib/requests/routes.js.erb'; + +export default async function runValidatorsForCompetitionList( + competitionIds, + selectedValidators, + applyFixWhenPossible, +) { + const { data } = await fetchJsonOrError(actionUrls.validators.forCompetitionList( + competitionIds, + selectedValidators, + applyFixWhenPossible, + )); + return data; +} diff --git a/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionsInRange.js b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionsInRange.js new file mode 100644 index 00000000000..f1801a44307 --- /dev/null +++ b/app/webpacker/components/Panel/pages/RunValidatorsPage/api/runValidatorsForCompetitionsInRange.js @@ -0,0 +1,15 @@ +import { fetchJsonOrError } from '../../../../../lib/requests/fetchWithAuthenticityToken'; +import { actionUrls } from '../../../../../lib/requests/routes.js.erb'; + +export default async function runValidatorsForCompetitionsInRange( + competitionRange, + selectedValidators, + applyFixWhenPossible, +) { + const { data } = await fetchJsonOrError(actionUrls.validators.forCompetitionsInRange( + JSON.stringify(competitionRange), + selectedValidators, + applyFixWhenPossible, + )); + return data; +} diff --git a/app/webpacker/lib/requests/routes.js.erb b/app/webpacker/lib/requests/routes.js.erb index adb4593f92e..b8d1079fcae 100644 --- a/app/webpacker/lib/requests/routes.js.erb +++ b/app/webpacker/lib/requests/routes.js.erb @@ -276,6 +276,10 @@ export const actionUrls = { users: { anonymize: (userId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.anonymize_user_path("${userId}")) %>`, }, + validators: { + forCompetitionList: (competitionIds, selectedValidators, applyFixWhenPossible) => `<%= CGI.unescape(Rails.application.routes.url_helpers.panel_validators_for_competition_list_path) %>?${jsonToQueryString({ competitionIds, selectedValidators, applyFixWhenPossible })}`, + forCompetitionsInRange: (competitionRange, selectedValidators, applyFixWhenPossible) => `<%= CGI.unescape(Rails.application.routes.url_helpers.panel_validators_for_competitions_in_range_path) %>?${jsonToQueryString({ competitionRange, selectedValidators, applyFixWhenPossible })}`, + }, } export const userPreferencesRoute = `<%= CGI.unescape(Rails.application.routes.url_helpers.profile_edit_path)%>?section=preferences`; @@ -308,8 +312,6 @@ export const paymentTicketUrl = (competitionId, donationIso) => `<%= CGI.unescap export const serverStatusPageUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.server_status_path) %>`; -export const runValidatorsUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_check_results_path) %>`; - export const createNewComersUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_finish_unfinished_persons_path) %>`; export const checkRecordsUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_check_regional_records_path) %>`; diff --git a/app/webpacker/lib/wca-data.js.erb b/app/webpacker/lib/wca-data.js.erb index 2615f4784b0..afc88072964 100644 --- a/app/webpacker/lib/wca-data.js.erb +++ b/app/webpacker/lib/wca-data.js.erb @@ -225,3 +225,7 @@ export const avatarImageTypes = <%= Rails.application.config.active_storage.web_ export const ticketTypes = <%= Ticket::TICKET_TYPES.to_json %>; export const ticketStatuses = <%= Ticket::TICKET_TYPES.transform_values { |value| value.safe_constantize&.statuses }.to_json %>; export const ticketLogActionTypes = <%= TicketLog.action_types.to_json %>; + +// ----- VALIDATORS ----- +export const ALL_VALIDATORS = <%= ResultsValidators::Utils::ALL_VALIDATORS.map(&:name) %>; +export const VALIDATORS_WITH_FIX = <%= ResultsValidators::Utils::VALIDATORS_WITH_FIX.map(&:name) %>; diff --git a/config/routes.rb b/config/routes.rb index 8180c5b0545..9f8ae0e7489 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,10 +190,11 @@ scope 'panel' do get 'staff' => 'panel#staff', as: :panel_staff get 'generate_db_token' => 'panel#generate_db_token', as: :panel_generate_db_token + get 'validators_for_competition_list' => 'panel#validators_for_competition_list', as: :panel_validators_for_competition_list + get 'validators_for_competitions_in_range' => 'panel#validators_for_competitions_in_range', as: :panel_validators_for_competitions_in_range end get 'panel/:panel_id' => 'panel#index', as: :panel_index scope 'panel-page' do - get 'run-validators' => 'admin#check_results', as: :admin_check_results get 'create-new-comers' => 'admin#finish_unfinished_persons', as: :admin_finish_unfinished_persons get 'check-records' => 'admin#check_regional_records', as: :admin_check_regional_records get 'compute-auxiliary-data' => 'admin#compute_auxiliary_data', as: :admin_compute_auxiliary_data @@ -280,7 +281,6 @@ get '/admin/all-voters' => 'admin#all_voters', as: :eligible_voters get '/admin/leader-senior-voters' => 'admin#leader_senior_voters', as: :leader_senior_voters get '/admin/validation_competitions' => "admin#compute_validation_competitions" - post '/admin/check_results' => 'admin#do_check_results' post '/admin/merge_people' => 'admin#do_merge_people' get '/admin/fix_results_selector' => 'admin#fix_results_selector', as: :admin_fix_results_ajax get '/admin/person_data' => 'admin#person_data'