discourse/lib/topic_creator.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

338 lines
9.6 KiB
Ruby
Raw Permalink Normal View History

# frozen_string_literal: true
class TopicCreator
attr_reader :user, :guardian, :opts
include HasErrors
def self.create(user, guardian, opts)
self.new(user, guardian, opts).create
end
def initialize(user, guardian, opts)
@user = user
@guardian = guardian
@opts = opts
@added_users = []
end
def valid?
topic = Topic.new(setup_topic_params)
# validate? will clear the error hash
# so we fire the validation event after
# this allows us to add errors
valid = topic.valid?
validate_visibility(topic)
category = find_category
if category.present? && guardian.can_tag?(topic)
tags = @opts[:tags].presence || []
FIX: Miscellaneous tagging errors (#21490) * FIX: Displaying the wrong number of minimum tags in the composer When the minimum number of tags set for the category is larger than the minimum number of tags set in the category tag-groups, the composer was displaying the wrong value. This commit fixes the value displayed in the composer to show the max value between the required for the category and the tag-groups set for the category. This bug was reported on Meta in https://meta.discourse.org/t/tags-from-multiple-tag-groups-required-only-suggest-select-at-least-one-tag/263817 * FIX: Limiting tags in categories not working as expected When a category was restricted to a tag group A, which was set to only allow one tag from the group per topic, selecting a tag belonging only to A returned other tags from A that also belonged to other group/s (if any). Example: Tag group A: alpha, beta, gamma, epsilon, delta Tag group B: alpha, beta, gamma Both tag groups set to only allow one tag from the group per topic. If Category 1 was set to only allow tags from the tag group A, and the first tag selected was epsilon, then, because they also belonged to tag group B, the tags alpha, beta, and gamma were still returned as valid options when they should not be. This commit ensures that once a tag from a tag group that restricts its tags to one per topic is selected, no other tag from this group is returned. This bug was reported on Meta in https://meta.discourse.org/t/limiting-tags-to-categories-not-working-as-expected/263143. * FIX: Moving topics does not prompt to add required tag for new category When a topic moved from a category to another, the tag requirements of the new category were not being checked. This allowed a topic to be created and moved to a category: - that limited the tags to a tag group, with the topic containing tags not allowed. - that required N tags from a tag group, with the topic not containing the required tags. This bug was reported on Meta in https://meta.discourse.org/t/moving-tagged-topics-does-not-prompt-to-add-required-tag-for-new-category/264138. * FIX: Editing topics with tag groups from parents allows incorrect tagging When there was a combination between parent tags defined in a tag group set to allow only one tag from the group per topic, and other tag groups relying on this restriction to combine the children tag types with the parent tag, editing a topic could allow the user to insert an invalid combination of these tags. Example: Automakers tag group: landhover, toyota - group set to limit one tag from the group per topic Toyota models group: land-cruiser, hilux, corolla Landhover models group: evoque, defender, discovery If a topic was initially set up with the tags toyota, land-cruiser it was possible to edit it by removing the tag toyota and adding the tag landhover and other landhover model tags like evoque for example. In this case, the topic would end up with the tags toyota, land-cruiser, landhover, evoque because Discourse will automatically insert the missing parent tag toyota when it detects the tag land-cruiser. This combination of tags would violate the restriction specified in the Automakers tag group resulting in an invalid combination of tags. This commit enforces that the "one tag from the group per topic" restriction is verified before updating the topic tags and also make sure the verification checks the compatibility of parent tags that would be automatically inserted. After the changes, the user will receive an error similar to: The tags land-cruiser, landhover cannot be used simultaneously. Please include only one of them.
2023-05-15 16:19:41 -04:00
# adds topic.errors
DiscourseTagging.validate_category_tags(guardian, topic, category, tags)
end
DiscourseEvent.trigger(:after_validate_topic, topic, self)
valid &&= topic.errors.empty?
add_errors_from(topic) unless valid
valid
end
def create
topic = Topic.new(setup_topic_params)
validate_visibility!(topic)
setup_tags(topic)
if fields = @opts[:custom_fields]
topic.custom_fields = fields
end
DiscourseEvent.trigger(:before_create_topic, topic, self)
setup_auto_close_time(topic)
process_private_message(topic)
save_topic(topic)
create_warning(topic)
watch_topic(topic)
create_shared_draft(topic)
UserActionManager.topic_created(topic)
topic
end
private
def validate_visibility(topic)
if !@opts[:skip_validations] && !topic.visible &&
!guardian.can_create_unlisted_topic?(topic, !!opts[:embed_url])
topic.errors.add(:base, :unable_to_unlist)
end
end
def validate_visibility!(topic)
validate_visibility(topic)
rollback_from_errors!(topic) if topic.errors.full_messages.present?
end
def create_shared_draft(topic)
return if @opts[:shared_draft].blank? || @opts[:shared_draft] == "false"
category_id =
@opts[:category].blank? ? SiteSetting.shared_drafts_category.to_i : @opts[:category]
SharedDraft.create(topic_id: topic.id, category_id: category_id)
end
def create_warning(topic)
return unless @opts[:is_warning]
# We can only attach warnings to PMs
rollback_with!(topic, :warning_requires_pm) unless topic.private_message?
# Don't create it if there is more than one user
rollback_with!(topic, :too_many_users) if @added_users.size != 1
# Create a warning record
2017-04-15 00:11:02 -04:00
UserWarning.create(topic: topic, user: @added_users.first, created_by: @user)
end
def watch_topic(topic)
topic.notifier.watch_topic!(topic.user_id) unless @opts[:auto_track] == false
topic.reload.topic_allowed_users.each do |tau|
next if tau.user_id == -1 || tau.user_id == topic.user_id
topic.notifier.watch!(tau.user_id)
end
topic.reload.topic_allowed_groups.each do |topic_allowed_group|
group = topic_allowed_group.group
begin
group.set_message_default_notification_levels!(topic)
rescue Group::GroupPmUserLimitExceededError => e
rollback_with!(
topic,
:too_large_group,
group_name: group.name,
limit: SiteSetting.group_pm_user_limit,
)
end
end
end
2014-02-06 14:52:50 -05:00
def setup_topic_params
@opts[:visible] = true if @opts[:visible].nil?
topic_params = {
title: @opts[:title],
user_id: @user.id,
last_post_user_id: @user.id,
visible: @opts[:visible],
}
%i[subtype archetype import_mode advance_draft].each do |key|
2014-02-06 14:52:50 -05:00
topic_params[key] = @opts[key] if @opts[key].present?
end
if topic_params[:import_mode] && @opts[:views].to_i > 0
topic_params[:views] = @opts[:views].to_i
end
if topic_params[:import_mode] && @opts[:participant_count].to_i > 0
topic_params[:participant_count] = @opts[:participant_count].to_i
end
# Automatically give it a moderator warning subtype if specified
topic_params[:subtype] = TopicSubtype.moderator_warning if @opts[:is_warning]
2014-02-06 14:52:50 -05:00
category = find_category
unless (@opts[:skip_validations] || @opts[:archetype] == Archetype.private_message)
@guardian.ensure_can_create!(Topic, category)
end
raise Discourse::InvalidParameters.new(:category) if @opts[:category].present? && category.nil?
topic_params[:category_id] = category.id if category.present?
topic_params[:created_at] = convert_time(@opts[:created_at]) if @opts[:created_at].present?
topic_params[:pinned_at] = convert_time(@opts[:pinned_at]) if @opts[:pinned_at].present?
topic_params[:pinned_globally] = @opts[:pinned_globally] if @opts[:pinned_globally].present?
topic_params[:external_id] = @opts[:external_id] if @opts[:external_id].present?
topic_params[:featured_link] = @opts[:featured_link]
topic_params
end
def convert_time(timestamp)
if timestamp.is_a?(Time)
timestamp
else
Time.zone.parse(timestamp.to_s)
end
end
2014-02-06 14:52:50 -05:00
def find_category
@category ||=
begin
# PM can't have a category
if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message
@opts.delete(:category)
end
2014-02-06 14:52:50 -05:00
return Category.find(SiteSetting.shared_drafts_category) if @opts[:shared_draft]
if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /\A\d+\z/)
Category.find_by(id: @opts[:category])
end
end
2014-02-06 14:52:50 -05:00
end
def setup_tags(topic)
if @opts[:tags].present?
if @opts[:skip_validations]
DiscourseTagging.add_or_create_tags_by_name(topic, @opts[:tags])
else
valid_tags = DiscourseTagging.tag_topic_by_names(topic, @guardian, @opts[:tags])
unless valid_tags
topic.errors.add(:base, :unable_to_tag)
rollback_from_errors!(topic)
end
end
2018-03-28 14:40:26 -04:00
end
watched_words = WordWatcher.words_for_action(:tag)
if watched_words.present?
word_watcher = WordWatcher.new("#{@opts[:title]} #{@opts[:raw]}")
word_watcher_tags = topic.tags.map(&:name)
watched_words.each do |_, opts|
if word_watcher.word_matches?(opts[:word], case_sensitive: opts[:case_sensitive])
FEATURE: Add support for case-sensitive Watched Words (#17445) * FEATURE: Add case-sensitivity flag to watched_words Currently, all watched words are matched case-insensitively. This flag allows a watched word to be flagged for case-sensitive matching. To allow allow for backwards compatibility the flag is set to false by default. * FEATURE: Support case-sensitive creation of Watched Words via API Extend admin creation and upload of Watched Words to support case sensitive flag. This lays the ground work for supporting case-insensitive matching of Watched Words. Support for an extra column has also been introduced for the Watched Words upload CSV file. The new column structure is as follows: word,replacement,case_sentive * FEATURE: Enable case-sensitive matching of Watched Words WordWatcher's word_matcher_regexp now returns a list of regular expressions instead of one case-insensitive regular expression. With the ability to flag a Watched Word as case-sensitive, an action can have words of both sensitivities.This makes the use of the global Regexp::IGNORECASE flag added to all words problematic. To get around platform limitations around the use of subexpression level switches/flags, a list of regular expressions is returned instead, one for each case sensitivity. Word matching has also been updated to use this list of regular expressions instead of one. * FEATURE: Use case-sensitive regular expressions for Watched Words Update Watched Words regular expressions matching and processing to handle the extra metadata which comes along with the introduction of case-sensitive Watched Words. This allows case-sensitive Watched Words to matched as such. * DEV: Simplify type casting of case-sensitive flag from uploads Use builtin semantics instead of a custom method for converting string case flags in uploaded Watched Words to boolean. * UX: Add case-sensitivity details to Admin Watched Words UI Update Watched Word form to include a toggle for case-sensitivity. This also adds support for, case-sensitive testing and matching of Watched Word in the admin UI. * DEV: Code improvements from review feedback - Extract watched word regex creation out to a utility function - Make JS array presence check more explicit and readable * DEV: Extract Watched Word regex creation to utility function Clean-up work from review feedback. Reduce code duplication. * DEV: Rename word_matcher_regexp to word_matcher_regexp_list Since a list is returned now instead of a single regular expression, change `word_matcher_regexp` to `word_matcher_regexp_list` to better communicate this change. * DEV: Incorporate WordWatcher updates from upstream Resolve conflicts and ensure apply_to_text does not remove non-word characters in matches that aren't at the beginning of the line.
2022-08-02 04:06:03 -04:00
word_watcher_tags += opts[:replacement].split(",")
end
end
DiscourseTagging.tag_topic_by_names(topic, Discourse.system_user.guardian, word_watcher_tags)
end
end
def setup_auto_close_time(topic)
return if @opts[:auto_close_time].blank?
return unless @guardian.can_moderate?(topic)
topic.set_auto_close(@opts[:auto_close_time], by_user: @user)
end
def process_private_message(topic)
2014-02-06 14:52:50 -05:00
return unless @opts[:archetype] == Archetype.private_message
topic.subtype = TopicSubtype.user_to_user unless topic.subtype
if @opts[:target_usernames].blank? && @opts[:target_emails].blank? &&
@opts[:target_group_names].blank?
rollback_with!(topic, :no_user_selected)
end
if @opts[:target_emails].present? && !@guardian.can_send_private_messages_to_email?
rollback_with!(topic, :send_to_email_disabled)
end
add_users(topic, @opts[:target_usernames])
add_emails(topic, @opts[:target_emails])
add_groups(topic, @opts[:target_group_names])
topic.topic_allowed_users.build(user_id: @user.id) if !@added_users.include?(user)
end
def save_topic(topic)
topic.disable_rate_limits! if @opts[:skip_validations]
rollback_from_errors!(topic) unless topic.save(validate: !@opts[:skip_validations])
end
def add_users(topic, usernames)
return unless usernames
names = usernames.split(",").flatten.map(&:downcase)
len = 0
User
.includes(:user_option)
.where("username_lower in (?)", names)
.find_each do |user|
check_can_send_permission!(topic, user)
@added_users << user
topic.topic_allowed_users.build(user_id: user.id)
len += 1
end
rollback_with!(topic, :target_user_not_found) unless len == names.length
end
def add_emails(topic, emails)
return unless emails
begin
emails = emails.split(",").flatten
len = 0
emails.each do |email|
display_name = email.split("@").first
if user = find_or_create_user(email, display_name)
if !@added_users.include?(user)
@added_users << user
topic.topic_allowed_users.build(user_id: user.id)
end
len += 1
end
end
ensure
rollback_with!(topic, :target_user_not_found) unless len == emails.length
end
end
def add_groups(topic, groups)
return unless groups
names = groups.split(",").flatten.map(&:downcase)
len = 0
Group
.where("lower(name) in (?)", names)
.each do |group|
check_can_send_permission!(topic, group)
topic.topic_allowed_groups.build(group_id: group.id)
len += 1
2015-12-20 01:21:31 -05:00
group.update_columns(has_messages: true) unless group.has_messages
end
rollback_with!(topic, :target_group_not_found) unless len == names.length
end
def check_can_send_permission!(topic, obj)
unless @opts[:skip_validations] ||
2019-01-24 06:52:17 -05:00
@guardian.can_send_private_message?(
obj,
notify_moderators: topic&.subtype == TopicSubtype.notify_moderators,
)
rollback_with!(topic, :cant_send_pm)
end
end
def find_or_create_user(email, display_name)
user = User.find_by_email(email)
if !user && SiteSetting.enable_staged_users
username = UserNameSuggester.sanitize_username(display_name) if display_name.present?
user =
User.create!(
email: email,
username: UserNameSuggester.suggest(username.presence || email),
name: display_name.presence || User.suggest_name(email),
staged: true,
)
end
user
end
end