diff --git a/app/controllers/ticket_requests_controller.rb b/app/controllers/ticket_requests_controller.rb index 790fc9b4..ce2444fc 100644 --- a/app/controllers/ticket_requests_controller.rb +++ b/app/controllers/ticket_requests_controller.rb @@ -42,7 +42,7 @@ def download CSV.open(csv_file.path, 'w', write_headers: true, - headers: TicketRequest.csv_header) do |csv| + headers: TicketRequest.csv_header) do |csv| TicketRequest.for_csv(@event).each do |row| csv << row end @@ -154,11 +154,11 @@ def update # Allow ticket request to edit guests and nothing else ticket_request_params = permitted_params[:ticket_request] - guests = (Array(ticket_request_params[:guest_list]) || []) + guests = (Array(ticket_request_params[:adult_guest_list]) || []) .flatten.map(&:presence) .compact - ticket_request_params.delete(:guest_list) + ticket_request_params.delete(:adult_guest_list) ticket_request_params[:guests] = guests if @ticket_request.valid? && @ticket_request.update!(ticket_request_params) @@ -286,7 +286,8 @@ def permitted_params :agrees_to_terms, :early_arrival_passes, :late_departure_passes, - { guest_list: [] } + { adult_guest_list: [] }, + { kid_guest_list: [] }, ] ) .to_hash diff --git a/app/models/ticket_request.rb b/app/models/ticket_request.rb index 35f229bc..56eadac7 100644 --- a/app/models/ticket_request.rb +++ b/app/models/ticket_request.rb @@ -19,6 +19,7 @@ # donation :decimal(8, 2) default(0.0) # early_arrival_passes :integer default(0), not null # guests :text +# guests_kids :text # kids :integer default(0), not null # late_departure_passes :integer default(0), not null # needs_assistance :boolean default(FALSE), not null @@ -45,7 +46,7 @@ class << self # @description # This method returns a two-dimensional array. The first row is the header row, # and then for each ticket request we return the primary user with the ticket request info, - # followed by one row per guest. + # followed by one row per kid. def for_csv(event) table = [] @@ -64,16 +65,23 @@ def for_csv(event) table << row - ticket_request.guests.each do |guest| - age_string = guest.include?(',') ? guest.gsub(/.*,/, '').strip : '' - first, last, = guest.split(/[\s,]+/) + ticket_request.guests&.each do |guest| + next if guest.blank? + + guest.include?(',') ? guest.gsub(/.*,/, '').strip : '' + first, last, = guest.split(/\s+/) email = guest.include?('<') ? guest.gsub(/.*.*/, '') : '' + table << ["#{first} #{last}", email, 'Adult', ''] + end - next if "#{first} #{last}" == ticket_request.user.name || email == ticket_request.user.email + ticket_request.guests_kids&.each do |kid| + next if kid.blank? - kids_age = age_string.empty? ? '' : kids_age(age_string) + age_string = kid.include?(',') ? kid.gsub(/.*,/, '').strip : '' + first, last, = kid.split(/\s+/) + kids_age = age_string.empty? ? '' : kids_age(age_string) - table << ["#{first} #{last}", email, 'Yes', kids_age] + table << ["#{first} #{last}", '', 'Kid', kids_age] end end @@ -81,7 +89,7 @@ def for_csv(event) end def csv_header - ['Name', 'Email', 'Guest?', 'Kids Age', *csv_columns.map(&:titleize)] + ['Name', 'Email', 'Guest Type', 'Kids Age', *csv_columns.map(&:titleize)] end def csv_columns @@ -164,6 +172,8 @@ def kids_age(string) # Serialize guest emails as an array in a text field. serialize :guests, coder: Psych, type: Array + # Serialize kids names and ages as an array in a text field. + serialize :guests_kids, coder: Psych, type: Array attr_accessible :user_id, :adults, :kids, :cabins, :needs_assistance, :notes, :status, :special_price, :event_id, @@ -171,7 +181,7 @@ def kids_age(string) :car_camping, :car_camping_explanation, :previous_contribution, :address_line1, :address_line2, :city, :state, :zip_code, :country_code, :admin_notes, :agrees_to_terms, - :early_arrival_passes, :late_departure_passes, :guests + :early_arrival_passes, :late_departure_passes, :guests, :guests_kids normalize_attributes :notes, :role_explanation, :previous_contribution, :admin_notes, :car_camping_explanation @@ -190,14 +200,16 @@ def kids_age(string) validates :kids, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :cabins, allow_nil: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :role, presence: true, inclusion: { in: ROLES.keys } - validates :role_explanation, presence: { if: -> { role == ROLE_OTHER } }, length: { maximum: 400 } + validates :role_explanation, presence: { if: -> { role == ROLE_OTHER } }, length: { maximum: 200 } validates :previous_contribution, length: { maximum: 250 } validates :notes, length: { maximum: 500 } - validates :guests, length: { maximum: 10 } validates :special_price, allow_nil: true, numericality: { greater_than_or_equal_to: 0 } validates :donation, numericality: { greater_than_or_equal_to: 0 } validates :agrees_to_terms, presence: true + # Validate that the total number of adult + kid guests is less that the total_tickets - 1 (for the ticket request user) + validate :maximum_guests + scope :completed, -> { where(status: STATUS_COMPLETED) } scope :pending, -> { where(status: STATUS_PENDING) } scope :awaiting_payment, -> { where(status: STATUS_AWAITING_PAYMENT) } @@ -319,23 +331,31 @@ def total_tickets adults + kids end - def guest_count + def guest_expected_count total_tickets - 1 end - def guests_specified - Array(guests).size + def adult_guests_count + Array(guests || []).size || 0 + end + + def kid_guests_count + Array(guests_kids || []).size || 0 end - def guest_list + def adult_guest_list [].tap do |guest_list| guest_list << user.name_and_email guests.each { |guest| guest_list << guest } end.compact end + def kid_guest_list + guests_kids.compact + end + def all_guests_specified? - guests_specified >= guest_count + adult_guests_count + kid_guests_count == guests_expected_count end def country_name @@ -356,6 +376,20 @@ def set_defaults # Remove empty guests # Note that guests are serialized as an array field. - self.guests = Array(guests).map { |guest| guest&.strip }.select(&:present?).compact + self.guests = Array(guests).map { |guest| guest&.strip }.select(&:present?).compact + self.guests_kids = Array(guests_kids).map { |guest| guest&.strip }.select(&:present?).compact + end + + def maximum_guests + if adults.present? && guests.present? && adult_guests_count > (adults - 1) + # adult guests must be less that adult tickets - 1 (for the request owner) + errors.add(:base, 'You provided more adult guests than tickets') + false + elsif kids.present? && kids.positive? && guests_kids.present? && kid_guests_count > kids + errors.add(:base, 'You provided more kid guests than kid tickets') + false + else + true + end end end diff --git a/app/views/events/guest_list.html.haml b/app/views/events/guest_list.html.haml index c9b56d76..526bf38b 100644 --- a/app/views/events/guest_list.html.haml +++ b/app/views/events/guest_list.html.haml @@ -1,4 +1,4 @@ -= render partial: 'shared/nav_event', locals: { event: @event, active_tab: { guest_list: 'active' } } += render partial: 'shared/nav_event', locals: { event: @event, active_tab: { adult_guest_list: 'active' } } .card .card-header.bg-primary-subtle diff --git a/app/views/events/index.html.haml b/app/views/events/index.html.haml index aea54f4c..0556b61b 100644 --- a/app/views/events/index.html.haml +++ b/app/views/events/index.html.haml @@ -32,7 +32,7 @@ %td.p-1.text-end.optional.align-content-center = number_with_delimiter(event.ticket_requests.count) %td.p-1.text-end.align-content-center - = number_with_delimiter(event.ticket_requests.sum(&:guest_count)) + = number_with_delimiter(event.ticket_requests.sum(&:guests_expected_count)) %td.p-1.text-end.optional.align-content-center = number_to_currency(event.ticket_requests.completed.sum(&:cost)) %td.p-1.text-end.optional.align-content-center diff --git a/app/views/ticket_requests/_form.html.haml b/app/views/ticket_requests/_form.html.haml index d5b702eb..4d5283fe 100644 --- a/app/views/ticket_requests/_form.html.haml +++ b/app/views/ticket_requests/_form.html.haml @@ -31,41 +31,60 @@ - else = form_for @ticket_request, url: { controller: :ticket_requests, action: form_action_name }, data: { "turbo-target": "_top", turbo: false } do |f| - = f.hidden_field :event_id, value: @event.id - - - if is_update - %hr - %h4 Guests - %p - Please list everyone in your party including the kids. We do not (currently) send automated emails to any of the guests you specify. - %p - For adults — please list their full name, and an email address as shown in the below example. - .content-fluid - .row - .col-lg-6.col-xl-6.col-md-12.col-sm-12 + .container-fluid + .row + .col-12 + = f.hidden_field :event_id, value: @event.id + + - if is_update + %a{name: "guests"}/ + %p + .control-group + %h4 Guests + %p + Please list everyone in your party including the kids. We do not (currently) send automated emails to any of the guests you specify. + %p + For adults — please list their full name, and an email address as shown in the below example. + %p + DJs should write their DJ Name, as well as the regular name and email address. - list_finalized = (@event.start_time - Time.current) < ::Event::GUEST_LIST_FINAL_WITHIN - - if list_finalized - %span.text-error - Guest list has already been finalized. If you need to update your guests, please email - = mail_to 'tickets@fnf.org' - %br - - else - %h4 Adult Guests - %h5 Examples: - %ul - %li DJ Carl Cox (as himself) <carl@carlcox.com> - %li John Digweed <digweed@bedrock-records.com> - - - total_guests = @ticket_request.guest_count - - adult_guests = @ticket_request.adults - 1 - - adult_guests.times do |guest_id| - - next if @ticket_request.guests[guest_id] == "#{@ticket_request.user.name} <#{@ticket_request.user.email}>" - = f.label "Adult Guest #{guest_id + 1}" - = f.text_field :guest_list, readonly: list_finalized, multiple: true, value: Array(@ticket_request.guests[0...adult_guests])[guest_id], style: 'width: 80%' - - - if @ticket_request.kids.positive? - .col-lg-6.col-xl-6.col-md-12.col-sm-12 + .row + - if list_finalized + %span.text-error + Guest list has already been finalized. If you need to update your guests, please email + = mail_to 'tickets@fnf.org' + %br + - else + .col-sm-12.col-md-12.col-lg-6.col-xl-6 + %h5 Adults + %p.text-success + %strong Adult Name Examples + %ul + %li DJ Carl Cox (as himself) <carl@carlcox.com> + %li John Digweed <digweed@bedrock-records.com> + + - total_guests = @ticket_request.guests_expected_count + - adult_guests = @ticket_request.adults + - adult_guests.times do |guest_id| + = f.label "Adult Guest #{guest_id + 1}" + - default_value = guest_id == 0 ? @ticket_request.user.name_and_email : '' + = f.text_field :adult_guest_list, readonly: list_finalized, multiple: true, value: @ticket_request.guests[guest_id] || default_value, style: 'width: 80%' + + - if @ticket_request.kids.positive? + .col-sm-12.col-md-12.col-lg-6.col-xl-6 + %h5 Kids + %p.text-success + %strong Kid Name Examples + %ul + %li Taylor Swift, 12 + %li Jonah Hill, 8 + %li Channing Tatum, 5 + .small Please enter "Full Name, Age" eg "Justin Bieber, 12" + - kid_guests = @ticket_request.kids + - kid_guests.times do |guest_id| + = f.label "Kid Number #{guest_id + 1}" + = f.text_field :kid_guest_list, readonly: list_finalized, multiple: true, value: @ticket_request.guests_kids[guest_id], style: 'width: 80%' %h4 Kid Guests %h5 Examples: @@ -77,35 +96,33 @@ = f.label "Kid Guest #{guest_id + 1}" = f.text_field :guest_list, readonly: list_finalized, multiple: true, value: Array(@ticket_request.guests[(adult_guests)...total_guests])[guest_id], style: 'width: 80%' - %hr - %h4 How are you contributing to the event? .content-fluid + %h5 How are you contributing to the event? .row .col-lg-6.col-xl-6.col-md-12.col-sm-12 .input-group-large %fieldset %p - = f.label :role_volunteer do - = f.label :role_volunteer, class: 'radio inline' do - = f.radio_button :role, TicketRequest::ROLE_VOLUNTEER, - data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_VOLUNTEER] } - = TicketRequest::ROLES[TicketRequest::ROLE_VOLUNTEER] - = f.label :role_contributor, class: 'radio inline' do - = f.radio_button :role, TicketRequest::ROLE_CONTRIBUTOR, - data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_CONTRIBUTOR] } - = TicketRequest::ROLES[TicketRequest::ROLE_CONTRIBUTOR] - = f.label :role_coordinator, class: 'radio inline' do - = f.radio_button :role, TicketRequest::ROLE_COORDINATOR, - data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_COORDINATOR] } - = TicketRequest::ROLES[TicketRequest::ROLE_COORDINATOR] - = f.label :role_uber_coordinator, class: 'radio inline' do - = f.radio_button :role, TicketRequest::ROLE_UBER_COORDINATOR, - data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_UBER_COORDINATOR] } - = TicketRequest::ROLES[TicketRequest::ROLE_UBER_COORDINATOR] - = f.label :role_other, class: 'radio inline' do - = f.radio_button :role, TicketRequest::ROLE_OTHER, - data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_OTHER] } - = TicketRequest::ROLES[TicketRequest::ROLE_OTHER] + = f.label :role_volunteer, class: 'radio inline' do + = f.radio_button :role, TicketRequest::ROLE_VOLUNTEER, + data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_VOLUNTEER] } + = TicketRequest::ROLES[TicketRequest::ROLE_VOLUNTEER] + = f.label :role_contributor, class: 'radio inline' do + = f.radio_button :role, TicketRequest::ROLE_CONTRIBUTOR, + data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_CONTRIBUTOR] } + = TicketRequest::ROLES[TicketRequest::ROLE_CONTRIBUTOR] + = f.label :role_coordinator, class: 'radio inline' do + = f.radio_button :role, TicketRequest::ROLE_COORDINATOR, + data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_COORDINATOR] } + = TicketRequest::ROLES[TicketRequest::ROLE_COORDINATOR] + = f.label :role_uber_coordinator, class: 'radio inline' do + = f.radio_button :role, TicketRequest::ROLE_UBER_COORDINATOR, + data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_UBER_COORDINATOR] } + = TicketRequest::ROLES[TicketRequest::ROLE_UBER_COORDINATOR] + = f.label :role_other, class: 'radio inline' do + = f.radio_button :role, TicketRequest::ROLE_OTHER, + data: { 'max-tickets' => TicketRequest::TICKET_LIMITS[TicketRequest::ROLE_OTHER] } + = TicketRequest::ROLES[TicketRequest::ROLE_OTHER] .col-lg-6.col-xl-6.col-md-12.col-sm-12.align-content-md-center %p.muted.role-explanation{ class: TicketRequest::ROLE_VOLUNTEER } diff --git a/app/views/ticket_requests/show.html.haml b/app/views/ticket_requests/show.html.haml index a13bc8ac..2faecd7a 100644 --- a/app/views/ticket_requests/show.html.haml +++ b/app/views/ticket_requests/show.html.haml @@ -199,16 +199,16 @@ %strong = @ticket_request.notes - - if @ticket_request.guest_count > 0 + - if @ticket_request.guests_expected_count > 0 %tr %td %td %hr %tr.align-middle - %td.text-end.small.p-2.px-4.text-nowrap= 'Guest'.pluralize(@ticket_request.guest_count) + ':' + %td.text-end.small.p-2.px-4.text-nowrap= 'Guest'.pluralize(@ticket_request.guests_expected_count) + ':' %td %em - - @ticket_request.guest_count.times do |guest_id| + - @ticket_request.guests_expected_count.times do |guest_id| .text-nowrap.small = Array(@ticket_request.guests)[guest_id] || 'Unspecified' %br @@ -250,7 +250,7 @@ = link_to event_ticket_requests_path(@event), class: 'btn btn-success btn-round btn-sm' do All Requests - - if @event.admin?(current_user) || @ticket_request.guest_count > 0 + - if @event.admin?(current_user) || @ticket_request.guests_expected_count > 0 = link_to edit_event_ticket_request_path(@event, @ticket_request), class: 'btn btn-primary btn-round btn-sm mx-0' do = @event.admin?(current_user) ? 'Edit Ticket Request' : 'Edit Guests' diff --git a/db/migrate/20240520043030_add_guest_kids_to_ticket_requests.rb b/db/migrate/20240520043030_add_guest_kids_to_ticket_requests.rb new file mode 100644 index 00000000..d14073da --- /dev/null +++ b/db/migrate/20240520043030_add_guest_kids_to_ticket_requests.rb @@ -0,0 +1,5 @@ +class AddGuestKidsToTicketRequests < ActiveRecord::Migration[7.1] + def change + add_column :ticket_requests, :guests_kids, :text + end +end diff --git a/db/structure.sql b/db/structure.sql index e9a5c4c6..4e3f5606 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -354,7 +354,8 @@ CREATE TABLE public.ticket_requests ( early_arrival_passes integer DEFAULT 0 NOT NULL, late_departure_passes integer DEFAULT 0 NOT NULL, guests text, - deleted_at timestamp(6) without time zone + deleted_at timestamp(6) without time zone, + guests_kids text ); @@ -712,6 +713,7 @@ CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING b SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20240520043030'), ('20240516225937'), ('20240513035005'), ('20240509025037'), diff --git a/spec/factories/event.rb b/spec/factories/event.rb index cacd1182..99e1dc88 100644 --- a/spec/factories/event.rb +++ b/spec/factories/event.rb @@ -11,8 +11,8 @@ adult_ticket_price { Random.rand(100..150) } kid_ticket_price { Random.rand(40..50) } cabin_price { nil } - max_adult_tickets_per_request { Random.rand(2..4) } - max_kid_tickets_per_request { 2 } + max_adult_tickets_per_request { 4 } + max_kid_tickets_per_request { 3 } max_cabins_per_request { nil } tickets_require_approval { true } diff --git a/spec/factories/ticket_request.rb b/spec/factories/ticket_request.rb index 6cf449fa..fc2f5b10 100644 --- a/spec/factories/ticket_request.rb +++ b/spec/factories/ticket_request.rb @@ -2,8 +2,8 @@ FactoryBot.define do factory :ticket_request do - adults { event&.max_adult_tickets_per_request || 1 } - kids { Random.rand(2) } + adults { 3 } + kids { 1 } cabins { Random.rand(2) } needs_assistance { [true, false].sample } notes { Faker::Lorem.paragraph } @@ -15,6 +15,12 @@ ] end + guests_kids do + [ + "#{Faker::Name.first_name} #{Faker::Name.last_name}, 2", + ] + end + user event diff --git a/spec/models/ticket_request_spec.rb b/spec/models/ticket_request_spec.rb index a3481954..3e263232 100644 --- a/spec/models/ticket_request_spec.rb +++ b/spec/models/ticket_request_spec.rb @@ -372,8 +372,8 @@ its(:total_tickets) { is_expected.to be(5) } end - describe '#guests' do - subject(:ticket_request) { create(:ticket_request, guests:) } + describe '#guests (adults)' do + subject(:ticket_request) { create(:ticket_request, guests:, adults: 3) } let(:guests) do [ @@ -409,8 +409,8 @@ end end - describe '#guest_list' do - subject { ticket_request.guest_list } + describe '#adult_guest_list' do + subject { ticket_request.adult_guest_list } it { is_expected.to be_a(Array) } @@ -424,23 +424,42 @@ let(:event) { create(:event) } let(:number_of_active_requests) { 5 } let(:number_of_inactive_requests) { 2 } - - let(:guest_list) { ['Konstantin Gredeskoul ', 'Matt Levy '] } + let(:user) { create(:user) } + let(:adult_guest_list) { ['Konstantin Gredeskoul ', 'Matt Levy '] } + let(:kid_guest_list) { ['Kid One, 12', 'Kig Two, 5', 'Kid Three, 12'] } before do # These are completed (number_of_active_requests - 2).times do |_index| - event.ticket_requests.create!(user: create(:user), agrees_to_terms: true, status: TicketRequest::STATUS_COMPLETED, guests: guest_list) + event.ticket_requests.create!(user:, + agrees_to_terms: true, + status: TicketRequest::STATUS_COMPLETED, + guests: adult_guest_list, + guests_kids: kid_guest_list, + adults: 3, + kids: 3) end # These are still waiting on the payment (number_of_active_requests - 3).times do - event.ticket_requests.create!(user: create(:user), agrees_to_terms: true, status: TicketRequest::STATUS_AWAITING_PAYMENT, guests: guest_list) + event.ticket_requests.create!(user: create(:user), + agrees_to_terms: true, + status: TicketRequest::STATUS_AWAITING_PAYMENT, + guests: adult_guest_list, + guests_kids: kid_guest_list, + adults: 3, + kids: 3) end # These should not be included in the CSV, as they have not been approved number_of_inactive_requests.times do - event.ticket_requests.create!(user: create(:user), agrees_to_terms: true, status: TicketRequest::STATUS_PENDING, guests: guest_list) + event.ticket_requests.create!(user: create(:user), + agrees_to_terms: true, + status: TicketRequest::STATUS_PENDING, + guests: adult_guest_list, + guests_kids: kid_guest_list, + adults: 3, + kids: 3) end end @@ -455,24 +474,30 @@ expect(event.ticket_requests.active.size).to eq(number_of_active_requests) end - it 'each request should have two guests' do + it 'each request should have two adult guests' do event.ticket_requests.each do |tr| expect(tr.guests.size).to eq(2) end end + it 'each request should have three kid guests' do + event.ticket_requests.each do |tr| + expect(tr.guests_kids.size).to eq(3) + end + end + it { is_expected.to be_a(Array) } it { is_expected.not_to eq [] } # number of active requests with two guests each - its(:size) { is_expected.to eq(number_of_active_requests * 3) } + its(:size) { is_expected.to eq(number_of_active_requests * 6) } end describe '.csv_columns' do subject { described_class.csv_header } - it { is_expected.to start_with %w[Name Email Guest?] } + it { is_expected.to start_with ['Name', 'Email', 'Guest Type', 'Kids Age'] } end end end