Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
41fb6f9
Testing file v1
Ethan-Stone1 Mar 4, 2026
b4339cc
First changes on Event Duplication
Ethan-Stone1 Mar 6, 2026
df9fde6
Duplication and tests added
Ethan-Stone1 Mar 6, 2026
503e6a0
Update Tests
Ethan-Stone1 Mar 6, 2026
dd4ec04
Creating only one copy sends to Events list
Ethan-Stone1 Mar 6, 2026
c4782e8
Duplicatation fails if not specified between 1 and 100 events
Ethan-Stone1 Mar 6, 2026
916b1a2
Add conference duplication feature (Iteration 2)
zhouyijun111 Mar 6, 2026
a7df48b
yes erge remote-tracking branch 'origin/main' into feature/conference…
zhouyijun111 Mar 6, 2026
34ff0c3
Update test suite to fix latency failing tests
Ethan-Stone1 Mar 6, 2026
08619ba
Fix some tests
Ethan-Stone1 Mar 6, 2026
bf0b245
Add timeout for race condition test
Ethan-Stone1 Mar 6, 2026
f4d05b4
Add timeouts to the rest of the tests
Ethan-Stone1 Mar 6, 2026
0694aac
Rubocop, more test fixes
Ethan-Stone1 Mar 6, 2026
d01c55f
More test fixes
Ethan-Stone1 Mar 6, 2026
aee09c5
Implement Event Duplication
Ethan-Stone1 Mar 6, 2026
901cfdc
Implement Event Duplication
Ethan-Stone1 Mar 6, 2026
8e53c08
update ruby version
Ethan-Stone1 Mar 6, 2026
36ccd15
Bump ruby to 3.3.10
Ethan-Stone1 Mar 6, 2026
4e0f88a
Darwin in the gemfile
Ethan-Stone1 Mar 6, 2026
60d3f72
Fix registration form not displaying validation errors
li-xinwei Mar 11, 2026
000b5ae
Merge pull request #62 from cs169/fix/registration-error-messages
zhouyijun111 Mar 11, 2026
1b9e7a4
Make Duplication one transaction
Ethan-Stone1 Mar 13, 2026
edf6853
Merge pull request #60 from cs169/Duplicate_Event
Ethan-Stone1 Mar 18, 2026
3edb5a8
Merge branch 'main' of https://github.com/cs169/snapcon
Ethan-Stone1 Mar 20, 2026
d8e50e6
Update info.yml
li-xinwei Mar 20, 2026
8f5adc5
Migrate from Stripe Charges API to Stripe Checkout Sessions API
li-xinwei Mar 29, 2026
3a8c3f3
Add conference duplication feature (Iteration 2) (#59)
zhouyijun111 Apr 3, 2026
db28357
Add conference duplication feature (Iteration 2) (#59)
zhouyijun111 Apr 3, 2026
946137e
Add conference duplication feature (Iteration 2) (#59)
zhouyijun111 Apr 3, 2026
cf0ffbd
Merge pull request #59 from cs169/feature/conference-duplication
zhouyijun111 Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.3.8
3.3.10
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
ruby 3.3.8
ruby 3.3.10
nodejs 16.20.2
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -705,6 +709,7 @@ GEM
zeitwerk (2.6.13)

PLATFORMS
arm64-darwin-25
x64-mingw-ucrt
x86_64-linux

Expand Down Expand Up @@ -827,7 +832,7 @@ DEPENDENCIES
whenever

RUBY VERSION
ruby 3.3.10p183
ruby 3.3.8p144

BUNDLED WITH
2.5.6
7 changes: 5 additions & 2 deletions app/assets/stylesheets/osem-payments.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.stripe-button-el {
float: right;
.payment-actions {
margin-top: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
45 changes: 44 additions & 1 deletion app/controllers/admin/conferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/controllers/admin/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,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
Expand Down
72 changes: 51 additions & 21 deletions app/controllers/payments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
82 changes: 82 additions & 0 deletions app/models/conference.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading