Commit 4ea549d0 authored by Krasimir Angelov's avatar Krasimir Angelov

Export seat usage for a group as CSV

Allow owners to export seat usage list as a CSV file.

https://gitlab.com/gitlab-org/gitlab/-/issues/262877
parent 306cdcc7
...@@ -56,6 +56,7 @@ export default { ...@@ -56,6 +56,7 @@ export default {
'total', 'total',
'namespaceName', 'namespaceName',
'namespaceId', 'namespaceId',
'seatUsageExportPath',
'billableMemberToRemove', 'billableMemberToRemove',
'search', 'search',
'sort', 'sort',
...@@ -146,6 +147,10 @@ export default { ...@@ -146,6 +147,10 @@ export default {
</h4> </h4>
<gl-badge>{{ total }}</gl-badge> <gl-badge>{{ total }}</gl-badge>
</div> </div>
<gl-button v-if="seatUsageExportPath" data-testid="export-button" :href="seatUsageExportPath">
{{ s__('Billing|Export list') }}
</gl-button>
</div> </div>
<div class="gl-bg-gray-10 gl-p-3"> <div class="gl-bg-gray-10 gl-p-3">
......
...@@ -12,11 +12,11 @@ export default (containerId = 'js-seat-usage') => { ...@@ -12,11 +12,11 @@ export default (containerId = 'js-seat-usage') => {
return false; return false;
} }
const { namespaceId, namespaceName } = containerEl.dataset; const { namespaceId, namespaceName, seatUsageExportPath } = containerEl.dataset;
return new Vue({ return new Vue({
el: containerEl, el: containerEl,
store: new Vuex.Store(initialStore({ namespaceId, namespaceName })), store: new Vuex.Store(initialStore({ namespaceId, namespaceName, seatUsageExportPath })),
render(createElement) { render(createElement) {
return createElement(SubscriptionSeats); return createElement(SubscriptionSeats);
}, },
......
export default ({ namespaceId = null, namespaceName = null } = {}) => ({ export default ({ namespaceId = null, namespaceName = null, seatUsageExportPath = null } = {}) => ({
isLoading: false, isLoading: false,
hasError: false, hasError: false,
namespaceId, namespaceId,
namespaceName, namespaceName,
seatUsageExportPath,
members: [], members: [],
total: null, total: null,
page: null, page: null,
......
...@@ -3,11 +3,40 @@ ...@@ -3,11 +3,40 @@
class Groups::SeatUsageController < Groups::ApplicationController class Groups::SeatUsageController < Groups::ApplicationController
before_action :authorize_admin_group_member! before_action :authorize_admin_group_member!
before_action :verify_namespace_plan_check_enabled before_action :verify_namespace_plan_check_enabled
before_action :seat_usage_export_enabled?, if: -> { request.format.csv? }
layout "group_settings" layout "group_settings"
feature_category :purchase feature_category :purchase
def show def show
respond_to do |format|
format.html do
end
format.csv do
result = Groups::SeatUsageExportService.execute(group, current_user)
if result.success?
stream_csv_headers(csv_filename)
self.response_body = result.payload
else
flash[:alert] = _('Failed to generate export, please try again later.')
redirect_to group_seat_usage_path(group)
end
end
end
end
private
def csv_filename
"seat-usage-export-#{Time.current.to_s(:number)}.csv"
end
def seat_usage_export_enabled?
not_found unless Feature.enabled?(:seat_usage_export, group, default_enabled: :yaml)
end end
end end
# frozen_string_literal: true
module Groups
class SeatUsageExportService
include Gitlab::Allowable
def self.execute(group, user)
new(group, user).execute
end
def initialize(group, user)
@group = group
@current_user = user
end
def execute
return insufficient_permissions unless can?(current_user, :admin_group_member, group)
ServiceResponse.success(payload: csv_builder.render)
rescue StandardError => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
ServiceResponse.error(message: 'Failed to generate export')
end
private
attr_reader :group, :current_user
def insufficient_permissions
ServiceResponse.error(message: 'Insufficient permissions to generate export')
end
def csv_builder
@csv_builder = CsvBuilders::Stream.new(data, header_to_value_hash)
end
def data
result = BilledUsersFinder.new(group, order_by: 'id_asc').execute
result[:users] || User.none
end
def header_to_value_hash
{
'Id' => 'id',
'Name' => 'name',
'Username' => 'username',
'Email' => -> (user) { user.email if user.managed_by?(current_user) },
'State' => 'state'
}
end
end
end
- page_title s_('SeatUsage|Seat usage') - page_title s_('SeatUsage|Seat usage')
- add_to_breadcrumbs _('Billing'), group_billings_path(@group) - add_to_breadcrumbs _('Billing'), group_billings_path(@group)
#js-seat-usage{ data: { namespace_id: @group.id, namespace_name: @group.name } } #js-seat-usage{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: (group_seat_usage_path(@group, format: :csv) if Feature.enabled?(:seat_usage_export, @group, default_enabled: :yaml)) } }
---
name: seat_usage_export
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67202
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/337397
milestone: '14.2'
type: development
group: group::utilization
default_enabled: false
...@@ -12,8 +12,8 @@ RSpec.describe Groups::SeatUsageController do ...@@ -12,8 +12,8 @@ RSpec.describe Groups::SeatUsageController do
stub_application_setting(check_namespace_plan: true) stub_application_setting(check_namespace_plan: true)
end end
def get_show def get_show(format: :html)
get :show, params: { group_id: group } get :show, params: { group_id: group }, format: format
end end
subject { response } subject { response }
...@@ -23,11 +23,68 @@ RSpec.describe Groups::SeatUsageController do ...@@ -23,11 +23,68 @@ RSpec.describe Groups::SeatUsageController do
group.add_owner(user) group.add_owner(user)
end end
context 'when html format' do
before do
stub_feature_flags(seat_usage_export: false)
end
it 'renders show with 200 status code' do it 'renders show with 200 status code' do
get_show get_show
is_expected.to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
is_expected.to render_template(:show) expect(response).to render_template(:show)
end
end
context 'when csv format' do
context 'when seat_usage_export feature flag is disabled' do
before do
stub_feature_flags(seat_usage_export: false)
end
it 'responds with 404 status code' do
get_show(format: :csv)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when seat_usage_export feature flag is enabled' do
before do
stub_feature_flags(seat_usage_export: true)
expect(Groups::SeatUsageExportService).to receive(:execute).with(group, user).and_return(result)
end
context 'when export is successful' do
let(:csv_data) do
<<~CSV
Name,Username,State
Administrator,root,active
CSV
end
let(:result) { ServiceResponse.success(payload: csv_data) }
it 'streams the csv with 200 status code' do
get_show(format: :csv)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8; header=present')
expect(response.body).to eq(csv_data)
end
end
context 'when export fails' do
let(:result) { ServiceResponse.error(message: 'Something went wrong!') }
it 'sets alert message and redirects' do
get_show(format: :csv)
expect(flash[:alert]).to eq 'Failed to generate export, please try again later.'
expect(response).to redirect_to(group_seat_usage_path(group))
end
end
end
end end
end end
...@@ -36,11 +93,21 @@ RSpec.describe Groups::SeatUsageController do ...@@ -36,11 +93,21 @@ RSpec.describe Groups::SeatUsageController do
group.add_developer(user) group.add_developer(user)
end end
context 'when html format' do
it 'renders 403 when user is not an owner' do it 'renders 403 when user is not an owner' do
get_show get_show
is_expected.to have_gitlab_http_status(:forbidden) is_expected.to have_gitlab_http_status(:forbidden)
end end
end end
context 'when csv format' do
it 'renders 403 when user is not an owner' do
get_show(format: :csv)
is_expected.to have_gitlab_http_status(:forbidden)
end
end
end
end end
end end
...@@ -28,6 +28,8 @@ const actionSpies = { ...@@ -28,6 +28,8 @@ const actionSpies = {
const providedFields = { const providedFields = {
namespaceName: 'Test Group Name', namespaceName: 'Test Group Name',
namespaceId: '1000', namespaceId: '1000',
seatUsageExportPath: '/groups/test_group/-/seat_usage.csv',
}; };
const fakeStore = ({ initialState, initialGetters }) => const fakeStore = ({ initialState, initialGetters }) =>
...@@ -73,6 +75,8 @@ describe('Subscription Seats', () => { ...@@ -73,6 +75,8 @@ describe('Subscription Seats', () => {
const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]'); const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]');
const findPageHeadingBadge = () => findPageHeading().find(GlBadge); const findPageHeadingBadge = () => findPageHeading().find(GlBadge);
const findExportButton= () => wrapper.findByTestId('export-button');
const findSearchBox = () => wrapper.findComponent(FilterSortContainerRoot); const findSearchBox = () => wrapper.findComponent(FilterSortContainerRoot);
const findPagination = () => wrapper.findComponent(GlPagination); const findPagination = () => wrapper.findComponent(GlPagination);
...@@ -146,6 +150,12 @@ describe('Subscription Seats', () => { ...@@ -146,6 +150,12 @@ describe('Subscription Seats', () => {
}); });
}); });
describe('export button', () => {
it('has the correct href', () => {
expect(findExportButton().attributes().href).toBe(providedFields.seatUsageExportPath);
});
});
describe('table content', () => { describe('table content', () => {
it('renders the correct data', () => { it('renders the correct data', () => {
const serializedTable = findSerializedTable(findTable()); const serializedTable = findSerializedTable(findTable());
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::SeatUsageExportService do
describe '#execute', :aggregate_failures do
let(:group) { create(:group, :private) }
let(:owner) { create(:user, name: 'Owner', username: 'owner', state: 'active') }
subject(:result) { described_class.new(group, owner).execute }
context 'when user is allowed to export seat usage data' do
let(:developer) { create(:user, name: 'Dev', username: 'dev', email: 'dev@example.org', state: 'active', managing_group: group) }
let(:reporter) { create(:user, name: 'Reporter', username: 'reporter', state: 'active') }
before do
group.add_owner(owner)
end
context 'when successful' do
let(:payload) { result.payload.to_a }
context 'when group has members' do
before do
group.add_developer(developer)
group.add_reporter(reporter)
end
it 'returns csv data' do
expect(payload).to eq([
"Id,Name,Username,Email,State\n",
"#{owner.id},Owner,owner,,active\n",
"#{developer.id},Dev,dev,dev@example.org,active\n",
"#{reporter.id},Reporter,reporter,,active\n"
])
end
end
context 'when group has no members' do
it 'returns no rows' do
finder = double
expect(BilledUsersFinder).to receive(:new).and_return(finder)
expect(finder).to receive(:execute).and_return({})
expect(payload).to eq(["Id,Name,Username,Email,State\n"])
end
end
end
context 'when it fails' do
it 'returns error' do
finder = double
expect(BilledUsersFinder).to receive(:new).and_return(finder)
expect(finder).to receive(:execute).and_raise(PG::QueryCanceled)
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
expect(result).to be_error
expect(result.message).to eq('Failed to generate export')
end
end
end
context 'when user is not allowed to export seat usage data' do
before do
group.add_developer(owner)
end
it 'returns error' do
expect(result).to be_error
expect(result.message).to eq('Insufficient permissions to generate export')
end
end
end
end
...@@ -5265,6 +5265,9 @@ msgstr "" ...@@ -5265,6 +5265,9 @@ msgstr ""
msgid "Billing|Enter at least three characters to search." msgid "Billing|Enter at least three characters to search."
msgstr "" msgstr ""
msgid "Billing|Export list"
msgstr ""
msgid "Billing|Group" msgid "Billing|Group"
msgstr "" msgstr ""
...@@ -13624,6 +13627,9 @@ msgstr "" ...@@ -13624,6 +13627,9 @@ msgstr ""
msgid "Failed to find import label for Jira import." msgid "Failed to find import label for Jira import."
msgstr "" msgstr ""
msgid "Failed to generate export, please try again later."
msgstr ""
msgid "Failed to generate report, please try again after sometime" msgid "Failed to generate report, please try again after sometime"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment