Commit e6f407d5 authored by Imre Farkas's avatar Imre Farkas

Merge branch '21800-mentioned-users-models-with-array-type' into 'master'

Store mentioned users, groups, projects in DB using postgres array type

See merge request gitlab-org/gitlab!19088
parents 7e262371 79e4ed28
......@@ -281,6 +281,10 @@ class Commit
project.notes.for_commit_id(self.id)
end
def user_mentions
CommitUserMention.where(commit_id: self.id)
end
def discussion_notes
notes.non_diff_notes
end
......
# frozen_string_literal: true
class CommitUserMention < UserMention
belongs_to :note
end
......@@ -80,6 +80,66 @@ module Mentionable
all_references(current_user).users
end
def store_mentions!
# if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded
# because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be
# successful if mentionable.save is successful.
#
# This line will get removed when we remove the feature flag.
return true unless store_mentioned_users_to_db_enabled?
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
# One retry should be enough as next time `model_user_mention` should return the existing mention record, that
# threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
elsif user_mention.persisted?
user_mention.destroy!
end
true
end
end
def referenced_users
User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
end
def referenced_projects(current_user = nil)
Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user)
end
def referenced_project_users(current_user = nil)
User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct
end
def referenced_groups(current_user = nil)
# TODO: IMPORTANT: Revisit before using it.
# Check DB data for max mentioned groups per mentionable:
#
# select issue_id, count(mentions_count.men_gr_id) gr_count from
# (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id
# from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count
# group by mentions_count.issue_id order by gr_count desc limit 10
Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user)
end
def referenced_group_users(current_user = nil)
User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct
end
def directly_addressed_users(current_user = nil)
all_references(current_user).directly_addressed_users
end
......@@ -171,6 +231,26 @@ module Mentionable
def mentionable_params
{}
end
# User mention that is parsed from model description rather then its related notes.
# Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
# Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
# a description attribute.
#
# Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
# in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize
end
# We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level
# and not the project level as epics are defined at group level and we want to have epics store user mentions as well
# for the test period.
# During the test period the flag should be enabled at the group level.
def store_mentioned_users_to_db_enabled?
return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group) if self.respond_to?(:project)
return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group)
end
end
Mentionable.prepend_if_ee('EE::Mentionable')
......@@ -42,6 +42,7 @@ class Issue < ApplicationRecord
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention"
has_one :sentry_issue
validates :project, presence: true
......
# frozen_string_literal: true
class IssueUserMention < UserMention
belongs_to :issue
belongs_to :note
end
......@@ -71,6 +71,7 @@ class MergeRequest < ApplicationRecord
has_many :merge_request_assignees
has_many :assignees, class_name: "User", through: :merge_request_assignees
has_many :user_mentions, class_name: "MergeRequestUserMention"
has_many :deployment_merge_requests
......
# frozen_string_literal: true
class MergeRequestUserMention < UserMention
belongs_to :merge_request
belongs_to :note
end
......@@ -499,8 +499,18 @@ class Note < ApplicationRecord
project
end
def user_mentions
noteable.user_mentions.where(note: self)
end
private
# Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception
# in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block.
def model_user_mention
user_mentions.first_or_initialize
end
def system_note_viewable_by?(user)
return true unless system_note_metadata
......
......@@ -37,6 +37,7 @@ class Snippet < ApplicationRecord
belongs_to :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention"
delegate :name, :email, to: :author, prefix: true, allow_nil: true
......@@ -69,6 +70,8 @@ class Snippet < ApplicationRecord
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> { includes(author: :status) }
attr_mentionable :description
participant :author
participant :notes_with_associations
......
# frozen_string_literal: true
class SnippetUserMention < UserMention
belongs_to :snippet
belongs_to :note
end
# frozen_string_literal: true
class UserMention < ApplicationRecord
self.abstract_class = true
def has_mentions?
mentioned_users_ids.present? || mentioned_groups_ids.present? || mentioned_projects_ids.present?
end
private
def mentioned_users
User.where(id: mentioned_users_ids)
end
def mentioned_groups
Group.where(id: mentioned_groups_ids)
end
def mentioned_projects
Project.where(id: mentioned_projects_ids)
end
end
......@@ -21,7 +21,11 @@ class CreateSnippetService < BaseService
spam_check(snippet, current_user)
if snippet.save
snippet_saved = snippet.with_transaction_returning_status do
snippet.save && snippet.store_mentions!
end
if snippet_saved
UserAgentDetailService.new(snippet, @request).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create)
end
......
......@@ -163,7 +163,11 @@ class IssuableBaseService < BaseService
before_create(issuable)
if issuable.save
issuable_saved = issuable.with_transaction_returning_status do
issuable.save && issuable.store_mentions!
end
if issuable_saved
Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
after_create(issuable)
......@@ -224,7 +228,11 @@ class IssuableBaseService < BaseService
update_project_counters = issuable.project && update_project_counter_caches?(issuable)
ensure_milestone_available(issuable)
if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) }
issuable_saved = issuable.with_transaction_returning_status do
issuable.save(touch: should_touch) && issuable.store_mentions!
end
if issuable_saved
Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
handle_changes(issuable, old_associations: old_associations)
......
......@@ -33,7 +33,11 @@ module Notes
NewNoteWorker.perform_async(note.id)
end
if !only_commands && note.save
note_saved = note.with_transaction_returning_status do
!only_commands && note.save && note.store_mentions!
end
if note_saved
if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
note.discussion.convert_to_discussion!(save: true)
end
......
......@@ -7,7 +7,11 @@ module Notes
old_mentioned_users = note.mentioned_users(current_user).to_a
note.update(params.merge(updated_by: current_user))
note.assign_attributes(params.merge(updated_by: current_user))
note.with_transaction_returning_status do
note.save && note.store_mentions!
end
only_commands = false
......
......@@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService
snippet.assign_attributes(params)
spam_check(snippet, current_user)
snippet.save.tap do |succeeded|
Gitlab::UsageDataCounters::SnippetCounter.count(:update) if succeeded
snippet_saved = snippet.with_transaction_returning_status do
snippet.save && snippet.store_mentions!
end
if snippet_saved
Gitlab::UsageDataCounters::SnippetCounter.count(:update)
end
end
end
---
title: Store users, groups, projects mentioned in Markdown to DB tables
merge_request: 19088
author:
type: added
......@@ -7,6 +7,7 @@ module DesignManagement
include Gitlab::FileTypeDetection
include Gitlab::Utils::StrongMemoize
include Referable
include Mentionable
belongs_to :project, inverse_of: :designs
belongs_to :issue
......@@ -16,6 +17,7 @@ module DesignManagement
# This is a polymorphic association, so we can't count on FK's to delete the
# data
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "DesignUserMention"
validates :project, :filename, presence: true
validates :issue, presence: true, unless: :importing?
......
# frozen_string_literal: true
class DesignUserMention < UserMention
belongs_to :design, class_name: 'DesignManagement::Design'
belongs_to :note
end
......@@ -51,6 +51,7 @@ module EE
has_many :epic_issues
has_many :issues, through: :epic_issues
has_many :user_mentions, class_name: "EpicUserMention"
validates :group, presence: true
validate :validate_parent, on: :create
......
# frozen_string_literal: true
class EpicUserMention < UserMention
belongs_to :epic
belongs_to :note
end
......@@ -23,4 +23,8 @@ class Review < ApplicationRecord
ext
end
def user_mentions
merge_request.user_mentions.where.not(note_id: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Epic, 'Mentionable' do
describe '#store_mentions!' do
it_behaves_like 'mentions in description', :epic
it_behaves_like 'mentions in notes', :epic do
let(:note) { create(:note_on_epic) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :epic do
let(:note) { create(:note_on_epic) }
let(:mentionable) { note.noteable }
end
end
end
describe DesignManagement::Design do
describe '#store_mentions!' do
it_behaves_like 'mentions in notes', :design do
let(:note) { create(:diff_note_on_design) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :design do
let(:note) { create(:diff_note_on_design) }
let(:mentionable) { note.noteable }
end
end
end
......@@ -17,6 +17,7 @@ describe DesignManagement::Design do
it { is_expected.to have_many(:actions) }
it { is_expected.to have_many(:versions) }
it { is_expected.to have_many(:notes).dependent(:delete_all) }
it { is_expected.to have_many(:user_mentions) }
end
describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
describe DesignUserMention do
describe 'associations' do
it { is_expected.to belong_to(:design) }
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
......@@ -16,6 +16,7 @@ describe Epic do
it { is_expected.to belong_to(:parent) }
it { is_expected.to have_many(:epic_issues) }
it { is_expected.to have_many(:children) }
it { is_expected.to have_many(:user_mentions).class_name("EpicUserMention") }
end
describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
describe EpicUserMention do
describe 'associations' do
it { is_expected.to belong_to(:epic) }
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
......@@ -2,7 +2,7 @@
module Banzai
module ReferenceParser
class MentionedUsersByGroupParser < BaseParser
class MentionedGroupParser < BaseParser
GROUP_ATTR = 'data-group'
self.reference_type = :user
......
......@@ -2,7 +2,7 @@
module Banzai
module ReferenceParser
class MentionedUsersByProjectParser < ProjectParser
class MentionedProjectParser < ProjectParser
PROJECT_ATTR = 'data-project'
self.reference_type = :user
......
......@@ -3,7 +3,7 @@
module Gitlab
# Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor
REFERABLES = %i(user issue label milestone
REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project
merge_request snippet commit commit_range directly_addressed_user epic).freeze
attr_accessor :project, :current_user, :author
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Banzai::ReferenceParser::MentionedUsersByGroupParser do
describe Banzai::ReferenceParser::MentionedGroupParser do
include ReferenceParserHelpers
let(:group) { create(:group, :private) }
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Banzai::ReferenceParser::MentionedUsersByProjectParser do
describe Banzai::ReferenceParser::MentionedProjectParser do
include ReferenceParserHelpers
let(:group) { create(:group, :private) }
......
......@@ -34,6 +34,7 @@ issues:
- zoom_meetings
- vulnerability_links
- related_vulnerabilities
- user_mentions
events:
- author
- project
......@@ -82,6 +83,7 @@ snippets:
- notes
- award_emoji
- user_agent_detail
- user_mentions
releases:
- author
- project
......@@ -142,6 +144,7 @@ merge_requests:
- description_versions
- deployment_merge_requests
- deployments
- user_mentions
external_pull_requests:
- project
merge_request_diff:
......@@ -539,6 +542,7 @@ design: &design
- actions
- versions
- notes
- user_mentions
designs: *design
actions:
- design
......
......@@ -166,6 +166,21 @@ describe Issue, "Mentionable" do
create(:issue, project: project, description: description, author: author)
end
end
describe '#store_mentions!' do
it_behaves_like 'mentions in description', :issue
it_behaves_like 'mentions in notes', :issue do
let(:note) { create(:note_on_issue) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :issue do
let(:note) { create(:note_on_issue) }
let(:mentionable) { note.noteable }
end
end
end
describe Commit, 'Mentionable' do
......@@ -221,4 +236,56 @@ describe Commit, 'Mentionable' do
end
end
end
describe '#store_mentions!' do
it_behaves_like 'mentions in notes', :commit do
let(:note) { create(:note_on_commit) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :commit do
let(:note) { create(:note_on_commit) }
let(:mentionable) { note.noteable }
end
end
end
describe MergeRequest, 'Mentionable' do
describe '#store_mentions!' do
it_behaves_like 'mentions in description', :merge_request
it_behaves_like 'mentions in notes', :merge_request do
let(:project) { create(:project) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :merge_request do
let(:project) { create(:project) }
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:note) { create(:note_on_merge_request, noteable: merge_request, project: merge_request.project) }
let(:mentionable) { note.noteable }
end
end
end
describe Snippet, 'Mentionable' do
describe '#store_mentions!' do
it_behaves_like 'mentions in description', :project_snippet
it_behaves_like 'mentions in notes', :project_snippet do
let(:note) { create(:note_on_project_snippet) }
let(:mentionable) { note.noteable }
end
end
describe 'load mentions' do
it_behaves_like 'load mentions from DB', :project_snippet do
let(:note) { create(:note_on_project_snippet) }
let(:mentionable) { note.noteable }
end
end
end
......@@ -12,6 +12,7 @@ describe Issue do
it { is_expected.to belong_to(:duplicated_to).class_name('Issue') }
it { is_expected.to belong_to(:closed_by).class_name('User') }
it { is_expected.to have_many(:assignees) }
it { is_expected.to have_many(:user_mentions).class_name("IssueUserMention") }
it { is_expected.to have_one(:sentry_issue) }
end
......
......@@ -17,6 +17,7 @@ describe MergeRequest do
it { is_expected.to belong_to(:merge_user).class_name("User") }
it { is_expected.to have_many(:assignees).through(:merge_request_assignees) }
it { is_expected.to have_many(:merge_request_diffs) }
it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") }
context 'for forks' do
let!(:project) { create(:project) }
......
......@@ -18,6 +18,7 @@ describe Snippet do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:notes).dependent(:destroy) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") }
end
describe 'validation' do
......
# frozen_string_literal: true
require 'spec_helper'
describe CommitUserMention do
describe 'associations' do
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
# frozen_string_literal: true
require 'spec_helper'
describe IssueUserMention do
describe 'associations' do
it { is_expected.to belong_to(:issue) }
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequestUserMention do
describe 'associations' do
it { is_expected.to belong_to(:merge_request) }
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
# frozen_string_literal: true
require 'spec_helper'
describe SnippetUserMention do
describe 'associations' do
it { is_expected.to belong_to(:snippet) }
it { is_expected.to belong_to(:note) }
end
it_behaves_like 'has user mentions'
end
......@@ -195,3 +195,153 @@ shared_examples 'an editable mentionable' do
subject.create_new_cross_references!(author)
end
end
shared_examples_for 'mentions in description' do |mentionable_type|
describe 'when store_mentioned_users_to_db feature disabled' do
before do
stub_feature_flags(store_mentioned_users_to_db: false)
mentionable.store_mentions!
end
context 'when mentionable description contains mentions' do
let(:user) { create(:user) }
let(:mentionable) { create(mentionable_type, description: "#{user.to_reference} some description") }
it 'stores no mentions' do
expect(mentionable.user_mentions.count).to eq 0
end
end
end
describe 'when store_mentioned_users_to_db feature enabled' do
before do
stub_feature_flags(store_mentioned_users_to_db: true)
mentionable.store_mentions!
end
context 'when mentionable description has no mentions' do
let(:mentionable) { create(mentionable_type, description: "just some description") }
it 'stores no mentions' do
expect(mentionable.user_mentions.count).to eq 0
end
end
context 'when mentionable description contains mentions' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:mentionable_desc) { "#{user.to_reference} some description #{group.to_reference(full: true)} and @all" }
let(:mentionable) { create(mentionable_type, description: mentionable_desc) }
it 'stores mentions' do
add_member(user)
expect(mentionable.user_mentions.count).to eq 1
expect(mentionable.referenced_users).to match_array([user])
expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
expect(mentionable.referenced_groups(user)).to match_array([group])
end
end
end
end
shared_examples_for 'mentions in notes' do |mentionable_type|
context 'when mentionable notes contain mentions' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:note_desc) { "#{user.to_reference} and #{group.to_reference(full: true)} and @all" }
let!(:mentionable) { note.noteable }
before do
note.update(note: note_desc)
note.store_mentions!
add_member(user)
end
it 'returns all mentionable mentions' do
expect(mentionable.user_mentions.count).to eq 1
expect(mentionable.referenced_users).to eq [user]
expect(mentionable.referenced_projects(user)).to eq [mentionable.project].compact # epic.project is nil, and we want empty []
expect(mentionable.referenced_groups(user)).to eq [group]
end
end
end
shared_examples_for 'load mentions from DB' do |mentionable_type|
context 'load stored mentions' do
let_it_be(:user) { create(:user) }
let_it_be(:mentioned_user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:note_desc) { "#{mentioned_user.to_reference} and #{group.to_reference(full: true)} and @all" }
before do
note.update(note: note_desc)
note.store_mentions!
add_member(user)
end
context 'when stored user mention contains ids of inexistent records' do
before do
user_mention = note.send(:model_user_mention)
mention_ids = {
mentioned_users_ids: user_mention.mentioned_users_ids.to_a << User.maximum(:id).to_i.succ,
mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << Project.maximum(:id).to_i.succ,
mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << Group.maximum(:id).to_i.succ
}
user_mention.update(mention_ids)
end
it 'filters out inexistent mentions' do
expect(mentionable.referenced_users).to match_array([mentioned_user])
expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
expect(mentionable.referenced_groups(user)).to match_array([group])
end
end
context 'when private projects and groups are mentioned' do
let(:mega_user) { create(:user) }
let(:private_project) { create(:project, :private) }
let(:project_member) { create(:project_member, user: create(:user), project: private_project) }
let(:private_group) { create(:group, :private) }
let(:group_member) { create(:group_member, user: create(:user), group: private_group) }
before do
user_mention = note.send(:model_user_mention)
mention_ids = {
mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << private_project.id,
mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << private_group.id
}
user_mention.update(mention_ids)
add_member(mega_user)
private_project.add_developer(mega_user)
private_group.add_developer(mega_user)
end
context 'when user has no access to some mentions' do
it 'filters out inaccessible mentions' do
expect(mentionable.referenced_projects(user)).to match_array([mentionable.project].compact) # epic.project is nil, and we want empty []
expect(mentionable.referenced_groups(user)).to match_array([group])
end
end
context 'when user has access to all mentions' do
it 'returns all mentions' do
expect(mentionable.referenced_projects(mega_user)).to match_array([mentionable.project, private_project].compact) # epic.project is nil, and we want empty []
expect(mentionable.referenced_groups(mega_user)).to match_array([group, private_group])
end
end
end
end
end
def add_member(user)
issuable_parent = if mentionable.is_a?(Epic)
mentionable.group
else
mentionable.project
end
issuable_parent&.add_developer(user)
end
# frozen_string_literal: true
require 'spec_helper'
shared_examples_for 'has user mentions' do
describe '#has_mentions?' do
context 'when no mentions' do
it 'returns false' do
expect(subject.mentioned_users_ids).to be nil
expect(subject.mentioned_projects_ids).to be nil
expect(subject.mentioned_groups_ids).to be nil
expect(subject.has_mentions?).to be false
end
end
context 'when mentioned_users_ids not null' do
subject { described_class.new(mentioned_users_ids: [1, 2, 3]) }
it 'returns true' do
expect(subject.has_mentions?).to be true
end
end
context 'when mentioned projects' do
subject { described_class.new(mentioned_projects_ids: [1, 2, 3]) }
it 'returns true' do
expect(subject.has_mentions?).to be true
end
end
context 'when mentioned groups' do
subject { described_class.new(mentioned_groups_ids: [1, 2, 3]) }
it 'returns true' do
expect(subject.has_mentions?).to be true
end
end
end
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