Commit 52c378d7 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ee-users-search-results' into 'master'

[EE] Add users search results to global search

See merge request gitlab-org/gitlab-ee!8985
parents 7380e48c 09da07e6
......@@ -29,6 +29,7 @@ class SearchController < ApplicationController
@search_objects = search_service.search_objects
render_commits if @scope == 'commits'
eager_load_user_status if @scope == 'users'
check_single_commit_result
end
......@@ -54,6 +55,12 @@ class SearchController < ApplicationController
@search_objects = prepare_commits_for_rendering(@search_objects)
end
def eager_load_user_status
return if Feature.disabled?(:users_search, default_enabled: true)
@search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
end
def check_single_commit_result
if @search_results.single_commit_result?
only_commit = @search_results.objects('commits').first
......
......@@ -366,7 +366,8 @@ module ProjectsHelper
blobs: :download_code,
commits: :download_code,
merge_requests: :read_merge_request,
notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet],
members: :read_project_member
)
end
......
......@@ -201,6 +201,16 @@ module SearchHelper
def limited_count(count, limit = 1000)
count > limit ? "#{limit}+" : count
end
def search_tabs?(tab)
return false if Feature.disabled?(:users_search, default_enabled: true)
if @project
project_search_tabs?(:members)
else
can?(current_user, :read_users_list)
end
end
end
SearchHelper.prepend(EE::SearchHelper)
......@@ -23,7 +23,8 @@ module Search
def allowed_scopes
strong_memoize(:allowed_scopes) do
%w[issues merge_requests milestones]
allowed_scopes = %w[issues merge_requests milestones]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
end
end
......
......@@ -11,6 +11,12 @@ module Search
@group = group
end
def execute
Gitlab::GroupSearchResults.new(
current_user, projects, group, params[:search], default_project_filter: default_project_filter
)
end
def projects
return Project.none unless group
return @projects if defined? @projects
......
......@@ -16,7 +16,12 @@ module Search
end
def scope
@scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' }
@scope ||= begin
allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
allowed_scopes.delete(params[:scope]) { 'blobs' }
end
end
end
end
......
- users = capture_haml do
- if search_tabs?(:members)
%li{ class: active_when(@scope == 'users') }
= link_to search_filter_path(scope: 'users') do
Users
%span.badge.badge-pill
= limited_count(@search_results.limited_users_count)
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
......@@ -45,6 +53,7 @@
= _("Commits")
%span.badge.badge-pill
= @search_results.commits_count
= users
- elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') }
......@@ -94,3 +103,4 @@
= _("Wiki")
%span.badge.badge-pill
= limited_count(@search_results.wiki_blobs_count)
= users
%ul.content-list
%li
.avatar-cell.d-none.d-sm-block
= user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40')
.user-info
= link_to user_path(user), class: 'd-none d-sm-inline' do
.item-title
= user.name
= user_status(user)
.cgray= user.to_reference
---
title: Add users search results to global search
merge_request: 21197
author: Alexis Reigel
type: added
......@@ -17,7 +17,7 @@ GET /search
| `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs.
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs, users.
If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs and commits. Find more about [the feature](../integration/elasticsearch.md).
......@@ -255,7 +255,7 @@ Example response:
### Scope: snippet_blobs
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/search?scope=snippet_blobs&search=test
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/search?scope=snippet_blos&search=test
```
Example response:
......@@ -375,6 +375,27 @@ Example response:
]
```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
## Group Search API
Search within the specified group.
......@@ -391,7 +412,7 @@ GET /groups/:id/search
| `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones.
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, users.
If Elasticsearch is enabled additional scopes available are blobs, wiki_blobs and commits. Find more about [the feature](../integration/elasticsearch.md).
......@@ -687,6 +708,27 @@ Example response:
]
```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/3/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
## Project Search API
Search within the specified project.
......@@ -703,7 +745,7 @@ GET /projects/:id/search
| `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs.
Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs, users.
The response depends on the requested scope.
......@@ -1016,4 +1058,25 @@ Example response:
]
```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/6/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
[ce-41763]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41763
......@@ -9,7 +9,9 @@ module EE
override :execute
def execute
if ::Gitlab::CurrentSettings.elasticsearch_search?
::Gitlab::Elastic::SearchResults.new(current_user, params[:search], elastic_projects, elastic_global)
::Gitlab::Elastic::SearchResults.new(current_user, params[:search],
elastic_projects, projects,
elastic_global)
else
super
end
......
......@@ -3,6 +3,8 @@
module EE
module Search
module GroupService
extend ::Gitlab::Utils::Override
def elastic_projects
@elastic_projects ||= projects.pluck(:id) # rubocop:disable CodeReuse/ActiveRecord
end
......@@ -10,6 +12,15 @@ module EE
def elastic_global
false
end
override :execute
def execute
return super unless ::Gitlab::CurrentSettings.elasticsearch_search?
::Gitlab::Elastic::GroupSearchResults.new(
current_user, elastic_projects, projects, group, params[:search],
elastic_global, default_project_filter: default_project_filter)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Elastic
# Always prefer to use the full class namespace when specifying a
# superclass inside a module, because autoloading can occur in a
# different order between execution environments.
class GroupSearchResults < Gitlab::Elastic::SearchResults
delegate :users, to: :generic_search_results
delegate :limited_users_count, to: :generic_search_results
attr_reader :group, :default_project_filter
def initialize(current_user, limit_project_ids, limit_projects, group, query, public_and_internal_projects, default_project_filter: false, per_page: 20)
super(current_user, query, limit_project_ids, limit_projects, public_and_internal_projects)
@default_project_filter = default_project_filter
@group = group
end
def objects(scope, page = nil)
case scope
when 'users'
users.page(page).per(per_page)
else
super
end
end
def generic_search_results
@generic_search_results ||= Gitlab::GroupSearchResults.new(current_user, limit_projects, group, query, default_project_filter: default_project_filter)
end
end
end
end
......@@ -8,6 +8,9 @@ module Gitlab
class ProjectSearchResults < Gitlab::Elastic::SearchResults
attr_reader :project, :repository_ref
delegate :users, to: :generic_search_results
delegate :limited_users_count, to: :generic_search_results
def initialize(current_user, query, project_id, repository_ref = nil)
@current_user = current_user
@project = Project.find(project_id)
......@@ -26,11 +29,17 @@ module Gitlab
wiki_blobs.page(page).per(per_page)
when 'commits'
commits(page: page, per_page: per_page)
when 'users'
users.page(page).per(per_page)
else
super
end
end
def generic_search_results
@generic_search_results ||= Gitlab::ProjectSearchResults.new(current_user, project, query, repository_ref)
end
def blobs_count
@blobs_count ||= blobs.total_count
end
......
......@@ -7,11 +7,15 @@ module Gitlab
# Limit search results by passed project ids
# It allows us to search only for projects user has access to
attr_reader :limit_project_ids
attr_reader :limit_project_ids, :limit_projects
def initialize(current_user, query, limit_project_ids, public_and_internal_projects = true)
delegate :users, to: :generic_search_results
delegate :limited_users_count, to: :generic_search_results
def initialize(current_user, query, limit_project_ids, limit_projects = nil, public_and_internal_projects = true)
@current_user = current_user
@limit_project_ids = limit_project_ids
@limit_projects = limit_projects
@query = query
@public_and_internal_projects = public_and_internal_projects
end
......@@ -32,11 +36,17 @@ module Gitlab
wiki_blobs.page(page).per(per_page)
when 'commits'
commits(page: page, per_page: per_page)
when 'users'
users.page(page).per(per_page)
else
Kaminari.paginate_array([])
end
end
def generic_search_results
@generic_search_results ||= Gitlab::SearchResults.new(current_user, limit_projects, query)
end
def projects_count
@projects_count ||= projects.total_count
end
......
require 'spec_helper'
describe Gitlab::Elastic::GroupSearchResults do
set(:user) { create(:user) }
set(:group) { create(:group) }
set(:guest) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::GUEST) } }
before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end
context 'user search' do
subject(:results) { described_class.new(user, nil, nil, group, guest.username, nil) }
before do
expect(Gitlab::GroupSearchResults).to receive(:new).and_call_original
end
it { expect(results.objects('users')).to eq([guest]) }
it { expect(results.limited_users_count).to eq(1) }
end
end
......@@ -224,4 +224,15 @@ describe Gitlab::Elastic::ProjectSearchResults do
expect(results.issues_count).to eq 3
end
end
context 'user search' do
subject(:results) { described_class.new(user, project.owner.username, project.id) }
before do
expect(Gitlab::ProjectSearchResults).to receive(:new).and_call_original
end
it { expect(results.objects('users')).to eq([project.owner]) }
it { expect(results.limited_users_count).to eq(1) }
end
end
......@@ -5,17 +5,17 @@ module API
module SearchHelpers
def self.global_search_scopes
# This is a separate method so that EE can redefine it.
%w(projects issues merge_requests milestones snippet_titles snippet_blobs)
%w(projects issues merge_requests milestones snippet_titles snippet_blobs users)
end
def self.group_search_scopes
# This is a separate method so that EE can redefine it.
%w(projects issues merge_requests milestones)
%w(projects issues merge_requests milestones users)
end
def self.project_search_scopes
# This is a separate method so that EE can redefine it.
%w(issues merge_requests milestones notes wiki_blobs commits blobs)
%w(issues merge_requests milestones notes wiki_blobs commits blobs users)
end
end
end
......
......@@ -17,7 +17,8 @@ module API
blobs: Entities::Blob,
wiki_blobs: Entities::Blob,
snippet_titles: Entities::Snippet,
snippet_blobs: Entities::Snippet
snippet_blobs: Entities::Snippet,
users: Entities::UserBasic
}.freeze
def search(additional_params = {})
......@@ -51,6 +52,12 @@ module API
# Defining this method here as a noop allows us to easily extend it in
# EE, without having to modify this file directly.
end
def check_users_search_allowed!
if params[:scope].to_sym == :users && Feature.disabled?(:users_search, default_enabled: true)
render_api_error!({ error: _("Scope not supported with disabled 'users_search' feature!") }, 400)
end
end
end
resource :search do
......@@ -67,6 +74,7 @@ module API
end
get do
verify_search_scope!
check_users_search_allowed!
present search, with: entity
end
......@@ -87,6 +95,7 @@ module API
end
get ':id/(-/)search' do
verify_search_scope!
check_users_search_allowed!
present search(group_id: user_group.id), with: entity
end
......@@ -106,6 +115,8 @@ module API
use :pagination
end
get ':id/(-/)search' do
check_users_search_allowed!
present search(project_id: user_project.id), with: entity
end
end
......
# frozen_string_literal: true
module Gitlab
class GroupSearchResults < SearchResults
def initialize(current_user, limit_projects, group, query, default_project_filter: false, per_page: 20)
super(current_user, limit_projects, query, default_project_filter: default_project_filter, per_page: per_page)
@group = group
end
# rubocop:disable CodeReuse/ActiveRecord
def users
# 1: get all groups the current user has access to
groups = GroupsFinder.new(current_user).execute.joins(:users)
# 2: Get the group's whole hierarchy
group_users = @group.direct_and_indirect_users
# 3: get all users the current user has access to (->
# `SearchResults#users`), which also applies the query.
users = super
# 4: filter for users that belong to the previously selected groups
users
.where(id: group_users.select('id'))
.where(id: groups.select('members.user_id'))
end
# rubocop:enable CodeReuse/ActiveRecord
end
end
......@@ -22,11 +22,17 @@ module Gitlab
paginated_blobs(wiki_blobs, page)
when 'commits'
Kaminari.paginate_array(commits).page(page).per(per_page)
when 'users'
users.page(page).per(per_page)
else
super(scope, page, false)
end
end
def users
super.where(id: @project.team.members) # rubocop:disable CodeReuse/ActiveRecord
end
def blobs_count
@blobs_count ||= blobs.count
end
......
......@@ -32,6 +32,8 @@ module Gitlab
merge_requests.page(page).per(per_page)
when 'milestones'
milestones.page(page).per(per_page)
when 'users'
users.page(page).per(per_page)
else
Kaminari.paginate_array([]).page(page).per(per_page)
end
......@@ -71,6 +73,12 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop:disable CodeReuse/ActiveRecord
def limited_users_count
@limited_users_count ||= users.limit(count_limit).count
end
# rubocop:enable CodeReuse/ActiveRecord
def single_commit_result?
false
end
......@@ -79,6 +87,12 @@ module Gitlab
1001
end
def users
return User.none unless Ability.allowed?(current_user, :read_users_list)
UsersFinder.new(current_user, search: query).execute
end
private
def projects
......
......@@ -8816,6 +8816,9 @@ msgstr ""
msgid "Scope"
msgstr ""
msgid "Scope not supported with disabled 'users_search' feature!"
msgstr ""
msgid "Scoped issue boards"
msgstr ""
......
require 'spec_helper'
describe 'User searches for users' do
context 'when on the dashboard' do
it 'finds the user' do
create(:user, username: 'gob_bluth', name: 'Gob Bluth')
sign_in(create(:user))
visit dashboard_projects_path
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Users 1')
click_on('Users 1')
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
end
end
context 'when on the project page' do
it 'finds the user belonging to the project' do
project = create(:project)
user1 = create(:user, username: 'gob_bluth', name: 'Gob Bluth')
create(:project_member, :developer, user: user1, project: project)
user2 = create(:user, username: 'michael_bluth', name: 'Michael Bluth')
create(:project_member, :developer, user: user2, project: project)
create(:user, username: 'gob_2018', name: 'George Oscar Bluth')
sign_in(user1)
visit projects_path(project)
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
expect(page).not_to have_content('Michael Bluth')
expect(page).not_to have_content('@michael_bluth')
expect(page).not_to have_content('George Oscar Bluth')
expect(page).not_to have_content('@gob_2018')
end
end
context 'when on the group page' do
it 'finds the user belonging to the group' do
group = create(:group)
user1 = create(:user, username: 'gob_bluth', name: 'Gob Bluth')
create(:group_member, :developer, user: user1, group: group)
user2 = create(:user, username: 'michael_bluth', name: 'Michael Bluth')
create(:group_member, :developer, user: user2, group: group)
create(:user, username: 'gob_2018', name: 'George Oscar Bluth')
sign_in(user1)
visit group_path(group)
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
expect(page).not_to have_content('Michael Bluth')
expect(page).not_to have_content('@michael_bluth')
expect(page).not_to have_content('George Oscar Bluth')
expect(page).not_to have_content('@gob_2018')
end
end
end
require 'spec_helper'
describe Gitlab::GroupSearchResults do
let(:user) { create(:user) }
describe 'user search' do
let(:group) { create(:group) }
it 'returns the users belonging to the group matching the search query' do
user1 = create(:user, username: 'gob_bluth')
create(:group_member, :developer, user: user1, group: group)
user2 = create(:user, username: 'michael_bluth')
create(:group_member, :developer, user: user2, group: group)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the subgroup matching the search query', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
subgroup = create(:group, parent: group)
create(:group_member, :developer, user: user1, group: subgroup)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the parent group matching the search query', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
parent_group = create(:group, children: [group])
create(:group_member, :developer, user: user1, group: parent_group)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'does not return the user belonging to the private subgroup', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
subgroup = create(:group, :private, parent: group)
create(:group_member, :developer, user: user1, group: subgroup)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq []
end
it 'does not return the user belonging to an unrelated group' do
user = create(:user, username: 'gob_bluth')
unrelated_group = create(:group)
create(:group_member, :developer, user: user, group: unrelated_group)
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq []
end
end
end
......@@ -412,4 +412,36 @@ describe Gitlab::ProjectSearchResults do
end
end
end
describe 'user search' do
it 'returns the user belonging to the project matching the search query' do
project = create(:project)
user1 = create(:user, username: 'gob_bluth')
create(:project_member, :developer, user: user1, project: project)
user2 = create(:user, username: 'michael_bluth')
create(:project_member, :developer, user: user2, project: project)
create(:user, username: 'gob_2018')
result = described_class.new(user, project, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the group matching the search query' do
group = create(:group)
project = create(:project, namespace: group)
user1 = create(:user, username: 'gob_bluth')
create(:group_member, :developer, user: user1, group: group)
create(:user, username: 'gob_2018')
result = described_class.new(user, project, 'gob').objects('users')
expect(result).to eq [user1]
end
end
end
......@@ -121,6 +121,22 @@ describe Gitlab::SearchResults do
results.objects('issues')
end
end
describe '#users' do
it 'does not call the UsersFinder when the current_user is not allowed to read users list' do
allow(Ability).to receive(:allowed?).and_return(false)
expect(UsersFinder).not_to receive(:new).with(user, search: 'foo').and_call_original
results.objects('users')
end
it 'calls the UsersFinder' do
expect(UsersFinder).to receive(:new).with(user, search: 'foo').and_call_original
results.objects('users')
end
end
end
it 'does not list issues on private projects' do
......
......@@ -77,6 +77,28 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
context 'for users scope' do
before do
create(:user, name: 'billy')
get api('/search', user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api('/search', user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for snippet_titles scope' do
before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
......@@ -192,6 +214,40 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
context 'for users scope' do
before do
user = create(:user, name: 'billy')
create(:group_member, :developer, user: user, group: group)
get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for users scope with group path as id' do
before do
user1 = create(:user, name: 'billy')
create(:group_member, :developer, user: user1, group: group)
get api("/groups/#{CGI.escape(group.full_path)}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
end
end
end
......@@ -269,6 +325,29 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end
context 'for users scope' do
before do
user1 = create(:user, name: 'billy')
create(:project_member, :developer, user: user1, project: project)
get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for notes scope' do
before do
create(:note_on_merge_request, project: project, note: 'awesome note')
......
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