Commit 79e4ed28 authored by Alexandru Croitor's avatar Alexandru Croitor Committed by Imre Farkas

Store mentioned users, groups, projects in DB

When a note is created or updated it gets parsed and
users, groups, projects mentioned in the note are stored in respective
db table, or deleted when note is deleted.
parent 7e262371
......@@ -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