Commit 85703cee authored by Toon Claes's avatar Toon Claes

Merge branch 'scope_board_to_iteration' into 'master'

Scope board to iteration

See merge request gitlab-org/gitlab!49012
parents 4b3fb6fe 328bbc5e
......@@ -12,10 +12,16 @@ module Timebox
include FromUnion
TimeboxStruct = Struct.new(:title, :name, :id) do
include GlobalID::Identification
# Ensure these models match the interface required for exporting
def serializable_hash(_opts = {})
{ title: title, name: name, id: id }
end
def self.declarative_policy_class
"TimeboxPolicy"
end
end
# Represents a "No Timebox" state used for filtering Issues and Merge
......@@ -24,8 +30,6 @@ module Timebox
Any = TimeboxStruct.new('Any Timebox', '', -1)
Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
Started = TimeboxStruct.new('Started', '#started', -3)
# For Iteration
Current = TimeboxStruct.new('Current', '#current', -4)
included do
# Defines the same constants above, but inside the including class.
......@@ -33,7 +37,6 @@ module Timebox
const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
const_set :Started, TimeboxStruct.new('Started', '#started', -3)
const_set :Current, TimeboxStruct.new('Current', '#current', -4)
alias_method :timebox_id, :id
......
......@@ -9,6 +9,10 @@ class Milestone < ApplicationRecord
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
class Predefined
ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze
end
has_many :milestone_releases
has_many :releases, through: :milestone_releases
......
# frozen_string_literal: true
class TimeboxPolicy < BasePolicy
# stub permissions policy on None, Any, Upcoming, Started and Current timeboxes
rule { default }.policy do
enable :read_iteration
enable :read_milestone
end
end
......@@ -1294,6 +1294,11 @@ type Board {
"""
id: ID!
"""
The board iteration.
"""
iteration: Iteration
"""
Labels of the board
"""
......@@ -23339,6 +23344,11 @@ input UpdateBoardInput {
"""
id: BoardID!
"""
The ID of iteration to be assigned to the board.
"""
iterationId: IterationID
"""
The IDs of labels to be added to the board
"""
......
......@@ -3449,6 +3449,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iteration",
"description": "The board iteration.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Iteration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "labels",
"description": "Labels of the board",
......@@ -68291,6 +68305,16 @@
},
"defaultValue": null
},
{
"name": "iterationId",
"description": "The ID of iteration to be assigned to the board.",
"type": {
"kind": "SCALAR",
"name": "IterationID",
"ofType": null
},
"defaultValue": null
},
{
"name": "weight",
"description": "The weight value to be assigned to the board",
......@@ -247,6 +247,7 @@ Represents a project or group board.
| `hideBacklogList` | Boolean | Whether or not backlog list is hidden |
| `hideClosedList` | Boolean | Whether or not closed list is hidden |
| `id` | ID! | ID (global ID) of the board |
| `iteration` | Iteration | The board iteration. |
| `labels` | LabelConnection | Labels of the board |
| `lists` | BoardListConnection | Lists of the board |
| `milestone` | Milestone | The board milestone |
......
......@@ -7,7 +7,7 @@ module EE
override :board_params
def board_params
params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
params.require(:board).permit(:name, :weight, :milestone_id, :iteration_id, :assignee_id, label_ids: [])
end
def authorize_read_parent
......
......@@ -64,7 +64,7 @@ module EE
end
def filter_by_current_iteration?
params[:iteration_id].to_s.casecmp(::Iteration::Current.title) == 0
params[:iteration_id].to_s.casecmp(::Iteration::Predefined::Current.title) == 0
end
def filter_by_iteration_title?
......
......@@ -26,6 +26,9 @@ module EE
field :milestone, type: ::Types::MilestoneType, null: true,
description: 'The board milestone'
field :iteration, type: ::Types::IterationType, null: true,
description: 'The board iteration.'
field :weight, type: GraphQL::INT_TYPE, null: true,
description: 'Weight of the board'
end
......
......@@ -31,11 +31,20 @@ module Mutations
loads: ::Types::UserType,
description: 'The ID of user to be assigned to the board'
# Cannot pre-load ::Types::MilestoneType because we are also assigning values like:
# ::Timebox::None(0), ::Timebox::Upcoming(-2) or ::Timebox::Started(-3), that cannot be resolved to a DB record.
argument :milestone_id,
::Types::GlobalIDType[::Milestone],
required: false,
description: 'The ID of milestone to be assigned to the board'
# Cannot pre-load ::Types::IterationType because we are also assigning values like:
# ::Iteration::Predefined::None(0) or ::Iteration::Predefined::Current(-4), that cannot be resolved to a DB record.
argument :iteration_id,
::Types::GlobalIDType[::Iteration],
required: false,
description: 'The ID of iteration to be assigned to the board.'
argument :weight,
GraphQL::INT_TYPE,
required: false,
......@@ -106,6 +115,9 @@ module Mutations
::GitlabSchema.parse_gid(label_id, expected_type: ::Label).model_id
end
# we need this because we also pass `gid://gitlab/Iteration/-4` or `gid://gitlab/Iteration/-4`
# as `iteration_id` when we scope board to `Iteration::Predefined::Current` or `Iteration::Predefined::None`
args[:iteration_id] = args[:iteration_id].model_id if args[:iteration_id]
args
end
......
......@@ -43,6 +43,10 @@ module EE
return unless resource_parent&.feature_available?(:scoped_issue_board)
case milestone_id
when ::Milestone::None.id
::Milestone::None
when ::Milestone::Any.id
::Milestone::Any
when ::Milestone::Upcoming.id
::Milestone::Upcoming
when ::Milestone::Started.id
......@@ -56,12 +60,12 @@ module EE
return unless resource_parent&.feature_available?(:scoped_issue_board)
case iteration_id
when ::Iteration::None.id
::Iteration::None
when ::Iteration::Any.id
::Iteration::Any
when ::Iteration::Current.id
::Iteration::Current
when ::Iteration::Predefined::None.id
::Iteration::Predefined::None
when ::Iteration::Predefined::Any.id
::Iteration::Predefined::Any
when ::Iteration::Predefined::Current.id
::Iteration::Predefined::Current
else
super
end
......
......@@ -4,6 +4,15 @@ module EE
module Iteration
extend ActiveSupport::Concern
# For Iteration
class Predefined
None = ::Timebox::TimeboxStruct.new('None', 'none', ::Timebox::None.id).freeze
Any = ::Timebox::TimeboxStruct.new('Any', 'any', ::Timebox::Any.id).freeze
Current = ::Timebox::TimeboxStruct.new('Current', 'current', -4).freeze
ALL = [None, Any, Current].freeze
end
prepended do
include Timebox
......
......@@ -4,7 +4,7 @@ module EE
module Boards
module BaseService
# rubocop: disable CodeReuse/ActiveRecord
def set_assignee
def filter_assignee
return unless params.key?(:assignee_id)
assignee = ::User.find_by(id: params.delete(:assignee_id))
......@@ -13,14 +13,9 @@ module EE
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def set_milestone
return unless params.key?(:milestone_id)
milestone_id = params[:milestone_id]
return if [::Milestone::None.id,
::Milestone::Upcoming.id,
::Milestone::Started.id].include?(milestone_id)
def filter_milestone
return if params[:milestone_id].blank?
return if ::Milestone::Predefined::ALL.map(&:id).include?(params[:milestone_id].to_i)
finder_params =
case parent
......@@ -30,13 +25,22 @@ module EE
{ project_ids: [parent.id], group_ids: parent.group&.self_and_ancestors }
end
milestone = ::MilestonesFinder.new(finder_params).find_by(id: milestone_id)
milestone = ::MilestonesFinder.new(finder_params).find_by(id: params[:milestone_id])
params[:milestone_id] = milestone&.id
params.delete(:milestone_id) unless milestone
end
# rubocop: enable CodeReuse/ActiveRecord
def set_labels
def filter_iteration
return if params[:iteration_id].blank?
return if ::Iteration::Predefined::ALL.map(&:id).include?(params[:iteration_id].to_i)
iteration = IterationsFinder.new(current_user, iterations_finder_params).find_by(id: params[:iteration_id]) # rubocop: disable CodeReuse/ActiveRecord
params.delete(:iteration_id) unless iteration
end
def filter_labels
if params.key?(:label_ids)
params[:label_ids] = (labels_service.filter_labels_ids_in_param(:label_ids) || [])
elsif params.key?(:labels)
......@@ -47,6 +51,10 @@ module EE
def labels_service
@labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
end
def iterations_finder_params
IterationsFinder.params_for_parent(parent, include_ancestors: true).merge(state: 'all')
end
end
end
end
......@@ -7,8 +7,10 @@ module EE
override :create_board!
def create_board!
set_assignee
set_milestone
filter_assignee
filter_labels
filter_milestone
filter_iteration
super
end
......
......@@ -9,6 +9,7 @@ module EE
def execute(board)
unless parent.feature_available?(:scoped_issue_board)
params.delete(:milestone_id)
params.delete(:iteration_id)
params.delete(:assignee_id)
params.delete(:label_ids)
params.delete(:labels)
......@@ -17,9 +18,10 @@ module EE
params.delete(:hide_closed_list)
end
set_assignee
set_milestone
set_labels
filter_assignee
filter_labels
filter_milestone
filter_iteration
super
end
......
......@@ -17,6 +17,10 @@ class TimeboxReportService
end
def execute
# There is no data to return for fake timeboxes like
# Milestone::None, Milestone::Any, Milestone::Started, Milestone::Upcoming,
# Iteration::None, Iteration::Any, Iteration::Current
return ServiceResponse.success(payload: { burnup_time_series: {}, stats: {} }) if timebox.is_a?(::Timebox::TimeboxStruct)
return ServiceResponse.error(message: _('%{timebox_type} does not support burnup charts' % { timebox_type: timebox_type })) unless timebox.supports_timebox_charts?
return ServiceResponse.error(message: _('%{timebox_type} must have a start and due date' % { timebox_type: timebox_type })) if timebox.start_date.blank? || timebox.due_date.blank?
return ServiceResponse.error(message: _('Burnup chart could not be generated due to too many events')) if resource_events.num_tuples > EVENT_COUNT_LIMIT
......
......@@ -16,7 +16,7 @@ module EE
params :negatable_issue_filter_params_ee do
optional :iteration_id, types: [Integer, String],
integer_or_custom_value: [IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY, ::Iteration::Current.title.downcase],
integer_or_custom_value: ::Iteration::Predefined::ALL.map { |iteration| iteration.name.downcase },
desc: 'Return issues which are assigned to the iteration with the given ID'
optional :iteration_title, type: String,
desc: 'Return issues which are assigned to the iteration with the given title'
......
......@@ -60,13 +60,14 @@ RSpec.describe Projects::BoardsController do
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
let(:label) { create(:label) }
let(:project_label) { create(:label, project: project) }
let(:create_params) do
{ name: 'Backend',
weight: 1,
milestone_id: milestone.id,
assignee_id: user.id,
label_ids: [label.id] }
label_ids: [label.id, project_label.id] }
end
it 'returns a successful 200 response' do
......@@ -87,7 +88,8 @@ RSpec.describe Projects::BoardsController do
board = Board.first
expect(Board.count).to eq(1)
expect(board).to have_attributes(create_params.except(:assignee_id))
expect(board).to have_attributes(create_params.except(:assignee_id, :label_ids))
expect(board.labels).to eq([project_label])
expect(board.assignee).to eq(user)
end
end
......@@ -130,14 +132,15 @@ RSpec.describe Projects::BoardsController do
let(:board) { create(:board, project: project, name: 'Backend') }
let(:user) { create(:user) }
let(:milestone) { create(:milestone, project: project) }
let(:label) { create(:label, project: project) }
let(:label) { create(:label) }
let(:project_label) { create(:label, project: project) }
let(:update_params) do
{ name: 'Frontend',
weight: 1,
milestone_id: milestone.id,
assignee_id: user.id,
label_ids: [label.id] }
label_ids: [label.id, project_label.id] }
end
context 'with valid params' do
......@@ -156,7 +159,8 @@ RSpec.describe Projects::BoardsController do
it 'updates board with valid params' do
update_board board, update_params
expect(board.reload).to have_attributes(update_params.except(:assignee_id))
expect(board.reload).to have_attributes(update_params.except(:assignee_id, :label_ids))
expect(board.labels).to eq([project_label])
expect(board.assignee).to eq(user)
end
end
......
......@@ -154,7 +154,7 @@ RSpec.describe IssuesFinder do
context 'filter issues by current iteration' do
let(:current_iteration) { nil }
let(:params) { { group_id: group, iteration_id: ::Iteration::Current.title } }
let(:params) { { group_id: group, iteration_id: ::Iteration::Predefined::Current.title } }
let!(:current_iteration_issue) { create(:issue, project: project1, iteration: current_iteration) }
context 'when no current iteration is found' do
......@@ -171,7 +171,7 @@ RSpec.describe IssuesFinder do
end
context 'filter by negated current iteration' do
let(:params) { { group_id: group, not: { iteration_id: ::Iteration::Current.title } } }
let(:params) { { group_id: group, not: { iteration_id: ::Iteration::Predefined::Current.title } } }
it 'returns filtered issues' do
expect(issues).to contain_exactly(issue1, iteration_1_issue, iteration_2_issue)
......
......@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Board'] do
it 'includes the ee specific fields' do
expect(described_class).to have_graphql_fields(
:assignee, :epics, :hide_backlog_list, :hide_closed_list, :labels, :milestone, :weight
:assignee, :epics, :hide_backlog_list, :hide_closed_list, :labels, :milestone, :iteration, :weight
).at_least
end
end
......@@ -3,10 +3,12 @@
require 'spec_helper'
RSpec.describe Mutations::Boards::Update do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
......@@ -23,6 +25,7 @@ RSpec.describe Mutations::Boards::Update do
weight: 3,
assignee_id: user.to_global_id,
milestone_id: milestone.to_global_id,
iteration_id: iteration.to_global_id,
label_ids: [label1.to_global_id, label2.to_global_id]
}
end
......@@ -59,6 +62,7 @@ RSpec.describe Mutations::Boards::Update do
weight: 3,
assignee: user,
milestone: milestone,
iteration: iteration,
labels: contain_exactly(label1, label2)
}
......@@ -67,6 +71,18 @@ RSpec.describe Mutations::Boards::Update do
expect(board.reload).to have_attributes(expected_attributes)
end
context 'when passing current iteration' do
before do
mutation_params.merge!(iteration_id: Iteration::Predefined::Current.to_global_id)
end
it 'updates board with current iteration' do
subject
expect(board.reload.iteration.id).to eq(Iteration::Predefined::Current.id)
end
end
context 'when passing labels param' do
before do
mutation_params.delete(:label_ids)
......
......@@ -44,6 +44,18 @@ RSpec.describe Board do
stub_licensed_features(scoped_issue_board: true)
end
it 'returns Milestone::None for started milestone id' do
board.milestone_id = Milestone::None.id
expect(board.milestone).to eq Milestone::None
end
it 'returns Milestone::Any for started milestone id' do
board.milestone_id = Milestone::Any.id
expect(board.milestone).to eq Milestone::Any
end
it 'returns Milestone::Upcoming for upcoming milestone id' do
board.milestone_id = Milestone::Upcoming.id
......@@ -62,12 +74,6 @@ RSpec.describe Board do
expect(board.milestone).to eq milestone
end
it 'returns nil for invalid milestone id' do
board.milestone_id = -1
expect(board.milestone).to be_nil
end
end
it 'returns nil when the feature is not available' do
......@@ -95,22 +101,22 @@ RSpec.describe Board do
stub_licensed_features(scoped_issue_board: true)
end
it 'returns Iteration::None, when iteration_id is None.id' do
board.iteration_id = Iteration::None.id
it 'returns Iteration::Predefined::None, when iteration_id is None.id' do
board.iteration_id = Iteration::Predefined::None.id
expect(board.iteration).to eq Iteration::None
expect(board.iteration).to eq Iteration::Predefined::None
end
it 'returns Iteration::Any, when iteration_id is Any.id' do
board.iteration_id = Iteration::Any.id
it 'returns Iteration::Predefined::Any, when iteration_id is Any.id' do
board.iteration_id = Iteration::Predefined::Any.id
expect(board.iteration).to eq Iteration::Any
expect(board.iteration).to eq Iteration::Predefined::Any
end
it 'returns Iteration::Current, when iteration_id is Current.id' do
board.iteration_id = Iteration::Current.id
it 'returns ::Iteration::Predefined::Current, when iteration_id is Current.id' do
board.iteration_id = Iteration::Predefined::Current.id
expect(board.iteration).to eq Iteration::Current
expect(board.iteration).to eq Iteration::Predefined::Current
end
it 'returns iteration for valid iteration id' do
......
......@@ -90,5 +90,9 @@ RSpec.describe Boards::CreateService, services: true do
it_behaves_like 'setting a milestone scope' do
subject { described_class.new(parent, double, milestone_id: milestone.id).execute.payload }
end
it_behaves_like 'setting an iteration scope' do
subject { described_class.new(parent, nil, iteration_id: iteration.id).execute.payload }
end
end
end
......@@ -32,9 +32,10 @@ RSpec.describe Boards::UpdateService, services: true do
stub_licensed_features(scoped_issue_board: true)
assignee = create(:user)
milestone = create(:milestone, group: group)
iteration = create(:iteration, group: group)
label = create(:group_label, group: board.group)
user = create(:user)
params = { milestone_id: milestone.id, assignee_id: assignee.id, label_ids: [label.id], hide_backlog_list: true, hide_closed_list: true }
params = { milestone_id: milestone.id, iteration_id: iteration.id, assignee_id: assignee.id, label_ids: [label.id], hide_backlog_list: true, hide_closed_list: true }
service = described_class.new(group, user, params)
service.execute(board)
......@@ -45,12 +46,12 @@ RSpec.describe Boards::UpdateService, services: true do
it 'filters unpermitted params when scoped issue board is not enabled' do
stub_licensed_features(scoped_issue_board: false)
params = { milestone_id: double, assignee_id: double, label_ids: double, weight: double, hide_backlog_list: true, hide_closed_list: true }
params = { milestone_id: double, iteration_id: double, assignee_id: double, label_ids: double, weight: double, hide_backlog_list: true, hide_closed_list: true }
service = described_class.new(project, double, params)
service.execute(board)
expected_attributes = { milestone: nil, assignee: nil, labels: [], hide_backlog_list: false, hide_closed_list: false }
expected_attributes = { milestone: nil, iteration: nil, assignee: nil, labels: [], hide_backlog_list: false, hide_closed_list: false }
expect(board.reload).to have_attributes(expected_attributes)
end
......@@ -62,6 +63,14 @@ RSpec.describe Boards::UpdateService, services: true do
end
end
it_behaves_like 'setting an iteration scope' do
subject { board.reload }
before do
described_class.new(parent, nil, iteration_id: iteration.id).execute(board)
end
end
describe '#set_labels' do
def expect_label_assigned(user, board, params, expected_labels)
service = described_class.new(board.resource_parent, user, params)
......
# frozen_string_literal: true
RSpec.shared_examples 'setting a milestone scope' do
RSpec.shared_examples 'setting a timebox scope' do |timebox_type|
before do
stub_licensed_features(scoped_issue_board: true)
end
shared_examples 'an invalid milestone' do
context 'when milestone is from another project / group' do
let(:milestone) { create(:milestone) }
shared_examples "an invalid #{timebox_type}" do
context "when #{timebox_type} is from another project / group" do
let(timebox_type) { create(timebox_type.to_sym) } # rubocop:disable Rails/SaveBang
it { expect(subject.milestone).to be_nil }
it { expect(subject.try(timebox_type)).to be_nil }
end
end
shared_examples 'a predefined milestone' do
context 'Upcoming' do
let(:milestone) { ::Milestone::Upcoming }
shared_examples "a group #{timebox_type}" do
context "when #{timebox_type} is in current group" do
let(timebox_type) { create(timebox_type.to_sym, group: group) }
it { expect(subject.try(timebox_type)).to eq(try(timebox_type)) }
end
context "when #{timebox_type} is in an ancestor group" do
let(timebox_type) { create(timebox_type.to_sym, group: ancestor_group) }
it { expect(subject.try(timebox_type)).to eq(try(timebox_type)) }
end
end
let(:ancestor_group) { create(:group) }
let(:group) { create(:group, parent: ancestor_group) }
context 'for a group board' do
let(:parent) { group }
it_behaves_like "an invalid #{timebox_type}"
it_behaves_like "a predefined #{timebox_type}"
it_behaves_like "a group #{timebox_type}"
end
context 'for a project board' do
let(:project) { create(:project, :private, group: group) }
let(:parent) { project }
it_behaves_like "an invalid #{timebox_type}"
it_behaves_like "a predefined #{timebox_type}"
it_behaves_like "a group #{timebox_type}"
if timebox_type.to_sym == :milestone
context 'when milestone is a project milestone' do
let(:milestone) { create(:milestone, project: project) }
it { expect(subject.milestone).to eq(milestone) }
end
end
end
end
RSpec.shared_examples 'setting a milestone scope' do
shared_examples "a predefined milestone" do
context 'None' do
let(:milestone) { ::Milestone::None }
it { expect(subject.milestone).to eq(milestone) }
end
context 'Started' do
let(:milestone) { ::Milestone::Started }
context 'Any' do
let(:milestone) { ::Milestone::Any }
it { expect(subject.milestone).to eq(milestone) }
end
end
shared_examples 'a group milestone' do
context 'when milestone is a group milestone' do
let(:milestone) { create(:milestone, group: group) }
context 'Upcoming' do
let(:milestone) { ::Milestone::Upcoming }
it { expect(subject.milestone).to eq(milestone) }
end
context 'when milestone is an an ancestor group milestone' do
let(:milestone) { create(:milestone, group: ancestor_group) }
context 'Started' do
let(:milestone) { ::Milestone::Started }
it { expect(subject.milestone).to eq(milestone) }
end
end
let(:ancestor_group) { create(:group) }
let(:group) { create(:group, parent: ancestor_group) }
it_behaves_like 'setting a timebox scope', :milestone
end
context 'for a group board' do
let(:parent) { group }
RSpec.shared_examples 'setting an iteration scope' do
shared_examples 'a predefined iteration' do
context 'None' do
let(:iteration) { ::Iteration::Predefined::None }
it_behaves_like 'an invalid milestone'
it_behaves_like 'a predefined milestone'
it_behaves_like 'a group milestone'
end
it { expect(subject.iteration).to eq(iteration) }
end
context 'for a project board' do
let(:project) { create(:project, :private, group: group) }
let(:parent) { project }
context 'Any' do
let(:iteration) { ::Iteration::Predefined::Any }
it_behaves_like 'an invalid milestone'
it_behaves_like 'a predefined milestone'
it_behaves_like 'a group milestone'
it { expect(subject.iteration).to eq(iteration) }
end
context 'when milestone is a project milestone' do
let(:milestone) { create(:milestone, project: project) }
context 'Current' do
let(:iteration) { ::Iteration::Predefined::Current }
it { expect(subject.milestone).to eq(milestone) }
it { expect(subject.iteration).to eq(iteration) }
end
end
it_behaves_like 'setting a timebox scope', :iteration
end
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