spree_cm_commissioner 2.9.1.pre.pre4 → 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/product_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/trip.rb +10 -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/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/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/trip_result.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +37 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%# Static, always on the page (hidden until the JS in manage_accounts.html.erb adds `.open`). The
|
|
2
|
+
Generate button opens this immediately, then fetches the tokens over AJAX and fills the
|
|
3
|
+
textarea — no page navigation. Fully self-contained: visuals come from the inline <style> here,
|
|
4
|
+
no dependency on Bootstrap's JS/CSS or any external bundle. %>
|
|
5
|
+
<div id="login_tokens_modal_backdrop" class="login-tokens-modal-backdrop"></div>
|
|
6
|
+
<div id="loginTokensModal" class="login-tokens-modal" role="dialog" aria-labelledby="loginTokensModalLabel">
|
|
7
|
+
<div class="login-tokens-modal-header">
|
|
8
|
+
<h5 class="mb-0" id="loginTokensModalLabel"><%= Spree.t('load_test.accounts.login_tokens_json') %></h5>
|
|
9
|
+
<button type="button" class="btn btn-sm btn-outline-secondary login-tokens-modal-close" data-dismiss="modal" aria-label="<%= Spree.t(:close) %>">
|
|
10
|
+
<span aria-hidden="true">×</span>
|
|
11
|
+
</button>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="login-tokens-modal-body">
|
|
14
|
+
<div class="d-flex justify-content-between align-items-center mb-2 flex-shrink-0">
|
|
15
|
+
<label class="mb-0"><%= Spree.t('load_test.accounts.copy_into_k6') %></label>
|
|
16
|
+
<button type="button" class="btn btn-sm btn-outline-primary p-1" title="<%= Spree.t('load_test.accounts.copy_json') %>" aria-label="<%= Spree.t('load_test.accounts.copy_json') %>"
|
|
17
|
+
onclick="navigator.clipboard.writeText(document.getElementById('login_tokens_json').value); show_flash('success', <%= Spree.t('load_test.accounts.json_copied').to_json %>)">
|
|
18
|
+
<%= svg_icon name: 'clipboard', width: 16, height: 16, classes: 'm-0' %>
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
<div id="login_tokens_status" class="text-muted small mb-2 flex-shrink-0"></div>
|
|
22
|
+
<%= text_area_tag :login_tokens_json, nil,
|
|
23
|
+
readonly: true, class: 'form-control font-monospace login-tokens-modal-textarea' %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.login-tokens-modal-backdrop {
|
|
29
|
+
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);
|
|
30
|
+
opacity: 0; visibility: hidden; transition: opacity .2s ease; z-index: 1040;
|
|
31
|
+
}
|
|
32
|
+
.login-tokens-modal-backdrop.open { opacity: 1; visibility: visible; }
|
|
33
|
+
.login-tokens-modal {
|
|
34
|
+
position: fixed; top: 0; right: 0; height: 100%; width: 560px; max-width: 92vw;
|
|
35
|
+
background: #fff; box-shadow: -2px 0 12px rgba(0, 0, 0, .2);
|
|
36
|
+
transform: translateX(100%); transition: transform .25s ease; z-index: 1050;
|
|
37
|
+
display: flex; flex-direction: column;
|
|
38
|
+
}
|
|
39
|
+
.login-tokens-modal.open { transform: translateX(0); }
|
|
40
|
+
.login-tokens-modal-header {
|
|
41
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
42
|
+
padding: 1rem 1.25rem; border-bottom: 1px solid #e9ecef; flex-shrink: 0;
|
|
43
|
+
}
|
|
44
|
+
.login-tokens-modal-close {
|
|
45
|
+
line-height: 1; font-size: 1.25rem; padding: 0 .5rem;
|
|
46
|
+
}
|
|
47
|
+
.login-tokens-modal-body {
|
|
48
|
+
padding: 1.25rem; overflow: hidden; flex: 1; min-height: 0;
|
|
49
|
+
display: flex; flex-direction: column;
|
|
50
|
+
}
|
|
51
|
+
.login-tokens-modal-textarea { flex: 1; min-height: 0; resize: none; }
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
|
|
2
|
+
|
|
3
|
+
<%= link_to Spree.t('load_test.back_to_load_testing'), admin_system_load_testing_index_path, class: 'btn btn-outline-secondary btn-sm mb-3' %>
|
|
4
|
+
|
|
5
|
+
<div class="row">
|
|
6
|
+
<div class="col-md-12">
|
|
7
|
+
<div class="card mb-3">
|
|
8
|
+
<div class="card-header"><h5 class="mb-0"><%= Spree.t('load_test.accounts.manage_accounts_platform') %></h5></div>
|
|
9
|
+
<div class="card-body">
|
|
10
|
+
<p class="text-muted">
|
|
11
|
+
<%= Spree.t('load_test.accounts.manage_accounts_body_html').html_safe %>
|
|
12
|
+
</p>
|
|
13
|
+
<%= form_tag admin_system_load_test_accounts_path, method: :post do %>
|
|
14
|
+
<div class="row">
|
|
15
|
+
<div class="col-md-3 form-group">
|
|
16
|
+
<label for="count"><%= Spree.t('load_test.accounts.count') %></label>
|
|
17
|
+
<%= number_field_tag :count, params[:count] || 20, class: 'form-control', min: 1 %>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="col-md-4 form-group">
|
|
20
|
+
<label for="email_prefix"><%= Spree.t('load_test.accounts.email_prefix') %></label>
|
|
21
|
+
<%= text_field_tag :email_prefix, params[:email_prefix] || 'loadtest', class: 'form-control' %>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="col-md-5 form-group">
|
|
24
|
+
<label for="email_domain"><%= Spree.t('load_test.accounts.email_domain') %></label>
|
|
25
|
+
<%= text_field_tag :email_domain, params[:email_domain], class: 'form-control', placeholder: Spree.t('load_test.accounts.email_domain_placeholder') %>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<%= button_tag Spree.t('load_test.accounts.create_accounts'), type: :submit, class: 'btn btn-primary' %>
|
|
29
|
+
<%= button_tag Spree.t('load_test.accounts.destroy_accounts'), type: :submit,
|
|
30
|
+
formaction: bulk_destroy_admin_system_load_test_accounts_path,
|
|
31
|
+
data: { confirm: Spree.t('load_test.accounts.destroy_accounts_confirm') },
|
|
32
|
+
class: 'btn btn-outline-danger' %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="card mb-3">
|
|
38
|
+
<div class="card-header"><h5 class="mb-0"><%= Spree.t('load_test.accounts.generate_login_tokens_platform') %></h5></div>
|
|
39
|
+
<div class="card-body">
|
|
40
|
+
<p class="text-muted">
|
|
41
|
+
<%= Spree.t('load_test.accounts.generate_login_tokens_body_html', ttl: SpreeCmCommissioner::LoadTest::LoginToken::TTL.inspect).html_safe %>
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
|
45
|
+
<%# Drives the table's own pagination below — a plain GET reload, separate from the AJAX
|
|
46
|
+
Generate Login Tokens form. Changing Per Page resets back to page 1. A typed number
|
|
47
|
+
input rather than Spree's usual 25/50/75 dropdown, since the load-test pool can run
|
|
48
|
+
well past those fixed sizes. %>
|
|
49
|
+
<%= form_tag admin_system_load_test_accounts_path, method: :get, class: 'form-inline mb-3' do %>
|
|
50
|
+
<label for="per_page" class="mr-2"><%= Spree.t('load_test.accounts.per_page') %></label>
|
|
51
|
+
<%= number_field_tag :per_page, @export_accounts.limit_value, class: 'form-control mr-2 mb-2', style: 'width: 100px', min: 1 %>
|
|
52
|
+
<%= button_tag Spree.t('load_test.accounts.apply'), type: :submit, name: nil, class: 'btn btn-outline-secondary mb-2' %>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<%# JS below intercepts submit: opens the drawer immediately, then fetches the tokens as
|
|
56
|
+
JSON into it — no navigation. data-turbo:false keeps Turbo out of the form entirely.
|
|
57
|
+
Hidden page/per_page mirror the table's own pagination below, so the generated tokens
|
|
58
|
+
always match what's on screen — page the table to change which accounts get tokens. %>
|
|
59
|
+
<%= form_tag admin_system_load_test_login_tokens_path,
|
|
60
|
+
method: :post, id: 'login_tokens_form', class: 'form-inline mb-3', data: { turbo: false } do %>
|
|
61
|
+
<%= hidden_field_tag :page, @export_accounts.current_page %>
|
|
62
|
+
<%= hidden_field_tag :per_page, @export_accounts.limit_value %>
|
|
63
|
+
<%= button_tag Spree.t('load_test.accounts.generate_login_tokens'), type: :submit, class: 'btn btn-primary mb-2' %>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<% if @export_accounts.any? %>
|
|
68
|
+
<div class="table-responsive">
|
|
69
|
+
<table class="table mb-0">
|
|
70
|
+
<thead class="text-muted">
|
|
71
|
+
<tr>
|
|
72
|
+
<th><%= Spree.t('load_test.accounts.table_no') %></th>
|
|
73
|
+
<th><%= Spree.t('load_test.accounts.table_email') %></th>
|
|
74
|
+
<th><%= Spree.t('load_test.accounts.table_user_id') %></th>
|
|
75
|
+
<th><%= Spree.t('load_test.accounts.table_created_at') %></th>
|
|
76
|
+
<th><%= Spree.t('load_test.accounts.table_last_sign_in_at') %></th>
|
|
77
|
+
<th><%= Spree.t('load_test.accounts.table_current_sign_in_at') %></th>
|
|
78
|
+
</tr>
|
|
79
|
+
</thead>
|
|
80
|
+
<tbody>
|
|
81
|
+
<% @export_accounts.each_with_index do |account, index| %>
|
|
82
|
+
<tr>
|
|
83
|
+
<td><%= @export_accounts.offset_value + index + 1 %></td>
|
|
84
|
+
<td><%= account.email %></td>
|
|
85
|
+
<td><code><%= account.id %></code></td>
|
|
86
|
+
<td><%= account.created_at&.strftime('%Y-%m-%d %H:%M') %></td>
|
|
87
|
+
<td><%= account.last_sign_in_at&.strftime('%Y-%m-%d %H:%M') || '—' %></td>
|
|
88
|
+
<td><%= account.current_sign_in_at&.strftime('%Y-%m-%d %H:%M') || '—' %></td>
|
|
89
|
+
</tr>
|
|
90
|
+
<% end %>
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<%= paginate @export_accounts, theme: 'admin-twitter-bootstrap-4' %>
|
|
96
|
+
<% else %>
|
|
97
|
+
<div class="m-5 text-center no-objects-found">
|
|
98
|
+
<%= Spree.t('load_test.accounts.no_accounts_found') %>
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
<%# Always on the page (outside the .row/.col so its position:fixed is viewport-relative), hidden
|
|
108
|
+
until the JS below opens it. Populated over AJAX — see the fetch handler. %>
|
|
109
|
+
<%= render "login_tokens_modal" %>
|
|
110
|
+
|
|
111
|
+
<script>
|
|
112
|
+
(function() {
|
|
113
|
+
var i18n = {
|
|
114
|
+
generating: <%= Spree.t('load_test.accounts.generating').to_json.html_safe %>,
|
|
115
|
+
errorPrefix: <%= Spree.t('load_test.accounts.error_prefix').to_json.html_safe %>,
|
|
116
|
+
requestFailedTemplate: <%= Spree.t('load_test.accounts.request_failed', status: '%{status}').to_json.html_safe %>
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
var form = document.getElementById('login_tokens_form');
|
|
120
|
+
var drawer = document.getElementById('loginTokensModal');
|
|
121
|
+
var backdrop = document.getElementById('login_tokens_modal_backdrop');
|
|
122
|
+
var textarea = document.getElementById('login_tokens_json');
|
|
123
|
+
var status = document.getElementById('login_tokens_status');
|
|
124
|
+
if (!form || !drawer || !backdrop) return;
|
|
125
|
+
|
|
126
|
+
function openDrawer() {
|
|
127
|
+
drawer.classList.add('open');
|
|
128
|
+
backdrop.classList.add('open');
|
|
129
|
+
document.body.style.overflow = 'hidden';
|
|
130
|
+
}
|
|
131
|
+
function closeDrawer() {
|
|
132
|
+
drawer.classList.remove('open');
|
|
133
|
+
backdrop.classList.remove('open');
|
|
134
|
+
document.body.style.removeProperty('overflow');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
backdrop.addEventListener('click', closeDrawer);
|
|
138
|
+
drawer.querySelectorAll('[data-dismiss="modal"]').forEach(function(btn) {
|
|
139
|
+
btn.addEventListener('click', closeDrawer);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
form.addEventListener('submit', function(event) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
|
|
145
|
+
// Open first, show a loading state — the drawer never waits on the request to appear.
|
|
146
|
+
openDrawer();
|
|
147
|
+
textarea.value = '';
|
|
148
|
+
status.textContent = i18n.generating;
|
|
149
|
+
|
|
150
|
+
var csrf = document.querySelector('meta[name="csrf-token"]');
|
|
151
|
+
fetch(form.action, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
credentials: 'same-origin',
|
|
154
|
+
headers: {
|
|
155
|
+
'Accept': 'application/json',
|
|
156
|
+
'X-CSRF-Token': csrf ? csrf.content : ''
|
|
157
|
+
},
|
|
158
|
+
body: new FormData(form)
|
|
159
|
+
})
|
|
160
|
+
.then(function(response) {
|
|
161
|
+
return response.json().then(function(data) {
|
|
162
|
+
if (!response.ok) throw new Error(data.error || i18n.requestFailedTemplate.replace('%{status}', response.status));
|
|
163
|
+
return data;
|
|
164
|
+
});
|
|
165
|
+
})
|
|
166
|
+
.then(function(data) {
|
|
167
|
+
textarea.value = JSON.stringify(data, null, 2);
|
|
168
|
+
status.textContent = '';
|
|
169
|
+
})
|
|
170
|
+
.catch(function(error) {
|
|
171
|
+
status.textContent = i18n.errorPrefix + error.message;
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
})();
|
|
175
|
+
</script>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
|
|
2
|
+
|
|
3
|
+
<%= link_to Spree.t('load_test.back_to_load_testing'), admin_system_load_testing_index_path, class: 'btn btn-outline-secondary btn-sm mb-3' %>
|
|
4
|
+
|
|
5
|
+
<p class="text-muted">
|
|
6
|
+
<%= Spree.t('load_test.flows.description_html').html_safe %>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<% @load_test_flows.each_with_index do |(flow_name, flow), index| %>
|
|
10
|
+
<details class="card mb-3" <%= 'open' if index.zero? %>>
|
|
11
|
+
<summary class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer; list-style: none;">
|
|
12
|
+
<div>
|
|
13
|
+
<h6 class="mb-1"><%= flow_name.to_s.humanize %></h6>
|
|
14
|
+
<span class="text-muted small"><%= flow[:description] %></span>
|
|
15
|
+
</div>
|
|
16
|
+
<%= svg_icon name: 'chevron-down', width: 16, height: 16 %>
|
|
17
|
+
</summary>
|
|
18
|
+
|
|
19
|
+
<div class="card-body">
|
|
20
|
+
<div class="mb-3">
|
|
21
|
+
<div class="text-muted text-uppercase small mb-1" style="letter-spacing: .04em;"><%= Spree.t('load_test.flows.save_to') %></div>
|
|
22
|
+
<code><%= flow[:destination] %></code>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<% if flow[:data_button] %>
|
|
26
|
+
<div class="alert alert-primary d-flex mb-3" role="alert">
|
|
27
|
+
<%= svg_icon name: 'cursor-fill', width: 18, height: 18, classes: 'mr-2 flex-shrink-0' %>
|
|
28
|
+
<div>
|
|
29
|
+
<strong><%= Spree.t('load_test.flows.data_button_label', label: flow[:data_button][:label]) %></strong> — <%= flow[:data_button][:where] %>
|
|
30
|
+
<% if flow[:data_button][:note] %>
|
|
31
|
+
<div class="text-muted small mt-1"><%= flow[:data_button][:note] %></div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<% if flow[:source] %>
|
|
38
|
+
<div class="mb-3">
|
|
39
|
+
<div class="text-muted text-uppercase small mb-2" style="letter-spacing: .04em;"><%= Spree.t('load_test.flows.look_up_manually') %></div>
|
|
40
|
+
<div class="table-responsive">
|
|
41
|
+
<table class="table table-sm mb-0">
|
|
42
|
+
<thead class="text-muted">
|
|
43
|
+
<tr>
|
|
44
|
+
<th><%= Spree.t('load_test.flows.field') %></th>
|
|
45
|
+
<th><%= Spree.t('load_test.flows.where_to_find_it') %></th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<% flow[:source].each do |field, instructions| %>
|
|
50
|
+
<tr>
|
|
51
|
+
<td class="align-top text-nowrap"><code><%= field %></code></td>
|
|
52
|
+
<td class="text-muted"><%= instructions %></td>
|
|
53
|
+
</tr>
|
|
54
|
+
<% end %>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
61
|
+
<% if flow[:doc] %>
|
|
62
|
+
<p class="text-muted small mb-0"><%= Spree.t('load_test.flows.full_request_table_html', doc: flow[:doc]).html_safe %></p>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
</details>
|
|
66
|
+
<% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%= render 'spree/admin/shared/system_tabs', current: :load_testing %>
|
|
2
|
+
|
|
3
|
+
<div class="row">
|
|
4
|
+
<div class="col-md-12">
|
|
5
|
+
<div class="list-group">
|
|
6
|
+
<%= link_to admin_system_load_test_accounts_path,
|
|
7
|
+
class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center' do %>
|
|
8
|
+
<div>
|
|
9
|
+
<strong><%= Spree.t('load_test.index.manage_accounts') %></strong>
|
|
10
|
+
<div class="text-muted small"><%= Spree.t('load_test.index.manage_accounts_description') %></div>
|
|
11
|
+
</div>
|
|
12
|
+
<%= svg_icon name: 'chevron-right', width: 20, height: 20 %>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<%= link_to admin_system_load_test_flows_path,
|
|
16
|
+
class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center' do %>
|
|
17
|
+
<div>
|
|
18
|
+
<strong><%= Spree.t('load_test.index.test_data_sources') %></strong>
|
|
19
|
+
<div class="text-muted small"><%= Spree.t('load_test.index.test_data_sources_description') %></div>
|
|
20
|
+
</div>
|
|
21
|
+
<%= svg_icon name: 'chevron-right', width: 20, height: 20 %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :variants } %>
|
|
2
|
+
|
|
3
|
+
<% content_for :page_title do %>
|
|
4
|
+
<%= Spree.t(:fixture, default: 'Fixture') %> — <%= @variant.options_text.presence || @variant.sku %>
|
|
5
|
+
<% end %>
|
|
6
|
+
|
|
7
|
+
<% filename = "#{@fixture_env}.json" %>
|
|
8
|
+
|
|
9
|
+
<div class="card mb-4">
|
|
10
|
+
<div class="card-body">
|
|
11
|
+
<% if @fixture_issues.blank? %>
|
|
12
|
+
<div class="alert alert-success" role="alert">
|
|
13
|
+
<strong>Ready for load testing.</strong> This variant is purchasable and sits under an event, so the
|
|
14
|
+
booking flow can run against it end-to-end.
|
|
15
|
+
</div>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="alert alert-warning" role="alert">
|
|
18
|
+
<strong>Not ready for load testing.</strong> The booking flow will not complete against this variant until
|
|
19
|
+
the following is fixed:
|
|
20
|
+
<ul class="mb-0 mt-2">
|
|
21
|
+
<% @fixture_issues.each do |issue| %>
|
|
22
|
+
<li><%= issue %></li>
|
|
23
|
+
<% end %>
|
|
24
|
+
</ul>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<p class="text-muted">
|
|
29
|
+
Save this as <code><%= filename %></code> in
|
|
30
|
+
<code>load_testing/config/fixture/platform/booking_ticket_flow/</code>
|
|
31
|
+
(git-ignored).
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<div class="position-relative" data-controller="clipboard">
|
|
35
|
+
<div class="position-absolute d-flex" style="top: .5rem; right: .5rem;">
|
|
36
|
+
<button type="button" class="btn btn-sm btn-primary"
|
|
37
|
+
data-action="clipboard#copy"
|
|
38
|
+
onclick="show_flash('success', '<%= j Spree.t(:copied_to_clipboard, default: 'Copied to clipboard') %>')">
|
|
39
|
+
<%= Spree.t(:copy, default: 'Copy') %>
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
<textarea readonly data-clipboard-target="source" rows="12"
|
|
43
|
+
class="form-control text-monospace bg-light"
|
|
44
|
+
style="resize: vertical;"><%= JSON.pretty_generate(@fixture) %></textarea>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<%= link_to Spree.t(:back), spree.admin_product_variants_path(@product), class: 'btn btn-outline-secondary' %>
|
|
@@ -41,13 +41,17 @@ Doorkeeper.configure do
|
|
|
41
41
|
# custom configuration https://github.com/doorkeeper-gem/doorkeeper/wiki/Customizing-Token-Expiration
|
|
42
42
|
custom_access_token_expires_in do |context|
|
|
43
43
|
# context.grant_type for grant_type, context.client for client, context.scopes for scopes
|
|
44
|
-
|
|
45
|
-
when Doorkeeper::OAuth::CLIENT_CREDENTIALS # see Doorkeeper::OAuth::GRANT_TYPES for other types
|
|
46
|
-
7.days.to_i
|
|
47
|
-
when Doorkeeper::OAuth::PASSWORD
|
|
48
|
-
1.year.to_i
|
|
49
|
-
else
|
|
44
|
+
if context.resource_owner.respond_to?(:load_test_account?) && context.resource_owner.load_test_account?
|
|
50
45
|
2.hours.to_i
|
|
46
|
+
else
|
|
47
|
+
case context.grant_type
|
|
48
|
+
when Doorkeeper::OAuth::CLIENT_CREDENTIALS # see Doorkeeper::OAuth::GRANT_TYPES for other types
|
|
49
|
+
7.days.to_i
|
|
50
|
+
when Doorkeeper::OAuth::PASSWORD
|
|
51
|
+
1.year.to_i
|
|
52
|
+
else
|
|
53
|
+
2.hours.to_i
|
|
54
|
+
end
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
@@ -10,6 +10,7 @@ module Spree
|
|
|
10
10
|
# store_accessor key (public_metadata), not a DB column, so it must be permitted explicitly
|
|
11
11
|
@@product_attributes << :enable_telegram_alert unless @@product_attributes.include?(:enable_telegram_alert)
|
|
12
12
|
@@product_attributes << :enable_send_receipt unless @@product_attributes.include?(:enable_send_receipt)
|
|
13
|
+
@@product_attributes << :enable_social_sharing_email unless @@product_attributes.include?(:enable_social_sharing_email)
|
|
13
14
|
|
|
14
15
|
# Permitted all guest attributes for now as permitting only some guest attributes is not working by design
|
|
15
16
|
# of spree add_item service, if we want to only permit some guest attributes,
|
|
@@ -92,6 +93,35 @@ module Spree
|
|
|
92
93
|
age_group
|
|
93
94
|
nationality_group
|
|
94
95
|
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
line_items_attributes: [
|
|
99
|
+
:id,
|
|
100
|
+
{
|
|
101
|
+
guests_attributes: [
|
|
102
|
+
:id,
|
|
103
|
+
:first_name,
|
|
104
|
+
:last_name,
|
|
105
|
+
:address,
|
|
106
|
+
:dob,
|
|
107
|
+
:gender,
|
|
108
|
+
:occupation_id,
|
|
109
|
+
:other_occupation,
|
|
110
|
+
:nationality_id,
|
|
111
|
+
:age,
|
|
112
|
+
:emergency_contact,
|
|
113
|
+
:phone_number,
|
|
114
|
+
:other_organization,
|
|
115
|
+
:social_contact,
|
|
116
|
+
:social_contact_platform,
|
|
117
|
+
:expectation,
|
|
118
|
+
:country_code,
|
|
119
|
+
:upload_later,
|
|
120
|
+
:contact,
|
|
121
|
+
{ guest_dynamic_fields_attributes: %i[id dynamic_field_id dynamic_field_option_id value _destroy] }
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
95
125
|
}
|
|
96
126
|
]
|
|
97
127
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Registry of k6 load-test flows (../../../load_testing) for the `platform` workflow.
|
|
2
|
+
# See load_test_flows/tenant.yml for the tenant-scoped counterpart and the shared field docs.
|
|
3
|
+
#
|
|
4
|
+
# Fields:
|
|
5
|
+
# workflow: platform | tenant — matches load_testing/{endpoints,tests}/<workflow>/
|
|
6
|
+
# description: one-line summary of what the flow exercises
|
|
7
|
+
# destination: where the JSON goes, relative to load_testing/ (git-ignored, <env>.json)
|
|
8
|
+
# example: the matching example.json/example fixture, for the expected shape
|
|
9
|
+
# data_button: present when an admin/organizer page can generate the JSON directly
|
|
10
|
+
# (Fixture-style copy/paste button) — label + where to click
|
|
11
|
+
# source: present when there's no generator yet — plain instructions for where in
|
|
12
|
+
# admin to look up each field by hand
|
|
13
|
+
# doc: path to the flow's own doc.md for the full request table
|
|
14
|
+
|
|
15
|
+
booking_ticket_flow:
|
|
16
|
+
description: Browse → event → product → cart → checkout → cash payment → booking.
|
|
17
|
+
destination: config/fixture/platform/booking_ticket_flow/<env>.json
|
|
18
|
+
example: config/fixture/platform/booking_ticket_flow/example.json
|
|
19
|
+
data_button:
|
|
20
|
+
label: Fixture
|
|
21
|
+
where: Admin → pick the ticket product → Variants → the variant's row → "Fixture" button
|
|
22
|
+
note: >-
|
|
23
|
+
Runs a purchasable/under-event readiness check and resolves event_permalink,
|
|
24
|
+
product_slug, product_id, variant_id for you — click Copy and paste the whole thing in.
|
|
25
|
+
doc: tests/platform/booking_ticket_flow/doc.md
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Registry of k6 load-test flows (../../../load_testing) for the `tenant` workflow.
|
|
2
|
+
# See load_test_flows/platform.yml for the platform-scoped counterpart and the shared field docs.
|
|
3
|
+
#
|
|
4
|
+
# Not every tenant supports every flow (e.g. a tenant with no ABA payment method has no
|
|
5
|
+
# aba_topup_vote_flow) — the fixture generators flag an issue at request time when the
|
|
6
|
+
# current tenant is missing what a flow needs, rather than this file trying to enumerate it.
|
|
7
|
+
|
|
8
|
+
voting_flow:
|
|
9
|
+
description: Homepage → show detail (contestants + judges) → cast vote → refresh.
|
|
10
|
+
destination: config/fixture/tenant/voting_flow/<env>.json
|
|
11
|
+
example: config/fixture/tenant/voting_flow/example.json
|
|
12
|
+
data_button:
|
|
13
|
+
label: Fixture
|
|
14
|
+
where: >-
|
|
15
|
+
Organizer → Shows → Season → Episode → Voting Sessions table → row "⋮" menu →
|
|
16
|
+
Fixture → pick "Voting flow"
|
|
17
|
+
note: Resolves show_slug and voting_session_id straight off the voting session you picked.
|
|
18
|
+
doc: tests/tenant/voting_flow/doc.md
|
|
19
|
+
|
|
20
|
+
aba_topup_vote_flow:
|
|
21
|
+
description: Top-up Vote screen paid via ABA (PayWay v2 sandbox).
|
|
22
|
+
destination: config/fixture/tenant/aba_topup_vote_flow/<env>.json
|
|
23
|
+
example: config/fixture/tenant/aba_topup_vote_flow/example.json
|
|
24
|
+
data_button:
|
|
25
|
+
label: Fixture
|
|
26
|
+
where: >-
|
|
27
|
+
Organizer → Shows → Season → Episode → Voting Sessions table → row "⋮" menu →
|
|
28
|
+
Fixture → pick "Aba topup vote flow"
|
|
29
|
+
note: >-
|
|
30
|
+
Resolves show_slug, voting_session_id, payment_method_id, and the payway config off
|
|
31
|
+
the tenant's active ABA/PayWay payment method — flags an issue if none is configured yet.
|
|
32
|
+
doc: tests/tenant/aba_topup_vote_flow/doc.md
|
|
33
|
+
|
|
34
|
+
cash_on_topup_vote_flow:
|
|
35
|
+
description: Top-up Vote screen paid via a Cash On payment method (no external gateway).
|
|
36
|
+
destination: config/fixture/tenant/cash_on_topup_vote_flow/<env>.json
|
|
37
|
+
example: config/fixture/tenant/cash_on_topup_vote_flow/example.json
|
|
38
|
+
data_button:
|
|
39
|
+
label: Fixture
|
|
40
|
+
where: >-
|
|
41
|
+
Organizer → Shows → Season → Episode → Voting Sessions table → row "⋮" menu →
|
|
42
|
+
Fixture → pick "Cash on topup vote flow"
|
|
43
|
+
note: >-
|
|
44
|
+
Resolves show_slug, voting_session_id, and payment_method_id off the tenant's active
|
|
45
|
+
Cash On payment method — flags an issue if none is configured yet.
|
|
46
|
+
doc: tests/tenant/cash_on_topup_vote_flow/doc.md
|
data/config/locales/en.yml
CHANGED
|
@@ -40,6 +40,34 @@ en:
|
|
|
40
40
|
invalid_client_credentials: "Invalid client credentials"
|
|
41
41
|
success_disabled_2fa: "Successfully disabled 2FA"
|
|
42
42
|
|
|
43
|
+
load_test:
|
|
44
|
+
generate_voting_fixture_data:
|
|
45
|
+
unknown_flow: "Unknown flow: %{flow}. Must be one of %{flows}"
|
|
46
|
+
no_show: "Voting session has no show — show_slug will be blank."
|
|
47
|
+
no_payment_method: "No active %{label} payment method found for this tenant — set one up first (Shared Console > Payment Methods)."
|
|
48
|
+
sandbox_host_mismatch: >
|
|
49
|
+
Payment method's host (%{host}) doesn't look like ABA's sandbox — PayWay v2 has no
|
|
50
|
+
sandbox/production flag to check programmatically, so double check by eye before running
|
|
51
|
+
this against anything but a throwaway account.
|
|
52
|
+
session_status_invalid: 'Voting session status is %{status}, but casting a vote requires status "enabled".'
|
|
53
|
+
manual_advance_never_votes: 'Voting session type is "manual_advance", which never accepts direct votes.'
|
|
54
|
+
voting_not_opened: "Voting hasn't opened yet — it opens %{opens_at}."
|
|
55
|
+
voting_already_closed: "Voting has already closed — it closed %{closes_at}."
|
|
56
|
+
no_contestants: >
|
|
57
|
+
Voting session has no contestants assigned yet — the flow picks a random contestant ID
|
|
58
|
+
from GET voting_contestants and has nothing to pick from.
|
|
59
|
+
no_eligible_credit: >
|
|
60
|
+
No active VotingCredit rows exist for this session (or its episode/show) at all — the
|
|
61
|
+
load-test accounts casting votes will each still need their own credit (VoteCreditDeductor
|
|
62
|
+
rejects a vote with no eligible, unused credit).
|
|
63
|
+
no_vote_package: >
|
|
64
|
+
No active, purchasable paid vote package found for this session (or its episode/show) —
|
|
65
|
+
the topup flow adds one to cart and has nothing to add. Create one under this show/season
|
|
66
|
+
(status=active, priced above 0, in stock or backorderable).
|
|
67
|
+
payment_method_labels:
|
|
68
|
+
aba: "ABA (PayWay)"
|
|
69
|
+
cash_on: "Cash On"
|
|
70
|
+
|
|
43
71
|
hello: "Hello world"
|
|
44
72
|
|
|
45
73
|
recaptcha:
|
|
@@ -227,6 +255,7 @@ en:
|
|
|
227
255
|
description: "Description"
|
|
228
256
|
enable_telegram_alert: "Enable Telegram Alert"
|
|
229
257
|
enable_send_receipt: "Enable Send Receipt"
|
|
258
|
+
enable_social_sharing_email: "Enable Social Sharing Email"
|
|
230
259
|
registered_by:
|
|
231
260
|
system_registered: "System Registered"
|
|
232
261
|
self_registered: "Self Registered"
|
|
@@ -284,6 +313,11 @@ en:
|
|
|
284
313
|
invalid_session_token: "Invalid session token"
|
|
285
314
|
intel_phone_number: "Intel Phone Number"
|
|
286
315
|
phone_number: "Phone Number"
|
|
316
|
+
contact: "Contact"
|
|
317
|
+
organization: "Organization"
|
|
318
|
+
expectation: "Expectation"
|
|
319
|
+
social_contact: "Social Contact"
|
|
320
|
+
social_contact_platform: "Social Contact Platform"
|
|
287
321
|
new_customer: "New Customer"
|
|
288
322
|
listing_price: "Listing Price"
|
|
289
323
|
length: "Length"
|
|
@@ -509,6 +543,64 @@ en:
|
|
|
509
543
|
already_checked_in: "Guest has already checked in for this session"
|
|
510
544
|
check_in_session_required: "Check-in session required"
|
|
511
545
|
check_in_session_is_not_allowed_or_invalid: "Check-in session is not allowed or invalid for this guest/ticket"
|
|
546
|
+
load_test:
|
|
547
|
+
nav_label: "Load Testing"
|
|
548
|
+
back_to_load_testing: "Back to Load Testing"
|
|
549
|
+
index:
|
|
550
|
+
manage_accounts: "Manage Accounts"
|
|
551
|
+
manage_accounts_description: "Create/destroy load-test accounts and mint their login tokens."
|
|
552
|
+
test_data_sources: "Test Data Sources"
|
|
553
|
+
test_data_sources_description: "Where to find/generate the JSON each k6 flow needs."
|
|
554
|
+
accounts:
|
|
555
|
+
manage_accounts_platform: "Manage Accounts — Platform"
|
|
556
|
+
manage_accounts_body_html: >
|
|
557
|
+
Ensures N load-test accounts exist (idempotent — existing accounts are skipped), or
|
|
558
|
+
soft-deletes them after production testing (not needed on staging — accounts are
|
|
559
|
+
reusable across runs). Accounts can never log in with a real password; each authenticates
|
|
560
|
+
with a stateless, signed JWT instead. Mint those below with
|
|
561
|
+
<strong>Generate Login Tokens</strong> — k6 sends the JWT as <code>user_id_token</code>
|
|
562
|
+
when it logs in, no separate token-minting step needed.
|
|
563
|
+
count: "Count"
|
|
564
|
+
email_prefix: "Email Prefix"
|
|
565
|
+
email_domain: "Email Domain (optional)"
|
|
566
|
+
email_domain_placeholder: "<tenant-slug>.test or bookmeplus.test"
|
|
567
|
+
create_accounts: "Create Accounts"
|
|
568
|
+
destroy_accounts: "Destroy Accounts"
|
|
569
|
+
destroy_accounts_confirm: "Destroy this account pool?"
|
|
570
|
+
generate_login_tokens_platform: "Generate Login Tokens — Platform"
|
|
571
|
+
generate_login_tokens_body_html: >
|
|
572
|
+
Mints tokens for exactly the accounts listed in the table below — set Per Page and page
|
|
573
|
+
through it first, then generate. Each token is a stateless, signed JWT
|
|
574
|
+
(<code>sub</code> = the account, <code>aud</code> = you, plus an expiry) valid for
|
|
575
|
+
%{ttl} — nothing is stored.
|
|
576
|
+
per_page: "Per Page"
|
|
577
|
+
apply: "Apply"
|
|
578
|
+
generate_login_tokens: "Generate Login Tokens"
|
|
579
|
+
table_no: "No."
|
|
580
|
+
table_email: "Email"
|
|
581
|
+
table_user_id: "User ID"
|
|
582
|
+
table_created_at: "Created At"
|
|
583
|
+
table_last_sign_in_at: "Last Sign In At"
|
|
584
|
+
table_current_sign_in_at: "Current Sign In At"
|
|
585
|
+
no_accounts_found: "No load-test accounts found."
|
|
586
|
+
login_tokens_json: "Login Tokens JSON"
|
|
587
|
+
copy_into_k6: "Copy into k6 (Platform)"
|
|
588
|
+
copy_json: "Copy JSON"
|
|
589
|
+
json_copied: "JSON copied"
|
|
590
|
+
generating: "Generating…"
|
|
591
|
+
error_prefix: "Error: "
|
|
592
|
+
request_failed: "Request failed (%{status})"
|
|
593
|
+
flows:
|
|
594
|
+
description_html: >
|
|
595
|
+
Each k6 flow reads its data from a git-ignored JSON file under <code>load_testing/config/</code>.
|
|
596
|
+
Expand a flow below for where to find its real values — see
|
|
597
|
+
<code>gems/spree_cm_commissioner/config/load_test_flows/{platform,tenant}.yml</code> to add or edit flows.
|
|
598
|
+
save_to: "Save to"
|
|
599
|
+
data_button_label: '"%{label}" button'
|
|
600
|
+
look_up_manually: "Look up manually"
|
|
601
|
+
field: "Field"
|
|
602
|
+
where_to_find_it: "Where to find it"
|
|
603
|
+
full_request_table_html: "Full request table: <code>load_testing/%{doc}</code>"
|
|
512
604
|
|
|
513
605
|
activerecord:
|
|
514
606
|
attributes:
|
|
@@ -823,6 +915,8 @@ en:
|
|
|
823
915
|
verification_failed: "Human verification failed"
|
|
824
916
|
service_unavailable: "Verification service is temporarily unavailable"
|
|
825
917
|
spree_cm_commissioner:
|
|
918
|
+
draft_order:
|
|
919
|
+
line_items_missing: "Line items are missing"
|
|
826
920
|
voting_contestants:
|
|
827
921
|
assigner:
|
|
828
922
|
not_eliminated: "Only eliminated contestants can be added to a Save Contestants session. %{name} is %{status}."
|
data/config/locales/km.yml
CHANGED
|
@@ -666,6 +666,8 @@ km:
|
|
|
666
666
|
service_unavailable: "សេវាកម្មពេលនេះមិនមានការផ្ទៀងផ្ទាត់"
|
|
667
667
|
|
|
668
668
|
spree_cm_commissioner:
|
|
669
|
+
draft_order:
|
|
670
|
+
line_items_missing: "គ្មានទំនិញនៅក្នុងកន្ត្រកទេ"
|
|
669
671
|
voting_contestants:
|
|
670
672
|
assigner:
|
|
671
673
|
not_eliminated: "តែអ្នកចូលប្រកួតដែលត្រូវបានលុបចោលប៉ុណ្ណោះអាចបន្ថែមទៅ Save Contestants session បាន។ %{name} មានស្ថានភាព %{status}។"
|