diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 6f94f9d78..02779506f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -495,6 +495,8 @@ RSpec/SubjectStub: RSpec/VerifiedDoubles: Exclude: - 'spec/datatables/user_datatable_spec.rb' + - 'spec/features/ticket_purchases_spec.rb' + - 'spec/models/payment_spec.rb' - 'spec/pdfs/ticket_pdf_spec.rb' # Offense count: 1 diff --git a/.ruby-version b/.ruby-version index 37d02a6e3..5f6fc5edc 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.8 +3.3.10 diff --git a/.tool-versions b/.tool-versions index c47fe8b0d..c0c3bc749 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -ruby 3.3.8 +ruby 3.3.10 nodejs 16.20.2 diff --git a/Gemfile b/Gemfile index 9f888c822..346785483 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,7 @@ gem 'cloudinary' # for internationalizing gem 'rails-i18n' # Windows: timezone data (required on Windows for tzinfo) -gem 'tzinfo-data', platforms: %i[ windows jruby ] +gem 'tzinfo-data', platforms: %i[windows jruby] # as authentification framework gem 'devise' diff --git a/Gemfile.lock b/Gemfile.lock index 834cb45fa..2fd7a3990 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,6 +254,7 @@ GEM faraday (~> 2.0) fastimage (2.3.0) feature (1.4.0) + ffi (1.17.0-arm64-darwin) ffi (1.17.0-x64-mingw-ucrt) ffi (1.17.0-x86_64-linux-gnu) font-awesome-sass (6.5.1) @@ -383,6 +384,8 @@ GEM next_rails (1.3.0) colorize (>= 0.8.1) nio4r (2.7.0) + nokogiri (1.16.6-arm64-darwin) + racc (~> 1.4) nokogiri (1.16.6-x64-mingw-ucrt) racc (~> 1.4) nokogiri (1.16.6-x86_64-linux) @@ -641,6 +644,7 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sqlite3 (1.7.2-arm64-darwin) sqlite3 (1.7.2-x64-mingw-ucrt) sqlite3 (1.7.2-x86_64-linux) ssrf_filter (1.1.2) @@ -668,7 +672,7 @@ GEM turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.3) + tzinfo-data (1.2026.1) tzinfo (>= 1.0.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) @@ -705,6 +709,7 @@ GEM zeitwerk (2.6.13) PLATFORMS + arm64-darwin-25 x64-mingw-ucrt x86_64-linux diff --git a/app/assets/javascripts/osem-switch.js b/app/assets/javascripts/osem-switch.js index b4517c1cb..dd60d576d 100644 --- a/app/assets/javascripts/osem-switch.js +++ b/app/assets/javascripts/osem-switch.js @@ -1,15 +1,61 @@ function checkboxSwitch(selector){ $(selector).bootstrapSwitch(); - $(selector).on('switchChange.bootstrapSwitch', function(event, state) { - var url = $(this).attr('url') + state; - var method = $(this).attr('method') || 'patch'; + // Prevent duplicated event handlers when the page is re-rendered. + $(selector).off('switchChange.bootstrapSwitch'); + $(selector).off('.osemSwitchGuard'); - $.ajax({ - url: url, - type: method, - dataType: 'script' - }); + // Mark as user-initiated before bootstrapSwitch triggers switchChange. + // Important: bootstrapSwitch often binds clicks on its generated wrapper/label, + // so we must listen on those too (not only on the hidden checkbox input). + $(selector).each(function() { + var $input = $(this); + $input.data('osem-user-toggle', false); + + var $wrapper = $input.closest('.bootstrap-switch'); + if ($wrapper.length === 0) { + $wrapper = $input.parent(); + } + + $wrapper.off('click.osemSwitchGuard mouseup.osemSwitchGuard touchend.osemSwitchGuard pointerup.osemSwitchGuard'); + $wrapper.on( + 'click.osemSwitchGuard mouseup.osemSwitchGuard touchend.osemSwitchGuard pointerup.osemSwitchGuard', + function() { + $input.data('osem-user-toggle', true); + } + ); + }); + + $(selector).on('switchChange.bootstrapSwitch', function(_event, state) { + var $el = $(this); + if (!$el.data('osem-user-toggle')) { + return; + } + + // bootstrapSwitch can emit multiple switchChange events per user click. + // Delay the request slightly, then read the final checkbox state to send once. + var existingTimer = $el.data('osem-user-toggle-timer'); + if (existingTimer) { + clearTimeout(existingTimer); + } + + var method = $el.attr('method') || 'patch'; + var urlBase = $el.attr('url'); + + var timer = setTimeout(function() { + $el.data('osem-user-toggle', false); + + var checked = $el.is(':checked'); + var url = urlBase + (checked ? 'true' : 'false'); + + $.ajax({ + url: url, + type: method, + dataType: 'script' + }); + }, 180); + + $el.data('osem-user-toggle-timer', timer); }); } diff --git a/app/assets/stylesheets/osem-payments.scss b/app/assets/stylesheets/osem-payments.scss index cfa451b40..b4a34130e 100644 --- a/app/assets/stylesheets/osem-payments.scss +++ b/app/assets/stylesheets/osem-payments.scss @@ -1,3 +1,6 @@ -.stripe-button-el { - float: right; +.payment-actions { + margin-top: 15px; + display: flex; + justify-content: space-between; + align-items: center; } diff --git a/app/controllers/admin/conferences_controller.rb b/app/controllers/admin/conferences_controller.rb index 17d2bc131..331966835 100644 --- a/app/controllers/admin/conferences_controller.rb +++ b/app/controllers/admin/conferences_controller.rb @@ -67,19 +67,62 @@ def index end def new - @conference = Conference.new + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference = Conference.new( + description: source.description, + timezone: source.timezone, + start_hour: source.start_hour, + end_hour: source.end_hour, + color: source.color, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + organization_id: source.organization_id + ) + @duplicate_from_source = source.short_title + else + @conference = Conference.new + end + else + @conference = Conference.new + end end def create @conference = Conference.new(conference_params) + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + if source && can?(:read, source) + @conference.assign_attributes( + description: source.description, + custom_css: source.custom_css, + ticket_layout: source.ticket_layout, + registration_limit: source.registration_limit, + booth_limit: source.booth_limit, + color: source.color, + start_hour: source.start_hour, + end_hour: source.end_hour + ) + end + end + if @conference.save # user that creates the conference becomes organizer of that conference current_user.add_role :organizer, @conference + if params[:duplicate_from].present? + source = Conference.find_by(short_title: params[:duplicate_from]) + @conference.copy_associations_from(source) if source && can?(:read, source) + end + redirect_to admin_conference_path(id: @conference.short_title), notice: 'Conference was successfully created.' else + @duplicate_from_source = params[:duplicate_from] flash.now[:error] = 'Could not create conference. ' + @conference.errors.full_messages.to_sentence render action: 'new' end diff --git a/app/controllers/admin/emails_controller.rb b/app/controllers/admin/emails_controller.rb index b232a2638..679313605 100644 --- a/app/controllers/admin/emails_controller.rb +++ b/app/controllers/admin/emails_controller.rb @@ -28,11 +28,11 @@ def index def email_params params.require(:email_settings).permit(:send_on_registration, - :send_on_accepted, :send_on_rejected, :send_on_confirmed_without_registration, + :send_on_accepted, :send_on_tentative_accepted, :send_on_rejected, :send_on_confirmed_without_registration, :send_on_submitted_proposal, :submitted_proposal_subject, :submitted_proposal_body, - :registration_subject, :accepted_subject, :rejected_subject, :confirmed_without_registration_subject, - :registration_body, :accepted_body, :rejected_body, :confirmed_without_registration_body, + :registration_subject, :accepted_subject, :tentative_accepted_subject, :rejected_subject, :confirmed_without_registration_subject, + :registration_body, :accepted_body, :tentative_accepted_body, :rejected_body, :confirmed_without_registration_body, :send_on_conference_dates_updated, :conference_dates_updated_subject, :conference_dates_updated_body, :send_on_conference_registration_dates_updated, :conference_registration_dates_updated_subject, :conference_registration_dates_updated_body, :send_on_venue_updated, :venue_updated_subject, :venue_updated_body, diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index 939b4d967..8a2d34c48 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -115,6 +115,46 @@ def new @superevents = @program.events.where(superevent: true) end + def preview_tentative_accept + email_settings = @conference.email_settings + @tentative_subject = email_settings.tentative_accepted_subject.presence + default_body = email_settings.tentative_accepted_body.presence + @missing_committee_review = @event.committee_review.blank? + @tentative_body = EmailTemplateParser.parse_template(default_body, email_settings.get_values(@conference, @event.submitter, @event)) unless @missing_committee_review + render :tentative_accept + end + + def tentative_accept + email_settings = @conference.email_settings + + if @event.committee_review.blank? + flash.now[:alert] = 'Committee feedback is required before sending a tentative acceptance email.' + @tentative_subject = email_settings.tentative_accepted_subject.presence + email_settings.tentative_accepted_body.presence + @missing_committee_review = true + render :tentative_accept and return + end + + @tentative_subject = email_settings.tentative_accepted_subject.presence + default_body = email_settings.tentative_accepted_body.presence + @missing_committee_review = false + @tentative_body = EmailTemplateParser.parse_template(default_body, email_settings.get_values(@conference, @event.submitter, @event)) + send_mail = email_settings.send_on_tentative_accepted + + begin + @event.tentatively_accept(send_mail: send_mail, subject: @tentative_subject, body: @tentative_body) + @event.save! + flash[:notice] = 'Event tentatively accepted!' + redirect_to admin_conference_program_events_path(conference_id: @conference.short_title) and return + rescue ActiveRecord::RecordInvalid => e + flash.now[:error] = "Could not save tentative acceptance: #{e.record.errors.full_messages.join(', ')}" + render :tentative_accept + rescue Transitions::InvalidTransition => e + flash.now[:error] = "Update state failed. #{e.message}" + render :tentative_accept + end + end + def accept send_mail = @event.program.conference.email_settings.send_on_accepted subject = @event.program.conference.email_settings.accepted_subject.blank? @@ -182,6 +222,30 @@ def toggle_attendance end end + def duplicate + count = params[:count].to_i # Invalid input will be treated as 0, which will be caught by validation below + + # Validate count + unless count.between?(1, 100) + flash[:alert] = 'Invalid number of duplicates. Please enter a number between 1 and 100.' + redirect_to admin_conference_program_event_path(@conference.short_title, @event) + return + end + + duplicator = EventDuplicator.new(@event, current_user) + duplicated_events = duplicator.duplicate(count) + + flash[:notice] = if duplicated_events.length == 1 + "Event '#{duplicated_events.first.title}' duplicated successfully." + else + "#{duplicated_events.length} copies of '#{@event.title}' created successfully." + end + redirect_to admin_conference_program_events_path(@conference.short_title) + rescue StandardError + flash[:alert] = 'Could not duplicate event' + redirect_to admin_conference_program_event_path(@conference.short_title, @event) + end + def destroy @event = Event.find(params[:id]) if @event.destroy diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index ca455dce8..aff52e410 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -6,7 +6,7 @@ class RolesController < Admin::BaseController before_action :set_selection authorize_resource :role, except: :index # Show flash message with ajax calls - after_action :prepare_unobtrusive_flash, only: :toggle_user + after_action :prepare_unobtrusive_flash, only: %i[toggle_user toggle_comment_notifications] def index @roles = Role.where(resource: @conference) @@ -21,7 +21,11 @@ def show else toggle_user_admin_conference_role_path(@conference.short_title, @role.name) end - @users = @role.users + @users_roles = UsersRole.where(role: @role).includes(:user) + @comment_notifications_url = + if @track.nil? + toggle_comment_notifications_admin_conference_role_path(@conference.short_title, @role.name) + end end def edit @@ -103,6 +107,30 @@ def toggle_user end end + def toggle_comment_notifications + user = User.find_by(email: user_params[:email]) + state = user_params[:state] + + redirect_url = admin_conference_role_path(@conference.short_title, @role.name) + unless user + redirect_to redirect_url, error: 'Could not find user. Please provide a valid email!' and return + end + + users_role = UsersRole.find_by(user: user, role: @role) + unless users_role + redirect_to redirect_url, error: 'Could not find organizer setting for this user.' and return + end + + # Be tolerant to different representations coming from the client (e.g. "true", "1", true). + email_notifications = ActiveModel::Type::Boolean.new.cast(state) + users_role.update!(email_notifications: email_notifications) + + respond_to do |format| + format.js + format.html { redirect_to redirect_url, notice: 'Successfully updated notification setting.' } + end + end + protected def set_selection diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 17a4b94b2..1d28b81a4 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -2,7 +2,7 @@ class PaymentsController < ApplicationController before_action :authenticate_user! - load_and_authorize_resource + load_and_authorize_resource only: %i[index new] load_resource :conference, find_by: :short_title authorize_resource :conference_registrations, class: Registration @@ -11,7 +11,6 @@ def index end def new - # TODO: use "base currency" session[:selected_currency] = params[:currency] if params[:currency].present? selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency from_currency = @conference.tickets.first.price_currency @@ -27,15 +26,51 @@ def new end def create - @payment = Payment.new payment_params session[:selected_currency] = params[:currency] if params[:currency].present? selected_currency = session[:selected_currency] || @conference.tickets.first.price_currency - from_currency = @conference.tickets.first.price_currency - if @payment.purchase && @payment.save - update_purchased_ticket_purchases + @payment = Payment.new( + user: current_user, + conference: @conference, + currency: selected_currency + ) + authorize! :create, @payment + + unless @payment.save + redirect_to new_conference_payment_path(@conference.short_title), + error: @payment.errors.full_messages.to_sentence + return + end + + session[:has_registration_ticket] = params[:has_registration_ticket] + + checkout_session = @payment.create_checkout_session( + success_url: success_conference_payments_url(@conference.short_title) + '?session_id={CHECKOUT_SESSION_ID}', + cancel_url: cancel_conference_payments_url(@conference.short_title) + ) + + if checkout_session + redirect_to checkout_session.url, allow_other_host: true + else + @payment.destroy + redirect_to new_conference_payment_path(@conference.short_title), + error: @payment.errors.full_messages.to_sentence.presence || 'Could not create checkout session. Please try again.' + end + end - has_registration_ticket = params[:has_registration_ticket] + def success + @payment = Payment.find_by(stripe_session_id: params[:session_id]) + + if @payment.nil? + redirect_to new_conference_payment_path(@conference.short_title), + error: 'Payment not found. Please try again.' + return + end + + if @payment.complete_checkout + update_purchased_ticket_purchases(@payment) + + has_registration_ticket = session.delete(:has_registration_ticket) if has_registration_ticket == 'true' registration = @conference.register_user(current_user) if registration @@ -50,26 +85,21 @@ def create notice: 'Thanks! Your ticket is booked successfully.' end else - # TODO-SNAPCON: This case is not tested at all - @total_amount_to_pay = CurrencyConversion.convert_currency(@conference, Ticket.total_price(@conference, current_user, paid: false), from_currency, selected_currency) - @unpaid_ticket_purchases = current_user.ticket_purchases.unpaid.by_conference(@conference) - flash.now[:error] = @payment.errors.full_messages.to_sentence + ' Please try again with correct credentials.' - render :new + redirect_to new_conference_payment_path(@conference.short_title), + error: 'Payment could not be completed. Please try again.' end end - private - - def payment_params - params.permit(:stripe_customer_email, :stripe_customer_token) - .merge(stripe_customer_email: params[:stripeEmail], - stripe_customer_token: params[:stripeToken], - user: current_user, conference: @conference, currency: session[:selected_currency]) + def cancel + redirect_to new_conference_payment_path(@conference.short_title), + notice: 'Payment was cancelled. You can try again when ready.' end - def update_purchased_ticket_purchases + private + + def update_purchased_ticket_purchases(payment) current_user.ticket_purchases.by_conference(@conference).unpaid.each do |ticket_purchase| - ticket_purchase.pay(@payment) + ticket_purchase.pay(payment) end end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index c2e0a71bb..05a5dab49 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -2,6 +2,7 @@ class RegistrationsController < Devise::RegistrationsController prepend_before_action :check_captcha, only: [:create] + before_action :prevent_local_signup, only: [:create] protected @@ -38,4 +39,11 @@ def check_captcha respond_with_navigational(resource) { render :new } end end + + def prevent_local_signup + return unless Feature.active?(:prevent_local_signups) + + redirect_to new_user_registration_path, + alert: 'Local sign-ups are disabled. Please use Google or Snap! to create an account.' + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 492c98a7f..5ca0852aa 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -211,6 +211,6 @@ def inyourtz(time, timezone, &) def visible_conference_links @visible_conference_links ||= - Conference.all.select(:id, :organization_id, :title, :short_title, :start_date).includes(:splashpage, :organization).select { |conf| can?(:show, conf) }.group_by(&:organization) + Conference.all.select(:id, :title, :short_title, :start_date).includes(:splashpage).select { |conf| can?(:show, conf) }.sort_by(&:start_date).reverse end end diff --git a/app/helpers/conference_helper.rb b/app/helpers/conference_helper.rb index 3a2c3a136..64610987e 100644 --- a/app/helpers/conference_helper.rb +++ b/app/helpers/conference_helper.rb @@ -35,6 +35,9 @@ def conference_color(conference) # adds events to icalendar for proposals in a conference def icalendar_proposals(calendar, proposals, conference) proposals.each do |proposal| + # some 2021 events have nil time or event_type + next if proposal.time.nil? || proposal.event_type.nil? + calendar.event do |e| e.dtstart = proposal.time e.dtend = proposal.time + (proposal.event_type.length * 60) @@ -49,7 +52,7 @@ def icalendar_proposals(calendar, proposals, conference) if v e.geo = v.latitude, v.longitude if v.latitude && v.longitude location = '' - location += "#{proposal.room.name} - " if proposal.room.name + location += "#{proposal.room.name} - " if proposal.room&.name location += " - #{v.street}, " if v.street location += "#{v.postalcode} #{v.city}, " if v.postalcode && v.city location += "#{v.country_name}, " if v.country_name diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 86f129806..78e226f01 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -104,6 +104,7 @@ def difficulty_dropdown(event, difficulties, conference_id) end # TODO-SNAPCON: Move to admin helper + # rubocop:disable Metrics/MethodLength def state_dropdown(event, conference_id, email_settings) selection = event.state.humanize options = [] @@ -123,6 +124,12 @@ def state_dropdown(event, conference_id, email_settings) ] end end + if event.transition_possible? :tentatively_accept + options << [ + 'Tentatively Accept', + preview_tentative_accept_admin_conference_program_event_path(conference_id, event), :get + ] + end if event.transition_possible? :reject options << [ 'Reject', @@ -159,6 +166,7 @@ def state_dropdown(event, conference_id, email_settings) end active_dropdown(selection, options) end + # rubocop:enable Metrics/MethodLength # TODO-SNAPCON: Move to admin helper def event_switch_checkbox(event, attribute, conference_id) @@ -318,9 +326,10 @@ def active_dropdown(selection, options) # # Selection is the string to show by default, which is clicked to expose the # dropdown options. - # Options is a list of 2-item lists; for each entry: + # Options is a list of lists; for each entry: # * [0] is the text of the option, - # * [1] is the link url for the options + # * [1] is the link url for the option, + # * [2] is the optional HTTP method symbol (defaults to :patch) content_tag('div', class: 'dropdown') do content_tag( 'a', @@ -333,7 +342,8 @@ def active_dropdown(selection, options) end + content_tag('ul', class: 'dropdown-menu') do options.collect do |option| - content_tag('li', link_to(option[0], option[1], method: :patch)) + method = option[2] || :patch + content_tag('li', link_to(option[0], option[1], method: method)) end.join.html_safe end end diff --git a/app/helpers/format_helper.rb b/app/helpers/format_helper.rb index a2bff0cec..4395300b4 100644 --- a/app/helpers/format_helper.rb +++ b/app/helpers/format_helper.rb @@ -180,6 +180,15 @@ def restricted_markdown(text, truncate: 2000) truncate(sanitize(markdown.render(text), remove_elements: %w[a]), length: truncate) end + def markdown_with_variables(text, conference, escape_html = true) + return '' if text.nil? + + parser = EmailTemplateParser.new(conference) + values = parser.retrieve_values + text = EmailTemplateParser.parse_template(text, values) + markdown(text, escape_html) + end + def markdown(text, escape_html = true) return '' if text.nil? diff --git a/app/mailers/mailbot.rb b/app/mailers/mailbot.rb index 58a9f6a9b..a4153f657 100644 --- a/app/mailers/mailbot.rb +++ b/app/mailers/mailbot.rb @@ -54,6 +54,16 @@ def acceptance_mail(event) mail(subject: @conference.email_settings.accepted_subject, cc: @speakers) end + def tentative_acceptance_mail(event, subject: nil, body: nil) + @user = event.submitter + @conference = event.program.conference + @speakers = event.speakers.map(&:email) + @email_body = body.presence || @conference.email_settings.generate_event_mail(event, @conference.email_settings.tentative_accepted_body) + email_subject = subject.presence || @conference.email_settings.tentative_accepted_subject + + mail(subject: email_subject, cc: @speakers) + end + def submitted_proposal_mail(event) @user = event.submitter @speakers = event.speakers.map(&:email) diff --git a/app/models/admin_ability.rb b/app/models/admin_ability.rb index 07e72ec20..bc48dcc33 100644 --- a/app/models/admin_ability.rb +++ b/app/models/admin_ability.rb @@ -163,7 +163,7 @@ def signed_in_with_organizer_role(user, conf_ids_for_organization_admin = []) role.resource_type == 'Conference' || role.resource_type == 'Track' end - can [:edit, :update, :toggle_user], Role do |role| + can [:edit, :update, :toggle_user, :toggle_comment_notifications], Role do |role| (role.resource_type == 'Conference' && (conf_ids.include? role.resource_id)) || (role.resource_type == 'Track' && (track_ids.include? role.resource_id)) end diff --git a/app/models/conference.rb b/app/models/conference.rb index 585420049..586204c3f 100644 --- a/app/models/conference.rb +++ b/app/models/conference.rb @@ -841,6 +841,88 @@ def ended? end_date < Time.current end + ## + # Copies associations from another conference (for duplication). + # Includes: registration_period, email_settings, venue+rooms, tickets, + # event_types, tracks, difficulty_levels, sponsorship_levels, sponsors. + # Excludes: events, registrations, and other user/attendee data. + # + def copy_associations_from(source) + return unless source && source != self + + # Registration period (clamp to new conference dates) + if source.registration_period.present? + rp = source.registration_period + start_d = [rp.start_date, start_date].max + end_d = [rp.end_date, end_date].min + start_d = end_d = start_date if start_d > end_d + create_registration_period!(start_date: start_d, end_date: end_d) + end + + # Email settings (conference already has one from create_email_settings) + if source.email_settings.present? && email_settings.present? + attrs = source.email_settings.attributes.except('id', 'conference_id', 'created_at', 'updated_at') + email_settings.update!(attrs) + end + + # Venue and rooms (map old room id -> new room for tracks later) + room_id_map = {} + if source.venue.present? + new_venue = create_venue!( + source.venue.attributes.slice('name', 'street', 'city', 'country', 'description', 'postalcode', 'website', 'latitude', 'longitude') + ) + source.venue.rooms.order(:id).each_with_index do |old_room, _idx| + new_room = new_venue.rooms.create!( + old_room.attributes.slice('name', 'size', 'order').merge(guid: SecureRandom.urlsafe_base64) + ) + room_id_map[old_room.id] = new_room.id + end + end + + # Tickets (conference already has one free ticket from create_free_ticket; skip source's free to avoid duplicate) + source.tickets.each do |t| + next if t.title == 'Free Access' && t.price_cents.zero? + + tickets.create!( + t.attributes.slice('title', 'description', 'price_cents', 'price_currency', 'registration_ticket', 'visible', 'email_subject', 'email_body') + ) + end + + # Event types and difficulty levels (program exists from after_create) + source.program&.event_types&.each do |et| + program.event_types.create!( + et.attributes.slice('title', 'length', 'color', 'description', 'minimum_abstract_length', 'maximum_abstract_length', 'submission_template', 'enable_public_submission') + ) + end + source.program&.difficulty_levels&.each do |dl| + program.difficulty_levels.create!( + dl.attributes.slice('title', 'description', 'color') + ) + end + + # Tracks (assign new room by same index, or nil if no room) + source.program&.tracks&.each do |t| + old_room_id = t.room_id + new_room_id = old_room_id ? room_id_map[old_room_id] : nil + program.tracks.create!( + t.attributes.slice('name', 'short_name', 'description', 'color', 'state', 'relevance', 'start_date', 'end_date', 'cfp_active').merge( + guid: SecureRandom.urlsafe_base64, + room_id: new_room_id + ) + ) + end + + # Sponsorship levels and sponsors + source.sponsorship_levels.each do |sl| + new_sl = sponsorship_levels.create!(sl.attributes.slice('title', 'position')) + sl.sponsors.each do |sp| + new_sl.sponsors.create!( + sp.attributes.slice('name', 'description', 'website_url') + ) + end + end + end + private # Returns a different html colour for every i and consecutive colors are diff --git a/app/models/email_settings.rb b/app/models/email_settings.rb index bd2debddb..fc4390aa4 100644 --- a/app/models/email_settings.rb +++ b/app/models/email_settings.rb @@ -36,9 +36,12 @@ # send_on_registration :boolean default(FALSE) # send_on_rejected :boolean default(FALSE) # send_on_submitted_proposal :boolean default(FALSE) +# send_on_tentative_accepted :boolean default(FALSE) # send_on_venue_updated :boolean default(FALSE) # submitted_proposal_body :text # submitted_proposal_subject :string +# tentative_accepted_body :text +# tentative_accepted_subject :string # venue_updated_body :text # venue_updated_subject :string # created_at :datetime diff --git a/app/models/event.rb b/app/models/event.rb index 373a2ae63..7d75694df 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -110,6 +110,7 @@ class Event < ApplicationRecord scope :confirmed, -> { where(state: 'confirmed') } scope :unconfirmed, -> { where(state: 'unconfirmed') } + scope :tentatively_accepted, -> { where(state: 'tentatively_accepted') } scope :canceled, -> { where(state: 'canceled') } scope :withdrawn, -> { where(state: 'withdrawn') } scope :highlighted, -> { where(is_highlight: true) } @@ -120,6 +121,7 @@ class Event < ApplicationRecord state :new state :withdrawn state :unconfirmed + state :tentatively_accepted state :confirmed state :canceled state :rejected @@ -131,7 +133,10 @@ class Event < ApplicationRecord transitions to: :withdrawn, from: %i[new unconfirmed confirmed] end event :accept do - transitions to: :unconfirmed, from: [:new], on_transition: :process_acceptance + transitions to: :unconfirmed, from: %i[new tentatively_accepted], on_transition: :process_acceptance + end + event :tentatively_accept do + transitions to: :tentatively_accepted, from: [:new], on_transition: :process_tentative_acceptance end event :confirm do transitions to: :confirmed, from: :unconfirmed, on_transition: :process_confirmation @@ -140,17 +145,18 @@ class Event < ApplicationRecord transitions to: :canceled, from: %i[unconfirmed confirmed] end event :reject do - transitions to: :rejected, from: [:new], on_transition: :process_rejection + transitions to: :rejected, from: %i[new tentatively_accepted], on_transition: :process_rejection end end COLORS = { - new: '#0000FF', # blue - withdrawn: '#FF8000', # orange - unconfirmed: '#FFFF00', # yellow - confirmed: '#00FF00', # green - canceled: '#848484', # grey - rejected: '#FF0000' # red + new: '#0000FF', # blue + withdrawn: '#FF8000', # orange + unconfirmed: '#FFFF00', # yellow + tentatively_accepted: '#FFA500', # amber + confirmed: '#00FF00', # green + canceled: '#848484', # grey + rejected: '#FF0000' # red }.freeze ## @@ -245,6 +251,15 @@ def process_acceptance(options) end end + def process_tentative_acceptance(options) + if program.conference.email_settings.send_on_tentative_accepted && + program.conference.email_settings.tentative_accepted_body && + program.conference.email_settings.tentative_accepted_subject && + options[:send_mail].present? + Mailbot.tentative_acceptance_mail(self, subject: options[:subject], body: options[:body]).deliver_later + end + end + def process_rejection(options) if program.conference.email_settings.send_on_rejected && program.conference.email_settings.rejected_body && diff --git a/app/models/payment.rb b/app/models/payment.rb index 5fa61c2f9..74927bcb3 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -13,15 +13,18 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# class Payment < ApplicationRecord has_many :ticket_purchases belongs_to :user belongs_to :conference - attr_accessor :stripe_customer_email, :stripe_customer_token - validates :status, presence: true validates :user_id, presence: true validates :conference_id, presence: true @@ -41,21 +44,82 @@ def stripe_description "Tickets for #{conference.title} #{user.name} #{user.email}" end - def purchase - gateway_response = Stripe::Charge.create source: stripe_customer_token, - receipt_email: stripe_customer_email, - description: stripe_description, - amount: amount_to_pay, - currency: currency - - self.amount = gateway_response[:amount] - self.last4 = gateway_response[:source][:last4] - self.authorization_code = gateway_response[:id] - self.status = 'success' - true + def unpaid_ticket_purchases + user.ticket_purchases.unpaid.by_conference(conference) + end + + def create_checkout_session(success_url:, cancel_url:) + line_items = build_line_items + return nil if line_items.empty? + + session = Stripe::Checkout::Session.create( + payment_method_types: ['card'], + mode: 'payment', + customer_email: user.email, + line_items: line_items, + success_url: success_url, + cancel_url: cancel_url, + metadata: { + payment_id: id, + conference_id: conference_id, + user_id: user_id + } + ) + + update(stripe_session_id: session.id) + session + rescue Stripe::StripeError => e + self.status = 'failure' + save + errors.add(:base, e.message) + nil + end + + def complete_checkout + session = Stripe::Checkout::Session.retrieve( + id: stripe_session_id, + expand: ['payment_intent.latest_charge'] + ) + + if session.payment_status == 'paid' + charge = session.payment_intent&.latest_charge + + self.amount = session.amount_total + self.last4 = charge&.payment_method_details&.card&.last4 + self.authorization_code = session.payment_intent&.id + self.status = 'success' + save + else + self.status = 'failure' + save + false + end rescue Stripe::StripeError => e errors.add(:base, e.message) self.status = 'failure' + save false end + + private + + def build_line_items + unpaid_ticket_purchases.includes(:ticket).map do |tp| + unit_amount = CurrencyConversion.convert_currency( + conference, tp.ticket.price, tp.ticket.price_currency, currency + ).fractional + + { + price_data: { + currency: currency.downcase, + product_data: { + name: tp.title, + description: tp.description.presence || "#{conference.title} - #{tp.title}" + }, + unit_amount: unit_amount + }, + quantity: tp.quantity + } + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index bfa75bb97..664eff87b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -102,7 +102,20 @@ def for_registration(conference) # add scope scope :comment_notifiable, lambda { |conference| - joins(:roles).where('roles.name IN (?)', %i[organizer cfp]).where('roles.resource_type = ? AND roles.resource_id = ?', 'Conference', conference.id) + joins(users_roles: :role) + .where(roles: { resource_type: 'Conference', + resource_id: conference.id, + name: %i[organizer cfp] }) + .where( + Role.arel_table[:name] + .eq('cfp') + .or( + Role.arel_table[:name] + .eq('organizer') + .and(UsersRole.arel_table[:email_notifications].eq(true)) + ) + ) + .distinct } # scopes for user distributions diff --git a/app/models/users_role.rb b/app/models/users_role.rb index 621d42d37..53c755daa 100644 --- a/app/models/users_role.rb +++ b/app/models/users_role.rb @@ -4,9 +4,10 @@ # # Table name: users_roles # -# id :bigint not null, primary key -# role_id :integer -# user_id :integer +# id :bigint not null, primary key +# email_notifications :boolean default(TRUE), not null +# role_id :integer +# user_id :integer # # Indexes # diff --git a/app/services/email_template_parser.rb b/app/services/email_template_parser.rb index 9c74e2d78..1d40942b5 100644 --- a/app/services/email_template_parser.rb +++ b/app/services/email_template_parser.rb @@ -1,5 +1,5 @@ class EmailTemplateParser - def initialize(conference, user) + def initialize(conference, user = nil) @conference = conference @user = user end @@ -7,8 +7,6 @@ def initialize(conference, user) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists def retrieve_values(event = nil, booth = nil, quantity = nil, ticket = nil) h = { - 'email' => @user.email, - 'name' => @user.name, 'conference' => @conference.title, 'conference_start_date' => @conference.start_date, 'conference_end_date' => @conference.end_date, @@ -23,6 +21,10 @@ def retrieve_values(event = nil, booth = nil, quantity = nil, ticket = nil) @conference.short_title, host: Rails.application.routes.default_url_options[:host] ) } + if @user + h['email'] = @user.email + h['name'] = @user.name + end if @conference.program.cfp h['cfp_start_date'] = @conference.program.cfp.start_date h['cfp_end_date'] = @conference.program.cfp.end_date diff --git a/app/services/event_duplicator.rb b/app/services/event_duplicator.rb new file mode 100644 index 000000000..9d4a68df1 --- /dev/null +++ b/app/services/event_duplicator.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class EventDuplicator + def initialize(event, submitter = nil) + @event = event + @submitter = submitter + end + + def duplicate(count = 1) + duplicated_events = [] + @event.class.transaction do + count.times do + duplicated_events << create_duplicate + end + end + duplicated_events + end + + private + + def create_duplicate + duplicate_event = Event.create!( + # Content fields + title: @event.title, + subtitle: @event.subtitle, + abstract: @event.abstract, + description: @event.description, + submission_text: @event.submission_text, + committee_review: @event.committee_review, + proposal_additional_speakers: @event.proposal_additional_speakers, + event_type: @event.event_type, + track: @event.track, + difficulty_level: @event.difficulty_level, + language: @event.language, + presentation_mode: @event.presentation_mode, + require_registration: @event.require_registration, + max_attendees: @event.max_attendees, + program: @event.program, + state: 'new', + progress: @event.progress, + public: @event.public, + is_highlight: @event.is_highlight, + superevent: @event.superevent, + + # Don't copy: start_time, room_id, parent_id (reset to nil) + start_time: nil, + room_id: nil, + parent_id: nil, + + # Submitter + submitter: @submitter + ) + + # Copy speakers and volunteers + duplicate_event.speakers = @event.speakers + duplicate_event.volunteers = @event.volunteers + duplicate_event.save! + + duplicate_event + end +end diff --git a/app/views/admin/conferences/_form_fields.html.haml b/app/views/admin/conferences/_form_fields.html.haml index c5439f1ac..cdd280c4a 100644 --- a/app/views/admin/conferences/_form_fields.html.haml +++ b/app/views/admin/conferences/_form_fields.html.haml @@ -23,10 +23,12 @@ = f.label :description = f.text_area :description, rows: 5, data: { provide: 'markdown' }, class: 'form-control' .help-block= markdown_hint('Splash page content') + = render partial: 'shared/help', locals: { id: 'description_help', show_event_variables: false, show_ticket_variables: false, show_user_variables: false } .form-group = f.label :registered_attendees_message, 'Message for Registered Attendees' = f.text_area :registered_attendees_message, rows: 5, data: { provide: 'markdown' }, class: 'form-control' .help-block= markdown_hint('Splash page content') + = render partial: 'shared/help', locals: { id: 'registered_attendees_help', show_event_variables: false, show_ticket_variables: false, show_user_variables: false } .form-group = f.color_field :color, size: 6, class: 'form-control' %span.help-block diff --git a/app/views/admin/conferences/new.html.haml b/app/views/admin/conferences/new.html.haml index 2d2ea53e2..c2059562b 100644 --- a/app/views/admin/conferences/new.html.haml +++ b/app/views/admin/conferences/new.html.haml @@ -1,4 +1,10 @@ +- if @duplicate_from_source + .row + .col-md-8 + .alert.alert-info + Duplicating from an existing conference. Please set Title, Short title, and Dates for the new conference. .row .col-md-8 = form_for(@conference, url: admin_conferences_path) do |f| + = hidden_field_tag :duplicate_from, @duplicate_from_source if @duplicate_from_source = render partial: 'form_fields', locals: { f: f } diff --git a/app/views/admin/conferences/show.html.haml b/app/views/admin/conferences/show.html.haml index 8d164643f..11821a2e6 100644 --- a/app/views/admin/conferences/show.html.haml +++ b/app/views/admin/conferences/show.html.haml @@ -1,6 +1,7 @@ %h1 %span.fa-solid.fa-gauge-high Dashboard for #{@conference.title} + = link_to 'Duplicate', new_admin_conference_path(duplicate_from: @conference.short_title), class: 'btn btn-primary btn-sm', style: 'margin-left: 12px;' %hr .row .col-sm-3.col-xs-6 diff --git a/app/views/admin/emails/index.html.haml b/app/views/admin/emails/index.html.haml index f60c4889f..9480f2a67 100644 --- a/app/views/admin/emails/index.html.haml +++ b/app/views/admin/emails/index.html.haml @@ -64,6 +64,22 @@ 'data-body-text' => "Dear {name}\n\nWe are very pleased to inform you that your submission {eventtitle} has been accepted for the conference {conference}.\n\nThe public page of your submission can be found at:\n{proposalslink}\nIf you havenĀ“t already registered for {conference}, please do as soon as possible:\n{registrationlink}\n\nFeel free to contact us with any questions or concerns.\n\nWe look forward to seeing you there.\n\nBest wishes\n\n{conference} Team" } Load Template %a.btn.btn-link.control_label.template_help_link{ 'data-name' => 'accepted_help' } Show Help = render partial: 'shared/help', locals: { id: 'accepted_help', show_event_variables: true, show_ticket_variables: false } + .checkbox + %label + = f.check_box :send_on_tentative_accepted, data: { name: 'email_settings_tentative_accepted_subject' }, class: 'send_on_radio' + Send an email to the submitter when the proposal is tentatively accepted? + .form-group + = f.label :tentative_accepted_subject, 'Subject' + = f.text_field :tentative_accepted_subject, class: 'form-control' + .form-group + = f.label :tentative_accepted_body, 'Body' + = f.text_area :tentative_accepted_body, rows: 10, cols: 20, class: 'form-control' + %a.btn.btn-link.control_label.load_template{ 'data-subject-input-id' => 'email_settings_tentative_accepted_subject', + 'data-subject-text' => 'Your submission has been tentatively accepted', + 'data-body-input-id' => 'email_settings_tentative_accepted_body', + 'data-body-text' => "Dear {name}\n\nWe are pleased to inform you that your submission {eventtitle} has been tentatively accepted for the conference {conference}, pending the following changes:\n\n{committee_review}\n\nPlease update your proposal accordingly and let us know when you are ready for final approval.\n\nIf you haven't already registered for {conference}, please do so as soon as possible:\n{registrationlink}\n\nBest wishes\n\n{conference} Team" } Load Template + %a.btn.btn-link.control_label.template_help_link{ 'data-name' => 'tentative_accepted_help' } Show Help + = render partial: 'shared/help', locals: { id: 'tentative_accepted_help', show_event_variables: true, show_ticket_variables: false } .checkbox %label = f.check_box :send_on_rejected, data: { name: 'email_settings_rejected_subject'}, class: 'send_on_radio' diff --git a/app/views/admin/events/_proposal.html.haml b/app/views/admin/events/_proposal.html.haml index 696306856..26d9542ae 100644 --- a/app/views/admin/events/_proposal.html.haml +++ b/app/views/admin/events/_proposal.html.haml @@ -10,6 +10,8 @@ - if @event.public = link_to 'Preview', conference_program_proposal_path(@conference.short_title, @event.id), class: 'btn btn-mini btn-primary' = link_to 'Registrations', registrations_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-success' + %button.btn.btn-mini.btn-info{'data-toggle' => 'modal', 'data-target' => '#duplicateEventModal'} + Duplicate = link_to 'Edit', edit_admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-mini btn-primary' = link_to 'Delete', admin_conference_program_event_path(@conference.short_title, @event), method: :delete, data: { confirm: 'Are you sure you want to delete this event?' }, class: 'btn btn-mini btn-danger' diff --git a/app/views/admin/events/preview_tentative_accept.html.haml b/app/views/admin/events/preview_tentative_accept.html.haml new file mode 100644 index 000000000..4de43531b --- /dev/null +++ b/app/views/admin/events/preview_tentative_accept.html.haml @@ -0,0 +1,28 @@ +%h2 Tentative Acceptance +- if @event.program.conference.email_settings.send_on_tentative_accepted + .alert.alert-success + Email sending is enabled for tentative acceptance. + - if @missing_committee_review + .alert.alert-warning + Committee feedback is required before sending a tentative acceptance email. + %br + Please add committee feedback on the event before continuing. + - else + .panel.panel-default + .panel-heading + %h3 Email preview + .panel-body + .form-group + %label Email subject + %p.form-control-static= @tentative_subject + .form-group + %label Email body + = simple_format(@tentative_body) +- else + .alert.alert-warning + Email sending is currently disabled for tentative acceptance. + += form_with url: tentative_accept_admin_conference_program_event_path(@conference.short_title, @event), method: :patch, local: true do |f| + .form-group + = f.submit 'Tentatively Accept', class: 'btn btn-primary', disabled: @missing_committee_review + = link_to 'Cancel', admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-default' diff --git a/app/views/admin/events/show.html.haml b/app/views/admin/events/show.html.haml index 6cd270117..3a0b42348 100644 --- a/app/views/admin/events/show.html.haml +++ b/app/views/admin/events/show.html.haml @@ -67,3 +67,23 @@ #proposal-commercials.tab-pane = render partial: 'shared/media_items', locals: { commercials: @event.commercials } + +#duplicateEventModal.modal.fade{tabindex: '-1', role: 'dialog', 'aria-labelledby' => 'duplicateEventModalLabel'} + .modal-dialog{role: 'document'} + .modal-content + .modal-header + %button.close{'data-dismiss' => 'modal', 'aria-label' => 'Close'} + %span{'aria-hidden' => 'true'} + × + %h4#duplicateEventModalLabel.modal-title + Duplicate Event + = form_with url: duplicate_admin_conference_program_event_path(@conference.short_title, @event), method: :post, local: true do |f| + .modal-body + .form-group + %label{for: 'duplicate_count'} Number of copies to create: + %input#duplicate_count.form-control{type: 'number', name: 'count', value: '1', min: '1', max: '100', required: true} + %small.form-text.text-muted + You can create up to 100 copies at once. The new events will be created with you as the submitter. + .modal-footer + %button.btn.btn-secondary{'data-dismiss' => 'modal'} Cancel + %button.btn.btn-primary{type: 'submit'} Create Copies diff --git a/app/views/admin/events/tentative_accept.html.haml b/app/views/admin/events/tentative_accept.html.haml new file mode 100644 index 000000000..4de43531b --- /dev/null +++ b/app/views/admin/events/tentative_accept.html.haml @@ -0,0 +1,28 @@ +%h2 Tentative Acceptance +- if @event.program.conference.email_settings.send_on_tentative_accepted + .alert.alert-success + Email sending is enabled for tentative acceptance. + - if @missing_committee_review + .alert.alert-warning + Committee feedback is required before sending a tentative acceptance email. + %br + Please add committee feedback on the event before continuing. + - else + .panel.panel-default + .panel-heading + %h3 Email preview + .panel-body + .form-group + %label Email subject + %p.form-control-static= @tentative_subject + .form-group + %label Email body + = simple_format(@tentative_body) +- else + .alert.alert-warning + Email sending is currently disabled for tentative acceptance. + += form_with url: tentative_accept_admin_conference_program_event_path(@conference.short_title, @event), method: :patch, local: true do |f| + .form-group + = f.submit 'Tentatively Accept', class: 'btn btn-primary', disabled: @missing_committee_review + = link_to 'Cancel', admin_conference_program_event_path(@conference.short_title, @event), class: 'btn btn-default' diff --git a/app/views/admin/roles/_users.html.haml b/app/views/admin/roles/_users.html.haml index bde7856bc..2c2b04a22 100644 --- a/app/views/admin/roles/_users.html.haml +++ b/app/views/admin/roles/_users.html.haml @@ -1,6 +1,6 @@ .page-header - %h3 Users (#{users.length}) -- if users.present? + %h3 Users (#{users_roles.length}) +- if users_roles.present? %table.datatable#users %thead - if ( can? :toggle_user, @role ) @@ -8,15 +8,24 @@ %th ID %th Name %th Email + - if @role.name == 'organizer' && can?(:toggle_user, @role) + %th Email notifications %tbody - - users.each do |user| + - users_roles.each do |users_role| + - user = users_role.user %tr - if ( can? :toggle_user, @role ) %td.text-right = hidden_field_tag "role[user_ids][]", nil - = check_box_tag @conference.short_title, @role.id, (@role.user_ids.include? user.id), url: "#{@url}?user[email]=#{user.email}&user[state]=", method: :post, class: 'switch-checkbox' + = check_box_tag @conference.short_title, @role.id, true, url: "#{@url}?user[email]=#{user.email}&user[state]=", method: :post, class: 'switch-checkbox' %td= user.id %td= user.name %td= user.email + - if @role.name == 'organizer' && can?(:toggle_user, @role) + %td + = check_box_tag 'email_notifications', users_role.id, users_role.email_notifications, + url: "#{@comment_notifications_url}?user[email]=#{user.email}&user[state]=", + method: :post, + class: 'switch-checkbox' - else %h5 No users found! diff --git a/app/views/admin/roles/show.html.haml b/app/views/admin/roles/show.html.haml index 629427fb1..18761ecc2 100644 --- a/app/views/admin/roles/show.html.haml +++ b/app/views/admin/roles/show.html.haml @@ -30,4 +30,4 @@ .row .col-md-12 #users_area - = render partial: 'users', locals: { users: @users } + = render partial: 'users', locals: { users_roles: @users_roles } diff --git a/app/views/admin/roles/toggle_comment_notifications.js.erb b/app/views/admin/roles/toggle_comment_notifications.js.erb new file mode 100644 index 000000000..6c67f5c33 --- /dev/null +++ b/app/views/admin/roles/toggle_comment_notifications.js.erb @@ -0,0 +1 @@ +$(".alert").remove(); diff --git a/app/views/conference_registrations/_registration_info.html.haml b/app/views/conference_registrations/_registration_info.html.haml index a952c067c..36118e9ab 100644 --- a/app/views/conference_registrations/_registration_info.html.haml +++ b/app/views/conference_registrations/_registration_info.html.haml @@ -32,4 +32,5 @@ (Scheduled on: #{event.time.to_date}) %br -= render 'conferences/code_of_conduct', organization: @conference.organization +- unless @conference.code_of_conduct.blank? + = render 'conferences/code_of_conduct', organization: @conference.organization diff --git a/app/views/conferences/_about_and_happening_now.haml b/app/views/conferences/_about_and_happening_now.haml index 05141967d..4017e0238 100644 --- a/app/views/conferences/_about_and_happening_now.haml +++ b/app/views/conferences/_about_and_happening_now.haml @@ -9,8 +9,8 @@ = content_for :about do #about -if @user_registered && conference.registered_attendees_message.present? - = markdown(conference.registered_attendees_message, false) - = markdown(conference.description, false) + = markdown_with_variables(conference.registered_attendees_message, conference, false) + = markdown_with_variables(conference.description, conference, false) %section#about-and-happening-now .container diff --git a/app/views/conferences/_conference_details.html.haml b/app/views/conferences/_conference_details.html.haml index b860d609a..2c87c2df7 100644 --- a/app/views/conferences/_conference_details.html.haml +++ b/app/views/conferences/_conference_details.html.haml @@ -20,7 +20,7 @@ %span>= conference.country_name - unless conference.description.blank? %p - = markdown(truncate(conference.description, length: 1000, separator: "\n", escape: false), escape_html=false) + = markdown_with_variables(truncate(conference.description, length: 1000, separator: "\n", escape: false), conference, escape_html=false) .col-md-2 .btn-group-vertical - if !@conference || @conference != conference diff --git a/app/views/devise/registrations/_form_fields.html.haml b/app/views/devise/registrations/_form_fields.html.haml index 5425d7474..36be44b2e 100644 --- a/app/views/devise/registrations/_form_fields.html.haml +++ b/app/views/devise/registrations/_form_fields.html.haml @@ -1,3 +1,11 @@ +- if defined?(resource) && resource.errors.any? + .alert.alert-danger + %h4 + = pluralize(resource.errors.count, 'error') + prohibited this account from being saved: + %ul + - resource.errors.full_messages.each do |message| + %li= message - unless @user.persisted? .form-group = f.label :username diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 1215e5de7..990f4fd6b 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -6,8 +6,17 @@ %h3.panel-title Sign Up .panel-body - = form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| - = render partial: 'form_fields', locals: { f: f } - - = render partial: 'devise/shared/openid' + - if Feature.active?(:prevent_local_signups) + %p.text-center + Create your account using one of the following services: + = render partial: 'devise/shared/sso_buttons' + %hr + %p.text-muted.text-center + %small + Already have a local account? + = link_to 'Sign in here', new_user_session_path + - else + = form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| + = render partial: 'form_fields', locals: { f: f } + = render partial: 'devise/shared/openid' = render partial: 'devise/shared/help' diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index f2f637d1f..34b320525 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -6,7 +6,20 @@ %h3.panel-title Sign In .panel-body - = form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| - = render partial: 'form_fields', locals: { f: f } - = render partial: 'devise/shared/openid' + - if Feature.active?(:prevent_local_signups) + = render partial: 'devise/shared/sso_buttons' + .row + .col-md-4 + %hr + .col-md-4 + %h4.text-center + or sign in with password + .col-md-4 + %hr + = form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| + = render partial: 'form_fields', locals: { f: f } + - else + = form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| + = render partial: 'form_fields', locals: { f: f } + = render partial: 'devise/shared/openid' = render partial: 'devise/shared/help' diff --git a/app/views/devise/shared/_sso_buttons.html.haml b/app/views/devise/shared/_sso_buttons.html.haml new file mode 100644 index 000000000..a62aa4763 --- /dev/null +++ b/app/views/devise/shared/_sso_buttons.html.haml @@ -0,0 +1,16 @@ +- sso_providers = omniauth_configured.select { |p| [:google, :discourse].include?(p) } +- unless sso_providers.empty? + .sso-buttons + - if sso_providers.include?(:google) + = button_to user_google_omniauth_authorize_path, class: 'btn btn-default btn-lg btn-block sso-btn' do + %i.fa-brands.fa-google + Sign in with Google + - if sso_providers.include?(:discourse) + = button_to user_discourse_omniauth_authorize_path, class: 'btn btn-default btn-lg btn-block sso-btn' do + %span + Sign in with Snap + %em> ! + %p.text-muted.text-center + %small + If you are not currently logged into Snap! or the Snap! Forums, you will need to log in + twice when using your Snap! account. diff --git a/app/views/layouts/_snapcon_nav.haml b/app/views/layouts/_snapcon_nav.haml index 82dc820ed..00c076cf7 100644 --- a/app/views/layouts/_snapcon_nav.haml +++ b/app/views/layouts/_snapcon_nav.haml @@ -9,9 +9,5 @@ All Events %b.caret %ul.dropdown-menu - - visible_conference_links.each_with_index do |(org, confs), index| - %li.dropdown-header= org.name - - confs.sort_by(&:start_date).each do |conf| - %li= link_to(conf.title, conference_path(conf.short_title)) - - if index < visible_conference_links.length - 1 - %li.divider + - visible_conference_links.each do |conf| + %li= link_to(conf.title, conference_path(conf.short_title)) diff --git a/app/views/payments/_payment.html.haml b/app/views/payments/_payment.html.haml index 76544211e..41c6ba5f8 100644 --- a/app/views/payments/_payment.html.haml +++ b/app/views/payments/_payment.html.haml @@ -1,31 +1,20 @@ -.div - .col-md-12.table-responsive - %table.table.table-hover.table-striped - %thead - %tr - %th Ticket - %th Quantity - %th Price - %th Total - %tbody +.table-responsive + %table.table.table-hover.table-striped + %thead + %tr + %th Ticket + %th Quantity + %th Price + %th Total + %tbody - @unpaid_ticket_purchases.each do |ticket| %tr - %td - = ticket.title - %td - = ticket.quantity - %td - = humanized_money_with_symbol ticket.purchase_price - %td - = humanized_money_with_symbol ticket.purchase_price * ticket.quantity -= form_tag conference_payments_path(@conference.short_title, :has_registration_ticket => @has_registration_ticket) do - %script.stripe-button{ src: "https://checkout.stripe.com/checkout.js", - data: { amount: @total_amount_to_pay.cents, - label: "Pay #{humanized_money_with_symbol @total_amount_to_pay}", - email: current_user.email, - currency: @currency, - name: ENV.fetch('OSEM_NAME', 'OSEM'), - description: "#{@conference.title} tickets", - key: ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key, - locale: "auto"}} - = link_to 'Edit Purchase', conference_tickets_path(@conference.short_title), class: 'btn btn-default' + %td= ticket.title + %td= ticket.quantity + %td= humanized_money_with_symbol ticket.purchase_price + %td= humanized_money_with_symbol ticket.purchase_price * ticket.quantity += form_tag conference_payments_path(@conference.short_title), method: :post do + = hidden_field_tag :has_registration_ticket, @has_registration_ticket + .payment-actions + = link_to 'Edit Purchase', conference_tickets_path(@conference.short_title), class: 'btn btn-default' + = submit_tag "Pay #{humanized_money_with_symbol @total_amount_to_pay}", class: 'btn btn-success' diff --git a/app/views/payments/new.html.haml b/app/views/payments/new.html.haml index 91a779f08..2e9f7b6e2 100644 --- a/app/views/payments/new.html.haml +++ b/app/views/payments/new.html.haml @@ -3,7 +3,7 @@ .col-xs-6.col-xs-offset-3 %h1 Payment Summary : - = humanized_money_with_symbol Money.new(@total_amount_to_pay) + = humanized_money_with_symbol @total_amount_to_pay .col-xs-8.col-xs-offset-2.well = render partial: 'payment' .row @@ -18,3 +18,4 @@ %small All payments are handled securely by our payment processor, = link_to 'Stripe', 'https://stripe.com', target: '_blank' + \. You will be redirected to Stripe's secure checkout page to complete your payment. diff --git a/app/views/shared/_help.html.haml b/app/views/shared/_help.html.haml index 8bf1ffc33..2ac9c3881 100644 --- a/app/views/shared/_help.html.haml +++ b/app/views/shared/_help.html.haml @@ -1,12 +1,13 @@ .template-help{ id: id } Valid attributes: %table.table - %tr - %td {email} - %td The user's email address - %tr - %td {name} - %td The user's full name + - if local_assigns.fetch(:show_user_variables, true) + %tr + %td {email} + %td The user's email address + %tr + %td {name} + %td The user's full name %tr %td {conference} %td The full conference title diff --git a/config/initializers/feature.rb b/config/initializers/feature.rb index dc374eb2c..46b7f7b0d 100644 --- a/config/initializers/feature.rb +++ b/config/initializers/feature.rb @@ -4,5 +4,6 @@ # configure features here repo.add_active_feature :recaptcha unless ENV['RECAPTCHA_SITE_KEY'].blank? || ENV['RECAPTCHA_SECRET_KEY'].blank? +repo.add_active_feature :prevent_local_signups if ENV['PREVENT_NEW_LOCAL_PASSWORDS'].present? Feature.set_repository repo diff --git a/config/puma.rb b/config/puma.rb index 69424aac8..93ad7c872 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -27,13 +27,13 @@ worker_count = ENV.fetch('WEB_CONCURRENCY') { Gem.win_platform? ? 0 : 2 }.to_i workers worker_count # Set a 10 minute timeout in development for debugging. -worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' && worker_count > 0 +worker_timeout 60 * 60 * 10 if ENV.fetch('RAILS_ENV') == 'development' && worker_count.positive? # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write # process behavior so workers use less memory. -preload_app! if worker_count > 0 +preload_app! if worker_count.positive? lowlevel_error_handler do |ex, env| Sentry.capture_exception( diff --git a/config/routes.rb b/config/routes.rb index 62ac3dfdd..141a5d72a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,12 +113,15 @@ get :registrations post :comment patch :accept + get :preview_tentative_accept + patch :tentative_accept patch :confirm patch :cancel patch :reject patch :unconfirm patch :restart get :vote + post :duplicate end end resources :reports, only: :index @@ -139,6 +142,7 @@ resources :roles, except: %i[new create] do member do post :toggle_user + post :toggle_comment_notifications end end @@ -208,7 +212,12 @@ resource :conference_registration, path: 'register' resources :tickets, only: [:index] resources :ticket_purchases, only: %i[create destroy index] - resources :payments, only: %i[index new create] + resources :payments, only: %i[index new create] do + collection do + get :success + get :cancel + end + end resources :physical_tickets, only: %i[index show] resource :subscriptions, only: %i[create destroy] resource :schedule, only: [:show] do diff --git a/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb new file mode 100644 index 000000000..4b63911bf --- /dev/null +++ b/db/migrate/20260305000000_add_stripe_session_id_to_payments.rb @@ -0,0 +1,6 @@ +class AddStripeSessionIdToPayments < ActiveRecord::Migration[7.0] + def change + add_column :payments, :stripe_session_id, :string + add_index :payments, :stripe_session_id, unique: true + end +end diff --git a/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb b/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb new file mode 100644 index 000000000..b910699d3 --- /dev/null +++ b/db/migrate/20260320100000_add_email_notifications_to_users_roles.rb @@ -0,0 +1,5 @@ +class AddEmailNotificationsToUsersRoles < ActiveRecord::Migration[7.0] + def change + add_column :users_roles, :email_notifications, :boolean, default: true, null: false + end +end diff --git a/db/migrate/20260410133000_add_tentative_accepted_to_email_settings.rb b/db/migrate/20260410133000_add_tentative_accepted_to_email_settings.rb new file mode 100644 index 000000000..ddea16f14 --- /dev/null +++ b/db/migrate/20260410133000_add_tentative_accepted_to_email_settings.rb @@ -0,0 +1,7 @@ +class AddTentativeAcceptedToEmailSettings < ActiveRecord::Migration[5.0] + def change + add_column :email_settings, :send_on_tentative_accepted, :boolean, default: false + add_column :email_settings, :tentative_accepted_subject, :string + add_column :email_settings, :tentative_accepted_body, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 18d82ff98..18ee78714 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_01_042356) do +ActiveRecord::Schema[7.0].define(version: 2026_04_10_133000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -208,6 +208,9 @@ t.boolean "send_on_submitted_proposal", default: false t.string "submitted_proposal_subject" t.text "submitted_proposal_body" + t.boolean "send_on_tentative_accepted", default: false + t.string "tentative_accepted_subject" + t.text "tentative_accepted_body" end create_table "event_schedules", force: :cascade do |t| @@ -332,6 +335,8 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "currency" + t.string "stripe_session_id" + t.index ["stripe_session_id"], name: "index_payments_on_stripe_session_id", unique: true end create_table "physical_tickets", force: :cascade do |t| @@ -638,6 +643,7 @@ create_table "users_roles", force: :cascade do |t| t.integer "role_id" t.integer "user_id" + t.boolean "email_notifications", default: true, null: false t.index ["user_id", "role_id"], name: "index_users_roles_on_user_id_and_role_id" end diff --git a/dotenv.example b/dotenv.example index 372800ec9..a78e07054 100644 --- a/dotenv.example +++ b/dotenv.example @@ -19,7 +19,7 @@ # OSEM_DB_PORT=3306 # The user to access the database with -# OSEM_DB_USER=root +OSEM_DB_USER=root # The password to access the database with # OSEM_DB_PASSWORD=mysecretpassword @@ -102,6 +102,10 @@ # RECAPTCHA_SITE_KEY=1234 # RECAPTCHA_SECRET_KEY=5678 +# Prevent new local password sign-ups (force SSO via Google/Snap!) +# Set to any value to enable +# PREVENT_NEW_LOCAL_PASSWORDS=true + # The Conference#short_title to redirect the root URL to # OSEM_ROOT_CONFERENCE=osc18 diff --git a/info.yml b/info.yml index 494e6be19..a3da935a7 100644 --- a/info.yml +++ b/info.yml @@ -1,27 +1,24 @@ project: - name: 'snapcon' # Your project name, e.g. Cue-to-cue - owner: 'CS169L-26' # Do not change - teamId: '04' # Your team number, e.g. 02 - identities: - heroku: 'https://sp26-04-snapcon.herokuapp.com' # Your Heroku app URL + name: 'Snapcon' + owner: 'CS169L-Sp26' + teamId: 'CS169L-Sp26' + identities: {} + notifications: + email: 'ethanstone@berkeley.edu,bcsikes@berkeley.edu,zhouyijun@berkeley.edu,xinweili@berkeley.edu' members: - member1: # Add all project members - name: 'Ethan' # Member 1 name - surname: 'Stone' # Member 1 last name - githubUsername: 'Ethan-Stone1' # Member 1 GitHub username - herokuEmail: 'ethanstone@berkeley.edu' # Member 1 Heroku username + member1: + name: 'Xinwei' + surname: 'Li' + githubUsername: 'li-xinwei' member2: - name: 'Benjamin' - surname: 'Sikes' - githubUsername: 'sikesbc' - herokuEmail: 'bcsikes@berkeley.edu' - member3: name: 'Yijun' surname: 'Zhou' githubUsername: 'zhouyijun111' - herokuEmail: 'zhouyijun@berkeley.edu' + member3: + name: 'Ethan' + surname: 'Stone' + githubUsername: 'Ethan-Stone1' member4: - name: 'Xinwei' - surname: 'Li' - githubUsername: 'li-xinwei' - herokuEmail: 'xinweili@berkeley.edu' \ No newline at end of file + name: 'Benjamin' + surname: 'Sikes' + githubUsername: 'sikesbc' diff --git a/lib/tasks/ben_demo.rake b/lib/tasks/ben_demo.rake new file mode 100644 index 000000000..ff8d4752d --- /dev/null +++ b/lib/tasks/ben_demo.rake @@ -0,0 +1,624 @@ +# frozen_string_literal: true + +namespace :data do + desc 'Seed Ben Demo Conference 2026 for live class demo of conference + event duplication feature' + task ben_demo: :environment do + short_title = 'bendemo25' + + existing = Conference.find_by(short_title: short_title) + if existing + puts "Destroying existing '#{short_title}' conference to regenerate..." + Role.where(resource: existing).delete_all + existing.destroy! + end + + puts 'Creating Ben Demo Conference 2026...' + + # --- Organization --- + org = Organization.first_or_create!(name: 'Demo Organization') + puts "Using organization: #{org.name}" + + # --- Conference --- + conference = Conference.create!( + title: 'Ben Demo Conference 2026', + short_title: short_title, + organization: org, + start_date: Date.new(2026, 10, 14), + end_date: Date.new(2026, 10, 16), + start_hour: 9, + end_hour: 18, + timezone: 'America/Los_Angeles', + description: 'Ben Demo Conference is a three-day gathering for software engineers, ' \ + 'architects, and technical leaders focused on modern web development, ' \ + 'distributed systems, and applied machine learning. ' \ + 'Attendees leave with practical techniques they can apply the following Monday.' + ) + puts "Created conference: #{conference.title} (id=#{conference.id})" + + # --- Venue + Rooms --- + venue = Venue.create!( + conference: conference, + name: 'Berkeley Convention Center', + street: '2026 Center St', + city: 'Berkeley', + country: 'US', + postalcode: '94704' + ) + puts "Created venue: #{venue.name}" + + room_data = [ + { name: 'Main Auditorium', size: 800, order: 1 }, + { name: 'Hall A', size: 300, order: 2 }, + { name: 'Hall B', size: 300, order: 3 }, + { name: 'Workshop Room 1', size: 80, order: 4 }, + { name: 'Workshop Room 2', size: 80, order: 5 }, + { name: 'Networking Lounge', size: 200, order: 6 } + ] + + rooms = room_data.map do |r| + room = venue.rooms.create!(name: r[:name], size: r[:size], order: r[:order]) + puts " Created room: #{room.name}" + room + end + + main_hall, hall_a, hall_b, workshop1, workshop2, _lounge = rooms + + # --- Program --- + program = conference.program + puts "Program id=#{program.id}" + + # --- CFP --- + cfp = Cfp.create!( + program: program, + cfp_type: 'events', + start_date: Date.new(2026, 6, 1), + end_date: Date.new(2026, 8, 15), + description: 'We welcome proposals for talks (30 min) and workshops (90 min) on ' \ + 'frontend engineering, backend systems, AI/ML in production, and DevOps.' + ) + puts "Created CFP (events): #{cfp.start_date} - #{cfp.end_date}" + + # --- Event Types --- + talk_type = program.event_types.create!( + title: 'Talk', + length: 30, + color: '#1F77B4', + minimum_abstract_length: 100, + maximum_abstract_length: 1000 + ) + workshop_type = program.event_types.create!( + title: 'Workshop', + length: 90, + color: '#FF7F0E', + minimum_abstract_length: 100, + maximum_abstract_length: 1000 + ) + puts "Created event types: Talk, Workshop" + + # --- Tracks --- + # Colors must be unique per program. State is set to 'confirmed' via update_column + # after create so the valid_track validation on Event accepts them. + # Using AASM transitions would require a submitter for the confirm callback, + # which does not apply to organizer-created tracks. + track_data = [ + { name: 'Frontend Engineering', short_name: 'frontend', color: '#1F77B4' }, + { name: 'Backend Systems', short_name: 'backend', color: '#2CA02C' }, + { name: 'AI & Machine Learning', short_name: 'aiml', color: '#9467BD' }, + { name: 'DevOps & Infrastructure', short_name: 'devops', color: '#D62728' } + ] + + tracks = track_data.map do |t| + track = program.tracks.create!( + name: t[:name], + short_name: t[:short_name], + color: t[:color], + cfp_active: false, + state: 'new' + ) + track.update_column(:state, 'confirmed') + puts " Created track: #{track.name} (confirmed)" + track + end + + fe_track, be_track, ai_track, ops_track = tracks + + # --- Speakers (users) --- + speaker_data = [ + { name: 'Priya Mehta', email: 'priya.mehta@bendemo25.example.com', username: 'priyamehta_bd25' }, + { name: 'Carlos Rivera', email: 'carlos.rivera@bendemo25.example.com', username: 'crivera_bd25' }, + { name: 'Aisha Okonkwo', email: 'aisha.okonkwo@bendemo25.example.com', username: 'aokonkwo_bd25' }, + { name: 'James Whitfield', email: 'james.whitfield@bendemo25.example.com',username: 'jwhitfield_bd25' }, + { name: 'Mei-Ling Zhang', email: 'meiling.zhang@bendemo25.example.com', username: 'mlzhang_bd25' }, + { name: 'Dmitri Volkov', email: 'dmitri.volkov@bendemo25.example.com', username: 'dvolkov_bd25' }, + { name: 'Sara Lindqvist', email: 'sara.lindqvist@bendemo25.example.com', username: 'slindqvist_bd25' }, + { name: 'Kofi Asante', email: 'kofi.asante@bendemo25.example.com', username: 'kasante_bd25' } + ] + + speakers = speaker_data.map do |s| + user = User.find_or_initialize_by(email: s[:email]) + if user.new_record? + user.name = s[:name] + user.username = s[:username] + user.password = SecureRandom.hex(16) + user.skip_confirmation! + user.save! + end + puts " Speaker: #{user.name}" + user + end + + priya, carlos, aisha, james, meiling, dmitri, sara, kofi = speakers + + # --- Events --- + # Helper: create an event with speaker and schedule in one shot. + # The before_end_of_conference validation runs on :create and checks + # Date.today > conference.end_date. Conference end_date is 2026-10-16, + # so any create before that date will pass. + # The valid_track validation requires track.confirmed? -- ensured above. + # Abstracts must be >= 100 words per the event type minimum_abstract_length. + def make_event(program:, title:, subtitle:, abstract:, event_type:, track:, state: 'confirmed') + program.events.create!( + title: title, + subtitle: subtitle, + abstract: abstract, + event_type: event_type, + track: track, + state: state, + language: 'English' + ) + end + + events_list = [] + + events_list << make_event( + program: program, + title: 'React Server Components in Production: Lessons from Six Months', + subtitle: 'Moving beyond the hype to real-world performance wins', + abstract: "React Server Components promised to simplify data-fetching and shrink client bundles. " \ + "After six months running RSC in production at scale, we have real numbers to share. " \ + "This talk walks through our migration from a traditional single-page application, the " \ + "gotchas we hit with streaming hydration, and the performance improvements we measured " \ + "across three different page types. We reduced initial JavaScript payload by 38 percent " \ + "and improved Largest Contentful Paint by more than 700 milliseconds on median hardware.\n\n" \ + "We will cover our deployment architecture, how we structured the client and server " \ + "boundary, the caching strategy that surprised us, and the tooling we built to catch " \ + "accidental client-side data leaks before they reached production. Attendees will leave " \ + "with a concrete migration checklist and the war stories that informed it.", + event_type: talk_type, + track: fe_track + ) + + events_list << make_event( + program: program, + title: 'CSS Container Queries: The Composable Layout Revolution', + subtitle: 'Writing components that respond to their context, not the viewport', + abstract: "For years we wrote breakpoints against the viewport even though components live inside " \ + "containers of all shapes and sizes. Container queries finally let us write truly portable " \ + "UI. This session dives deep into @container, inline-size units, style queries, and the " \ + "new :has() pseudo-class that unlocks parent-aware styling without JavaScript.\n\n" \ + "We will refactor a real production design system live on stage, replacing a tangle of " \ + "media-query overrides with clean container-aware components. Along the way we will discuss " \ + "browser support timelines, progressive enhancement strategies for older browsers, and the " \ + "performance characteristics of container queries compared to resize observer approaches. " \ + "Expect plenty of before-and-after comparisons and a take-home demo repository you can " \ + "fork and use immediately.", + event_type: talk_type, + track: fe_track + ) + + events_list << make_event( + program: program, + title: 'State Machines for UI: Eliminating Impossible States', + subtitle: 'Using XState and statechart theory to tame complex async flows', + abstract: "Async UIs accumulate impossible states faster than any other part of a frontend. " \ + "Loading and error simultaneously. Data present but stale but also refreshing. A button " \ + "that is disabled for three different reasons at once. This talk introduces statechart-driven " \ + "UI as a discipline, not just a library choice. The insight is simple: if you cannot draw " \ + "the state machine, you do not understand the feature yet.\n\n" \ + "We will model three real-world components in XState: a multi-step checkout form, an " \ + "optimistic list with undo, and a real-time collaboration indicator. Then we will show how " \ + "the model drives unit tests, accessibility attributes, and Storybook stories almost " \ + "automatically. No XState experience required; statechart intuition is all you need.", + event_type: talk_type, + track: fe_track + ) + + events_list << make_event( + program: program, + title: 'Building Resilient APIs with Rate Limiting and Backpressure', + subtitle: 'Protecting your services without frustrating your callers', + abstract: "Rate limiting is easy to add and easy to get wrong. Naive fixed-window counters create " \ + "thundering herd problems at window resets. Leaky buckets under-protect during legitimate " \ + "traffic bursts. This talk surveys the algorithmic options available today: token bucket, " \ + "sliding log, and GCRA, explaining when each is the right choice and what the failure modes " \ + "look like under load.\n\n" \ + "We will then look at the caller side of the problem: how services should handle 429 " \ + "responses, implement exponential backoff with jitter, and propagate backpressure through " \ + "async queues without cascading failures. Real Nginx, Redis, and Sidekiq configurations are " \ + "included. Attendees will leave with a decision framework and copy-paste-ready config " \ + "snippets for the most common stack combinations.", + event_type: talk_type, + track: be_track + ) + + events_list << make_event( + program: program, + title: 'PostgreSQL Query Planner Deep Dive', + subtitle: 'Understanding EXPLAIN ANALYZE output to stop guessing about slow queries', + abstract: "Most engineers treat the query planner as a black box and sprinkle indexes hopefully. " \ + "This session demystifies EXPLAIN ANALYZE output line by line: what sequential scan versus " \ + "index scan actually means, when a nested loop beats a hash join, and why table statistics " \ + "matter more than indexes in many cases.\n\n" \ + "We will optimize a curated set of pathological real-world queries live on stage, covering " \ + "partial indexes, expression indexes, covering indexes, and the pg_stat_statements view that " \ + "shows you which queries hurt the most. You will leave knowing exactly which knob to turn " \ + "the next time a query is slow, and with enough mental model to read a query plan without " \ + "reaching for a tutorial.", + event_type: talk_type, + track: be_track + ) + + events_list << make_event( + program: program, + title: 'Event Sourcing Without the Ceremony', + subtitle: 'Capturing intent and history in a Rails app without an event-sourcing framework', + abstract: "Event sourcing carries a reputation for complexity: CQRS projections, eventual consistency, " \ + "saga orchestrators, and a complete rethink of your data model. But the core idea behind " \ + "event sourcing is simpler than its reputation: record what happened, not just the final " \ + "state. That idea can be adopted incrementally in a plain Rails application without a " \ + "framework and without a big-bang rewrite.\n\n" \ + "This talk presents a pragmatic subset: append-only domain events stored alongside existing " \ + "ActiveRecord models, a lightweight projection pattern for building read models, and a " \ + "migration strategy that keeps the existing system running the whole time. Three production " \ + "case studies show the tradeoffs you will actually face, not the idealized version from " \ + "conference talks.", + event_type: talk_type, + track: be_track + ) + + events_list << make_event( + program: program, + title: 'gRPC at the Edge: Streaming APIs for Real-Time Collaboration', + subtitle: 'Replacing polling with bidirectional streams in a multi-tenant SaaS product', + abstract: "Our collaborative editing feature started with polling every two seconds. It ended with " \ + "gRPC bidirectional streaming, a fanout service in Go, and latency measured in single-digit " \ + "milliseconds instead of seconds. This talk narrates that journey honestly, including the " \ + "dead ends we hit and the weeks we lost to load-balancer configuration.\n\n" \ + "Topics include Protobuf schema design for change feeds, the challenge of load-balancing " \ + "long-lived streams behind a standard application load balancer, and the surprising " \ + "operational differences between unary and streaming RPCs in production. Envoy proxy " \ + "configuration snippets and a comparison of client-side reconnect strategies are included. " \ + "Attendees building collaborative or real-time features will leave with a realistic picture " \ + "of what this migration actually costs.", + event_type: talk_type, + track: be_track + ) + + events_list << make_event( + program: program, + title: 'Practical RAG: Production Lessons from a Year of LLM Apps', + subtitle: 'What retrieval-augmented generation actually looks like at scale', + abstract: "Retrieval-Augmented Generation is the first architectural pattern most teams reach for " \ + "when adding LLMs to an existing product. After a year of running RAG in production across " \ + "three different domains: legal documents, customer support transcripts, and internal " \ + "knowledge bases, we have accumulated hard-won lessons about chunking strategies, embedding " \ + "model selection, reranking, and evaluation.\n\n" \ + "This talk shares what worked, what did not, and the evaluations we wish we had written on " \ + "day one. We will cover infrastructure choices including pgvector versus Pinecone versus " \ + "Weaviate, explain when those tradeoffs actually matter versus when they are premature " \ + "optimization, and walk through the failure modes that only appear at scale. Concrete " \ + "benchmarks and a reference architecture diagram are included.", + event_type: talk_type, + track: ai_track + ) + + events_list << make_event( + program: program, + title: 'Fine-Tuning vs Prompting: When to Pay the Training Tax', + subtitle: 'A decision framework for teams with limited ML resources', + abstract: "Every team running an LLM-powered feature faces the same question eventually: should we " \ + "fine-tune, or can clever prompting get us there? The honest answer depends on data volume, " \ + "latency requirements, cost targets, and how stable the task definition is. This talk lays " \ + "out a structured decision framework built from real production decisions.\n\n" \ + "We walk through three cases where we made the call in different directions: one where " \ + "prompting was sufficient and fine-tuning would have been waste, one where fine-tuning " \ + "unlocked a 40 percent quality improvement that no prompt engineering could match, and one " \ + "where we started with prompting and migrated later. We cover LoRA adapters, DPO for " \ + "preference alignment, and the evaluation harness that made each comparison statistically " \ + "defensible.", + event_type: talk_type, + track: ai_track + ) + + events_list << make_event( + program: program, + title: 'Responsible AI Deployment: Guardrails That Actually Work', + subtitle: 'Moving beyond content filters to robust, testable safety pipelines', + abstract: "Bolting a content filter onto an LLM API call is not a safety strategy. It is a " \ + "checkbox. This talk presents a layered approach to responsible AI deployment that treats " \ + "safety as an engineering discipline: input validation, output schema enforcement, semantic " \ + "guardrails, confidence thresholds, and human-in-the-loop escalation paths with measurable " \ + "coverage.\n\n" \ + "We examine each layer through the lens of testability. How do you write a regression test " \ + "for a guardrail? How do you know your safety pipeline has not regressed after a model " \ + "upgrade? We share the incident post-mortems that drove each addition to our pipeline and " \ + "the metrics we now track in production. Code examples are in Python and designed to be " \ + "tooling-agnostic so you can apply them regardless of which LLM provider you use.", + event_type: talk_type, + track: ai_track + ) + + events_list << make_event( + program: program, + title: 'Kubernetes Cost Optimization: From $40k to $18k per Month', + subtitle: 'Rightsizing, spot instances, and autoscaling without oncall nightmares', + abstract: "Cloud bills have a way of sneaking up on engineering teams, and Kubernetes makes it " \ + "surprisingly easy to over-provision at every layer. We reduced our monthly Kubernetes " \ + "spend by 55 percent over six months without a single production incident caused by the " \ + "cost work. This talk explains exactly how we did it and what we would do differently.\n\n" \ + "We cover VPA and KEDA for rightsizing and event-driven autoscaling, our phased strategy " \ + "for migrating stateless workloads to spot and preemptible instances with graceful drain " \ + "handling, and the observability setup using Kubecost and custom Grafana dashboards that " \ + "made every decision visible to both engineering and finance. The session ends with a " \ + "prioritized checklist of optimizations ordered by typical impact versus implementation " \ + "effort.", + event_type: talk_type, + track: ops_track + ) + + events_list << make_event( + program: program, + title: 'GitOps in Practice: ArgoCD Patterns for Multi-Tenant Clusters', + subtitle: 'Structuring policy, tenancy, and continuous delivery at scale', + abstract: "GitOps promises a single source of truth for cluster state, but the path from one " \ + "cluster and one team to many clusters and many teams reveals surprising complexity. " \ + "This talk shares the ArgoCD patterns we have settled on after two years of operating " \ + "a multi-tenant Kubernetes platform serving more than thirty product teams.\n\n" \ + "Topics include ApplicationSet generators for zero-touch tenant onboarding, the tradeoffs " \ + "between the app-of-apps pattern and flat repository layouts, RBAC design that does not " \ + "require a deep background in OPA to understand or maintain, and our drift-detection and " \ + "remediation runbook. We also discuss the cultural changes that made GitOps stick after " \ + "previous attempts with Helm failed to gain adoption.", + event_type: talk_type, + track: ops_track + ) + + events_list << make_event( + program: program, + title: 'OpenTelemetry: Distributed Tracing Across a Polyglot Stack', + subtitle: 'Instrumenting Ruby, Go, and Python services with a single observability backend', + abstract: "Distributed tracing sounds straightforward until you have services in three languages, " \ + "two message brokers, and a legacy monolith that predates modern APM tooling. This talk " \ + "walks through our OpenTelemetry adoption across a real heterogeneous system: auto- " \ + "instrumentation where it works, manual spans where it does not, and the collector " \ + "pipeline that fans out to both Jaeger and our commercial APM vendor.\n\n" \ + "We pay special attention to Rails and Sidekiq instrumentation quirks, baggage propagation " \ + "across async queue boundaries, and the Grafana dashboards we built to surface P99 latency " \ + "regressions before customers notice them. Attendees will leave with an instrumentation " \ + "checklist and an understanding of where OpenTelemetry still requires manual effort " \ + "despite the promise of automatic instrumentation.", + event_type: talk_type, + track: ops_track + ) + + # Two workshops + events_list << make_event( + program: program, + title: 'Hands-On: Building Your First RAG Pipeline', + subtitle: 'From a pile of PDFs to a working Q&A system in 90 minutes', + abstract: "In this hands-on workshop you will build a complete retrieval-augmented generation " \ + "pipeline from scratch. Starting with raw PDF documents, you will chunk and embed them " \ + "into a pgvector database, wire up a retrieval step that selects relevant passages, and " \ + "query an LLM with grounded context instead of relying on training data alone.\n\n" \ + "Prerequisites: Python 3.11 or newer, a laptop, and an OpenAI API key (free tier is " \ + "sufficient). All starter code is provided and we will walk through it together. By the " \ + "end of the session you will have a working system you understand end-to-end, hands-on " \ + "experience debugging retrieval quality issues, and the mental model needed to adapt the " \ + "pipeline to your own data sources. No prior LLM experience is required.", + event_type: workshop_type, + track: ai_track + ) + + events_list << make_event( + program: program, + title: 'Workshop: Zero-Downtime Deployments with Kamal and Health Checks', + subtitle: 'Configure rolling deploys, readiness probes, and automated rollbacks', + abstract: "Kamal makes Docker-based zero-downtime deploys accessible to teams that do not have a " \ + "Kubernetes budget or want the operational simplicity of deploying directly to VMs. In " \ + "this workshop we will deploy a sample Rails application to a pair of cloud VMs, configure " \ + "Traefik health checks that prevent bad releases from going live, implement database " \ + "migration strategies that avoid table locks, and trigger an automated rollback.\n\n" \ + "You will work in pairs on pre-provisioned VMs. Bring a laptop with Docker installed. " \ + "We will cover the Kamal configuration file in detail, discuss secrets management " \ + "options, and walk through the deploy lifecycle from image build to traffic cutover. " \ + "The session ends with a war-game exercise where attendees attempt to break their " \ + "partner's deploy pipeline while the other defends it.", + event_type: workshop_type, + track: ops_track + ) + + events_list << make_event( + program: program, + title: 'Accessibility Testing as Part of CI: No More Last-Minute Audits', + subtitle: 'Integrating axe-core, VoiceOver testing, and keyboard navigation checks into your pipeline', + abstract: "Accessibility is consistently pushed to the end of projects and then rushed or cut " \ + "entirely. The fix is not more awareness training or better intentions: it is automation " \ + "that catches the majority of issues before code review so that humans can focus on the " \ + "problems that require judgment.\n\n" \ + "This talk presents a layered accessibility testing strategy built from tools available " \ + "today in any JavaScript project. We cover axe-core integrated into Jest and Playwright " \ + "tests, snapshot testing for ARIA trees that catches regressions in screen reader " \ + "experience, keyboard navigation test helpers that simulate tab order without a real " \ + "browser, and the color-contrast linter that catches design-token regressions at the " \ + "source. Every technique shown has a corresponding CI configuration you can copy and " \ + "adapt to your own pipeline today.", + event_type: talk_type, + track: fe_track + ) + + puts "Created #{events_list.size} events" + + # --- Assign speakers to events --- + speaker_assignments = [ + [events_list[0], priya, 'speaker'], + [events_list[1], carlos, 'speaker'], + [events_list[2], aisha, 'speaker'], + [events_list[3], james, 'speaker'], + [events_list[4], meiling, 'speaker'], + [events_list[5], dmitri, 'speaker'], + [events_list[6], james, 'speaker'], + [events_list[6], sara, 'speaker'], # co-speaker + [events_list[7], aisha, 'speaker'], + [events_list[8], kofi, 'speaker'], + [events_list[9], meiling, 'speaker'], + [events_list[10], dmitri, 'speaker'], + [events_list[11], carlos, 'speaker'], + [events_list[12], sara, 'speaker'], + [events_list[13], priya, 'speaker'], + [events_list[14], kofi, 'speaker'], + [events_list[15], james, 'speaker'], + [events_list[15], aisha, 'speaker'] # co-speaker + ] + + speaker_assignments.each do |ev, user, role| + EventUser.create!(event: ev, user: user, event_role: role) + end + puts "Assigned speakers to events" + + # --- Schedule --- + # Create a schedule and assign it as selected + schedule = Schedule.create!(program: program) + program.update_column(:selected_schedule_id, schedule.id) + puts "Created schedule id=#{schedule.id}, set as selected" + + # Slot each event into a room + time. Three days, no double-booking. + # Day 0 = Oct 14, Day 1 = Oct 15, Day 2 = Oct 16 + # Format: [event_index, day_offset, start_hour, room] + slot_assignments = [ + # Day 1 - Oct 14 + [0, 0, 9, main_hall], # React Server Components + [3, 0, 9, hall_a], # Resilient APIs + [10, 0, 9, hall_b], # K8s Cost Optimization + [1, 0, 10, main_hall], # CSS Container Queries + [4, 0, 10, hall_a], # Postgres Query Planner + [11, 0, 10, hall_b], # GitOps ArgoCD + [13, 0, 11, workshop1], # RAG Workshop (90 min) + [2, 0, 13, main_hall], # State Machines for UI + [5, 0, 13, hall_a], # Event Sourcing + [12, 0, 13, hall_b], # OpenTelemetry + [15, 0, 14, workshop2], # Accessibility in CI + # Day 2 - Oct 15 + [7, 1, 9, main_hall], # Practical RAG + [6, 1, 9, hall_a], # gRPC streaming + [8, 1, 10, main_hall], # Fine-Tuning vs Prompting + [9, 1, 11, main_hall], # Responsible AI + [14, 1, 11, workshop1], # Kamal Workshop (90 min) + ] + + base_date = conference.start_date + + slot_assignments.each do |ev_idx, day_offset, hour, room| + event = events_list[ev_idx] + start_time = (base_date + day_offset.days).to_time.utc.change(hour: hour) + EventSchedule.create!( + event: event, + schedule: schedule, + room: room, + start_time: start_time + ) + end + puts "Scheduled #{slot_assignments.size} events across 2 days" + + # --- Tickets --- + # Conference#after_create creates one free registration ticket automatically. + # Add paid tiers on top. + conference.tickets.create!( + title: 'Early Bird', + description: 'Discounted rate for attendees who register before August 1, 2026.', + price_cents: 14_900, + price_currency: 'USD', + registration_ticket: false, + visible: true + ) + conference.tickets.create!( + title: 'Regular', + description: 'Standard conference admission including all sessions and meals.', + price_cents: 29_900, + price_currency: 'USD', + registration_ticket: false, + visible: true + ) + conference.tickets.create!( + title: 'Student', + description: 'Reduced-price ticket for full-time students. Student ID required at check-in.', + price_cents: 7_500, + price_currency: 'USD', + registration_ticket: false, + visible: true + ) + puts "Created 3 paid ticket tiers (Early Bird $149, Regular $299, Student $75)" + puts " (plus the automatic free registration ticket)" + + # --- Registration Period --- + RegistrationPeriod.create!( + conference: conference, + start_date: Date.new(2026, 7, 1), + end_date: Date.new(2026, 10, 16) + ) + puts "Created registration period: 2026-07-01 to 2026-10-16" + + # --- Attendee Registrations --- + attendee_data = [ + { name: 'Alex Morgan', email: 'alex.morgan@bendemo25.example.com', username: 'amorgan_bd25' }, + { name: 'Fatima Al-Hassan', email: 'fatima.alhassan@bendemo25.example.com',username: 'falhassan_bd25' }, + { name: 'Tom Nakamura', email: 'tom.nakamura@bendemo25.example.com', username: 'tnakamura_bd25' }, + { name: 'Elena Petrov', email: 'elena.petrov@bendemo25.example.com', username: 'epetrov_bd25' }, + { name: 'Marcus Webb', email: 'marcus.webb@bendemo25.example.com', username: 'mwebb_bd25' }, + { name: 'Nadia Osei', email: 'nadia.osei@bendemo25.example.com', username: 'nosei_bd25' }, + { name: 'Ryan Castellano', email: 'ryan.castellano@bendemo25.example.com',username: 'rcastellano_bd25' } + ] + + attendee_data.each do |a| + user = User.find_or_initialize_by(email: a[:email]) + if user.new_record? + user.name = a[:name] + user.username = a[:username] + user.password = SecureRandom.hex(16) + user.skip_confirmation! + user.save! + end + Registration.create!(conference: conference, user: user) + puts " Registered attendee: #{user.name}" + end + + # --- Splashpage --- + Splashpage.create!( + conference: conference, + public: true, + include_tracks: true, + include_program: true, + include_venue: true, + include_tickets: true, + include_registrations: true, + include_cfp: false, + include_sponsors: false, + include_lodgings: false, + include_social_media: false, + include_booths: false + ) + puts "Created splashpage (public)" + + puts '' + puts '=== Ben Demo Conference 2026 seeded successfully ===' + puts " Conference id: #{conference.id}" + puts " URL slug: #{short_title}" + puts " Tracks: #{tracks.size}" + puts " Rooms: #{rooms.size}" + puts " Events: #{events_list.size}" + puts " Scheduled: #{slot_assignments.size}" + puts " Tickets: #{conference.tickets.count} (including free registration ticket)" + puts " Registrations: #{conference.registrations.count}" + puts '' + puts "Run with: rake data:ben_demo" + end +end diff --git a/spec/controllers/admin/events_controller_spec.rb b/spec/controllers/admin/events_controller_spec.rb index 19b8048ab..123aa0d83 100644 --- a/spec/controllers/admin/events_controller_spec.rb +++ b/spec/controllers/admin/events_controller_spec.rb @@ -3,18 +3,19 @@ require 'spec_helper' describe Admin::EventsController do - let(:conference) { create(:conference) } - let!(:organizer) { create(:organizer, resource: conference) } - # The where_object() and where_object_changes() methods of paper_trail gem are broken when having: - # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 - # (the numbers could be different as long as there is this matching of IDs). - # We implemented or own where method to solve this and those ids are for testing this case. - let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } - let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } - let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } - + render_views with_versioning do describe 'GET #show' do + let(:conference) { create(:conference) } + let!(:organizer) { create(:organizer, resource: conference) } + # The where_object() and where_object_changes() methods of paper_trail gem are broken when having: + # an Event with ID 1, an Event with ID 2, and a commercial with ID 1, for event with ID 2 + # (the numbers could be different as long as there is this matching of IDs). + # We implemented or own where method to solve this and those ids are for testing this case. + let!(:event_without_commercial) { create(:event, id: 1, program: conference.program) } + let!(:event_with_commercial) { create(:event, id: 2, program: conference.program) } + let!(:event_commercial) { create(:event_commercial, id: 1, commercialable: event_with_commercial, url: 'https://www.youtube.com/watch?v=M9bq_alk-sw') } + before do sign_in(organizer) get :show, params: { id: event_without_commercial.id, conference_id: conference.short_title } @@ -27,4 +28,582 @@ end end end + + describe '#duplicate' do + let(:dup_conference) { create(:conference, short_title: 'snapcon2026') } + let(:program) { dup_conference.program } + let(:user) { create(:admin) } + let(:event_type) { create(:event_type, program: program) } + let(:track) { create(:track, state: 'confirmed', program: program) } + let(:difficulty_level) { create(:difficulty_level, program: program) } + let(:speaker) { create(:user) } + let(:volunteer) { create(:user) } + + let!(:original_event) do + event = create(:event, + program: program, + title: 'Original Event', + abstract: 'An abstract', + description: 'A description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, + require_registration: true, + max_attendees: 50, + state: 'confirmed') + event.speakers << speaker + event.volunteers << volunteer + event + end + + before do + sign_in user + end + + context 'with single duplicate' do + it 'creates one copy of the event' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'redirects to the events page' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + expect(response).to redirect_to(admin_conference_program_events_path(dup_conference.short_title)) + end + + it 'sets a success flash message' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + expect(flash[:notice]).to include('duplicated successfully') + end + + it 'assigns the current user as submitter' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_event = Event.last + expect(new_event.submitter).to eq user + end + end + + context 'with multiple duplicates' do + it 'creates the requested number of copies' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + end.to change(Event, :count).by(3) + end + + it 'redirects to the events page' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + expect(response).to redirect_to(admin_conference_program_events_path(dup_conference.short_title)) + end + + it 'shows the count of created copies in flash message' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + expect(flash[:notice]).to include('3 copies') + end + end + + context 'with invalid count' do + it 'shows error if count is 0' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 0 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'shows error if count is too high' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 200 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'shows error if count is negative' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: -5 + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + + it 'accepts valid count at upper boundary (100)' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 100 + } + end.to change(Event, :count).by(100) + + expect(flash[:notice]).to include('100 copies') + end + + it 'shows error if count is missing' do + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id + } + end.not_to change(Event, :count) + + expect(flash[:alert]).to include('Invalid number of duplicates') + end + end + + context 'field copying' do + let(:duplicate) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + Event.last + end + + it 'copies content fields' do + expect(duplicate.title).to eq original_event.title + expect(duplicate.abstract).to eq original_event.abstract + expect(duplicate.description).to eq original_event.description + expect(duplicate.subtitle).to eq original_event.subtitle + end + + it 'copies configuration fields' do + expect(duplicate.event_type).to eq original_event.event_type + expect(duplicate.track).to eq original_event.track + expect(duplicate.difficulty_level).to eq original_event.difficulty_level + expect(duplicate.language).to eq original_event.language + end + + it 'copies registration settings' do + expect(duplicate.require_registration).to eq original_event.require_registration + expect(duplicate.max_attendees).to eq original_event.max_attendees + end + + it 'copies speakers and volunteers' do + expect(duplicate.speakers).to include(speaker) + expect(duplicate.volunteers).to include(volunteer) + end + + it 'copies public status' do + expect(duplicate.public).to eq original_event.public + end + + it 'copies superevent status' do + expect(duplicate.superevent).to eq original_event.superevent + end + + it 'sets new event state' do + expect(duplicate.state).to eq 'new' + end + + it 'generates new guid' do + expect(duplicate.guid).not_to eq original_event.guid + expect(duplicate.guid).to be_present + end + + it 'does not copy start_time' do + expect(duplicate.start_time).to be_nil + end + + it 'does not copy room' do + expect(duplicate.room_id).to be_nil + end + + it 'does not have parent' do + expect(duplicate.parent_id).to be_nil + end + + it 'does not copy registrations' do + create(:registration) + original_event.registrations << Registration.first + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.registrations).to be_empty + end + + it 'does not copy votes' do + create(:vote, event: original_event) + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.votes).to be_empty + end + + it 'does not copy comments' do + create(:comment, commentable: original_event) + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + new_dup = Event.where(id: Event.last.id).first + expect(new_dup.comment_threads).to be_empty + end + end + + context 'duplicate of duplicate' do + let(:first_duplicate) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 1 + } + Event.last + end + + it 'can duplicate a duplicate' do + dup_id = first_duplicate.id + expect do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: dup_id, + count: 1 + } + end.to change(Event, :count).by(1) + end + + it 'preserves all fields when duplicating a duplicate' do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: first_duplicate.id, + count: 1 + } + second_duplicate = Event.last + + expect(second_duplicate.title).to eq original_event.title + expect(second_duplicate.speakers).to include(speaker) + expect(second_duplicate.volunteers).to include(volunteer) + expect(second_duplicate.difficulty_level).to eq original_event.difficulty_level + end + end + + context 'deletion independence' do + let(:duplicates) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 3 + } + Event.where(title: original_event.title).order(:created_at).last(3) + end + + it 'deleting a duplicate does not delete other duplicates' do + dup_to_delete = duplicates.first + second_dup_id = duplicates[1].id + third_dup_id = duplicates[2].id + + expect do + delete :destroy, params: { + conference_id: dup_conference.short_title, + id: dup_to_delete.id + } + end.to change(Event, :count).by(-1) + + expect(Event.exists?(second_dup_id)).to be true + expect(Event.exists?(third_dup_id)).to be true + end + + it 'deleting the original does not delete duplicates' do + dups_ids = duplicates.map(&:id) + + expect do + delete :destroy, params: { + conference_id: dup_conference.short_title, + id: original_event.id + } + end.to change(Event, :count).by(-1) + + dups_ids.each do |dup_id| + expect(Event.exists?(dup_id)).to be true + end + end + end + + context 'update independence' do + let(:duplicates) do + post :duplicate, params: { + conference_id: dup_conference.short_title, + id: original_event.id, + count: 2 + } + Event.where(title: original_event.title).order(:created_at).last(2) + end + + it 'updating original does not affect duplicates' do + dups = duplicates + original_title = original_event.title + new_title = 'Updated Original Title' + original_event.update(title: new_title) + + dups.each do |dup| + dup.reload + expect(dup.title).not_to eq new_title + expect(dup.title).to eq original_title + end + end + + it 'updating a duplicate does not affect original' do + dup = duplicates.first + new_title = 'Updated Duplicate Title' + dup.update(title: new_title) + + original_event.reload + expect(original_event.title).not_to eq new_title + end + + it 'updating a duplicate does not affect other duplicates' do + first_dup = duplicates.first + second_dup = duplicates.last + + first_dup.update(max_attendees: 100) + + second_dup.reload + expect(second_dup.max_attendees).to eq original_event.max_attendees + end + end + end + + describe 'GET #preview_tentative_accept' do + let(:conference) { create(:conference) } + let(:organizer) { create(:organizer, resource: conference) } + let(:event) { create(:event, program: conference.program) } + let(:email_settings) { conference.email_settings } + + before do + sign_in(organizer) + end + + context 'when email sending is disabled' do + before do + email_settings.update(send_on_tentative_accepted: false) + get :preview_tentative_accept, params: { conference_id: conference.short_title, id: event.id } + sleep 0.1 # Sleep to ensure any asynchronous operations have completed + end + + it 'shows warning about email setup' do + expect(response.body).to include('Email sending is currently disabled for tentative acceptance') + end + + it 'does not show email preview' do + expect(response.body).not_to include('Email preview') + end + end + + context 'when email sending is enabled' do + before do + email_settings.update(send_on_tentative_accepted: true) + end + + context 'without committee feedback' do + before do + event.update(committee_review: nil) + get :preview_tentative_accept, params: { conference_id: conference.short_title, id: event.id } + sleep 0.1 # Sleep to ensure any asynchronous operations have completed + end + + it 'shows committee feedback warning' do + expect(response.body).to include('Committee feedback is required before sending a tentative acceptance email') + end + + it 'disables the submit button' do + expect(response.body).to include('disabled') + end + + it 'does not show email preview' do + expect(response.body).not_to include('Email preview') + end + end + + context 'with committee feedback' do + before do + event.update(committee_review: 'Please update the abstract') + email_settings.update( + tentative_accepted_subject: 'Tentative Acceptance', + tentative_accepted_body: 'Your proposal has been tentatively accepted' + ) + get :preview_tentative_accept, params: { conference_id: conference.short_title, id: event.id } + sleep 0.1 # Sleep to ensure any asynchronous operations have completed + end + + it 'shows email preview' do + expect(response.body).to include('Email preview') + end + + it 'shows the correct subject' do + expect(response.body).to include('Tentative Acceptance') + end + + it 'shows the correct body' do + expect(response.body).to include('Your proposal has been tentatively accepted') + end + + it 'enables the submit button' do + expect(response.body).not_to include('disabled') + end + end + end + end + + describe 'PATCH #tentative_accept' do + let(:conference) { create(:conference) } + let(:organizer) { create(:organizer, resource: conference) } + let(:event) { create(:event, program: conference.program) } + let(:email_settings) { conference.email_settings } + + before do + sign_in(organizer) + email_settings.update(send_on_tentative_accepted: true) + end + + context 'without committee feedback' do + before do + event.update(committee_review: nil) + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + end + + it 'shows error message' do + expect(flash[:alert]).to eq('Committee feedback is required before sending a tentative acceptance email.') + end + + it 'does not change event state' do + event.reload + expect(event.state).to eq('new') + end + + it 'renders the tentative_accept template' do + expect(response).to render_template(:tentative_accept) + end + end + + context 'with committee feedback' do + before do + event.update(committee_review: 'Please update the abstract') + email_settings.update( + tentative_accepted_subject: 'Tentative Acceptance', + tentative_accepted_body: 'Your proposal has been tentatively accepted' + ) + end + + it 'changes event state to tentatively_accepted' do + expect do + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + event.reload + end.to change(event, :state).from('new').to('tentatively_accepted') + end + + it 'shows success message' do + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + expect(flash[:notice]).to eq('Event tentatively accepted!') + end + + it 'redirects to events index' do + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + expect(response).to redirect_to(admin_conference_program_events_path(conference.short_title)) + end + + context 'when email sending is disabled' do + before do + email_settings.update(send_on_tentative_accepted: false) + end + + it 'does not send an email' do + expect do + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + sleep 0.1 # Sleep to ensure asynchronous operations have completed + end.not_to change(ActionMailer::Base.deliveries, :count) + end + end + end + + context 'with invalid transition' do + before do + event.update(state: 'confirmed', committee_review: 'Some feedback') + patch :tentative_accept, params: { conference_id: conference.short_title, id: event.id } + end + + it 'shows error message' do + expect(flash[:error]).to include('Update state failed') + end + end + end + + describe 'committee feedback management' do + let(:conference) { create(:conference) } + let(:organizer) { create(:organizer, resource: conference) } + let(:event) { create(:event, program: conference.program, committee_review: 'Initial feedback') } + + before do + sign_in(organizer) + end + + context 'when updating committee feedback' do + it 'allows adding committee feedback' do + patch :update, params: { + conference_id: conference.short_title, + id: event.id, + event: { committee_review: 'Updated feedback' } + } + event.reload + expect(event.committee_review).to eq('Updated feedback') + end + + it 'allows clearing committee feedback' do + patch :update, params: { + conference_id: conference.short_title, + id: event.id, + event: { committee_review: '' } + } + event.reload + expect(event.committee_review).to be_blank + end + end + end end diff --git a/spec/controllers/conference_registration_controller_spec.rb b/spec/controllers/conference_registration_controller_spec.rb index dfe04003b..ddb238953 100644 --- a/spec/controllers/conference_registration_controller_spec.rb +++ b/spec/controllers/conference_registration_controller_spec.rb @@ -307,16 +307,47 @@ describe 'GET #edit' do before do @registration = create(:registration, conference: conference, user: user) - get :edit, params: { conference_id: conference.short_title } end - it 'assigns conference and registration variable' do - expect(assigns(:conference)).to eq conference - expect(assigns(:registration)).to eq @registration + context 'basic request' do + before do + get :edit, params: { conference_id: conference.short_title } + end + + it 'assigns conference and registration variable' do + expect(assigns(:conference)).to eq conference + expect(assigns(:registration)).to eq @registration + end + + it 'renders the edit template' do + expect(response).to render_template('edit') + end end - it 'renders the edit template' do - expect(response).to render_template('edit') + context 'code of conduct visibility' do + render_views + + context 'conference has no code of conduct' do + before do + conference.organization.update(code_of_conduct: nil) + get :edit, params: { conference_id: conference.short_title } + end + + it 'does not render the code of conduct modal' do + expect(response.body).not_to include('modal-code-of-conduct') + end + end + + context 'conference has a code of conduct' do + before do + conference.organization.update(code_of_conduct: 'Be nice to each other.') + get :edit, params: { conference_id: conference.short_title } + end + + it 'renders the code of conduct modal' do + expect(response.body).to include('modal-code-of-conduct') + end + end end end diff --git a/spec/factories/email_settings.rb b/spec/factories/email_settings.rb index c71132cd3..843d5e898 100644 --- a/spec/factories/email_settings.rb +++ b/spec/factories/email_settings.rb @@ -36,9 +36,12 @@ # send_on_registration :boolean default(FALSE) # send_on_rejected :boolean default(FALSE) # send_on_submitted_proposal :boolean default(FALSE) +# send_on_tentative_accepted :boolean default(FALSE) # send_on_venue_updated :boolean default(FALSE) # submitted_proposal_body :text # submitted_proposal_subject :string +# tentative_accepted_body :text +# tentative_accepted_subject :string # venue_updated_body :text # venue_updated_subject :string # created_at :datetime diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index a3df512e9..686fcf43e 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -13,8 +13,13 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# FactoryBot.define do factory :payment do user diff --git a/spec/features/event_duplication_spec.rb b/spec/features/event_duplication_spec.rb new file mode 100644 index 000000000..aca1e93a0 --- /dev/null +++ b/spec/features/event_duplication_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Event Duplication Feature', :js do + let(:conference) { create(:full_conference, short_title: 'snapcon2026') } + let(:program) { conference.program } + let(:user) { create(:admin) } + let(:event_type) { create(:event_type, program: program) } + let(:track) { create(:track, state: 'confirmed', program: program) } + let(:difficulty_level) { create(:difficulty_level, program: program) } + + let(:speaker) { create(:user) } + let(:venue) { conference.venue || create(:venue, conference: conference) } + let(:room) { create(:room, venue: venue) } + + let!(:original_event) do + event = create(:event, + program: program, + title: 'Test Event for Duplication', + abstract: 'Event abstract', + description: 'Event description', + event_type: event_type, + track: track, + difficulty_level: difficulty_level, + require_registration: true, + max_attendees: 50, + state: 'confirmed') + event.speakers << speaker + event + end + + before do + login_as user, scope: :user + end + + describe 'duplicating an event via web interface' do + it 'shows duplicate button on event details page' do + visit admin_conference_program_event_path(conference.short_title, original_event) + expect(page).to have_button('Duplicate') + end + + it 'opens the duplicate modal when clicking duplicate button' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + expect(page).to have_css('#duplicateEventModal.in') + expect(page).to have_field('count') + end + + it 'creates one copy by default' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + expect(page).to have_content('duplicated successfully') + + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 2 + + sleep 0.1 + end + end + expect(Event.where(title: original_event.title).count).to eq 2 + end + + it 'creates multiple copies when specified' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + + fill_in('count', with: 5) + click_button('Create Copies') + + expect(page).to have_content('5 copies') + + # Wait for duplicates to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 6 + + sleep 0.1 + end + end + expect(Event.where(title: original_event.title).count).to eq 6 + end + + it 'sets the current user as submitter of duplicates' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) + break if duplicates.count >= 1 + + sleep 0.1 + end + end + + duplicates = Event.where(title: original_event.title).where.not(id: original_event.id) + duplicates.each do |dup| + expect(dup.submitter).to eq user + end + end + + it 'preserves all event fields in duplicate' do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + # Wait for duplicate to be queryable (database transaction visibility) + duplicate = nil + Timeout.timeout(5) do + loop do + duplicate = Event.where(title: original_event.title).where.not(id: original_event.id).first + break if duplicate + + sleep 0.1 + end + end + + expect(duplicate.abstract).to eq original_event.abstract + expect(duplicate.description).to eq original_event.description + expect(duplicate.event_type).to eq original_event.event_type + expect(duplicate.track).to eq original_event.track + expect(duplicate.difficulty_level).to eq original_event.difficulty_level + expect(duplicate.require_registration).to eq original_event.require_registration + expect(duplicate.max_attendees).to eq original_event.max_attendees + expect(duplicate.speakers).to include(speaker) + end + + it 'shows the duplicate in the events list' do + original_event + original_count = program.events.count + + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + click_button('Create Copies') + + # Wait for duplicate to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + count = program.events.count + break if count > original_count + + sleep 0.1 + end + end + + visit admin_conference_program_events_path(conference.short_title) + expect(page).to have_content(original_event.title) + # Due to caching and datatable rendering, check the count increased + expect(program.events.count).to eq original_count + 1 + end + end + + describe 'deleting and updating duplicates' do + let(:duplicates) { Event.where(title: original_event.title).where.not(id: original_event.id).order(:created_at) } + + before do + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + fill_in('count', with: 3) + click_button('Create Copies') + + # Wait for duplicates to be queryable (database transaction visibility) + # We expect 1 original + 3 copies = 4 total events + Timeout.timeout(5) do + loop do + count = Event.where(title: original_event.title).count + break if count >= 4 + + sleep 0.1 + end + end + end + + it 'deleting a duplicate does not affect others' do + dup_id = duplicates.first.id + dup_title = duplicates.first.title + + visit admin_conference_program_event_path(conference.short_title, Event.find(dup_id)) + accept_confirm do + click_link('Delete', match: :first) + end + + expect(page).to have_content('Event successfully deleted.') + expect(Event.exists?(dup_id)).to be false + expect(Event.where(title: dup_title).count).to eq 3 # original + 2 remaining duplicates + end + + it 'can individually edit duplicates' do + duplicate = duplicates.first + new_subtitle = 'Updated Subtitle' + + visit edit_admin_conference_program_event_path(conference.short_title, duplicate) + fill_in('event_subtitle', with: new_subtitle) + click_button('Update Proposal') + + # Wait for the update to complete and page to redirect + expect(page).to have_content(original_event.title) + + duplicate.reload + expect(duplicate.subtitle).to eq new_subtitle + + # Original should not be affected + original_event.reload + expect(original_event.subtitle).not_to eq new_subtitle + end + end + + describe 'fuzz testing - rapid operations' do + it 'handles rapid duplicate, update, delete cycles' do + expect do + 3.times do |i| + visit admin_conference_program_event_path(conference.short_title, original_event) + current_count = Event.where(title: original_event.title).count + click_button('Duplicate') + fill_in('count', with: 2) + click_button('Create Copies') + + # Wait for duplicates to be queryable (database transaction visibility) + Timeout.timeout(5) do + loop do + new_count = Event.where(title: original_event.title).count + break if new_count >= current_count + 2 + + sleep 0.1 + end + end + + new_events = Event.where(title: original_event.title).where.not(id: original_event.id).last(2) + new_events.each do |event| + event.update(max_attendees: 100 + i) + end + + new_events.first&.destroy + end + end.not_to raise_error + end + + it 'maintains data integrity with many duplicates' do + 5.times do |iteration| + visit admin_conference_program_event_path(conference.short_title, original_event) + click_button('Duplicate') + fill_in('count', with: 2) + click_button('Create Copies') + + # Wait for duplicates to be queryable (database transaction visibility) + # This ensures the POST request completes before visiting the page again + expected_count = 1 + (2 * (iteration + 1)) + Timeout.timeout(5) do + loop do + actual_count = Event.where(title: original_event.title).count + break if actual_count >= expected_count + + sleep 0.1 + end + end + end + + all_events = Event.where(title: original_event.title) + all_events.each do |event| + expect(event.speakers).to include(speaker) + expect(event.event_type).to eq event_type + end + end + end +end diff --git a/spec/features/ticket_purchases_spec.rb b/spec/features/ticket_purchases_spec.rb index 32bb9fd1f..8771fac04 100644 --- a/spec/features/ticket_purchases_spec.rb +++ b/spec/features/ticket_purchases_spec.rb @@ -14,25 +14,6 @@ end let!(:participant) { create(:user) } - def make_stripe_purchase(card_number = '4242424242424242') - find('.stripe-button-el').click - - stripe_iframe = all('iframe[name=stripe_checkout_app]').last - sleep(5) - Capybara.within_frame stripe_iframe do - expect(page).to have_content(:all, "#{ENV.fetch('OSEM_NAME', nil)} tickets") - fill_in 'Card number', with: card_number - fill_in 'Expiry', with: '08/22' - fill_in 'CVC', with: '123' - click_button '$20.00' - sleep(20) - end - end - - def make_failed_stripe_purchase - make_stripe_purchase('4000000000000341') - end - context 'as a participant' do before do sign_in participant @@ -43,34 +24,12 @@ def make_failed_stripe_purchase end context 'who is not registered' do - it 'purchases and pays for a ticket succcessfully' do - visit root_path - click_link 'Register' - - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - click_button 'Register' - - fill_in "tickets__#{ticket.id}", with: '2' - expect(page).to have_current_path(conference_tickets_path(conference.short_title), ignore_query: true) + it 'purchases and is redirected to Stripe Checkout' do + mock_session = double('Stripe::Checkout::Session', + id: 'cs_test_123', + url: 'https://checkout.stripe.com/pay/cs_test_123') + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) - click_button 'Continue' - page.find('#flash') - expect(page).to have_current_path(new_conference_payment_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Please pay here to get tickets.') - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end - end - - it 'purchases ticket but payment fails', feature: true, js: true do visit root_path click_link 'Register' @@ -87,13 +46,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_failed_stripe_purchase - page.find('#flash') - expect(page).to have_current_path(conference_payments_path(conference.short_title), ignore_query: true) - expect(flash).to eq('Your card was declined. Please try again with correct credentials.') - end end it 'purchases free tickets' do @@ -152,13 +104,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: third_registration_ticket.id).first expect(purchase.quantity).to eq(1) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end it 'purchases more than one registration tickets of a single type' do @@ -238,13 +183,6 @@ def make_failed_stripe_purchase expect(purchase.quantity).to eq(1) expect(purchase.currency).to eq('EUR') expect(purchase.amount_paid).to eq(17.80) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - expect(page).to have_current_path(new_conference_conference_registration_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - end end end @@ -267,19 +205,6 @@ def make_failed_stripe_purchase expect(flash).to eq('Please pay here to get tickets.') purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first expect(purchase.quantity).to eq(2) - - if ENV['STRIPE_PUBLISHABLE_KEY'] || Rails.application.secrets.stripe_publishable_key - make_stripe_purchase - # expect(current_path).to eq(conference_conference_registration_path(conference.short_title)) - expect(page).to have_current_path(conference_physical_tickets_path(conference.short_title), - ignore_query: true) - expect(page).to have_content 'Your ticket is booked successfully.' - - click_button 'Unregister' - end - - purchase = TicketPurchase.where(user_id: participant.id, ticket_id: ticket.id).first - expect(purchase.quantity).to eq(2) end end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 808a2827d..3834df804 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -156,6 +156,26 @@ end end + describe '#visible_conference_links' do + before do + conference # force creation + allow_any_instance_of(ApplicationHelper).to receive(:can?).and_return(true) + end + + it 'returns conferences sorted by start date descending' do + confs = visible_conference_links + expect(confs).to eq confs.sort_by(&:start_date).reverse + end + + it 'does not group by organization' do + expect(visible_conference_links).to be_an(Array) + end + + it 'includes visible conferences' do + expect(visible_conference_links).to include(conference) + end + end + describe '#event_type_sentence' do before do create(:event_type, title: 'Keynote', program: conference.program, enable_public_submission: false) diff --git a/spec/helpers/conference_helper_spec.rb b/spec/helpers/conference_helper_spec.rb index 4efde09e5..d97d63aff 100644 --- a/spec/helpers/conference_helper_spec.rb +++ b/spec/helpers/conference_helper_spec.rb @@ -79,6 +79,61 @@ end end + describe '#icalendar_proposals' do + let!(:conference_with_venue) do + create(:conference, venue: create(:venue)) + end + let!(:contact_for_venue_conf) { create(:contact, conference: conference_with_venue) } + + context 'when an event has a nil event_type' do + it 'skips the event and adds no VEVENT to the calendar' do + cal = Icalendar::Calendar.new + event = create(:event, program: conference_with_venue.program) + # Bypass validation to simulate legacy 2021 data with nil event_type + event.update_column(:event_type_id, nil) + event.reload + + icalendar_proposals(cal, [event], conference_with_venue) + expect(cal.events).to be_empty + end + end + + context 'when an event has a nil time (no event_schedule for selected schedule)' do + it 'skips the event and adds no VEVENT to the calendar' do + cal = Icalendar::Calendar.new + event = create(:event, program: conference_with_venue.program) + # Event exists but has no event_schedule, so event.time returns nil + + icalendar_proposals(cal, [event], conference_with_venue) + expect(cal.events).to be_empty + end + end + + context 'when an event has a nil room and conference has a venue' do + it 'still adds the event but builds location without room name' do + cal = Icalendar::Calendar.new + event = create(:event_scheduled, program: conference_with_venue.program) + # Nullify the room on the event_schedule to simulate missing room data + event.event_schedules.each { |es| es.update_column(:room_id, nil) } + + icalendar_proposals(cal, [event], conference_with_venue) + expect(cal.events.size).to eq(1) + expect(cal.events.first.summary).to eq(event.title) + end + end + + context 'with valid events' do + it 'adds the event to the calendar with correct summary' do + cal = Icalendar::Calendar.new + event = create(:event_scheduled, program: conference_with_venue.program) + + icalendar_proposals(cal, [event], conference_with_venue) + expect(cal.events.size).to eq(1) + expect(cal.events.first.summary).to eq(event.title) + end + end + end + describe '#get_happening_next_events_schedules' do let!(:conference2) do create(:full_conference, start_date: 1.day.ago, end_date: 7.days.from_now, start_hour: 0, end_hour: 24) diff --git a/spec/helpers/format_helper_spec.rb b/spec/helpers/format_helper_spec.rb index 80c512529..7bb2beaa3 100644 --- a/spec/helpers/format_helper_spec.rb +++ b/spec/helpers/format_helper_spec.rb @@ -30,4 +30,35 @@ .to eq "

a

\n" end end + + describe 'markdown_with_variables' do + let(:conference) do + create(:conference, short_title: 'snap21', title: 'SnapCon 2021', + start_date: Date.new(2021, 7, 12), end_date: Date.new(2021, 7, 16)) + end + + it 'substitutes conference variables and renders markdown' do + text = 'Welcome to **{conference}**, running {conference_start_date} to {conference_end_date}.' + result = markdown_with_variables(text, conference, false) + expect(result).to include('Welcome to SnapCon 2021') + expect(result).to include('2021-07-12') + expect(result).to include('2021-07-16') + end + + it 'returns empty string for nil text' do + expect(markdown_with_variables(nil, conference)).to eq '' + end + + it 'renders normally when there are no placeholders' do + text = 'Just a regular description.' + expect(markdown_with_variables(text, conference, false)).to eq "

Just a regular description.

\n" + end + + it 'includes venue when conference has one' do + conference.venue = create(:venue) + text = 'Join us at {venue}.' + result = markdown_with_variables(text, conference, false) + expect(result).to include(conference.venue.name) + end + end end diff --git a/spec/models/conference_spec.rb b/spec/models/conference_spec.rb index e96d69fac..17116b45f 100644 --- a/spec/models/conference_spec.rb +++ b/spec/models/conference_spec.rb @@ -81,12 +81,13 @@ result = { DateTime.now.end_of_week => { - confirmed: 0, - unconfirmed: 0, - new: 0, - withdrawn: 0, - canceled: 0, - rejected: 0 + confirmed: 0, + unconfirmed: 0, + new: 0, + withdrawn: 0, + canceled: 0, + rejected: 0, + tentatively_accepted: 0 } } @@ -138,12 +139,13 @@ result = { DateTime.now.end_of_week => { - confirmed: 1, - unconfirmed: 1, - new: 1, - withdrawn: 1, - canceled: 1, - rejected: 1 + confirmed: 1, + unconfirmed: 1, + new: 1, + withdrawn: 1, + canceled: 1, + rejected: 1, + tentatively_accepted: 0 } } @@ -211,12 +213,13 @@ }, DateTime.now.end_of_week => { - confirmed: 1, - unconfirmed: 1, - new: 1, - withdrawn: 0, - canceled: 0, - rejected: 0 + confirmed: 1, + unconfirmed: 1, + new: 1, + withdrawn: 0, + canceled: 0, + rejected: 0, + tentatively_accepted: 0 } } @@ -835,7 +838,7 @@ canceled.accept!(@options) canceled.cancel! - @result = { 'New' => 1, 'Withdrawn' => 1, 'Unconfirmed' => 1, 'Confirmed' => 1, 'Canceled' => 1, 'Rejected' => 1 } + @result = { 'New' => 1, 'Withdrawn' => 1, 'Unconfirmed' => 1, 'Tentatively_accepted' => 0, 'Confirmed' => 1, 'Canceled' => 1, 'Rejected' => 1 } end it '#event_distribution does calculate correct values with events' do @@ -850,7 +853,7 @@ it 'event_distribution does calculate correct values with just a new event' do conference = create(:conference) create(:event, program: conference.program) - result = { 'New' => 1, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } + result = { 'New' => 1, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } expect(conference.event_distribution).to eq(result) end @@ -858,7 +861,7 @@ conference = create(:conference) event = create(:event, program: conference.program) event.withdraw! - result = { 'New' => 0, 'Withdrawn' => 1, 'Unconfirmed' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } + result = { 'New' => 0, 'Withdrawn' => 1, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } expect(conference.event_distribution).to eq(result) end @@ -866,7 +869,7 @@ conference = create(:conference) event = create(:event, program: conference.program) event.accept!(@options) - result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 1, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } + result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 1, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } expect(conference.event_distribution).to eq(result) end @@ -874,7 +877,7 @@ conference = create(:conference) event = create(:event, program: conference.program) event.reject!(@options) - result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 1 } + result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 1 } expect(conference.event_distribution).to eq(result) end @@ -884,7 +887,7 @@ event = create(:event, program: conference.program) event.accept!(@options) event.confirm! - result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Confirmed' => 1, 'Canceled' => 0, 'Rejected' => 0 } + result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 1, 'Canceled' => 0, 'Rejected' => 0 } expect(conference.event_distribution).to eq(result) end @@ -893,7 +896,7 @@ event = create(:event, program: conference.program) event.accept!(@options) event.cancel! - result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Confirmed' => 0, 'Canceled' => 1, 'Rejected' => 0 } + result = { 'New' => 0, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 1, 'Rejected' => 0 } expect(conference.event_distribution).to eq(result) end @@ -909,14 +912,14 @@ it 'self#event_distribution does calculate correct values with just a new event' do @conference.program.events.clear create(:event, program: @conference.program) - result = { 'New' => 1, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } + result = { 'New' => 1, 'Withdrawn' => 0, 'Unconfirmed' => 0, 'Tentatively_accepted' => 0, 'Confirmed' => 0, 'Canceled' => 0, 'Rejected' => 0 } expect(Conference.event_distribution).to eq(result) end it 'self#event_distribution does calculate correct values with just a new events from different conferences' do create(:event, program: @conference.program) - result = { 'New' => 2, 'Withdrawn' => 1, 'Unconfirmed' => 1, 'Confirmed' => 1, 'Canceled' => 1, 'Rejected' => 1 } + result = { 'New' => 2, 'Withdrawn' => 1, 'Unconfirmed' => 1, 'Tentatively_accepted' => 0, 'Confirmed' => 1, 'Canceled' => 1, 'Rejected' => 1 } expect(Conference.event_distribution).to eq(result) end end diff --git a/spec/models/email_settings_spec.rb b/spec/models/email_settings_spec.rb index ced013cc9..7336084ff 100644 --- a/spec/models/email_settings_spec.rb +++ b/spec/models/email_settings_spec.rb @@ -36,9 +36,12 @@ # send_on_registration :boolean default(FALSE) # send_on_rejected :boolean default(FALSE) # send_on_submitted_proposal :boolean default(FALSE) +# send_on_tentative_accepted :boolean default(FALSE) # send_on_venue_updated :boolean default(FALSE) # submitted_proposal_body :text # submitted_proposal_subject :string +# tentative_accepted_body :text +# tentative_accepted_subject :string # venue_updated_body :text # venue_updated_subject :string # created_at :datetime diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 9006b5546..ceaa4d688 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -16,12 +16,14 @@ let(:organization) { create(:organization) } describe 'validation' do - xit 'is not valid without a name' do + it 'is not valid without a name' do expect(subject).to validate_presence_of(:name) end end describe 'associations' do - xit { is_expected.to have_many(:conferences).dependent(:destroy) } + it 'has many conferences with dependent destroy' do + expect(subject).to have_many(:conferences).dependent(:destroy) + end end end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index 34b0132d2..4313d5a53 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -13,10 +13,14 @@ # created_at :datetime not null # updated_at :datetime not null # conference_id :integer not null +# stripe_session_id :string # user_id :integer not null # +# Indexes +# +# index_payments_on_stripe_session_id (stripe_session_id) UNIQUE +# require 'spec_helper' -require 'stripe_mock' describe Payment do context 'new payment' do @@ -46,117 +50,214 @@ end end - describe '#purchase' do + describe '#create_checkout_session' do let!(:user) { create(:user) } - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: stripe_helper.generate_card_token, - stripe_customer_email: user.email) - end let!(:conference) { create(:conference) } let!(:ticket_1) { create(:ticket, price: 10, price_currency: 'USD', conference: conference) } let!(:tickets) { { ticket_1.id.to_s => '2' } } - let(:stripe_helper) { StripeMock.create_test_helper } + let(:payment) { create(:payment, user: user, conference: conference) } - before { StripeMock.start } + before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } - after { StripeMock.stop } + context 'when the session is created successfully' do + let(:mock_session) do + double('Stripe::Checkout::Session', + id: 'cs_test_session_123', + url: 'https://checkout.stripe.com/pay/cs_test_session_123') + end - before { TicketPurchase.purchase(conference, user, tickets, ticket_1.price_currency) } + it 'creates a Stripe Checkout Session with line items' do + create_args = nil + allow(Stripe::Checkout::Session).to receive(:create) do |**args| + create_args = args + mock_session + end + + result = payment.create_checkout_session( + success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to eq(mock_session) + expect(create_args).to include( + payment_method_types: ['card'], + mode: 'payment', + customer_email: user.email + ) + expect(create_args[:line_items]).to contain_exactly( + hash_including( + price_data: hash_including( + currency: 'usd', + product_data: hash_including(name: ticket_1.title), + unit_amount: 1000 + ), + quantity: 2 + ) + ) + end + + it 'stores the session id on the payment' do + allow(Stripe::Checkout::Session).to receive(:create).and_return(mock_session) + + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + payment.reload + expect(payment.stripe_session_id).to eq('cs_test_session_123') + end + end + + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:create) + .and_raise(Stripe::StripeError.new('Test error')) + end + + it 'returns nil' do + result = payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(result).to be_nil + end + + it 'sets status to failure' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.status).to eq('failure') + end + + it 'adds error message' do + payment.create_checkout_session( + success_url: 'https://example.com/success', + cancel_url: 'https://example.com/cancel' + ) + + expect(payment.errors[:base]).to include('Test error') + end + end + end + + describe '#complete_checkout' do + let!(:user) { create(:user) } + let!(:conference) { create(:conference) } + let(:payment) { create(:payment, user: user, conference: conference, stripe_session_id: 'cs_test_123') } + + context 'when the payment was successful' do + let(:mock_charge) do + double('Stripe::Charge', + payment_method_details: double(card: double(last4: '4242'))) + end - context 'when the payment is successful' do - before { payment.purchase } + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'paid', + amount_total: 2000, + payment_intent: double(id: 'pi_test_123', latest_charge: mock_charge)) + end + + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) + end + + it 'sets status to success' do + payment.complete_checkout + expect(payment.status).to eq('success') + end it 'assigns amount' do + payment.complete_checkout expect(payment.amount).to eq(2000) end it 'assigns last4' do + payment.complete_checkout expect(payment.last4).to eq('4242') end - it "assigns 'success' to payment.status" do - expect(payment.status).to eq('success') + it 'assigns authorization_code from payment intent' do + payment.complete_checkout + expect(payment.authorization_code).to eq('pi_test_123') end + end - it 'assigns authorization_code' do - expect(payment.authorization_code).to eq('test_ch_3') + context 'when the payment was not successful' do + let(:mock_session) do + double('Stripe::Checkout::Session', + payment_status: 'unpaid', + payment_intent: nil) end - it 'assigns currency' do - expect(payment.currency).to eq('USD') + before do + allow(Stripe::Checkout::Session).to receive(:retrieve).and_return(mock_session) end - end - context 'if the payment is not successful' do - let(:payment) do - create(:payment, user: user, conference: conference, stripe_customer_token: 'bogus_card_token', - stripe_customer_email: user.email) + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - before { payment.purchase } + it 'returns false' do + expect(payment.complete_checkout).to be false + end + end - context 'when the card is invalid' do - it 'returns false' do - payment_result = payment.purchase - expect(payment_result).to be false - end + context 'when Stripe raises an error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::APIConnectionError.new('Connection failed')) + end - it 'assigns "failure" to payment.status' do - expect(payment.status).to eq('failure') - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error + end - it 'adds errors' do - expect(payment.errors[:base].count).to eq(1) - end + it 'sets status to failure' do + payment.complete_checkout + expect(payment.status).to eq('failure') end - context 'when the connection to Stripe drops' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIConnectionError.new) - expect { payment.purchase }.not_to raise_error - end + it 'returns false' do + expect(payment.complete_checkout).to be false end + end - context 'when there is a Stripe API Error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::APIError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an authentication error' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::AuthenticationError.new('Invalid API key')) end - context 'when there is authentication error' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::AuthenticationError.new) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when there is a card error' do - it 'raises exception' do - StripeMock.prepare_card_error(:card_declined) - expect { payment.purchase }.not_to raise_error - end + context 'when there is an invalid request' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::InvalidRequestError.new('Invalid session', {})) end - context 'when the request to Stripe is invalid' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::InvalidRequestError.new('Your request is invalid.', {}, code: 402)) - expect { payment.purchase }.not_to raise_error - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end + end - context 'when Stripe rate limit exceeds' do - it 'raises exception' do - StripeMock.prepare_error(Stripe::RateLimitError.new) - expect { payment.purchase }.not_to raise_error - end + context 'when Stripe rate limit exceeds' do + before do + allow(Stripe::Checkout::Session).to receive(:retrieve) + .and_raise(Stripe::RateLimitError.new('Rate limit exceeded')) end - context 'when the currency is invalid' do - it 'returns false' do - payment.currency = 'ABC' - expect(payment.purchase).to be false - end + it 'does not raise' do + expect { payment.complete_checkout }.not_to raise_error end end end diff --git a/spec/services/email_template_parser_spec.rb b/spec/services/email_template_parser_spec.rb new file mode 100644 index 000000000..2877d8b45 --- /dev/null +++ b/spec/services/email_template_parser_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EmailTemplateParser do + let(:conference) do + create(:conference, short_title: 'goto', start_date: Date.new(2014, 5, 1), end_date: Date.new(2014, 5, 6)) + end + let(:user) { create(:user, username: 'johnd', email: 'john@doe.com', name: 'John Doe') } + + describe '#retrieve_values' do + context 'without a user' do + let(:parser) { described_class.new(conference, nil) } + + it 'returns conference values without user values' do + values = parser.retrieve_values + expect(values['conference']).to eq conference.title + expect(values['conference_start_date']).to eq Date.new(2014, 5, 1) + expect(values['conference_end_date']).to eq Date.new(2014, 5, 6) + expect(values).not_to have_key('email') + expect(values).not_to have_key('name') + end + + it 'includes venue when conference has one' do + conference.venue = create(:venue) + values = parser.retrieve_values + expect(values['venue']).to eq conference.venue.name + expect(values['venue_address']).to eq conference.venue.address + end + + it 'includes cfp dates when conference has cfp' do + create(:cfp, start_date: Date.new(2014, 4, 29), end_date: Date.new(2014, 5, 6), + program: conference.program) + values = parser.retrieve_values + expect(values['cfp_start_date']).to eq Date.new(2014, 4, 29) + expect(values['cfp_end_date']).to eq Date.new(2014, 5, 6) + end + end + + context 'with a user' do + let(:parser) { described_class.new(conference, user) } + + it 'includes user values' do + values = parser.retrieve_values + expect(values['email']).to eq 'john@doe.com' + expect(values['name']).to eq 'John Doe' + expect(values['conference']).to eq conference.title + end + end + end + + describe '.parse_template' do + it 'substitutes conference variables in text' do + values = { 'conference' => 'SnapCon 2014', 'conference_start_date' => Date.new(2014, 5, 1) } + text = 'Welcome to {conference}, starting {conference_start_date}!' + result = described_class.parse_template(text, values) + expect(result).to eq 'Welcome to SnapCon 2014, starting 2014-05-01!' + end + + it 'leaves unknown placeholders alone' do + values = { 'conference' => 'SnapCon 2014' } + text = 'Welcome to {conference}, dear {name}!' + result = described_class.parse_template(text, values) + expect(result).to eq 'Welcome to SnapCon 2014, dear {name}!' + end + end +end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 722e270d4..4929d449c 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +begin + require 'database_cleaner/active_record' +rescue LoadError + require 'database_cleaner' +end + RSpec.configure do |config| config.before(:suite) do DatabaseCleaner.clean_with(:truncation)