spree_cm_commissioner 2.9.4 → 2.9.6
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/event_metadata.rb +5 -0
- 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 +52 -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 +35 -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
|
@@ -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,59 @@ 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? }
|
|
39
|
+
|
|
40
|
+
base.after_commit :sync_event_has_check_in_groups_flag, on: %i[create destroy], if: -> { check_in? }
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def generate_unique_name
|
|
28
44
|
return if name.present?
|
|
29
45
|
return if presentation.blank?
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
slug = presentation.downcase.parameterize(separator: '-')
|
|
48
|
+
# Include the event so a vendor can reuse a group name (e.g. "VIP") across events without
|
|
49
|
+
# colliding on the globally-unique spree_roles.name.
|
|
50
|
+
self.name = check_in? ? "#{vendor.slug}-event-#{event_id}-#{slug}" : "#{vendor.slug}-#{slug}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether this check-in group lets a crew check in the given line item. The group's mode decides
|
|
54
|
+
# how product_ids is read: all = everything, include = allowlist, exclude = blocklist. A group
|
|
55
|
+
# with no products behaves like "all" regardless of mode.
|
|
56
|
+
def check_in_allows?(line_item)
|
|
57
|
+
return true if check_in_all? || product_ids.blank?
|
|
58
|
+
|
|
59
|
+
matched = product_ids.include?(line_item.product_id)
|
|
60
|
+
check_in_exclude? ? !matched : matched
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The concrete product ids this group covers, for scoping a data pull (the dashboard preview,
|
|
64
|
+
# the operator export). `nil` = unrestricted — an all-mode group or one with no products
|
|
65
|
+
# covers every ticket.
|
|
66
|
+
def check_in_scoped_product_ids(event_product_ids)
|
|
67
|
+
return nil if check_in_all? || product_ids.blank?
|
|
68
|
+
|
|
69
|
+
ids = product_ids.map(&:to_i)
|
|
70
|
+
check_in_exclude? ? event_product_ids.map(&:to_i) - ids : ids
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Keeps Taxon#has_check_in_groups? in sync so User#check_in_group_for can skip querying
|
|
76
|
+
# spree_roles for events with none. Recomputes from scratch rather than assuming true/false from
|
|
77
|
+
# the create/destroy alone, since an event can have more than one check-in group.
|
|
78
|
+
def sync_event_has_check_in_groups_flag
|
|
79
|
+
event.update!(has_check_in_groups: event.check_in_groups.exists?)
|
|
32
80
|
end
|
|
33
81
|
end
|
|
34
82
|
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
|
|
|
@@ -5,7 +5,11 @@ module SpreeCmCommissioner
|
|
|
5
5
|
has_many :show_person_images, -> { order(:position) }, as: :viewable, class_name: 'SpreeCmCommissioner::ShowPersonImage', dependent: :destroy
|
|
6
6
|
has_many :assignments, class_name: 'SpreeCmCommissioner::ShowPersonAssignment', dependent: :destroy
|
|
7
7
|
has_many :shows, through: :assignments, source: :show
|
|
8
|
-
has_many :assigned_contestants,
|
|
8
|
+
has_many :assigned_contestants,
|
|
9
|
+
-> { active },
|
|
10
|
+
class_name: 'SpreeCmCommissioner::ShowContestant',
|
|
11
|
+
foreign_key: :assigned_show_person_id,
|
|
12
|
+
dependent: :nullify
|
|
9
13
|
|
|
10
14
|
validates :name, presence: true
|
|
11
15
|
|
|
@@ -12,7 +12,7 @@ module SpreeCmCommissioner
|
|
|
12
12
|
validates :role, presence: true
|
|
13
13
|
|
|
14
14
|
has_many :assigned_contestants,
|
|
15
|
-
-> (assignment) { where(show_id: assignment.show_id) },
|
|
15
|
+
-> (assignment) { where(show_id: assignment.show_id).active },
|
|
16
16
|
class_name: 'SpreeCmCommissioner::ShowContestant',
|
|
17
17
|
primary_key: :show_person_id,
|
|
18
18
|
foreign_key: :assigned_show_person_id,
|
|
@@ -18,6 +18,8 @@ module SpreeCmCommissioner
|
|
|
18
18
|
base.has_many :products, through: :classifications, class_name: 'Spree::Product'
|
|
19
19
|
base.has_many :guests, foreign_key: :event_id, class_name: 'SpreeCmCommissioner::Guest', dependent: :nullify
|
|
20
20
|
base.has_many :check_ins, as: :checkinable, class_name: 'SpreeCmCommissioner::CheckIn', dependent: :nullify
|
|
21
|
+
# Check-in groups for this event (Spree::Role, role_type :check_in). Scoped by event_id (indexed),
|
|
22
|
+
base.has_many :check_in_groups, -> { filter_check_in }, class_name: 'Spree::Role', foreign_key: :event_id
|
|
21
23
|
base.has_many :customer_taxons, class_name: 'SpreeCmCommissioner::CustomerTaxon'
|
|
22
24
|
|
|
23
25
|
base.has_many :seat_layouts, as: :layoutable, class_name: 'SpreeCmCommissioner::SeatLayout', dependent: :destroy
|
|
@@ -13,10 +13,13 @@ module SpreeCmCommissioner
|
|
|
13
13
|
through: :vendors,
|
|
14
14
|
source: :payment_methods
|
|
15
15
|
|
|
16
|
-
validates :host, presence: true,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
validates :host, presence: true,
|
|
17
|
+
uniqueness: true,
|
|
18
|
+
format: { without: %r{\Ahttps?://}, message: :format }
|
|
19
|
+
|
|
20
|
+
validates :payment_host, uniqueness: true,
|
|
21
|
+
allow_blank: true,
|
|
22
|
+
format: { without: %r{\Ahttps?://}, message: :format }
|
|
20
23
|
|
|
21
24
|
validates :sms_sender_id, length: { maximum: 11 }, allow_blank: true
|
|
22
25
|
|
|
@@ -35,13 +38,21 @@ module SpreeCmCommissioner
|
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
def formatted_url
|
|
38
|
-
|
|
41
|
+
format_host(host)
|
|
42
|
+
end
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
def payment_url
|
|
45
|
+
format_host(payment_host)
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
private
|
|
44
49
|
|
|
50
|
+
def format_host(host)
|
|
51
|
+
return if host.blank?
|
|
52
|
+
|
|
53
|
+
Rails.env.development? || Rails.env.test? ? "http://#{host}" : "https://#{host}"
|
|
54
|
+
end
|
|
55
|
+
|
|
45
56
|
def generate_slug
|
|
46
57
|
self.slug = name.parameterize if slug.blank?
|
|
47
58
|
end
|
|
@@ -154,6 +154,41 @@ module SpreeCmCommissioner
|
|
|
154
154
|
has_spree_role?('operator')
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
+
# Check-in Group: this crew's check-in group (Spree::Role, role_type :check_in) for the event.
|
|
158
|
+
# One role per user per event — memoized per event so a bulk check-in looks it up once, not per
|
|
159
|
+
# guest. `nil` = no group (can check in any product). Uses `key?` (not `||=`) so the common "no
|
|
160
|
+
# group" result — itself `nil` — is cached too, instead of re-querying on every call.
|
|
161
|
+
def check_in_group_for(event_id)
|
|
162
|
+
@check_in_group_cache ||= {}
|
|
163
|
+
return @check_in_group_cache[event_id] if @check_in_group_cache.key?(event_id)
|
|
164
|
+
|
|
165
|
+
@check_in_group_cache[event_id] = spree_roles.find_by(role_type: :check_in, event_id: event_id)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Whether this user may check in the given guest. Events with no check-in groups at all are
|
|
169
|
+
# unrestricted. Otherwise admins and organizers are never restricted; everyone else may check in
|
|
170
|
+
# the guest's ticket only if their check-in group allows it (include/exclude evaluated on that one
|
|
171
|
+
# group).
|
|
172
|
+
def can_check_in_guest?(guest)
|
|
173
|
+
return true unless guest.event.has_check_in_groups?
|
|
174
|
+
return true if admin? || organizer?
|
|
175
|
+
|
|
176
|
+
group = check_in_group_for(guest.event_id)
|
|
177
|
+
return true if group.nil?
|
|
178
|
+
|
|
179
|
+
group.check_in_allows?(guest.line_item)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# This user's check-in role for the event, used to scope the operator offline export (one role, one
|
|
183
|
+
# export — see Exports::OperatorGuestJsonGzip#role). `nil` = unrestricted (admins/organizers, crew in
|
|
184
|
+
# no group, or an event with no check-in groups at all — see #can_check_in_guest?).
|
|
185
|
+
def check_in_role_for(event)
|
|
186
|
+
return nil unless event.has_check_in_groups?
|
|
187
|
+
return nil if admin? || organizer?
|
|
188
|
+
|
|
189
|
+
check_in_group_for(event.id)
|
|
190
|
+
end
|
|
191
|
+
|
|
157
192
|
def vendor_organizer?(vendor)
|
|
158
193
|
spree_roles.any? do |role|
|
|
159
194
|
role.name == "#{vendor.id}-organizer"
|
|
@@ -41,6 +41,8 @@ module SpreeCmCommissioner
|
|
|
41
41
|
validate :advanced_to_and_advanced_from_must_differ
|
|
42
42
|
|
|
43
43
|
delegate :name, :contestant_number, :category, :gender, :eliminated_at, to: :show_contestant, allow_nil: true
|
|
44
|
+
delegate :assigned_show_person, to: :show_contestant, allow_nil: true
|
|
45
|
+
delegate :name, to: :assigned_show_person, prefix: true, allow_nil: true
|
|
44
46
|
delegate :can_vote?, to: :voting_session, allow_nil: true
|
|
45
47
|
|
|
46
48
|
def special_rule_type=(value)
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
class EventChartQueries
|
|
3
|
-
attr_reader :taxon_id, :chart_type, :refreshed
|
|
3
|
+
attr_reader :taxon_id, :chart_type, :check_in_session_id, :refreshed
|
|
4
4
|
|
|
5
5
|
# chart_type: 'participation | gender | entry_type' taxon_id: taxon_id, refreshed: false
|
|
6
|
-
|
|
6
|
+
# check_in_session_id: scopes check-in counts to a single session. When nil, a guest
|
|
7
|
+
# is counted once regardless of how many sessions they checked into.
|
|
8
|
+
def initialize(taxon_id:, chart_type:, check_in_session_id: nil, refreshed: false)
|
|
7
9
|
@taxon_id = taxon_id
|
|
8
10
|
@chart_type = chart_type || 'participation'
|
|
11
|
+
@check_in_session_id = check_in_session_id.presence
|
|
9
12
|
@refreshed = refreshed
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def call
|
|
13
|
-
cache_key =
|
|
16
|
+
cache_key = [chart_type, taxon_id, check_in_session_id].compact.join('-')
|
|
14
17
|
Rails.cache.delete(cache_key) if refreshed
|
|
15
18
|
|
|
16
19
|
product_charts = Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
|
|
@@ -38,10 +41,10 @@ module SpreeCmCommissioner
|
|
|
38
41
|
|
|
39
42
|
def base_joins
|
|
40
43
|
query = SpreeCmCommissioner::Guest
|
|
41
|
-
join_type = chart_type.in?(%w[participation
|
|
44
|
+
join_type = chart_type.in?(%w[participation guest_occupation guest_nationality]) ? 'LEFT' : 'INNER'
|
|
42
45
|
|
|
43
46
|
query
|
|
44
|
-
.joins("#{join_type} JOIN cm_check_ins ON
|
|
47
|
+
.joins("#{join_type} JOIN cm_check_ins ON #{check_in_join_condition}")
|
|
45
48
|
.joins('INNER JOIN spree_line_items ON spree_line_items.id = cm_guests.line_item_id')
|
|
46
49
|
.joins('INNER JOIN spree_variants ON spree_variants.id = spree_line_items.variant_id')
|
|
47
50
|
.joins('INNER JOIN spree_orders ON spree_orders.id = spree_line_items.order_id')
|
|
@@ -49,6 +52,19 @@ module SpreeCmCommissioner
|
|
|
49
52
|
.joins('INNER JOIN spree_products_taxons ON spree_products.id = spree_products_taxons.product_id')
|
|
50
53
|
.joins('INNER JOIN spree_taxons ON spree_taxons.id = spree_products_taxons.taxon_id')
|
|
51
54
|
.where(spree_orders: { state: 'complete' }, spree_taxons: { parent_id: taxon_id })
|
|
55
|
+
.where.not(cm_guests: { state: :transferred })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Filter check-ins to a single session inside the JOIN's ON clause (not WHERE), so
|
|
59
|
+
# LEFT JOINs still keep no-show guests. When no session is given, every check-in row
|
|
60
|
+
# is joined and the DISTINCT-guest counts below collapse multiple sessions into one.
|
|
61
|
+
def check_in_join_condition
|
|
62
|
+
condition = 'cm_check_ins.guest_id = cm_guests.id'
|
|
63
|
+
return condition if check_in_session_id.blank?
|
|
64
|
+
|
|
65
|
+
ActiveRecord::Base.sanitize_sql_array(
|
|
66
|
+
["#{condition} AND cm_check_ins.check_in_session_id = ?", check_in_session_id]
|
|
67
|
+
)
|
|
52
68
|
end
|
|
53
69
|
|
|
54
70
|
def participation_event_chart
|
|
@@ -117,7 +133,7 @@ module SpreeCmCommissioner
|
|
|
117
133
|
spree_products.name AS product_name,
|
|
118
134
|
occupation_taxons.name AS name,
|
|
119
135
|
COUNT(DISTINCT cm_guests.id) AS total,
|
|
120
|
-
COUNT(DISTINCT cm_check_ins.
|
|
136
|
+
COUNT(DISTINCT cm_check_ins.guest_id) AS show'
|
|
121
137
|
)
|
|
122
138
|
.group('spree_products.id, spree_products.name, occupation_taxons.name')
|
|
123
139
|
.group_by(&:product_id)
|
|
@@ -136,7 +152,7 @@ module SpreeCmCommissioner
|
|
|
136
152
|
spree_products.name AS product_name,
|
|
137
153
|
nationality_taxons.name AS name,
|
|
138
154
|
COUNT(DISTINCT cm_guests.id) AS total,
|
|
139
|
-
COUNT(DISTINCT cm_check_ins.
|
|
155
|
+
COUNT(DISTINCT cm_check_ins.guest_id) AS show'
|
|
140
156
|
)
|
|
141
157
|
.group('spree_products.id, spree_products.name, nationality_taxons.name')
|
|
142
158
|
.group_by(&:product_id)
|
|
@@ -7,10 +7,25 @@ module SpreeCmCommissioner
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def event_id = params[:event_id]
|
|
10
|
+
def product_ids = params[:product_ids]
|
|
10
11
|
|
|
11
|
-
def call
|
|
12
|
+
def call
|
|
12
13
|
return SpreeCmCommissioner::Guest.none if event_id.blank?
|
|
13
14
|
|
|
15
|
+
# Optional check-in-group scope (see Role#check_in_scoped_product_ids), set by the operator export
|
|
16
|
+
# and the dashboard preview. nil = unrestricted; an array narrows to guests whose ticket's product
|
|
17
|
+
# is in it — an EMPTY array is a real "no tickets" scope and correctly yields no guests.
|
|
18
|
+
if product_ids.nil?
|
|
19
|
+
query
|
|
20
|
+
else
|
|
21
|
+
query.joins(line_item: :variant).where(spree_variants: { product_id: product_ids })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# The base guest search: QR (order / line item / guest / user), term, ids, or the whole event.
|
|
28
|
+
def query # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
|
14
29
|
if params[:qr_data].present? && order_qr_data?
|
|
15
30
|
search_by_order_qr
|
|
16
31
|
elsif params[:qr_data].present? && line_item_qr_data?
|
|
@@ -31,8 +46,6 @@ module SpreeCmCommissioner
|
|
|
31
46
|
end
|
|
32
47
|
end
|
|
33
48
|
|
|
34
|
-
private
|
|
35
|
-
|
|
36
49
|
def search_by_order_qr
|
|
37
50
|
order = Spree::Order.complete_or_canceled.search_by_qr_data!(params[:qr_data])
|
|
38
51
|
order.guests.where(event_id: event_id)
|
|
@@ -48,6 +48,7 @@ module SpreeCmCommissioner
|
|
|
48
48
|
.joins('INNER JOIN spree_products_taxons ON spree_products.id = spree_products_taxons.product_id')
|
|
49
49
|
.joins('INNER JOIN spree_taxons ON spree_taxons.id = spree_products_taxons.taxon_id')
|
|
50
50
|
.where(spree_orders: { state: 'complete' }, spree_taxons: { parent_id: taxon_id })
|
|
51
|
+
.where.not(cm_guests: { state: :transferred })
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def participation_pie_chart
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Returns a voting_session's contestants, each carrying the current user's own
|
|
2
|
+
# vote tally for that contestant as a computed my_vote_count attribute.
|
|
3
|
+
#
|
|
4
|
+
# The DB (cm_votes) is the source of truth for per-user tallies — Redis only
|
|
5
|
+
# holds the global, non-user-scoped leaderboard totals, so it cannot answer
|
|
6
|
+
# "how many votes did THIS user give each contestant".
|
|
7
|
+
#
|
|
8
|
+
# Runs as two queries total, regardless of contestant count:
|
|
9
|
+
# 1. voting_contestants LEFT JOINed against a per-contestant SUM(quantity)
|
|
10
|
+
# subquery scoped to this user+session (no N+1, no per-record query)
|
|
11
|
+
# 2. a batched preload of show_contestant (needed for name/contestant_number)
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# SpreeCmCommissioner::UserVotesPerContestantQuery.new(
|
|
15
|
+
# user: current_user, voting_session: session
|
|
16
|
+
# ).contestants
|
|
17
|
+
module SpreeCmCommissioner
|
|
18
|
+
class UserVotesPerContestantQuery
|
|
19
|
+
attr_reader :user, :voting_session
|
|
20
|
+
|
|
21
|
+
def initialize(user:, voting_session:)
|
|
22
|
+
@user = user
|
|
23
|
+
@voting_session = voting_session
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def contestants
|
|
27
|
+
return SpreeCmCommissioner::VotingContestant.none if user.nil? || voting_session.nil?
|
|
28
|
+
|
|
29
|
+
voting_session.voting_contestants
|
|
30
|
+
.includes(:show_contestant)
|
|
31
|
+
.select('cm_voting_contestants.*, COALESCE(user_votes.total_quantity, 0) AS my_vote_count')
|
|
32
|
+
.joins(
|
|
33
|
+
"LEFT JOIN (#{user_votes_subquery.to_sql}) user_votes " \
|
|
34
|
+
'ON user_votes.contestant_id = cm_voting_contestants.id'
|
|
35
|
+
)
|
|
36
|
+
.order(:vote_number)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def user_votes_subquery
|
|
42
|
+
SpreeCmCommissioner::Vote
|
|
43
|
+
.select(:contestant_id, 'SUM(quantity) AS total_quantity')
|
|
44
|
+
.where(user_id: user.id, voting_session_id: voting_session.id)
|
|
45
|
+
.group(:contestant_id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module V2
|
|
3
|
+
module Tenant
|
|
4
|
+
# Serializes a VotingContestant together with the current user's own vote
|
|
5
|
+
# tally (my_vote_count) for that contestant in a voting session.
|
|
6
|
+
#
|
|
7
|
+
# my_vote_count is a virtual attribute (see VotingContestant) that the
|
|
8
|
+
# controller sets on each record before serialization — see
|
|
9
|
+
# MyVotesController#collection.
|
|
10
|
+
class MyVoteSerializer < BaseSerializer
|
|
11
|
+
set_type :voting_contestant
|
|
12
|
+
|
|
13
|
+
attributes :name, :contestant_number, :vote_number, :my_vote_count
|
|
14
|
+
|
|
15
|
+
attribute :voting_session_id, &:voting_session_id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -5,7 +5,8 @@ module Spree
|
|
|
5
5
|
set_type :show
|
|
6
6
|
|
|
7
7
|
attributes :slug, :description, :permalink, :from_date, :to_date, :preview, :created_at, :updated_at,
|
|
8
|
-
:free_vote_limit, :free_vote_limit_type, :show_type, :credit_votable_type
|
|
8
|
+
:free_vote_limit, :free_vote_limit_type, :show_type, :credit_votable_type,
|
|
9
|
+
:background_color, :foreground_color
|
|
9
10
|
|
|
10
11
|
attribute :name, &:display_name
|
|
11
12
|
|
|
@@ -10,6 +10,8 @@ module Spree
|
|
|
10
10
|
|
|
11
11
|
attribute :voting_session_id, &:voting_session_id
|
|
12
12
|
|
|
13
|
+
attribute :assigned_person_name, &:assigned_show_person_name
|
|
14
|
+
|
|
13
15
|
attribute :can_vote, &:can_vote?
|
|
14
16
|
|
|
15
17
|
belongs_to :voting_session, serializer: ::Spree::V2::Tenant::VotingSessionSerializer
|
|
@@ -19,12 +19,7 @@ module SpreeCmCommissioner
|
|
|
19
19
|
guest = indexed_guests[attrs[:guest_id].to_i]
|
|
20
20
|
raise ActiveRecord::RecordNotFound, 'Guest or related record not found' if guest.blank?
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
fail_check_in!(guest, 'Cannot check in Guest from cancelled order') if order.canceled?
|
|
24
|
-
|
|
25
|
-
# Validate guest state for check-in
|
|
26
|
-
fail_check_in!(guest, 'Guest is locked and cannot be checked in') if guest.locked?
|
|
27
|
-
fail_check_in!(guest, 'Guest has been transferred and cannot be checked in') if guest.transferred?
|
|
22
|
+
validate_guest_for_check_in!(guest, check_in_by)
|
|
28
23
|
|
|
29
24
|
check_in = check_in!(guest, attrs, check_in_by, confirmed_at)
|
|
30
25
|
update_guest!(guest, attrs[:guest_attributes]) if attrs[:guest_attributes].present?
|
|
@@ -48,6 +43,15 @@ module SpreeCmCommissioner
|
|
|
48
43
|
raise error
|
|
49
44
|
end
|
|
50
45
|
|
|
46
|
+
def validate_guest_for_check_in!(guest, check_in_by)
|
|
47
|
+
fail_check_in!(guest, I18n.t('spree.check_in.order_canceled')) if guest.line_item.order.canceled?
|
|
48
|
+
fail_check_in!(guest, I18n.t('spree.check_in.guest_locked')) if guest.locked?
|
|
49
|
+
fail_check_in!(guest, I18n.t('spree.check_in.guest_transferred')) if guest.transferred?
|
|
50
|
+
|
|
51
|
+
# Check-in Groups: crew may only check in the ticket types in their check-in groups for this event.
|
|
52
|
+
fail_check_in!(guest, I18n.t('spree.check_in.check_in_group_not_allowed')) if check_in_by && !check_in_by.can_check_in_guest?(guest)
|
|
53
|
+
end
|
|
54
|
+
|
|
51
55
|
def check_in!(guest, check_in_attributes, check_in_by, confirmed_at)
|
|
52
56
|
check_in = SpreeCmCommissioner::CheckIn.new(
|
|
53
57
|
guest: guest,
|
|
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
|
|
|
8
8
|
IMPORTABLE_COLUMNS = %w[
|
|
9
9
|
contestant_number name gender category status origin age bio
|
|
10
10
|
facebook instagram twitter linkedin youtube tiktok telegram
|
|
11
|
+
video_highlights
|
|
11
12
|
].freeze
|
|
12
13
|
MAX_ROWS = 50
|
|
13
14
|
SOCIAL_LINK_COLUMNS = %w[facebook instagram twitter linkedin youtube tiktok telegram].freeze
|
|
@@ -71,7 +72,7 @@ module SpreeCmCommissioner
|
|
|
71
72
|
end
|
|
72
73
|
end
|
|
73
74
|
|
|
74
|
-
def extract_attrs(row)
|
|
75
|
+
def extract_attrs(row) # rubocop:disable Metrics/AbcSize
|
|
75
76
|
normalized = row.transform_keys { |k| k.to_s.strip.downcase.tr(' ', '_') }
|
|
76
77
|
|
|
77
78
|
social_links = SOCIAL_LINK_COLUMNS.each_with_object({}) do |key, hash|
|
|
@@ -91,9 +92,26 @@ module SpreeCmCommissioner
|
|
|
91
92
|
}.compact
|
|
92
93
|
|
|
93
94
|
attrs[:social_links] = social_links if social_links.any?
|
|
95
|
+
|
|
96
|
+
video_highlights = extract_video_highlights(normalized['video_highlights'])
|
|
97
|
+
attrs[:video_highlights] = video_highlights unless video_highlights.nil?
|
|
98
|
+
|
|
94
99
|
attrs
|
|
95
100
|
end
|
|
96
101
|
|
|
102
|
+
def extract_video_highlights(raw_value)
|
|
103
|
+
raw_value = raw_value.to_s.strip
|
|
104
|
+
return nil if raw_value.blank?
|
|
105
|
+
|
|
106
|
+
parsed = JSON.parse(raw_value)
|
|
107
|
+
raise "video_highlights must be a JSON array, got #{parsed.class}" unless parsed.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
result = SpreeCmCommissioner::ShowContestants::NormalizeVideoHighlights.call(raw_video_highlights: parsed)
|
|
110
|
+
raise "invalid video_highlights: #{result.error.value}" unless result.success?
|
|
111
|
+
|
|
112
|
+
result.value[:video_highlights]
|
|
113
|
+
end
|
|
114
|
+
|
|
97
115
|
def resolve_enum(mapping, value)
|
|
98
116
|
return nil if value.blank?
|
|
99
117
|
|
|
@@ -5,13 +5,15 @@ module SpreeCmCommissioner
|
|
|
5
5
|
class Create
|
|
6
6
|
prepend ::Spree::ServiceModule::Base
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# `role`: nil = unrestricted (full event); a Spree::Role (check_in type) scopes the file to that
|
|
9
|
+
# role's tickets (see User#check_in_role_for). One role, one export.
|
|
10
|
+
def call(exportable:, role: nil, force_create: false, options: {})
|
|
9
11
|
unless force_create
|
|
10
|
-
recent_export = find_recent_export(exportable)
|
|
12
|
+
recent_export = find_recent_export(exportable, role)
|
|
11
13
|
return success(recent_export) if recent_export
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
export = build_export(exportable, options)
|
|
16
|
+
export = build_export(exportable, role, options)
|
|
15
17
|
if export.save
|
|
16
18
|
success(export)
|
|
17
19
|
else
|
|
@@ -21,24 +23,29 @@ module SpreeCmCommissioner
|
|
|
21
23
|
|
|
22
24
|
private
|
|
23
25
|
|
|
24
|
-
def find_recent_export(exportable)
|
|
26
|
+
def find_recent_export(exportable, role)
|
|
25
27
|
# Cache exports for 24 hours (configurable via OPERATOR_GUEST_JSON_GZIP_CACHE_HOURS).
|
|
26
|
-
# This ensures
|
|
28
|
+
# This ensures staff sharing the same scope see consistent guest data throughout the event day,
|
|
27
29
|
# minimizes server load, and prevents mid-event data refresh issues.
|
|
28
30
|
#
|
|
31
|
+
# The cache is keyed on exportable AND role_id so a scoped operator is never handed a broader —
|
|
32
|
+
# or another group's — cached file, while same-role operators still share one. `role_id: nil`
|
|
33
|
+
# correctly matches unrestricted exports.
|
|
34
|
+
#
|
|
29
35
|
# Use force_create=true to manually trigger fresh exports when needed.
|
|
30
36
|
cache_duration = ENV.fetch('OPERATOR_GUEST_JSON_GZIP_CACHE_HOURS', '24').to_i.hours
|
|
31
37
|
|
|
32
|
-
SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.where(exportable: exportable)
|
|
38
|
+
SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.where(exportable: exportable, role_id: role&.id)
|
|
33
39
|
.not_failed
|
|
34
40
|
.where('created_at > ?', cache_duration.ago)
|
|
35
41
|
.order(created_at: :desc)
|
|
36
42
|
.first
|
|
37
43
|
end
|
|
38
44
|
|
|
39
|
-
def build_export(exportable, options)
|
|
45
|
+
def build_export(exportable, role, options)
|
|
40
46
|
SpreeCmCommissioner::Exports::OperatorGuestJsonGzip.new(
|
|
41
47
|
exportable: exportable,
|
|
48
|
+
role: role,
|
|
42
49
|
**options
|
|
43
50
|
)
|
|
44
51
|
end
|
|
@@ -19,7 +19,7 @@ module SpreeCmCommissioner
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def headers
|
|
22
|
-
columns.map { |col| col.titleize(keep_id_suffix: true) }.uniq
|
|
22
|
+
columns.map { |col| dynamic_fields_by_id[col]&.label || col.titleize(keep_id_suffix: true) }.uniq
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def build_row(guest)
|
|
@@ -41,15 +41,11 @@ module SpreeCmCommissioner
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def dynamic_field?(column)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
dynamic_field_labels.include?(column)
|
|
44
|
+
dynamic_fields_by_id.key?(column)
|
|
47
45
|
end
|
|
48
46
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dynamic_field_labels ||= load_dynamic_field_labels
|
|
47
|
+
def dynamic_fields_by_id
|
|
48
|
+
@dynamic_fields_by_id ||= load_dynamic_fields.index_by { |field| field.id.to_s }
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def dynamic_fields_exist?
|
|
@@ -58,12 +54,17 @@ module SpreeCmCommissioner
|
|
|
58
54
|
@has_dynamic_fields = event.event_products.joins(:product_dynamic_fields).exists?
|
|
59
55
|
end
|
|
60
56
|
|
|
61
|
-
def
|
|
62
|
-
|
|
57
|
+
def load_dynamic_fields
|
|
58
|
+
return [] unless dynamic_fields_exist?
|
|
59
|
+
|
|
60
|
+
SpreeCmCommissioner::DynamicField
|
|
61
|
+
.joins(:product_dynamic_fields)
|
|
62
|
+
.where(cm_product_dynamic_fields: { product_id: event.event_products.pluck(:id) })
|
|
63
|
+
.distinct
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def fetch_dynamic_field_value(guest, column)
|
|
66
|
-
guest_dynamic_field = guest.guest_dynamic_fields.find { |gdf| gdf.
|
|
67
|
+
guest_dynamic_field = guest.guest_dynamic_fields.find { |gdf| gdf.dynamic_field_id.to_s == column }
|
|
67
68
|
return '' unless guest_dynamic_field
|
|
68
69
|
|
|
69
70
|
if guest_dynamic_field.dynamic_field_option.present?
|
|
@@ -31,7 +31,7 @@ module SpreeCmCommissioner
|
|
|
31
31
|
|
|
32
32
|
def normalize_video(payload)
|
|
33
33
|
payload_hash = payload.to_h
|
|
34
|
-
uri =
|
|
34
|
+
uri = extract_uri(payload_hash).to_s.strip
|
|
35
35
|
|
|
36
36
|
{
|
|
37
37
|
'title' => extract_value(payload_hash, 'title').to_s.strip,
|
|
@@ -44,6 +44,10 @@ module SpreeCmCommissioner
|
|
|
44
44
|
hash[key] || hash[key.to_sym]
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def extract_uri(hash)
|
|
48
|
+
extract_value(hash, 'uri').presence || extract_value(hash, 'url')
|
|
49
|
+
end
|
|
50
|
+
|
|
47
51
|
def embed_youtube_url(uri)
|
|
48
52
|
result = UrlEmbed::YoutubeEmbed.call(url: uri)
|
|
49
53
|
result.success? ? result.value : uri
|