spree_cm_commissioner 2.9.1.pre.pre3 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.example +9 -0
- data/Gemfile.lock +2 -2
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +7 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_account_management.rb +80 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +46 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_login_token_generation.rb +53 -0
- data/app/controllers/spree/admin/guests_controller.rb +4 -2
- data/app/controllers/spree/admin/system/load_test_accounts_controller.rb +24 -0
- data/app/controllers/spree/admin/system/load_test_flows_controller.rb +11 -0
- data/app/controllers/spree/admin/system/load_test_login_tokens_controller.rb +16 -0
- data/app/controllers/spree/admin/system/load_testing_controller.rb +9 -0
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +48 -0
- data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -7
- data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +28 -0
- data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +49 -0
- data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -7
- data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +11 -0
- data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +0 -2
- data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +105 -0
- data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +71 -0
- data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +2 -5
- data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +23 -1
- data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +78 -0
- data/app/mailers/spree/order_mailer_decorator.rb +1 -0
- data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +17 -0
- data/app/models/concerns/spree_cm_commissioner/line_items_filter_scope.rb +5 -3
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +11 -0
- data/app/models/concerns/spree_cm_commissioner/product_delegation.rb +2 -0
- data/app/models/spree_cm_commissioner/homepage_section_relatable.rb +25 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +0 -6
- data/app/models/spree_cm_commissioner/payment_method_decorator.rb +2 -0
- data/app/models/spree_cm_commissioner/pricing_model_route.rb +8 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/trip.rb +13 -1
- data/app/models/spree_cm_commissioner/user_decorator.rb +6 -0
- data/app/overrides/spree/admin/payment_methods/index/pagination.html.erb.deface +3 -0
- data/app/overrides/spree/admin/payment_methods/index/preserve_tab_in_search.html.erb.deface +4 -0
- data/app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface +13 -0
- data/app/overrides/spree/admin/variants/index/fixture_action.html.erb.deface +9 -0
- data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +2 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
- data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +3 -1
- data/app/services/spree_cm_commissioner/draft_order/create.rb +86 -0
- data/app/services/spree_cm_commissioner/load_test/create_accounts.rb +68 -0
- data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +50 -0
- data/app/services/spree_cm_commissioner/load_test/flow_registry.rb +26 -0
- data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +140 -0
- data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +178 -0
- data/app/services/spree_cm_commissioner/load_test/login_token.rb +46 -0
- data/app/services/spree_cm_commissioner/order/guests/update.rb +56 -0
- data/app/services/spree_cm_commissioner/pricing_models/create_with_rule_groups.rb +9 -3
- data/app/services/spree_cm_commissioner/pricing_models/preview.rb +49 -6
- data/app/services/spree_cm_commissioner/pricing_models/update_with_rule_groups.rb +10 -7
- data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +6 -1
- data/app/services/spree_cm_commissioner/user_authenticator.rb +22 -1
- data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +13 -2
- data/app/views/spree/admin/featured_trips/index.html.erb +1 -1
- data/app/views/spree/admin/guests/_form.html.erb +37 -0
- data/app/views/spree/admin/shared/_system_tabs.html.erb +7 -0
- data/app/views/spree/admin/system/load_test_accounts/_login_tokens_modal.html.erb +52 -0
- data/app/views/spree/admin/system/load_test_accounts/index.html.erb +175 -0
- data/app/views/spree/admin/system/load_test_flows/index.html.erb +66 -0
- data/app/views/spree/admin/system/load_testing/index.html.erb +25 -0
- data/app/views/spree/admin/variants/fixture.html.erb +49 -0
- data/config/initializers/doorkeeper.rb +10 -6
- data/config/initializers/spree_permitted_attributes.rb +30 -0
- data/config/load_test_flows/platform.yml +25 -0
- data/config/load_test_flows/tenant.yml +46 -0
- data/config/locales/en.yml +94 -0
- data/config/locales/km.yml +2 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260630000001_add_trip_to_cm_pricing_model_routes.rb +7 -0
- data/db/migrate/20260703065239_add_position_to_cm_voting_contestants.rb +1 -1
- data/db/migrate/20260708150000_add_name_and_short_name_to_cm_trips.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/pricing_model_trip_factory.rb +11 -0
- data/lib/spree_cm_commissioner/trip_result.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +39 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Exchanges a Telegram OAuth authorization `code` for an `id_token` at
|
|
3
|
+
# Telegram's token endpoint. This is the server-side half of the mobile/web
|
|
4
|
+
# PKCE login: the client cannot call Telegram's /token directly (native apps
|
|
5
|
+
# have no registered HTTPS origin, and browsers are blocked by CORS), so it
|
|
6
|
+
# relays the exchange through us.
|
|
7
|
+
#
|
|
8
|
+
# Telegram validates the `redirect_uri` *parameter* against a BotFather-
|
|
9
|
+
# registered domain, not the caller's host, so this can safely run from the
|
|
10
|
+
# API server. This Telegram Login Widget client is confidential (it has a
|
|
11
|
+
# client_secret), so PKCE alone isn't enough — Telegram's /token endpoint
|
|
12
|
+
# still requires `client_secret`, which is why it must be injected here
|
|
13
|
+
# server-side rather than sent by the app.
|
|
14
|
+
#
|
|
15
|
+
# Docs: https://core.telegram.org/bots/telegram-login
|
|
16
|
+
class TelegramOauthTokenExchanger < BaseInteractor
|
|
17
|
+
TOKEN_ENDPOINT = 'https://oauth.telegram.org/token'.freeze
|
|
18
|
+
|
|
19
|
+
REQUIRED_PARAMS = %i[client_id code redirect_uri code_verifier].freeze
|
|
20
|
+
|
|
21
|
+
delegate :client_id, :code, :redirect_uri, :code_verifier, to: :context
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
missing = REQUIRED_PARAMS.find { |key| context[key].blank? }
|
|
25
|
+
return context.fail!(message: "#{missing}_missing") if missing
|
|
26
|
+
return context.fail!(message: 'telegram_oauth_client_secret_not_configured') if client_secret.blank?
|
|
27
|
+
|
|
28
|
+
response = post_to_telegram
|
|
29
|
+
|
|
30
|
+
context.status = response.code.to_i
|
|
31
|
+
context.body = response.body
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def client_secret
|
|
37
|
+
ENV['TELEGRAM_OAUTH_CLIENT_SECRET'].presence
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def post_to_telegram
|
|
41
|
+
uri = URI(TOKEN_ENDPOINT)
|
|
42
|
+
request = Net::HTTP::Post.new(uri)
|
|
43
|
+
request.set_form_data(
|
|
44
|
+
'client_id' => client_id,
|
|
45
|
+
'client_secret' => client_secret,
|
|
46
|
+
'code' => code,
|
|
47
|
+
'grant_type' => 'authorization_code',
|
|
48
|
+
'redirect_uri' => redirect_uri,
|
|
49
|
+
'code_verifier' => code_verifier
|
|
50
|
+
)
|
|
51
|
+
# Telegram's /token endpoint also checks the caller against the client's
|
|
52
|
+
# registered Trusted Origins. A plain server-to-server POST sends no
|
|
53
|
+
# Origin header by default, so without this Telegram rejects the
|
|
54
|
+
# exchange with {"error":"invalid_client"} even though the auth code
|
|
55
|
+
# itself was issued successfully moments earlier.
|
|
56
|
+
request['Origin'] = origin_from(redirect_uri)
|
|
57
|
+
|
|
58
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
CmAppLogger.error(label: 'telegram_token_exchange_failed', data: e.message)
|
|
61
|
+
context.fail!(message: 'telegram_token_exchange_failed', error: e.message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def origin_from(url)
|
|
65
|
+
uri = URI(url)
|
|
66
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
67
|
+
origin += ":#{uri.port}" if uri.port && uri.port != uri.default_port
|
|
68
|
+
origin
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -2,11 +2,8 @@ module SpreeCmCommissioner
|
|
|
2
2
|
class UserIdTokenAuthenticator < BaseInteractor
|
|
3
3
|
# :id_token, :tenant_id (optional)
|
|
4
4
|
def call
|
|
5
|
-
context.user =
|
|
6
|
-
|
|
7
|
-
else
|
|
8
|
-
checker.user
|
|
9
|
-
end
|
|
5
|
+
context.user = checker.user || register_user
|
|
6
|
+
context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.nil?
|
|
10
7
|
|
|
11
8
|
update_user_email if context.user.email.blank?
|
|
12
9
|
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
class UserPasswordAuthenticator < BaseInteractor
|
|
3
|
-
delegate :login, :password, :tenant_id, to: :context
|
|
3
|
+
delegate :login, :password, :login_token, :tenant_id, to: :context
|
|
4
4
|
|
|
5
5
|
def call
|
|
6
|
+
return authenticate_with_login_token if login_token.present?
|
|
7
|
+
|
|
6
8
|
context.user = Spree.user_class.find_user_by_login(login, tenant_id)
|
|
7
9
|
context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.nil?
|
|
8
10
|
|
|
11
|
+
# Load-test accounts have no usable password — they authenticate only via login_token above.
|
|
12
|
+
context.fail!(message: I18n.t('authenticator.incorrect_login')) if context.user.load_test_account?
|
|
13
|
+
|
|
9
14
|
if spree_confirmable? && active_for_authentication? && !validate_password(context.user)
|
|
10
15
|
context.fail!(message: I18n.t('authenticator.incorrect_password'))
|
|
11
16
|
end
|
|
@@ -16,6 +21,23 @@ module SpreeCmCommissioner
|
|
|
16
21
|
|
|
17
22
|
private
|
|
18
23
|
|
|
24
|
+
# Load-test accounts have no real password (see rake load_test:create_accounts) — instead they
|
|
25
|
+
# authenticate with a stateless, signed id_token (JWT). k6 sends it as the `id_token` param on
|
|
26
|
+
# grant_type=password with no username/password; UserAuthenticator routes it here as a
|
|
27
|
+
# login_token. The token is self-identifying, like a Google id_token: its `sub` names the
|
|
28
|
+
# account it logs in as, verified by signature + expiry via LoginToken.decode (no DB-stored
|
|
29
|
+
# secret, no email lookup). Tenant scoping is enforced afterward by
|
|
30
|
+
# UserAuthenticator#validate_tenant_match!.
|
|
31
|
+
def authenticate_with_login_token
|
|
32
|
+
payload = SpreeCmCommissioner::LoadTest::LoginToken.decode(login_token)
|
|
33
|
+
context.fail!(message: I18n.t('authenticator.incorrect_login')) if payload.nil?
|
|
34
|
+
|
|
35
|
+
user = Spree.user_class.find_by(id: payload['sub'])
|
|
36
|
+
context.fail!(message: I18n.t('authenticator.incorrect_login')) if user.nil? || !user.load_test_account?
|
|
37
|
+
|
|
38
|
+
context.user = user
|
|
39
|
+
end
|
|
40
|
+
|
|
19
41
|
def validate_password(user)
|
|
20
42
|
user.valid_for_authentication? { user.valid_password?(password) }
|
|
21
43
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Authenticates a user from a verified Telegram OIDC id_token. Resolves an
|
|
3
|
+
# existing user by their telegram identity (sub) or registers a new one.
|
|
4
|
+
# Mirrors the inline identity handling of UserTelegramWebAppAuthenticator so
|
|
5
|
+
# that both Telegram login flows resolve to the same UserIdentityProvider.
|
|
6
|
+
class UserTelegramOauthAuthenticator < BaseInteractor
|
|
7
|
+
delegate :id_token, :tenant_id, to: :context
|
|
8
|
+
|
|
9
|
+
# :id_token, :tenant_id (optional)
|
|
10
|
+
def call
|
|
11
|
+
context.fail!(message: 'telegram_id_token_missing') if id_token.blank?
|
|
12
|
+
|
|
13
|
+
provider = verified_provider
|
|
14
|
+
context.user = find_or_register_user(provider)
|
|
15
|
+
|
|
16
|
+
context.fail!(message: 'account_temporarily_deleted') if context.user.soft_deleted?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def verified_provider
|
|
22
|
+
result = SpreeCmCommissioner::TelegramOauthIdTokenProvider.call(id_token: id_token)
|
|
23
|
+
context.fail!(message: result.message) unless result.success?
|
|
24
|
+
|
|
25
|
+
result.provider
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_or_register_user(provider)
|
|
29
|
+
identity_checker = SpreeCmCommissioner::UserIdentityChecker.call(
|
|
30
|
+
identity_type: provider[:identity_type],
|
|
31
|
+
sub: provider[:sub],
|
|
32
|
+
tenant_id: tenant_id
|
|
33
|
+
)
|
|
34
|
+
return identity_checker.user if identity_checker.success?
|
|
35
|
+
|
|
36
|
+
register_user(provider)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def register_user(provider)
|
|
40
|
+
ActiveRecord::Base.transaction do
|
|
41
|
+
user = build_user(provider)
|
|
42
|
+
user.save!
|
|
43
|
+
link_identity!(user, provider)
|
|
44
|
+
user
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_user(provider)
|
|
49
|
+
user = Spree.user_class.new(
|
|
50
|
+
email: provider[:email],
|
|
51
|
+
tenant_id: tenant_id,
|
|
52
|
+
password: SecureRandom.base64(16),
|
|
53
|
+
**name_attributes(provider[:name])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Skip presence validation for email/login/phone_number for social signups.
|
|
57
|
+
user.registering_via_social_network = true
|
|
58
|
+
user.confirmed_at = Time.zone.now
|
|
59
|
+
user
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def link_identity!(user, provider)
|
|
63
|
+
user.user_identity_providers.create!(
|
|
64
|
+
identity_type: :telegram,
|
|
65
|
+
sub: provider[:sub],
|
|
66
|
+
name: provider[:username].presence || provider[:name]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def name_attributes(name)
|
|
71
|
+
full_name = name.to_s.strip
|
|
72
|
+
return {} if full_name.blank?
|
|
73
|
+
|
|
74
|
+
parts = full_name.split
|
|
75
|
+
{ first_name: parts[0], last_name: parts[1..].join(' ').presence }.compact
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -25,6 +25,7 @@ module Spree
|
|
|
25
25
|
def confirm_email(order, resend: false)
|
|
26
26
|
@order = order.respond_to?(:id) ? order : Spree::Order.find(order)
|
|
27
27
|
return false if @order.email.blank?
|
|
28
|
+
return false if @order.products.all?(&:vote_package?) # disable confirmation email for vote package orders
|
|
28
29
|
|
|
29
30
|
setup_tenant_and_store
|
|
30
31
|
if @tenant.present?
|
|
@@ -36,6 +36,23 @@ module SpreeCmCommissioner
|
|
|
36
36
|
guest_id_card
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
39
|
+
# Maps each KYC bit field to the actual Guest attribute(s) it stores.
|
|
40
|
+
KYC_FIELD_ATTRIBUTES = {
|
|
41
|
+
guest_name: %i[first_name last_name],
|
|
42
|
+
guest_gender: %i[gender],
|
|
43
|
+
guest_dob: %i[dob],
|
|
44
|
+
guest_occupation: %i[occupation_id other_occupation],
|
|
45
|
+
guest_nationality: %i[nationality_id],
|
|
46
|
+
guest_age: %i[age],
|
|
47
|
+
guest_emergency_contact: %i[emergency_contact],
|
|
48
|
+
guest_organization: %i[other_organization],
|
|
49
|
+
guest_expectation: %i[expectation],
|
|
50
|
+
guest_social_contact: %i[social_contact_platform social_contact],
|
|
51
|
+
guest_address: %i[address],
|
|
52
|
+
guest_phone_number: %i[phone_number],
|
|
53
|
+
guest_contact: %i[contact]
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
39
56
|
def kyc? = kyc != 0
|
|
40
57
|
|
|
41
58
|
BIT_FIELDS.each do |field, bit_value|
|
|
@@ -19,15 +19,17 @@ module SpreeCmCommissioner
|
|
|
19
19
|
scope :filter_by_event, lambda { |event|
|
|
20
20
|
case event
|
|
21
21
|
when 'upcoming'
|
|
22
|
-
complete_or_canceled.paid.where.not(to_date: nil).where(
|
|
22
|
+
complete_or_canceled.paid.where.not(to_date: nil).where.not(product_type: 'vote_package')
|
|
23
|
+
.where('to_date >= ?', Time.zone.now)
|
|
23
24
|
.joins(:order).where(spree_orders: { canceled_at: nil })
|
|
24
25
|
|
|
25
26
|
when 'complete'
|
|
26
|
-
complete_or_canceled.paid.where.not(to_date: nil).where(
|
|
27
|
+
complete_or_canceled.paid.where.not(to_date: nil).where.not(product_type: 'vote_package')
|
|
28
|
+
.where('to_date < ?', Time.zone.now)
|
|
27
29
|
.joins(:order).where(spree_orders: { canceled_at: nil })
|
|
28
30
|
|
|
29
31
|
when 'canceled'
|
|
30
|
-
canceled
|
|
32
|
+
canceled.where.not(product_type: 'vote_package')
|
|
31
33
|
else
|
|
32
34
|
none
|
|
33
35
|
end
|
|
@@ -21,6 +21,8 @@ module SpreeCmCommissioner
|
|
|
21
21
|
|
|
22
22
|
state_machine.after_transition to: :complete, do: :mark_user_as_has_incomplete_guest_info, if: :has_incomplete_guest_info?
|
|
23
23
|
state_machine.after_transition to: :complete, do: :send_transaction_email_to_user, if: :user_has_email?
|
|
24
|
+
|
|
25
|
+
state_machine.before_transition to: :resumed, do: :clear_cancellation_details
|
|
24
26
|
state_machine.after_transition to: :resumed, do: :precalculate_conversion
|
|
25
27
|
state_machine.around_transition to: :resumed, do: :unstock_inventory!
|
|
26
28
|
|
|
@@ -241,6 +243,7 @@ module SpreeCmCommissioner
|
|
|
241
243
|
def send_transaction_email_to_user
|
|
242
244
|
line_items.each do |line_item|
|
|
243
245
|
next if line_item.event.nil?
|
|
246
|
+
next unless line_item.enable_social_sharing_email?
|
|
244
247
|
|
|
245
248
|
SpreeCmCommissioner::EventTransactionalMailer.send_to_participant(line_item.event_id, user.id).deliver_later
|
|
246
249
|
end
|
|
@@ -370,5 +373,13 @@ module SpreeCmCommissioner
|
|
|
370
373
|
def de_allocate_resources
|
|
371
374
|
SpreeCmCommissioner::VotingCreditDeAllocationJob.perform_later(order_id: id)
|
|
372
375
|
end
|
|
376
|
+
|
|
377
|
+
# Reset the cancellation info when an order is resumed.
|
|
378
|
+
# We only assign the values here; the resume transition already saves the order,
|
|
379
|
+
# so they get saved together with it (one validated save, no update_columns).
|
|
380
|
+
def clear_cancellation_details
|
|
381
|
+
self.canceled_at = nil
|
|
382
|
+
self.canceler_id = nil
|
|
383
|
+
end
|
|
373
384
|
end
|
|
374
385
|
end
|
|
@@ -18,6 +18,31 @@ module SpreeCmCommissioner
|
|
|
18
18
|
(discontinue_on.nil? || discontinue_on >= current_time)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# This constant help preview sections to get the relatable class from the previewable_type of the preview role.
|
|
22
|
+
RELATABLE_CLASS_BY_PREVIEWABLE_TYPE = {
|
|
23
|
+
'Spree::Taxon' => 'Spree::Taxon',
|
|
24
|
+
'SpreeCmCommissioner::Show' => 'Spree::Taxon',
|
|
25
|
+
'Spree::Product' => 'Spree::Product'
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# homepage_section ids reachable through a user's preview roles
|
|
29
|
+
def self.section_ids_previewable_by(user)
|
|
30
|
+
# get previewable_id/previewable_type from the user's preview roles,
|
|
31
|
+
# eg: [[798, "SpreeCmCommissioner::Show"], [245, "Spree::Product"], [449, "Spree::Taxon"]
|
|
32
|
+
# Then batch the ids by relatable_type so we run one query per type instead of one per row
|
|
33
|
+
# eg. {"Spree::Taxon" => [798, 449], "Spree::Product" => [245]}
|
|
34
|
+
ids_by_relatable_type = user.preview_roles.pluck(:previewable_id, :previewable_type)
|
|
35
|
+
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(id, previewable_type), acc|
|
|
36
|
+
relatable_type = RELATABLE_CLASS_BY_PREVIEWABLE_TYPE[previewable_type]
|
|
37
|
+
acc[relatable_type] << id if relatable_type
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# find homepage_section_relatables matching those ids/types, then return the unique homepage_section ids
|
|
41
|
+
ids_by_relatable_type.flat_map do |relatable_type, ids|
|
|
42
|
+
where(relatable_type: relatable_type, relatable_id: ids).pluck(:homepage_section_id)
|
|
43
|
+
end.uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
private
|
|
22
47
|
|
|
23
48
|
def update_homepage_section
|
|
@@ -71,12 +71,6 @@ module SpreeCmCommissioner
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# override
|
|
75
|
-
def after_resume
|
|
76
|
-
super
|
|
77
|
-
update_columns(canceled_at: nil, canceler_id: nil) # rubocop:disable Rails/SkipsModelValidations
|
|
78
|
-
end
|
|
79
|
-
|
|
80
74
|
# override to allow storing cancellation reason in one DB write
|
|
81
75
|
def canceled_by(user, cancellation_reason: nil)
|
|
82
76
|
transaction do
|
|
@@ -5,6 +5,8 @@ module SpreeCmCommissioner
|
|
|
5
5
|
def self.prepended(base)
|
|
6
6
|
base.const_set(:DISPLAY, DISPLAY)
|
|
7
7
|
|
|
8
|
+
base.whitelisted_ransackable_attributes = %w[name]
|
|
9
|
+
|
|
8
10
|
# Expose a dedicated class helper so services can call Spree::PaymentMethod.ticket_transfer
|
|
9
11
|
# instead of duplicating lookup/creation logic.
|
|
10
12
|
base.define_singleton_method(:ticket_transfer) do
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
|
+
# A pricing-model assignment. A row is either:
|
|
3
|
+
# - route-scoped: trip_id NULL, keyed by route + O-D (unchanged legacy behaviour), or
|
|
4
|
+
# - trip-scoped: trip_id set, keyed by that specific trip + its own O-D (overrides route pricing
|
|
5
|
+
# for that trip only).
|
|
2
6
|
class PricingModelRoute < Base
|
|
3
7
|
belongs_to :route, class_name: 'SpreeCmCommissioner::Route'
|
|
8
|
+
belongs_to :trip, class_name: 'SpreeCmCommissioner::Trip', optional: true
|
|
4
9
|
belongs_to :pricing_model, class_name: 'SpreeCmCommissioner::PricingModel'
|
|
5
10
|
belongs_to :origin_place, class_name: 'SpreeCmCommissioner::Place'
|
|
6
11
|
belongs_to :destination_place, class_name: 'SpreeCmCommissioner::Place'
|
|
7
12
|
|
|
13
|
+
scope :route_scoped, -> { where(trip_id: nil) }
|
|
14
|
+
scope :trip_scoped, -> { where.not(trip_id: nil) }
|
|
15
|
+
|
|
8
16
|
scope :for_origin_destination, lambda { |origin_place_id, destination_place_id|
|
|
9
17
|
where(origin_place_id: origin_place_id, destination_place_id: destination_place_id)
|
|
10
18
|
}
|
|
@@ -134,6 +134,7 @@ module SpreeCmCommissioner
|
|
|
134
134
|
base.store_public_metadata :enable_inventory_hold, :boolean, default: false
|
|
135
135
|
base.store_public_metadata :enable_telegram_alert, :boolean, default: true
|
|
136
136
|
base.store_public_metadata :enable_send_receipt, :boolean, default: true
|
|
137
|
+
base.store_public_metadata :enable_social_sharing_email, :boolean, default: false
|
|
137
138
|
|
|
138
139
|
base.store_private_metadata :advertise_weight, :integer, default: 1
|
|
139
140
|
base.store_private_metadata :video_url, :string
|
|
@@ -22,6 +22,8 @@ module SpreeCmCommissioner
|
|
|
22
22
|
# before create, duplicate seat layout from vehicle_type if present
|
|
23
23
|
before_validation :duplicate_seat_layout_from_vehicle, if: -> { seat_layout.nil? && vehicle_type&.seat_layout.present? }
|
|
24
24
|
|
|
25
|
+
before_validation :set_name_and_short_name_from_product, on: :create
|
|
26
|
+
|
|
25
27
|
after_create :increment_route_metric_trip_count
|
|
26
28
|
after_create :increment_vendor_route_metric_trip_count
|
|
27
29
|
|
|
@@ -52,6 +54,9 @@ module SpreeCmCommissioner
|
|
|
52
54
|
has_many :trip_blazer_queries, as: :queryable, class_name: 'SpreeCmCommissioner::BlazerQueryable'
|
|
53
55
|
has_many :blazer_queries, through: :trip_blazer_queries, source: :blazer_query, class_name: 'Blazer::Query'
|
|
54
56
|
|
|
57
|
+
has_many :pricing_model_routes, class_name: 'SpreeCmCommissioner::PricingModelRoute', dependent: :destroy
|
|
58
|
+
has_many :pricing_models, through: :pricing_model_routes, class_name: 'SpreeCmCommissioner::PricingModel'
|
|
59
|
+
|
|
55
60
|
has_many :trip_stops, class_name: 'SpreeCmCommissioner::TripStop', dependent: :destroy
|
|
56
61
|
has_many :boarding_trip_stops, -> { boarding.order('departure_time ASC NULLS LAST, id ASC') }, class_name: 'SpreeCmCommissioner::TripStop'
|
|
57
62
|
has_many :drop_off_trip_stops, -> { drop_off.order('arrival_time ASC NULLS LAST') }, class_name: 'SpreeCmCommissioner::TripStop'
|
|
@@ -65,6 +70,7 @@ module SpreeCmCommissioner
|
|
|
65
70
|
has_many :inventory_items, through: :variants
|
|
66
71
|
has_many :blocks, through: :variants
|
|
67
72
|
|
|
73
|
+
validates :name, presence: true
|
|
68
74
|
validates :departure_time, presence: true
|
|
69
75
|
validates :duration, numericality: { greater_than: 0 }
|
|
70
76
|
|
|
@@ -120,7 +126,7 @@ module SpreeCmCommissioner
|
|
|
120
126
|
end
|
|
121
127
|
|
|
122
128
|
def display_name
|
|
123
|
-
|
|
129
|
+
self[:name]
|
|
124
130
|
end
|
|
125
131
|
|
|
126
132
|
def featured?
|
|
@@ -129,6 +135,12 @@ module SpreeCmCommissioner
|
|
|
129
135
|
|
|
130
136
|
private
|
|
131
137
|
|
|
138
|
+
# Snapshot product names on trip creation. Runs before validation
|
|
139
|
+
def set_name_and_short_name_from_product
|
|
140
|
+
self[:name] ||= product&.name
|
|
141
|
+
self[:short_name] ||= product&.short_name
|
|
142
|
+
end
|
|
143
|
+
|
|
132
144
|
def resolve_service_origin_id
|
|
133
145
|
route&.service_origin_id || vendor&.service_origin_id
|
|
134
146
|
end
|
|
@@ -60,6 +60,12 @@ module SpreeCmCommissioner
|
|
|
60
60
|
base.store_public_metadata :qr_data_invalidated_at, :datetime
|
|
61
61
|
base.store_public_metadata :branch_ids, :array, default: []
|
|
62
62
|
|
|
63
|
+
# Private - marks accounts created by rake load_test:create_accounts. Real passwords never
|
|
64
|
+
# work for these accounts (see UserPasswordAuthenticator) — login is only possible by sending
|
|
65
|
+
# a stateless, signed JWT (minted on demand from the admin UI, see LoadTest::LoginToken) as
|
|
66
|
+
# the id_token param. Nothing token-related is stored on the account itself.
|
|
67
|
+
base.store_private_metadata :load_test_account, :boolean, default: false
|
|
68
|
+
|
|
63
69
|
# Validations
|
|
64
70
|
base.validates :primary_service_type,
|
|
65
71
|
inclusion: { in: SpreeCmCommissioner::ServiceType::SERVICE_TYPES.map(&:to_s) },
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<div>
|
|
4
|
+
<%= f.field_container :enable_social_sharing_email do %>
|
|
5
|
+
<%= f.label :enable_social_sharing_email do %>
|
|
6
|
+
<%= f.check_box :enable_social_sharing_email %>
|
|
7
|
+
<strong><%= Spree.t(:enable_social_sharing_email) %></strong>
|
|
8
|
+
<% end %>
|
|
9
|
+
<small class="form-text text-muted">
|
|
10
|
+
When enabled, participants receive a transaction confirmation email after their order for this event is completed.
|
|
11
|
+
</small>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- insert_after "erb[loud]:contains('link_to_delete(variant, no_text: true)')" -->
|
|
2
|
+
|
|
3
|
+
<% if can?(:edit, variant) && !variant.deleted? %>
|
|
4
|
+
<%= link_to fixture_admin_product_variant_path(@product, variant),
|
|
5
|
+
class: 'btn btn-light btn-sm with-tip icon-link',
|
|
6
|
+
data: {title: Spree.t(:fixture, default: 'Fixture') } do %>
|
|
7
|
+
<%= svg_icon name: "clipboard-data.svg", width: '12', height: '12' %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
@@ -245,6 +245,8 @@ module SpreeCmCommissioner
|
|
|
245
245
|
def build_trip_result(trip:, trip_row:, parent_trip:)
|
|
246
246
|
SpreeCmCommissioner::TripResult.new(
|
|
247
247
|
id: trip.id,
|
|
248
|
+
name: trip.display_name,
|
|
249
|
+
short_name: trip.short_name,
|
|
248
250
|
departure_time: trip.departure_time,
|
|
249
251
|
duration: trip.duration,
|
|
250
252
|
distance: trip.distance,
|
|
@@ -15,8 +15,8 @@ module SpreeCmCommissioner
|
|
|
15
15
|
expires_in: 30.minutes
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
attributes :origin_place, :destination_place, :stops, :price, :compare_at_price, :currency, :quantity_available,
|
|
19
|
-
:allow_seat_selection, :departure_time, :route_type, :distance, :offset_days,
|
|
18
|
+
attributes :name, :short_name, :origin_place, :destination_place, :stops, :price, :compare_at_price, :currency, :quantity_available,
|
|
19
|
+
:max_capacity, :allow_seat_selection, :departure_time, :route_type, :distance, :offset_days,
|
|
20
20
|
:featured_until, :display_price, :display_compare_at_price,
|
|
21
21
|
:open_dated_product_id, :open_dated_price, :open_dated_compare_at_price
|
|
22
22
|
|
|
@@ -2,7 +2,9 @@ module SpreeCmCommissioner
|
|
|
2
2
|
module V2
|
|
3
3
|
module Storefront
|
|
4
4
|
class TripSerializer < BaseSerializer
|
|
5
|
-
attributes :departure_time, :arrival_time, :duration_in_hms, :route_type, :allow_seat_selection
|
|
5
|
+
attributes :departure_time, :arrival_time, :duration_in_hms, :route_type, :allow_seat_selection, :short_name
|
|
6
|
+
|
|
7
|
+
attribute :name, &:display_name
|
|
6
8
|
|
|
7
9
|
has_one :vehicle_type, serializer: ::SpreeCmCommissioner::V2::Storefront::TripVehicleTypeSerializer
|
|
8
10
|
has_one :seat_layout, serializer: SpreeCmCommissioner::V2::Storefront::SeatLayoutSerializer
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SpreeCmCommissioner::DraftOrder::Create builds a fresh order from a locally-assembled
|
|
2
|
+
# list of line items in a single request, then advances it to `address` to hold inventory
|
|
3
|
+
# where the product type requires it.
|
|
4
|
+
#
|
|
5
|
+
# This is the generic counterpart of SpreeCmCommissioner::TransitOrder::Create: instead of
|
|
6
|
+
# transit legs/seats it takes plain `line_items` params, so the app can browse/add entirely
|
|
7
|
+
# offline and only touch the server once, at Continue.
|
|
8
|
+
#
|
|
9
|
+
# Each line item is built the same way Spree::Cart::AddItem builds one: `options` is filtered
|
|
10
|
+
# down to Spree::PermittedAttributes.line_item_attributes and passed through `options:` to
|
|
11
|
+
# `order.line_items.new`, which routes it through LineItem#options= (`update_price_from_modifier`)
|
|
12
|
+
# — the same price computation the regular add-to-cart path uses, including per-date pricing for
|
|
13
|
+
# permanent-stock variants (see LineItemDecorator#update_price_from_modifier /
|
|
14
|
+
# Variant#price_for_date). guests_attributes rides through the same way, via the standard
|
|
15
|
+
# `accepts_nested_attributes_for :guests` writer.
|
|
16
|
+
#
|
|
17
|
+
# Attributes:
|
|
18
|
+
# - line_items: [Array<Hash>] each { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} }
|
|
19
|
+
# options may carry from_date/to_date (service/permanent-stock products) and guests_attributes
|
|
20
|
+
# (seat-selection products, [{ block_id: }, ...])
|
|
21
|
+
# - user: [Spree::User] the user for whom the order is being created (nil for guest checkout)
|
|
22
|
+
# - store: [Spree::Store] the current store (defaults to Spree::Store.default when nil)
|
|
23
|
+
# - currency: [String] the order currency (defaults to the store currency when nil)
|
|
24
|
+
# - tenant: [SpreeCmCommissioner::Tenant] the tenant the order is created under (nil outside tenant scope)
|
|
25
|
+
module SpreeCmCommissioner
|
|
26
|
+
module DraftOrder
|
|
27
|
+
class Create
|
|
28
|
+
prepend ::Spree::ServiceModule::Base
|
|
29
|
+
|
|
30
|
+
def call(line_items:, user: nil, store: nil, currency: nil, tenant: nil)
|
|
31
|
+
return failure(nil, I18n.t('spree_cm_commissioner.draft_order.line_items_missing')) if line_items.blank?
|
|
32
|
+
|
|
33
|
+
order = create_order!(line_items, user, store, currency, tenant)
|
|
34
|
+
success(order: order)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
failure(nil, e.message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_order!(line_items_params, user, store, currency, tenant)
|
|
40
|
+
store ||= Spree::Store.default
|
|
41
|
+
currency ||= store&.default_currency
|
|
42
|
+
order = Spree::Order.new(state: 'cart', use_billing: true, user: user, store: store, currency: currency, tenant: tenant)
|
|
43
|
+
line_items_params.each { |line_item_params| build_line_item!(order, store, line_item_params) }
|
|
44
|
+
|
|
45
|
+
ActiveRecord::Base.transaction do
|
|
46
|
+
raise StandardError, order_error_message(order) unless order.save
|
|
47
|
+
|
|
48
|
+
order.update_with_updater!
|
|
49
|
+
|
|
50
|
+
# Build blank guests per line item so the client can render guest fields offline.
|
|
51
|
+
order.line_items.each(&:generate_remaining_guests)
|
|
52
|
+
|
|
53
|
+
raise StandardError, order_error_message(order) if order.has_checkout_step?('address') && !order.next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
order
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Builds (unsaved) an order.line_items member — persisted together with the order via
|
|
60
|
+
# order.save's cascading autosave of new has_many records, same as TransitOrder::Create.
|
|
61
|
+
def build_line_item!(order, store, line_item_params)
|
|
62
|
+
variant = store.variants.find(line_item_params[:variant_id])
|
|
63
|
+
options = (line_item_params[:options] || {}).to_h.with_indifferent_access
|
|
64
|
+
permitted_opts = ::Spree::PermittedAttributes.line_item_attributes.flatten.index_with { |attribute| options[attribute] }
|
|
65
|
+
opts = permitted_opts.merge(currency: order.currency).compact
|
|
66
|
+
|
|
67
|
+
order.line_items.new(
|
|
68
|
+
variant: variant,
|
|
69
|
+
quantity: line_item_params[:quantity],
|
|
70
|
+
options: opts,
|
|
71
|
+
public_metadata: (line_item_params[:public_metadata] || {}).to_h,
|
|
72
|
+
private_metadata: (line_item_params[:private_metadata] || {}).to_h
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# order.errors already includes line item errors (accepts_nested_attributes_for autosaves them
|
|
77
|
+
# in). Skip full_message's attribute prefix when the validator already wrote a full sentence,
|
|
78
|
+
# e.g. before: "Quantity Not enough stock" -> after: "Not enough stock".
|
|
79
|
+
def order_error_message(order)
|
|
80
|
+
order.errors.map do |error|
|
|
81
|
+
error.raw_type.is_a?(String) || error.options[:message].is_a?(String) ? error.message : error.full_message
|
|
82
|
+
end.uniq.to_sentence
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|