spree_cm_commissioner 2.9.4 → 2.9.5
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/Gemfile.lock +1 -1
- data/app/controllers/concerns/spree_cm_commissioner/turnstile_protectable.rb +9 -7
- data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +1 -0
- data/app/controllers/spree/api/v2/operator/guest_json_gzips_controller.rb +14 -4
- data/app/controllers/spree/api/v2/storefront/account_checker_controller.rb +4 -0
- data/app/controllers/spree/api/v2/tenant/account/orders_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/my_votes_controller.rb +51 -0
- data/app/controllers/spree/api/v2/tenant/show_people_controller.rb +1 -4
- data/app/controllers/spree/api/v2/tenant/show_person_assignments_controller.rb +4 -1
- data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +6 -2
- data/app/interactors/spree_cm_commissioner/host_matcher.rb +6 -5
- data/app/interactors/spree_cm_commissioner/turnstile_token_validator.rb +43 -13
- data/app/models/concerns/spree_cm_commissioner/order_qr_codeable.rb +39 -0
- data/app/models/concerns/spree_cm_commissioner/order_scopes.rb +5 -0
- data/app/models/spree_cm_commissioner/check_in_session.rb +36 -0
- data/app/models/spree_cm_commissioner/exports/operator_guest_json_gzip.rb +26 -1
- data/app/models/spree_cm_commissioner/guest.rb +4 -0
- data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +9 -1
- data/app/models/spree_cm_commissioner/order_decorator.rb +2 -37
- data/app/models/spree_cm_commissioner/role_decorator.rb +41 -4
- data/app/models/spree_cm_commissioner/role_user_decorator.rb +11 -0
- data/app/models/spree_cm_commissioner/show_person.rb +5 -1
- data/app/models/spree_cm_commissioner/show_person_assignment.rb +1 -1
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -0
- data/app/models/spree_cm_commissioner/tenant.rb +17 -6
- data/app/models/spree_cm_commissioner/user_decorator.rb +32 -0
- data/app/models/spree_cm_commissioner/voting_contestant.rb +2 -0
- data/app/queries/spree_cm_commissioner/event_chart_queries.rb +23 -7
- data/app/queries/spree_cm_commissioner/guest_searcher_query.rb +16 -3
- data/app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb +1 -0
- data/app/queries/spree_cm_commissioner/user_votes_per_contestant_query.rb +48 -0
- data/app/serializers/spree/v2/tenant/my_vote_serializer.rb +19 -0
- data/app/serializers/spree/v2/tenant/show_serializer.rb +2 -1
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +2 -0
- data/app/services/spree_cm_commissioner/check_ins/create_bulk.rb +10 -6
- data/app/services/spree_cm_commissioner/imports/contestants/create.rb +19 -1
- data/app/services/spree_cm_commissioner/operator_guest_json_gzips/create.rb +14 -7
- data/app/services/spree_cm_commissioner/organizer/export_guest_csv_service.rb +12 -11
- data/app/services/spree_cm_commissioner/show_contestants/normalize_video_highlights.rb +5 -1
- data/app/views/spree/admin/tenants/_form.html.erb +13 -1
- data/config/locales/en.yml +12 -7
- data/config/locales/km.yml +12 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260720000001_add_check_in_fields_to_spree_roles.rb +11 -0
- data/db/migrate/20260723000000_add_payment_host_to_cm_tenants.rb +6 -0
- data/db/migrate/20260723000001_add_metadata_to_spree_roles.rb +11 -0
- data/db/migrate/20260728074153_add_role_to_cm_exports.rb +5 -0
- data/lib/spree_cm_commissioner/test_helper/factories/vehicle_factory.rb +1 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b5fced09e0795a88c5dfc1cd5ef84584dc557c0c8f0c26f0c1ed8f0c62f5b9a
|
|
4
|
+
data.tar.gz: 612e81f2f3b4f7e15e6481c9910523fabc9fed36c9b8b265c83bd9fa1834b826
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 460533fe4a445145c9edd9e4c0fa1c9fa4c4fc6153105f0dea7e9cf2e873e423e6f967ba734931b5206a9309706a40d92c0a0a13b93d3c59bc57d65fc8be023f
|
|
7
|
+
data.tar.gz: 5f11f494bc107000bd15cf51a6ba29f43c64436f8803257f793488fce84f8d4549c15057f2c5b169460cc601a9fd5cea86bf5ec044999467fe013f9a5c6e5778
|
data/Gemfile.lock
CHANGED
|
@@ -27,13 +27,15 @@ module SpreeCmCommissioner
|
|
|
27
27
|
return unless result.failure?
|
|
28
28
|
|
|
29
29
|
render json: {
|
|
30
|
-
errors: [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
errors: [
|
|
31
|
+
{
|
|
32
|
+
status: '403',
|
|
33
|
+
error_code: 'human_verification_failed',
|
|
34
|
+
title: I18n.t('turnstile.verification_failed'),
|
|
35
|
+
detail: result.message,
|
|
36
|
+
turnstile_error_code: result.respond_to?(:error_code) ? result.error_code : nil
|
|
37
|
+
}
|
|
38
|
+
]
|
|
37
39
|
}, status: :forbidden
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -26,6 +26,8 @@ module Spree
|
|
|
26
26
|
|
|
27
27
|
result = SpreeCmCommissioner::OperatorGuestJsonGzips::Create.call(
|
|
28
28
|
exportable: exportable,
|
|
29
|
+
# Scope the offline file to the requesting operator's check-in role (nil = unrestricted).
|
|
30
|
+
role: spree_current_user.check_in_role_for(exportable),
|
|
29
31
|
force_create: false,
|
|
30
32
|
options: {
|
|
31
33
|
created_by_id: spree_current_user.id.to_s,
|
|
@@ -53,11 +55,19 @@ module Spree
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
# override
|
|
58
|
+
# Scope the list to the requesting operator's check-in group, the same way the create cache
|
|
59
|
+
# does — otherwise a restricted crew could list (and download via exported_file_url) other
|
|
60
|
+
# groups' files for the event.
|
|
56
61
|
def scope
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
exportable = Spree::Taxon.find(params[:taxon_id])
|
|
63
|
+
|
|
64
|
+
SpreeCmCommissioner::Exports::OperatorGuestJsonGzip
|
|
65
|
+
.where(
|
|
66
|
+
exportable_type: 'Spree::Taxon',
|
|
67
|
+
exportable_id: params[:taxon_id],
|
|
68
|
+
role_id: spree_current_user.check_in_role_for(exportable)&.id
|
|
69
|
+
)
|
|
70
|
+
.order(created_at: :desc)
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
# override
|
|
@@ -3,6 +3,10 @@ module Spree
|
|
|
3
3
|
module V2
|
|
4
4
|
module Storefront
|
|
5
5
|
class AccountCheckerController < Spree::Api::V2::BaseController
|
|
6
|
+
include SpreeCmCommissioner::TurnstileProtectable
|
|
7
|
+
|
|
8
|
+
before_action :verify_turnstile!, only: :index
|
|
9
|
+
|
|
6
10
|
def index
|
|
7
11
|
checker = SpreeCmCommissioner::ExistingAccountChecker.call(filter_params.to_h)
|
|
8
12
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V2
|
|
4
|
+
module Tenant
|
|
5
|
+
# Returns the session's contestants each annotated with the current
|
|
6
|
+
# user's own vote tally (my_vote_count).
|
|
7
|
+
#
|
|
8
|
+
# Kept separate from the public VotingContestantsController — that
|
|
9
|
+
# endpoint does not require an authenticated user and is cacheable,
|
|
10
|
+
# whereas this one is strictly per-user and must never be cached.
|
|
11
|
+
class MyVotesController < BaseController
|
|
12
|
+
before_action :require_spree_current_user
|
|
13
|
+
before_action :load_voting_session
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
render_serialized_payload do
|
|
17
|
+
serialize_collection(collection)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def collection
|
|
24
|
+
@collection ||= SpreeCmCommissioner::UserVotesPerContestantQuery.new(
|
|
25
|
+
user: spree_current_user,
|
|
26
|
+
voting_session: @voting_session
|
|
27
|
+
).contestants
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load_voting_session
|
|
31
|
+
@voting_session = current_vendor.voting_sessions.find(params[:voting_session_id])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Overrided: skip collection_options (pagination/links) — this is a
|
|
35
|
+
# bounded per-session collection, not a paginated one.
|
|
36
|
+
def serialize_collection(collection)
|
|
37
|
+
collection_serializer.new(collection).serializable_hash
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def collection_serializer
|
|
41
|
+
Spree::V2::Tenant::MyVoteSerializer
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def model_class
|
|
45
|
+
SpreeCmCommissioner::VotingContestant
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -15,10 +15,7 @@ module Spree
|
|
|
15
15
|
scope = current_show.show_people
|
|
16
16
|
.joins(:assignments)
|
|
17
17
|
.distinct
|
|
18
|
-
.includes(
|
|
19
|
-
:assignments,
|
|
20
|
-
assigned_contestants: %i[show_contestant_images current_voting_session]
|
|
21
|
-
)
|
|
18
|
+
.includes(:assignments, :show_person_images)
|
|
22
19
|
|
|
23
20
|
role = params[:role].presence
|
|
24
21
|
return scope if role.blank?
|
|
@@ -15,7 +15,10 @@ module Spree
|
|
|
15
15
|
|
|
16
16
|
model_class.where(show_id: show_ids)
|
|
17
17
|
.where(show_person_id: params[:person_id], show_id: current_show.id)
|
|
18
|
-
.includes(:show_person, :show
|
|
18
|
+
.includes(:show_person, :show, assigned_contestants: %i[
|
|
19
|
+
show_contestant_images show_contestant_videos current_voting_session voting_contestants
|
|
20
|
+
]
|
|
21
|
+
)
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
def current_show
|
|
@@ -6,13 +6,17 @@ module Spree
|
|
|
6
6
|
before_action :load_voting_session, only: %i[index]
|
|
7
7
|
|
|
8
8
|
# override
|
|
9
|
+
#
|
|
10
|
+
# Ordered by vote_number. vote_number is nullable — Postgres' default
|
|
11
|
+
# ASC ordering places nulls last, so contestants without a vote_number
|
|
12
|
+
# appear at the end.
|
|
9
13
|
def collection
|
|
10
14
|
@collection ||= @voting_session
|
|
11
15
|
.voting_contestants
|
|
12
|
-
.includes(:voting_session, :
|
|
16
|
+
.includes(:voting_session, { show_contestant: :assigned_show_person },
|
|
13
17
|
:show_contestant_images, :show_contestant_videos,
|
|
14
18
|
:advanced_to, :advanced_from
|
|
15
|
-
).order(:
|
|
19
|
+
).order(:vote_number)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
# override
|
|
@@ -5,10 +5,10 @@ module SpreeCmCommissioner
|
|
|
5
5
|
def call
|
|
6
6
|
normalized_host = normalize_host(host)
|
|
7
7
|
|
|
8
|
-
context.store = find_store(normalized_host)
|
|
9
|
-
return if context.store.present?
|
|
10
|
-
|
|
11
8
|
context.tenant = find_tenant(normalized_host)
|
|
9
|
+
return if context.tenant.present?
|
|
10
|
+
|
|
11
|
+
context.store = find_store(normalized_host)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
private
|
|
@@ -18,11 +18,12 @@ module SpreeCmCommissioner
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def find_store(normalized_host)
|
|
21
|
-
Spree::Store
|
|
21
|
+
store_class = Spree::Store
|
|
22
|
+
store_class.find_by(code: normalized_host) || store_class.default
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def find_tenant(normalized_host)
|
|
25
|
-
SpreeCmCommissioner::Tenant.
|
|
26
|
+
SpreeCmCommissioner::Tenant.where('host = :host OR payment_host = :host', host: normalized_host).first
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
29
|
end
|
|
@@ -2,22 +2,23 @@ module SpreeCmCommissioner
|
|
|
2
2
|
class TurnstileTokenValidator < BaseInteractor
|
|
3
3
|
SITE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'.freeze
|
|
4
4
|
|
|
5
|
+
# https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
|
|
6
|
+
# Our own secret/setup is wrong -- not something a retry or a fresh token can fix.
|
|
7
|
+
CONFIG_ERROR_CODES = %w[missing-input-secret invalid-input-secret bad-request].freeze
|
|
8
|
+
# The token itself expired or was already redeemed -- a fresh token will fix this.
|
|
9
|
+
EXPIRED_ERROR_CODES = %w[timeout-or-duplicate].freeze
|
|
10
|
+
# The token we received was malformed/missing -- client should fetch a new one.
|
|
11
|
+
INVALID_TOKEN_ERROR_CODES = %w[missing-input-response invalid-input-response].freeze
|
|
12
|
+
|
|
5
13
|
delegate :token, :remote_ip, to: :context
|
|
6
14
|
|
|
7
15
|
def call
|
|
8
|
-
context.fail!(message: I18n.t('turnstile.token_missing')) if token.blank?
|
|
16
|
+
context.fail!(message: I18n.t('turnstile.token_missing'), error_code: :token_missing) if token.blank?
|
|
9
17
|
|
|
10
18
|
response = verify_token
|
|
11
19
|
body = parse_response(response)
|
|
12
20
|
|
|
13
|
-
unless body['success']
|
|
14
|
-
error_codes = body['error-codes']&.join(', ') || 'unknown'
|
|
15
|
-
Rails.logger.warn(
|
|
16
|
-
"Turnstile verification failed: #{error_codes} " \
|
|
17
|
-
"from IP #{remote_ip} | hostname=#{body['hostname']}"
|
|
18
|
-
)
|
|
19
|
-
context.fail!(message: I18n.t('turnstile.verification_failed'))
|
|
20
|
-
end
|
|
21
|
+
handle_failure(body) unless body['success']
|
|
21
22
|
|
|
22
23
|
context.challenge_ts = body['challenge_ts']
|
|
23
24
|
context.hostname = body['hostname']
|
|
@@ -26,24 +27,53 @@ module SpreeCmCommissioner
|
|
|
26
27
|
|
|
27
28
|
private
|
|
28
29
|
|
|
30
|
+
def handle_failure(body)
|
|
31
|
+
error_codes = Array(body['error-codes'])
|
|
32
|
+
Rails.logger.warn(
|
|
33
|
+
"Turnstile verification failed: #{error_codes.join(', ')} " \
|
|
34
|
+
"from IP #{remote_ip} | hostname=#{body['hostname']}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if error_codes.intersect?(CONFIG_ERROR_CODES)
|
|
38
|
+
# Not the visitor's fault -- alert ops, but don't leak config details to the client.
|
|
39
|
+
Rails.logger.error("Turnstile misconfiguration detected: #{error_codes.join(', ')}")
|
|
40
|
+
context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
|
|
41
|
+
elsif error_codes.intersect?(EXPIRED_ERROR_CODES)
|
|
42
|
+
context.fail!(message: I18n.t('turnstile.token_expired'), error_code: :token_expired)
|
|
43
|
+
elsif error_codes.intersect?(INVALID_TOKEN_ERROR_CODES)
|
|
44
|
+
context.fail!(message: I18n.t('turnstile.invalid_token'), error_code: :invalid_token)
|
|
45
|
+
else
|
|
46
|
+
context.fail!(message: I18n.t('turnstile.verification_failed'), error_code: :verification_failed)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
29
50
|
def parse_response(response)
|
|
30
51
|
JSON.parse(response.body)
|
|
31
52
|
rescue JSON::ParserError => e
|
|
32
53
|
Rails.logger.error("Turnstile response parse error: #{e.message}")
|
|
33
|
-
context.fail!(message: I18n.t('turnstile.service_unavailable'))
|
|
54
|
+
context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
|
|
34
55
|
end
|
|
35
56
|
|
|
36
57
|
def verify_token
|
|
37
|
-
connection.post do |req|
|
|
58
|
+
response = connection.post do |req|
|
|
38
59
|
req.body = {
|
|
39
60
|
secret: secret_key,
|
|
40
61
|
response: token,
|
|
41
62
|
remoteip: remote_ip
|
|
42
63
|
}
|
|
43
64
|
end
|
|
44
|
-
|
|
65
|
+
|
|
66
|
+
unless response.success?
|
|
67
|
+
Rails.logger.error("Turnstile API returned HTTP #{response.status}")
|
|
68
|
+
context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
response
|
|
72
|
+
rescue Faraday::Error => e
|
|
73
|
+
# Covers timeouts, connection refused/failed, SSL errors, DNS failures, etc. --
|
|
74
|
+
# anything that means Cloudflare's siteverify endpoint is temporarily unreachable.
|
|
45
75
|
Rails.logger.error("Turnstile API error: #{e.class} - #{e.message}")
|
|
46
|
-
context.fail!(message: I18n.t('turnstile.service_unavailable'))
|
|
76
|
+
context.fail!(message: I18n.t('turnstile.service_unavailable'), error_code: :service_unavailable)
|
|
47
77
|
end
|
|
48
78
|
|
|
49
79
|
def connection
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module OrderQrCodeable
|
|
3
|
+
def generate_svg_qr
|
|
4
|
+
qrcode = RQRCode::QRCode.new(qr_data)
|
|
5
|
+
qrcode.as_svg(
|
|
6
|
+
color: '000',
|
|
7
|
+
shape_rendering: 'crispEdges',
|
|
8
|
+
module_size: 5,
|
|
9
|
+
standalone: true,
|
|
10
|
+
use_path: true,
|
|
11
|
+
viewbox: '0 0 20 10'
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate_png_qr(size = 120)
|
|
16
|
+
qrcode = RQRCode::QRCode.new(qr_data)
|
|
17
|
+
qrcode.as_png(
|
|
18
|
+
bit_depth: 1,
|
|
19
|
+
border_modules: 1,
|
|
20
|
+
color_mode: ChunkyPNG::COLOR_GRAYSCALE,
|
|
21
|
+
color: 'black',
|
|
22
|
+
file: nil,
|
|
23
|
+
fill: 'white',
|
|
24
|
+
module_px_size: 4,
|
|
25
|
+
resize_exactly_to: false,
|
|
26
|
+
resize_gte_to: false,
|
|
27
|
+
size: size
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def qr_data
|
|
32
|
+
"#{number}-#{token}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def jwt_qr_data
|
|
36
|
+
SpreeCmCommissioner::Orders::JwtToken::Generate.call(order: self).value[:token]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -40,6 +40,11 @@ module SpreeCmCommissioner
|
|
|
40
40
|
scope :filter_by_vendor, lambda { |vendor|
|
|
41
41
|
joins(:line_items).where(spree_line_items: { vendor_id: vendor }).distinct
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
# Excludes orders containing a vote_package line item.
|
|
45
|
+
scope :exclude_vote_package, lambda {
|
|
46
|
+
where.not(id: Spree::LineItem.where(product_type: :vote_package).select(:order_id))
|
|
47
|
+
}
|
|
43
48
|
end
|
|
44
49
|
end
|
|
45
50
|
end
|
|
@@ -5,15 +5,51 @@ module SpreeCmCommissioner
|
|
|
5
5
|
belongs_to :event, class_name: 'Spree::Taxon', optional: false
|
|
6
6
|
|
|
7
7
|
has_many :check_in_rules, class_name: 'SpreeCmCommissioner::CheckInRule', dependent: :destroy
|
|
8
|
+
has_many :check_ins, class_name: 'SpreeCmCommissioner::CheckIn', dependent: :restrict_with_error
|
|
8
9
|
|
|
9
10
|
scope :active, -> { where(is_archived: false) }
|
|
10
11
|
|
|
11
12
|
accepts_nested_attributes_for :check_in_rules, allow_destroy: true
|
|
12
13
|
|
|
14
|
+
validate :start_time_must_be_present_datetime
|
|
15
|
+
validate :end_time_must_be_present_datetime
|
|
16
|
+
validate :end_time_after_start_time
|
|
17
|
+
|
|
13
18
|
after_commit :preload_check_in_session_ids_to_event_guests
|
|
14
19
|
|
|
20
|
+
self.whitelisted_ransackable_attributes = %w[is_archived]
|
|
21
|
+
|
|
15
22
|
private
|
|
16
23
|
|
|
24
|
+
def start_time_must_be_present_datetime
|
|
25
|
+
validate_present_datetime(:start_time)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def end_time_must_be_present_datetime
|
|
29
|
+
validate_present_datetime(:end_time)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Ensures the attribute was both entered by the user and is a real datetime.
|
|
33
|
+
# A datetime column type-casts unparseable input to nil while keeping the
|
|
34
|
+
# raw value in `*_before_type_cast`, so blank raw input means "not entered"
|
|
35
|
+
# and present raw input with a nil cast means "not a valid date/time".
|
|
36
|
+
def validate_present_datetime(attribute)
|
|
37
|
+
raw = read_attribute_before_type_cast(attribute)
|
|
38
|
+
|
|
39
|
+
if raw.blank?
|
|
40
|
+
errors.add(attribute, :blank)
|
|
41
|
+
elsif public_send(attribute).blank?
|
|
42
|
+
errors.add(attribute, :invalid)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def end_time_after_start_time
|
|
47
|
+
return if start_time.blank? || end_time.blank?
|
|
48
|
+
return if end_time > start_time
|
|
49
|
+
|
|
50
|
+
errors.add(:base, I18n.t('spree.check_in_session.end_time_after_start_time'))
|
|
51
|
+
end
|
|
52
|
+
|
|
17
53
|
def preload_check_in_session_ids_to_event_guests
|
|
18
54
|
SpreeCmCommissioner::Guests::PreloadCheckInSessionIdsJob.perform_later(event_id: event.id)
|
|
19
55
|
end
|
|
@@ -17,6 +17,12 @@ module SpreeCmCommissioner
|
|
|
17
17
|
'line_item.variant'
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
|
+
# Check-in group scope: one role, one export. `role` is the requesting operator's check-in group
|
|
21
|
+
# (see User#check_in_role_for) — the file holds only guests whose ticket that role's product scope
|
|
22
|
+
# allows. `role_id` IS the cache key: callers filter on it directly (a plain `where(role_id:)`),
|
|
23
|
+
# no product-set comparison needed. `role` nil = unrestricted (the full event).
|
|
24
|
+
belongs_to :role, class_name: 'Spree::Role', optional: true
|
|
25
|
+
|
|
20
26
|
store_public_metadata :guests_count, :integer, default: 0
|
|
21
27
|
store_public_metadata :created_by_id, :string
|
|
22
28
|
store_public_metadata :created_by_login, :string
|
|
@@ -35,7 +41,7 @@ module SpreeCmCommissioner
|
|
|
35
41
|
track_messages << "Export started at #{Time.current.iso8601}"
|
|
36
42
|
update(status: :progress, track_messages: track_messages)
|
|
37
43
|
|
|
38
|
-
guests_query =
|
|
44
|
+
guests_query = scoped_guests_query
|
|
39
45
|
self.guests_count = guests_query.count(:id)
|
|
40
46
|
|
|
41
47
|
track_messages << "Processing #{guests_count} guests in batches"
|
|
@@ -68,6 +74,25 @@ module SpreeCmCommissioner
|
|
|
68
74
|
|
|
69
75
|
private
|
|
70
76
|
|
|
77
|
+
# The event's guests, narrowed to the export's role's products when the export is scoped. Same
|
|
78
|
+
# GuestSearcherQuery (with its product_ids scope) the dashboard preview runs, so both always
|
|
79
|
+
# report the same guests. No role = unrestricted (full event).
|
|
80
|
+
def scoped_guests_query
|
|
81
|
+
SpreeCmCommissioner::GuestSearcherQuery.new(
|
|
82
|
+
event_id: exportable_id,
|
|
83
|
+
product_ids: role_scoped_product_ids
|
|
84
|
+
).call
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolves this export's role into the concrete product ids it covers, computed at export-run
|
|
88
|
+
# time so a later edit to the role's config doesn't leave an export scoped to stale data.
|
|
89
|
+
def role_scoped_product_ids
|
|
90
|
+
return nil if role.nil?
|
|
91
|
+
|
|
92
|
+
event_product_ids = role.check_in_exclude? ? exportable.event_products.ids : []
|
|
93
|
+
role.check_in_scoped_product_ids(event_product_ids)
|
|
94
|
+
end
|
|
95
|
+
|
|
71
96
|
def stream_export_data_and_includes_to_separate_files(guests_query, guests_file, included_file)
|
|
72
97
|
# Track "user:123", "order:456" to avoid duplicates includes. For current event scale, Set growth
|
|
73
98
|
# (~20-25 MB for 100K guests × 5 relationships) is manageable.
|
|
@@ -28,6 +28,10 @@ module SpreeCmCommissioner
|
|
|
28
28
|
scope :complete_or_canceled, -> { joins(:line_item).merge(Spree::LineItem.complete_or_canceled) }
|
|
29
29
|
scope :unassigned_event, -> { where(event_id: nil) }
|
|
30
30
|
scope :none_bib, -> { where(bib_prefix: [nil, '']) }
|
|
31
|
+
# Excludes guests whose ticket was transferred away. The original guest keeps its
|
|
32
|
+
# (still complete) line_item, so `complete` alone would double-count it alongside the
|
|
33
|
+
# new active guest created for the buyer. Use for attendee counts / aggregates.
|
|
34
|
+
scope :not_transferred, -> { where.not(state: :transferred) }
|
|
31
35
|
|
|
32
36
|
belongs_to :line_item, class_name: 'Spree::LineItem'
|
|
33
37
|
|
|
@@ -23,7 +23,15 @@ module SpreeCmCommissioner
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def update_conversion_for(product_taxon)
|
|
26
|
-
|
|
26
|
+
# Count guests (attendees) instead of line item quantity so this stays consistent
|
|
27
|
+
# with EventChartQueries, which counts DISTINCT cm_guests.id on complete orders.
|
|
28
|
+
# Flat join guest -> line_item -> variant -> product filtered by product_id: no
|
|
29
|
+
# IN-subquery, no self-join, cheapest plan of the variants we benchmarked.
|
|
30
|
+
total_count = SpreeCmCommissioner::Guest.complete
|
|
31
|
+
.not_transferred
|
|
32
|
+
.joins(line_item: { variant: :product })
|
|
33
|
+
.where(spree_products: { id: product_taxon.product_id })
|
|
34
|
+
.count
|
|
27
35
|
revenue = product_taxon.complete_line_items.sum('quantity * price')
|
|
28
36
|
|
|
29
37
|
product_taxon.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
|
@@ -15,6 +15,7 @@ module SpreeCmCommissioner
|
|
|
15
15
|
base.include SpreeCmCommissioner::RouteOrderCountable
|
|
16
16
|
base.include SpreeCmCommissioner::OrderScopes
|
|
17
17
|
base.include SpreeCmCommissioner::TicketTransferable
|
|
18
|
+
base.include SpreeCmCommissioner::OrderQrCodeable
|
|
18
19
|
|
|
19
20
|
base.before_create :link_by_phone_number
|
|
20
21
|
base.before_create :associate_customer
|
|
@@ -285,42 +286,6 @@ module SpreeCmCommissioner
|
|
|
285
286
|
"order/#{id}/has_incomplete_guest_info"
|
|
286
287
|
end
|
|
287
288
|
|
|
288
|
-
def generate_svg_qr
|
|
289
|
-
qrcode = RQRCode::QRCode.new(qr_data)
|
|
290
|
-
qrcode.as_svg(
|
|
291
|
-
color: '000',
|
|
292
|
-
shape_rendering: 'crispEdges',
|
|
293
|
-
module_size: 5,
|
|
294
|
-
standalone: true,
|
|
295
|
-
use_path: true,
|
|
296
|
-
viewbox: '0 0 20 10'
|
|
297
|
-
)
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
def generate_png_qr(size = 120)
|
|
301
|
-
qrcode = RQRCode::QRCode.new(qr_data)
|
|
302
|
-
qrcode.as_png(
|
|
303
|
-
bit_depth: 1,
|
|
304
|
-
border_modules: 1,
|
|
305
|
-
color_mode: ChunkyPNG::COLOR_GRAYSCALE,
|
|
306
|
-
color: 'black',
|
|
307
|
-
file: nil,
|
|
308
|
-
fill: 'white',
|
|
309
|
-
module_px_size: 4,
|
|
310
|
-
resize_exactly_to: false,
|
|
311
|
-
resize_gte_to: false,
|
|
312
|
-
size: size
|
|
313
|
-
)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def qr_data
|
|
317
|
-
"#{number}-#{token}"
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def jwt_qr_data
|
|
321
|
-
SpreeCmCommissioner::Orders::JwtToken::Generate.call(order: self).value[:token]
|
|
322
|
-
end
|
|
323
|
-
|
|
324
289
|
def enable_send_receipt?
|
|
325
290
|
products.any?(&:enable_send_receipt?)
|
|
326
291
|
end
|
|
@@ -331,7 +296,7 @@ module SpreeCmCommissioner
|
|
|
331
296
|
|
|
332
297
|
# override spree_vpago method
|
|
333
298
|
def payment_host
|
|
334
|
-
tenant&.formatted_url.presence || ENV.fetch('DEFAULT_URL_HOST')
|
|
299
|
+
tenant&.payment_url.presence || tenant&.formatted_url.presence || ENV.fetch('DEFAULT_URL_HOST')
|
|
335
300
|
end
|
|
336
301
|
|
|
337
302
|
# Returns arrays of connected line_item IDs grouped by connected_trip_id.
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module RoleDecorator
|
|
3
|
-
def self.prepended(base)
|
|
4
|
-
base.
|
|
3
|
+
def self.prepended(base) # rubocop:disable Metrics/AbcSize
|
|
4
|
+
base.include SpreeCmCommissioner::StoreMetadata
|
|
5
|
+
|
|
6
|
+
base.enum role_type: { internal: 0, external: 1, preview: 2, check_in: 3 }
|
|
7
|
+
base.enum check_in_mode: { all: 0, include: 1, exclude: 2 }, _prefix: :check_in
|
|
5
8
|
|
|
6
9
|
base.has_many :role_permissions, class_name: 'SpreeCmCommissioner::RolePermission'
|
|
7
10
|
base.has_many :permissions, through: :role_permissions, class_name: 'SpreeCmCommissioner::Permission'
|
|
8
11
|
base.has_many :preview_roles, class_name: 'SpreeCmCommissioner::PreviewRole'
|
|
9
12
|
|
|
13
|
+
# Check-in Group scoping: a check_in role belongs to one event (taxon) and lists the products
|
|
14
|
+
# (ticket types) it may check in, stored as a product_ids array — see #check_in_allows?.
|
|
15
|
+
base.store_public_metadata :product_ids, :array, default: []
|
|
16
|
+
base.belongs_to :event, class_name: 'Spree::Taxon', optional: true
|
|
17
|
+
|
|
10
18
|
base.belongs_to :vendor, optional: true
|
|
11
19
|
|
|
12
20
|
base.scope :non_vendor, -> { where(vendor_id: nil) }
|
|
@@ -16,19 +24,48 @@ module SpreeCmCommissioner
|
|
|
16
24
|
base.scope :filter_external, -> { where(role_type: :external) }
|
|
17
25
|
base.scope :filter_internal, -> { where(role_type: :internal) }
|
|
18
26
|
base.scope :filter_preview, -> { where(role_type: :preview) }
|
|
27
|
+
base.scope :filter_check_in, -> { where(role_type: :check_in) }
|
|
19
28
|
|
|
20
29
|
base.accepts_nested_attributes_for :role_permissions, allow_destroy: true
|
|
21
30
|
|
|
22
31
|
base.before_validation :generate_unique_name, if: -> { vendor_id.present? }
|
|
23
32
|
|
|
24
|
-
base.validates :presentation,
|
|
33
|
+
base.validates :presentation, presence: true, if: -> { vendor_id.present? }
|
|
34
|
+
base.validates :event_id, presence: true, if: -> { check_in? }
|
|
35
|
+
# Permission roles are unique per vendor; check-in groups are unique per event, so two
|
|
36
|
+
# events under the same vendor can each own a "VIP" group.
|
|
37
|
+
base.validates :presentation, uniqueness: { scope: :vendor_id }, if: -> { vendor_id.present? && !check_in? }
|
|
38
|
+
base.validates :presentation, uniqueness: { scope: %i[vendor_id event_id] }, if: -> { vendor_id.present? && check_in? }
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
def generate_unique_name
|
|
28
42
|
return if name.present?
|
|
29
43
|
return if presentation.blank?
|
|
30
44
|
|
|
31
|
-
|
|
45
|
+
slug = presentation.downcase.parameterize(separator: '-')
|
|
46
|
+
# Include the event so a vendor can reuse a group name (e.g. "VIP") across events without
|
|
47
|
+
# colliding on the globally-unique spree_roles.name.
|
|
48
|
+
self.name = check_in? ? "#{vendor.slug}-event-#{event_id}-#{slug}" : "#{vendor.slug}-#{slug}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this check-in group lets a crew check in the given line item. The group's mode decides
|
|
52
|
+
# how product_ids is read: all = everything, include = allowlist, exclude = blocklist. A group
|
|
53
|
+
# with no products behaves like "all" regardless of mode.
|
|
54
|
+
def check_in_allows?(line_item)
|
|
55
|
+
return true if check_in_all? || product_ids.blank?
|
|
56
|
+
|
|
57
|
+
matched = product_ids.include?(line_item.product_id)
|
|
58
|
+
check_in_exclude? ? !matched : matched
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The concrete product ids this group covers, for scoping a data pull (the dashboard preview,
|
|
62
|
+
# the operator export). `nil` = unrestricted — an all-mode group or one with no products
|
|
63
|
+
# covers every ticket.
|
|
64
|
+
def check_in_scoped_product_ids(event_product_ids)
|
|
65
|
+
return nil if check_in_all? || product_ids.blank?
|
|
66
|
+
|
|
67
|
+
ids = product_ids.map(&:to_i)
|
|
68
|
+
check_in_exclude? ? event_product_ids.map(&:to_i) - ids : ids
|
|
32
69
|
end
|
|
33
70
|
end
|
|
34
71
|
end
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module RoleUserDecorator
|
|
3
|
+
def self.prepended(base)
|
|
4
|
+
base.validate :single_check_in_role_per_event, if: -> { role&.check_in? }
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Restricts a user to at most one check-in role per event.
|
|
10
|
+
def single_check_in_role_per_event
|
|
11
|
+
existing_check_in_roles = user.spree_roles.filter_check_in.where(event_id: role.event_id).where.not(id: role_id)
|
|
12
|
+
errors.add(:role, 'already has a check-in group for this event') if existing_check_in_roles.exists?
|
|
13
|
+
end
|
|
3
14
|
end
|
|
4
15
|
end
|
|
5
16
|
|