mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 09:26:54 -06:00
b6d140e4bd
This is a follow-up to the new feature that allows a category to require a certain number of tags from a tag group. The tag input will shows results from the required group if none have been chosen yet. Once a require tag is selected, the tag input will include other results as usual. Staff users can ignore this restriction, so the input behaviour is unchanged for them.
366 lines
13 KiB
Ruby
366 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseTagging
|
|
|
|
TAGS_FIELD_NAME = "tags"
|
|
TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<>
|
|
TAGS_STAFF_CACHE_KEY = "staff_tag_names"
|
|
|
|
TAG_GROUP_TAG_IDS_SQL = <<-SQL
|
|
SELECT tag_id
|
|
FROM tag_group_memberships tgm
|
|
INNER JOIN tag_groups tg
|
|
ON tgm.tag_group_id = tg.id
|
|
SQL
|
|
|
|
def self.tag_topic_by_names(topic, guardian, tag_names_arg, append: false)
|
|
if guardian.can_tag?(topic)
|
|
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || []
|
|
|
|
old_tag_names = topic.tags.pluck(:name) || []
|
|
new_tag_names = tag_names - old_tag_names
|
|
removed_tag_names = old_tag_names - tag_names
|
|
|
|
# Protect staff-only tags
|
|
unless guardian.is_staff?
|
|
all_staff_tags = DiscourseTagging.staff_tag_names
|
|
hidden_tags = DiscourseTagging.hidden_tag_names
|
|
|
|
staff_tags = new_tag_names & all_staff_tags
|
|
staff_tags += new_tag_names & hidden_tags
|
|
if staff_tags.present?
|
|
topic.errors.add(:base, I18n.t("tags.staff_tag_disallowed", tag: staff_tags.join(" ")))
|
|
return false
|
|
end
|
|
|
|
staff_tags = removed_tag_names & all_staff_tags
|
|
if staff_tags.present?
|
|
topic.errors.add(:base, I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" ")))
|
|
return false
|
|
end
|
|
|
|
tag_names += removed_tag_names & hidden_tags
|
|
end
|
|
|
|
category = topic.category
|
|
tag_names = tag_names + old_tag_names if append
|
|
|
|
if tag_names.present?
|
|
# guardian is explicitly nil cause we don't want to strip all
|
|
# staff tags that already passed validation
|
|
tags = filter_allowed_tags(
|
|
Tag.where_name(tag_names),
|
|
nil, # guardian
|
|
for_topic: true,
|
|
category: category,
|
|
selected_tags: tag_names
|
|
).to_a
|
|
|
|
if tags.size < tag_names.size && (category.nil? || category.allow_global_tags || (category.tags.count == 0 && category.tag_groups.count == 0))
|
|
tag_names.each do |name|
|
|
unless Tag.where_name(name).exists?
|
|
tags << Tag.create(name: name)
|
|
end
|
|
end
|
|
end
|
|
|
|
# add missing mandatory parent tags
|
|
tag_ids = tags.map(&:id)
|
|
|
|
parent_tags_map = DB.query("
|
|
SELECT tgm.tag_id, tg.parent_tag_id
|
|
FROM tag_groups tg
|
|
INNER JOIN tag_group_memberships tgm
|
|
ON tgm.tag_group_id = tg.id
|
|
WHERE tg.parent_tag_id IS NOT NULL
|
|
AND tgm.tag_id IN (?)
|
|
", tag_ids).inject({}) do |h, v|
|
|
h[v.tag_id] ||= []
|
|
h[v.tag_id] << v.parent_tag_id
|
|
h
|
|
end
|
|
|
|
missing_parent_tag_ids = parent_tags_map.map do |_, parent_tag_ids|
|
|
(tag_ids & parent_tag_ids).size == 0 ? parent_tag_ids.first : nil
|
|
end.compact.uniq
|
|
|
|
unless missing_parent_tag_ids.empty?
|
|
tags = tags + Tag.where(id: missing_parent_tag_ids).all
|
|
end
|
|
|
|
return false unless validate_min_required_tags_for_category(guardian, topic, category, tags)
|
|
return false unless validate_required_tags_from_group(guardian, topic, category, tags)
|
|
|
|
if tags.size == 0
|
|
topic.errors.add(:base, I18n.t("tags.forbidden.invalid", count: new_tag_names.size))
|
|
return false
|
|
end
|
|
|
|
topic.tags = tags
|
|
else
|
|
return false unless validate_min_required_tags_for_category(guardian, topic, category)
|
|
return false unless validate_required_tags_from_group(guardian, topic, category)
|
|
|
|
topic.tags = []
|
|
end
|
|
topic.tags_changed = true
|
|
end
|
|
true
|
|
end
|
|
|
|
def self.validate_min_required_tags_for_category(guardian, topic, category, tags = [])
|
|
if !guardian.is_staff? &&
|
|
category &&
|
|
category.minimum_required_tags > 0 &&
|
|
tags.length < category.minimum_required_tags
|
|
|
|
topic.errors.add(:base, I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags))
|
|
false
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def self.validate_required_tags_from_group(guardian, topic, category, tags = [])
|
|
if !guardian.is_staff? &&
|
|
category &&
|
|
category.required_tag_group &&
|
|
(tags.length < category.min_tags_from_required_group ||
|
|
category.required_tag_group.tags.where("tags.id in (?)", tags.map(&:id)).count < category.min_tags_from_required_group)
|
|
|
|
topic.errors.add(:base,
|
|
I18n.t(
|
|
"tags.required_tags_from_group",
|
|
count: category.min_tags_from_required_group,
|
|
tag_group_name: category.required_tag_group.name
|
|
)
|
|
)
|
|
false
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
# Options:
|
|
# term: a search term to filter tags by name
|
|
# category: a Category to which the object being tagged belongs
|
|
# for_input: result is for an input field, so only show permitted tags
|
|
# for_topic: results are for tagging a topic
|
|
# selected_tags: an array of tag names that are in the current selection
|
|
def self.filter_allowed_tags(query, guardian, opts = {})
|
|
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
|
|
|
|
if !opts[:for_topic] && !selected_tag_ids.empty?
|
|
query = query.where('tags.id NOT IN (?)', selected_tag_ids)
|
|
end
|
|
|
|
term = opts[:term]
|
|
if term.present?
|
|
term = term.gsub("_", "\\_")
|
|
clean_tag(term)
|
|
term.downcase!
|
|
query = query.where('lower(tags.name) like ?', "%#{term}%")
|
|
end
|
|
|
|
# Filters for category-specific tags:
|
|
category = opts[:category]
|
|
|
|
if opts[:for_input] && !guardian.nil? && !guardian.is_staff? && category&.required_tag_group
|
|
required_tag_ids = category.required_tag_group.tags.pluck(:id)
|
|
if (required_tag_ids & selected_tag_ids).size < category.min_tags_from_required_group
|
|
query = query.where('tags.id IN (?)', required_tag_ids)
|
|
end
|
|
end
|
|
|
|
if category && (category.tags.count > 0 || category.tag_groups.count > 0)
|
|
if category.allow_global_tags
|
|
# Select tags that:
|
|
# * are restricted to the given category
|
|
# * belong to no tag groups and aren't restricted to other categories
|
|
# * belong to tag groups that are not restricted to any categories
|
|
query = query.where(<<~SQL, category.tag_groups.pluck(:id), category.id)
|
|
tags.id IN (
|
|
SELECT t.id FROM tags t
|
|
LEFT JOIN category_tags ct ON t.id = ct.tag_id
|
|
LEFT JOIN (
|
|
SELECT xtgm.tag_id, xtgm.tag_group_id
|
|
FROM tag_group_memberships xtgm
|
|
INNER JOIN category_tag_groups ctg
|
|
ON xtgm.tag_group_id = ctg.tag_group_id
|
|
) AS tgm ON t.id = tgm.tag_id
|
|
WHERE (tgm.tag_group_id IS NULL AND ct.category_id IS NULL)
|
|
OR tgm.tag_group_id IN (?)
|
|
OR ct.category_id = ?
|
|
)
|
|
SQL
|
|
else
|
|
# Select only tags that are restricted to the given category
|
|
query = query.where(<<~SQL, category.id, category.tag_groups.pluck(:id))
|
|
tags.id IN (
|
|
SELECT tag_id FROM category_tags WHERE category_id = ?
|
|
UNION
|
|
SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?)
|
|
)
|
|
SQL
|
|
end
|
|
elsif opts[:for_input] || opts[:for_topic] || category
|
|
# exclude tags that are restricted to other categories
|
|
if CategoryTag.exists?
|
|
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)")
|
|
end
|
|
|
|
if CategoryTagGroup.exists?
|
|
tag_group_ids = CategoryTagGroup.pluck(:tag_group_id).uniq
|
|
query = query.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))", tag_group_ids)
|
|
end
|
|
end
|
|
|
|
if opts[:for_input] || opts[:for_topic]
|
|
unless guardian.nil? || guardian.is_staff?
|
|
all_staff_tags = DiscourseTagging.staff_tag_names
|
|
query = query.where('tags.name NOT IN (?)', all_staff_tags) if all_staff_tags.present?
|
|
end
|
|
end
|
|
|
|
if opts[:for_input]
|
|
# exclude tag groups that have a parent tag which is missing from selected_tags
|
|
|
|
if selected_tag_ids.empty?
|
|
sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id IS NOT NULL)"
|
|
query = query.where(sql)
|
|
else
|
|
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
|
|
|
|
if exclude_group_ids.empty?
|
|
# tags that don't belong to groups that require a parent tag,
|
|
# and tags that belong to groups with parent tag selected
|
|
query = query.where(<<~SQL, selected_tag_ids, selected_tag_ids)
|
|
tags.id NOT IN (
|
|
#{TAG_GROUP_TAG_IDS_SQL}
|
|
WHERE tg.parent_tag_id NOT IN (?)
|
|
)
|
|
OR tags.id IN (
|
|
#{TAG_GROUP_TAG_IDS_SQL}
|
|
WHERE tg.parent_tag_id IN (?)
|
|
)
|
|
SQL
|
|
else
|
|
# It's possible that the selected tags violate some one-tag-per-group restrictions,
|
|
# so filter them out by picking one from each group.
|
|
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
|
|
.where(tag_id: selected_tag_ids)
|
|
.where(tag_group_id: exclude_group_ids)
|
|
.map(&:tag_id)
|
|
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.parent_tag_id NOT IN (?) OR tg.id in (?))) OR tags.id IN (?))"
|
|
query = query.where(sql, selected_tag_ids, exclude_group_ids, limit_tag_ids)
|
|
end
|
|
end
|
|
elsif opts[:for_topic] && !selected_tag_ids.empty?
|
|
# One tag per group restriction
|
|
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
|
|
|
|
unless exclude_group_ids.empty?
|
|
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
|
|
.where(tag_id: selected_tag_ids)
|
|
.where(tag_group_id: exclude_group_ids)
|
|
.map(&:tag_id)
|
|
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.id in (?))) OR tags.id IN (?))"
|
|
query = query.where(sql, exclude_group_ids, limit_tag_ids)
|
|
end
|
|
end
|
|
|
|
if guardian.nil? || guardian.is_staff?
|
|
query
|
|
else
|
|
filter_visible(query, guardian)
|
|
end
|
|
end
|
|
|
|
def self.one_per_topic_group_ids(selected_tag_ids)
|
|
TagGroup.where(one_per_topic: true)
|
|
.joins(:tag_group_memberships)
|
|
.where('tag_group_memberships.tag_id in (?)', selected_tag_ids)
|
|
.pluck(:id)
|
|
end
|
|
|
|
def self.filter_visible(query, guardian = nil)
|
|
guardian&.is_staff? ? query : query.where("tags.id NOT IN (#{hidden_tags_query.select(:id).to_sql})")
|
|
end
|
|
|
|
def self.hidden_tag_names(guardian = nil)
|
|
guardian&.is_staff? ? [] : hidden_tags_query.pluck(:name)
|
|
end
|
|
|
|
def self.hidden_tags_query
|
|
Tag.joins(:tag_groups)
|
|
.where('tag_groups.id NOT IN (
|
|
SELECT tag_group_id
|
|
FROM tag_group_permissions
|
|
WHERE group_id = ?)',
|
|
Group::AUTO_GROUPS[:everyone]
|
|
)
|
|
end
|
|
|
|
def self.staff_tag_names
|
|
tag_names = Discourse.cache.read(TAGS_STAFF_CACHE_KEY, tag_names)
|
|
|
|
if !tag_names
|
|
tag_names = Tag.joins(tag_groups: :tag_group_permissions)
|
|
.where('tag_group_permissions.group_id = ? AND tag_group_permissions.permission_type = ?',
|
|
Group::AUTO_GROUPS[:everyone],
|
|
TagGroupPermission.permission_types[:readonly]
|
|
).pluck(:name)
|
|
Discourse.cache.write(TAGS_STAFF_CACHE_KEY, tag_names, expires_in: 1.hour)
|
|
end
|
|
|
|
tag_names
|
|
end
|
|
|
|
def self.clear_cache!
|
|
Discourse.cache.delete(TAGS_STAFF_CACHE_KEY)
|
|
end
|
|
|
|
def self.clean_tag(tag)
|
|
tag = tag.dup
|
|
tag.downcase! if SiteSetting.force_lowercase_tags
|
|
tag.strip!
|
|
tag.gsub!(/\s+/, '-')
|
|
tag.squeeze!('-')
|
|
tag.gsub!(TAGS_FILTER_REGEXP, '')
|
|
tag[0...SiteSetting.max_tag_length]
|
|
end
|
|
|
|
def self.tags_for_saving(tags_arg, guardian, opts = {})
|
|
|
|
return [] unless guardian.can_tag_topics? && tags_arg.present?
|
|
|
|
tag_names = Tag.where_name(tags_arg).pluck(:name)
|
|
|
|
if guardian.can_create_tag?
|
|
tag_names += (tags_arg - tag_names).map { |t| clean_tag(t) }
|
|
tag_names.delete_if { |t| t.blank? }
|
|
tag_names.uniq!
|
|
end
|
|
|
|
return opts[:unlimited] ? tag_names : tag_names[0...SiteSetting.max_tags_per_topic]
|
|
end
|
|
|
|
def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {})
|
|
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || []
|
|
if taggable.tags.pluck(:name).sort != tag_names.sort
|
|
taggable.tags = Tag.where_name(tag_names).all
|
|
if taggable.tags.size < tag_names.size
|
|
new_tag_names = tag_names - taggable.tags.map(&:name)
|
|
new_tag_names.each do |name|
|
|
taggable.tags << Tag.create(name: name)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.muted_tags(user)
|
|
return [] unless user
|
|
TagUser.lookup(user, :muted).joins(:tag).pluck('tags.name')
|
|
end
|
|
end
|