Skip to content

Commit 0d541fd

Browse files
Improvise import registrations via CSV with preview
1 parent d06232c commit 0d541fd

File tree

14 files changed

+561
-181
lines changed

14 files changed

+561
-181
lines changed

app/controllers/registrations_controller.rb

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,52 @@ class RegistrationsController < ApplicationController
2121
before_action -> { redirect_to_root_unless_user(:can_manage_competition?, competition_from_params) },
2222
except: %i[index psych_sheet psych_sheet_event register payment_completion load_payment_intent stripe_webhook payment_denomination capture_paypal_payment]
2323

24-
before_action :competition_must_be_using_wca_registration!, except: %i[import do_import add do_add index psych_sheet psych_sheet_event stripe_webhook payment_denomination]
24+
before_action :competition_must_be_using_wca_registration!, except: %i[import do_import validate_and_convert_registrations add do_add index psych_sheet psych_sheet_event stripe_webhook payment_denomination]
2525
private def competition_must_be_using_wca_registration!
2626
return if competition_from_params.use_wca_registration?
2727

2828
flash[:danger] = I18n.t('registrations.flash.not_using_wca')
2929
redirect_to competition_path(competition_from_params)
3030
end
3131

32-
before_action :competition_must_not_be_using_wca_registration!, only: %i[import do_import]
32+
before_action :competition_must_not_be_using_wca_registration!, only: %i[import do_import validate_and_convert_registrations]
3333
private def competition_must_not_be_using_wca_registration!
3434
redirect_to competition_path(competition_from_params) if competition_from_params.use_wca_registration?
3535
end
3636

3737
before_action :validate_import_registration, only: %i[do_import]
3838
private def validate_import_registration
3939
@competition = competition_from_params
40+
41+
registration_rows = params.require(:registrations)
42+
43+
return render status: :unprocessable_content, json: { error: "Expected array of registrations" } unless registration_rows.is_a?(Array)
44+
45+
errors = [
46+
validate_registrations(registration_rows, @competition),
47+
competitor_limit_error(@competition, registration_rows.length),
48+
].compact.flatten
49+
50+
return render status: :unprocessable_content, json: { error: errors.compact.join(", ") } if errors.any?
51+
52+
@registration_rows = registration_rows.map do |row|
53+
country = Country.c_find_by_iso2(row[:countryIso2])
54+
55+
{
56+
name: row[:name],
57+
wca_id: row[:wcaId],
58+
country: country.id,
59+
gender: row[:gender],
60+
birth_date: row[:birthdate],
61+
email: row[:email],
62+
event_ids: row.dig(:registration, :eventIds) || [],
63+
}
64+
end
65+
end
66+
67+
before_action :validate_and_parse_registration_data, only: %i[validate_and_convert_registrations]
68+
private def validate_and_parse_registration_data
69+
@competition = competition_from_params
4070
file = params.require(:csv_registration_file)
4171

4272
@registration_rows = parse_csv_file(file.path, @competition)
@@ -71,10 +101,23 @@ class RegistrationsController < ApplicationController
71101

72102
filtered_rows.map do |row|
73103
event_ids = competition.competition_events.filter_map do |competition_event|
74-
competition_event.id if row[competition_event.event_id.to_sym] == "1"
104+
competition_event.event_id if row[competition_event.event_id.to_sym] == "1"
75105
end
76106

77-
row.to_hash.merge(event_ids: event_ids)
107+
{
108+
name: row[:name],
109+
wcaId: row[:wca_id]&.upcase,
110+
countryIso2: Country.c_find(row[:country]).iso2,
111+
gender: row[:gender],
112+
birthdate: row[:birth_date],
113+
email: row[:email]&.downcase,
114+
registration: {
115+
eventIds: event_ids,
116+
status: "accepted",
117+
isCompeting: true,
118+
registeredAt: Time.now.utc,
119+
},
120+
}
78121
end
79122
end
80123

@@ -105,29 +148,40 @@ class RegistrationsController < ApplicationController
105148
end
106149
end
107150

108-
dob_column_error = column_check(csv_rows, :birth_date, 'wrong_dob_format', :raw_dobs) do |raw_dob|
109-
Date.safe_parse(raw_dob)&.to_fs != raw_dob
110-
end
111-
112-
email_duplicate_error = column_check(csv_rows, :email, 'email_duplicates', :emails) do |email, emails|
113-
emails.count(email) > 1
114-
end
115-
116-
wca_id_duplicate_error = column_check(csv_rows, :wca_id, "wca_id_duplicates", :wca_ids) do |wca_id, wca_ids|
117-
wca_id.present? && wca_ids.count(wca_id) > 1
118-
end
119-
120-
[event_column_errors, dob_column_error, email_duplicate_error, wca_id_duplicate_error].flatten
151+
[
152+
event_column_errors,
153+
validate_dob_formats(csv_rows.pluck(:birth_date)),
154+
validate_no_duplicates(csv_rows.pluck(:email), 'email_duplicates', :emails),
155+
validate_no_duplicates(csv_rows.pluck(:wca_id).compact_blank, 'wca_id_duplicates', :wca_ids),
156+
].flatten.compact
121157
end
122158

123-
private def column_check(csv_rows, column_name, error_key, i18n_keyword)
124-
column_values = csv_rows.pluck(column_name)
159+
private def validate_registrations(registration_rows, competition)
160+
# Validate country codes
161+
invalid_countries = registration_rows.filter_map { |e| e[:countryIso2] }.uniq.reject { |iso2| Country.c_find_by_iso2(iso2) }
162+
163+
# Validate event IDs against competition events
164+
valid_event_ids = competition.competition_events.pluck(:event_id)
165+
all_event_ids = registration_rows.flat_map { |e| e.dig(:registration, :eventIds) || [] }.uniq
166+
invalid_event_ids = all_event_ids - valid_event_ids
167+
168+
[
169+
invalid_countries.any? && "Invalid country codes: #{invalid_countries.join(', ')}",
170+
invalid_event_ids.any? && "Invalid event IDs for this competition: #{invalid_event_ids.join(', ')}",
171+
validate_dob_formats(registration_rows.filter_map { |e| e[:birthdate] }),
172+
validate_no_duplicates(registration_rows.filter_map { |e| e[:email]&.downcase }, 'email_duplicates', :emails),
173+
validate_no_duplicates(registration_rows.filter_map { |e| e[:wcaId]&.upcase }, 'wca_id_duplicates', :wca_ids),
174+
].flatten.compact
175+
end
125176

126-
malformed_values = column_values.select do |value|
127-
yield value, column_values
128-
end.uniq
177+
private def validate_dob_formats(dobs)
178+
malformed = dobs.compact.reject { |dob| Date.safe_parse(dob)&.to_fs == dob }.uniq
179+
I18n.t("registrations.import.errors.wrong_dob_format", raw_dobs: malformed.join(", ")) if malformed.any?
180+
end
129181

130-
I18n.t("registrations.import.errors.#{error_key}", i18n_keyword => malformed_values.join(", ")) if malformed_values.any?
182+
private def validate_no_duplicates(values, error_key, i18n_keyword)
183+
duplicates = values.compact.select { |v| values.count(v) > 1 }.uniq
184+
I18n.t("registrations.import.errors.#{error_key}", i18n_keyword => duplicates.join(", ")) if duplicates.any?
131185
end
132186

133187
private def competitor_limit_error(competition, competitor_count)
@@ -202,7 +256,8 @@ def do_import
202256
registration.assign_attributes(competing_status: Registrations::Helper::STATUS_ACCEPTED) unless registration.accepted?
203257
registration.registration_competition_events = []
204258
registration_row[:event_ids].each do |event_id|
205-
registration.registration_competition_events.build(competition_event_id: event_id)
259+
competition_event = @competition.competition_events.find { |ce| ce.event_id == event_id }
260+
registration.registration_competition_events.build(competition_event_id: competition_event.id)
206261
end
207262
registration.save!
208263
registration.add_history_entry({ event_ids: registration.event_ids }, "user", current_user.id, "CSV Import")
@@ -218,6 +273,10 @@ def do_import
218273
render status: :unprocessable_content, json: { error: e.to_s }
219274
end
220275

276+
def validate_and_convert_registrations
277+
render status: :ok, json: @registration_rows
278+
end
279+
221280
def add
222281
@competition = competition_from_params
223282
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React, { useMemo } from 'react';
2+
import { useMutation } from '@tanstack/react-query';
3+
import { Button, Modal } from 'semantic-ui-react';
4+
import RegistrationsAdministrationTable from '../RegistrationsV2/RegistrationAdministration/RegistrationsAdministrationTable';
5+
import importRegistrations from './api/importRegistrations';
6+
import Errored from '../Requests/Errored';
7+
import I18n from '../../lib/i18n';
8+
9+
function transformRegistration(registrationRow, index) {
10+
// gender is not available in RegistrationsAdministrationTable,
11+
// hence it won't be available in preview.
12+
return {
13+
id: index,
14+
user_id: index,
15+
user: {
16+
wca_id: registrationRow.wcaId || null,
17+
name: registrationRow.name,
18+
country: { iso2: registrationRow.countryIso2 },
19+
dob: registrationRow.birthdate,
20+
email: registrationRow.email,
21+
},
22+
competing: {
23+
event_ids: registrationRow.registration.eventIds,
24+
registered_on: registrationRow.registration.registeredAt,
25+
},
26+
guests: 0,
27+
};
28+
}
29+
30+
const COLUMNS_EXPANDED = {
31+
dob: true,
32+
region: false,
33+
events: true,
34+
comments: false,
35+
email: true,
36+
timestamp: false,
37+
};
38+
39+
export default function RegistrationPreview({
40+
registrations, competitionId, onClose, onImportSuccess,
41+
}) {
42+
const {
43+
mutate: importMutate, isPending, isError, error,
44+
} = useMutation({
45+
mutationFn: importRegistrations,
46+
onSuccess: onImportSuccess,
47+
});
48+
49+
const tableRegistrations = useMemo(
50+
() => (registrations || []).map(transformRegistration),
51+
[registrations],
52+
);
53+
54+
const competitionInfo = useMemo(() => {
55+
const allEventIds = [...new Set(
56+
tableRegistrations.flatMap((r) => r.competing.event_ids),
57+
)];
58+
59+
return {
60+
id: competitionId,
61+
event_ids: allEventIds,
62+
'using_payment_integrations?': false,
63+
};
64+
}, [tableRegistrations, competitionId]);
65+
66+
return (
67+
<Modal
68+
open={!!registrations}
69+
onClose={onClose}
70+
closeOnEscape
71+
size="fullscreen"
72+
>
73+
<Modal.Header>Preview Registration Data</Modal.Header>
74+
<Modal.Content scrolling>
75+
{isError && <Errored error={error} />}
76+
<RegistrationsAdministrationTable
77+
columnsExpanded={COLUMNS_EXPANDED}
78+
registrations={tableRegistrations}
79+
selected={[]}
80+
competitionInfo={competitionInfo}
81+
isReadOnly
82+
sortable
83+
/>
84+
</Modal.Content>
85+
<Modal.Actions>
86+
<Button onClick={onClose} disabled={isPending}>
87+
{I18n.t('registrations.import.cancel')}
88+
</Button>
89+
<Button
90+
primary
91+
onClick={() => importMutate({ competitionId, registrations })}
92+
loading={isPending}
93+
>
94+
{I18n.t('registrations.import.import')}
95+
</Button>
96+
</Modal.Actions>
97+
</Modal>
98+
);
99+
}

app/webpacker/components/ImportRegistrations/UploadRegistrationCsv.jsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
import { useMutation } from '@tanstack/react-query';
22
import React, { useState } from 'react';
33
import { Form } from 'semantic-ui-react';
4-
import importRegistrations from './api/importRegistrations';
4+
import validateAndConvertRegistrations from './api/validateAndConvertRegistrations';
55
import Loading from '../Requests/Loading';
66
import Errored from '../Requests/Errored';
77
import I18n from '../../lib/i18n';
88

9-
export default function UploadRegistrationCsv({ competitionId, onImportSuccess }) {
9+
export default function UploadRegistrationCsv({
10+
competitionId, setRegistrationsToPreview,
11+
}) {
1012
const [csvFile, setCsvFile] = useState();
1113
const {
12-
mutate: importRegistrationsMutate, isPending, error, isError,
14+
mutate: validateAndConvertRegistrationsMutate, isPending, error, isError,
1315
} = useMutation({
14-
mutationFn: importRegistrations,
15-
onSuccess: onImportSuccess,
16+
mutationFn: validateAndConvertRegistrations,
17+
onSuccess: setRegistrationsToPreview,
1618
});
1719

1820
if (isPending) return <Loading />;
1921
if (isError) return <Errored error={error} />;
2022

2123
return (
22-
<Form onSubmit={() => importRegistrationsMutate({ competitionId, csvFile })}>
24+
<Form onSubmit={() => validateAndConvertRegistrationsMutate({ competitionId, csvFile })}>
2325
<Form.Input
2426
type="file"
2527
accept="text/csv"
@@ -31,7 +33,7 @@ export default function UploadRegistrationCsv({ competitionId, onImportSuccess }
3133
disabled={!csvFile}
3234
type="submit"
3335
>
34-
{I18n.t('registrations.import.import')}
36+
{I18n.t('registrations.import.preview')}
3537
</Form.Button>
3638
</Form>
3739
);

app/webpacker/components/ImportRegistrations/api/importRegistrations.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import { fetchJsonOrError } from '../../../lib/requests/fetchWithAuthenticityToken';
22
import { actionUrls } from '../../../lib/requests/routes.js.erb';
33

4-
export default async function importRegistrations({ competitionId, csvFile }) {
5-
const formData = new FormData();
6-
formData.append('csv_registration_file', csvFile);
7-
formData.append('competition_id', competitionId);
8-
4+
export default async function importRegistrations({ competitionId, registrations }) {
95
const { data } = await fetchJsonOrError(
106
actionUrls.competition.importRegistrations(competitionId),
117
{
128
method: 'POST',
13-
body: formData,
9+
headers: { 'Content-Type': 'application/json' },
10+
body: JSON.stringify({ registrations }),
1411
},
1512
);
1613

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { fetchJsonOrError } from '../../../lib/requests/fetchWithAuthenticityToken';
2+
import { actionUrls } from '../../../lib/requests/routes.js.erb';
3+
4+
export default async function validateAndConvertRegistrations({ competitionId, csvFile }) {
5+
const formData = new FormData();
6+
formData.append('csv_registration_file', csvFile);
7+
formData.append('competition_id', competitionId);
8+
9+
const { data } = await fetchJsonOrError(
10+
actionUrls.competition.validateAndConvertRegistrations(competitionId),
11+
{
12+
method: 'POST',
13+
body: formData,
14+
},
15+
);
16+
17+
return data;
18+
}

app/webpacker/components/ImportRegistrations/index.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
22
import { Message, Tab } from 'semantic-ui-react';
33
import I18n from '../../lib/i18n';
44
import UploadRegistrationCsv from './UploadRegistrationCsv';
5+
import RegistrationPreview from './RegistrationPreview';
56
import WCAQueryClientProvider from '../../lib/providers/WCAQueryClientProvider';
67

78
export default function Wrapper({ competitionId }) {
@@ -14,14 +15,15 @@ export default function Wrapper({ competitionId }) {
1415

1516
function ImportRegistrations({ competitionId }) {
1617
const [success, setSuccess] = useState();
18+
const [registrationsToPreview, setRegistrationsToPreview] = useState();
1719
const panes = [
1820
{
1921
menuItem: 'Upload Registration CSV',
2022
render: () => (
2123
<Tab.Pane>
2224
<UploadRegistrationCsv
2325
competitionId={competitionId}
24-
onImportSuccess={() => setSuccess(true)}
26+
setRegistrationsToPreview={setRegistrationsToPreview}
2527
/>
2628
</Tab.Pane>
2729
),
@@ -38,6 +40,12 @@ function ImportRegistrations({ competitionId }) {
3840
{I18n.t('registrations.import.info')}
3941
</Message>
4042
<Tab panes={panes} />
43+
<RegistrationPreview
44+
registrations={registrationsToPreview}
45+
competitionId={competitionId}
46+
onClose={() => setRegistrationsToPreview(null)}
47+
onImportSuccess={() => setSuccess(true)}
48+
/>
4149
</>
4250
);
4351
}

0 commit comments

Comments
 (0)