2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-03 23:54:20 +08:00
discourse/app/models/badge.rb
Régis Hanol aa33eaa798
FIX: Allow badges with SQL queries to be manually granted (#36866)
Previously, badges with custom SQL queries were hidden from the manual
grant dropdown in the admin UI. This was because `manually_grantable?`
returned false for any badge with a query, regardless of whether it was
a system badge or not.

This fix simplifies the logic: only system badges (the built-in
Discourse badges like "Basic User", "Member", etc.) are excluded from
manual granting. Custom badges with SQL queries can now be manually
granted to users.

Additionally, manually granted badges are now protected from
auto-revocation. When a badge's SQL query runs via backfill, it will
only revoke badges that were originally auto-granted (granted_by_id =
-1), leaving manually granted badges intact. This ensures that an
admin's deliberate decision to grant a badge isn't undone by the
automated badge system.

We considered adding a new `allow_manual_grant` setting per badge, which
would give admins explicit control over dropdown visibility. However,
this adds UI complexity and another knob to configure. The simpler
approach covers the common case: if you created a custom badge, you
probably want the option to grant it manually.

Note: Some users may have been relying on the old behavior to hide
badges from the dropdown by adding a dummy SQL query. This workaround
will no longer work - those badges will now appear in the dropdown.

Ref - t/139277
Ref - https://meta.discourse.org/t/296645
2026-01-09 13:58:09 +01:00

374 lines
9 KiB
Ruby

# frozen_string_literal: true
class Badge < ActiveRecord::Base
include GlobalPath
include HasSanitizableFields
# NOTE: These badge ids are not in order! They are grouped logically.
# When picking an id, *search* for it.
BasicUser = 1
Member = 2
Regular = 3
Leader = 4
Welcome = 5
NicePost = 6
GoodPost = 7
GreatPost = 8
Autobiographer = 9
Editor = 10
WikiEditor = 48
FirstLike = 11
FirstShare = 12
FirstFlag = 13
FirstLink = 14
FirstQuote = 15
FirstMention = 40
FirstEmoji = 41
FirstOnebox = 42
FirstReplyByEmail = 43
ReadGuidelines = 16
Reader = 17
NiceTopic = 18
GoodTopic = 19
GreatTopic = 20
NiceShare = 21
GoodShare = 22
GreatShare = 23
Anniversary = 24
Promoter = 25
Campaigner = 26
Champion = 27
PopularLink = 28
HotLink = 29
FamousLink = 30
Appreciated = 36
Respected = 37
Admired = 31
OutOfLove = 33
HigherLove = 34
CrazyInLove = 35
ThankYou = 38
GivesBack = 32
Empathetic = 39
Enthusiast = 45
Aficionado = 46
Devotee = 47
NewUserOfTheMonth = 44
# other consts
AutobiographerMinBioLength = 10
# used by serializer
attr_accessor :has_badge
def self.trigger_hash
@trigger_hash ||=
Badge::Trigger
.constants
.map do |k|
name = k.to_s.underscore
[name, Badge::Trigger.const_get(k)] unless name =~ /deprecated/
end
.compact
.to_h
end
module Trigger
None = 0
PostAction = 1
PostRevision = 2
TrustLevelChange = 4
UserChange = 8
DeprecatedPostProcessed = 16 # No longer in use
def self.is_none?(trigger)
[None].include? trigger
end
def self.uses_user_ids?(trigger)
[TrustLevelChange, UserChange].include? trigger
end
def self.uses_post_ids?(trigger)
[PostAction, PostRevision].include? trigger
end
end
belongs_to :badge_type
belongs_to :badge_grouping
belongs_to :image_upload, class_name: "Upload"
has_many :user_badges, dependent: :destroy
has_many :upload_references, as: :target, dependent: :destroy
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
validates :badge_type, presence: true
validates :allow_title, inclusion: [true, false]
validates :multiple_grant, inclusion: [true, false]
validates :description, length: { maximum: 500 }
validates :long_description, length: { maximum: 1000 }
scope :enabled, -> { where(enabled: true) }
before_save :sanitize_description
before_create :ensure_not_system
after_save do
if saved_change_to_image_upload_id?
UploadReference.ensure_exist!(upload_ids: [self.image_upload_id], target: self)
end
end
after_commit do
SvgSprite.expire_cache
UserStat.update_distinct_badge_count if saved_change_to_enabled?
UserBadge.ensure_consistency! if saved_change_to_enabled?
end
# fields that can not be edited on system badges
def self.protected_system_fields
%i[name badge_type_id multiple_grant target_posts show_posts query trigger auto_revoke listable]
end
def self.trust_level_badge_ids
(1..4).to_a
end
def self.like_badge_counts
@like_badge_counts ||= {
NicePost => 10,
GoodPost => 25,
GreatPost => 50,
NiceTopic => 10,
GoodTopic => 25,
GreatTopic => 50,
}
end
def self.ensure_consistency!
DB.exec <<~SQL
DELETE FROM user_badges
USING user_badges ub
LEFT JOIN users u ON u.id = ub.user_id
WHERE u.id IS NULL
AND user_badges.id = ub.id
SQL
DB.exec <<~SQL
WITH X AS (
SELECT badge_id
, COUNT(user_id) users
FROM user_badges
GROUP BY badge_id
)
UPDATE badges
SET grant_count = X.users
FROM X
WHERE id = X.badge_id
AND grant_count <> X.users
SQL
end
def clear_user_titles!
DB.exec(<<~SQL, badge_id: self.id, updated_at: Time.zone.now)
UPDATE users AS u
SET title = '', updated_at = :updated_at
FROM user_profiles AS up
WHERE up.user_id = u.id AND up.granted_title_badge_id = :badge_id
SQL
DB.exec(<<~SQL, badge_id: self.id)
UPDATE user_profiles AS up
SET granted_title_badge_id = NULL
WHERE up.granted_title_badge_id = :badge_id
SQL
end
##
# Update all user titles based on a badge to the new name
def update_user_titles!(new_title)
DB.exec(<<~SQL, granted_title_badge_id: self.id, title: new_title, updated_at: Time.zone.now)
UPDATE users AS u
SET title = :title, updated_at = :updated_at
FROM user_profiles AS up
WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id
SQL
end
##
# When a badge has its TranslationOverride cleared, reset
# all user titles granted to the standard name.
def reset_user_titles!
DB.exec(<<~SQL, granted_title_badge_id: self.id, updated_at: Time.zone.now)
UPDATE users AS u
SET title = badges.name, updated_at = :updated_at
FROM user_profiles AS up
INNER JOIN badges ON badges.id = up.granted_title_badge_id
WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id
SQL
end
def self.i18n_name(name)
name.to_s.downcase.tr(" ", "_")
end
def self.display_name(name)
I18n.t(i18n_key(name), default: name)
end
def self.i18n_key(name)
"badges.#{i18n_name(name)}.name"
end
def self.find_system_badge_id_from_translation_key(translation_key)
return unless translation_key.starts_with?("badges.")
badge_name_klass = translation_key.split(".").second.camelize
Badge.const_defined?(badge_name_klass) ? "Badge::#{badge_name_klass}".constantize : nil
end
def awarded_for_trust_level?
id <= 4
end
def reset_grant_count!
self.grant_count = UserBadge.where(badge_id: id).count
save!
end
def single_grant?
!self.multiple_grant?
end
def default_icon=(val)
if self.image_upload_id.blank?
self.icon ||= val
self.icon = val if self.icon == "certificate"
end
end
def default_allow_title=(val)
return if !self.new_record?
self.allow_title = val
end
def default_enabled=(val)
return if !self.new_record?
self.enabled = val
end
def default_badge_grouping_id=(val)
# allow to correct orphans
if !self.badge_grouping_id || self.badge_grouping_id <= BadgeGrouping::Other
self.badge_grouping_id = val
end
end
def display_name
self.class.display_name(name)
end
def translation_key
self.class.i18n_key(name)
end
def long_description
key = "badges.#{i18n_name}.long_description"
I18n.t(
key,
default: self[:long_description] || "",
base_uri: Discourse.base_path,
max_likes_per_day: SiteSetting.max_likes_per_day,
)
end
def long_description=(val)
self[:long_description] = val if val != long_description
end
def description
key = "badges.#{i18n_name}.description"
I18n.t(
key,
default: self[:description] || "",
base_uri: Discourse.base_path,
max_likes_per_day: SiteSetting.max_likes_per_day,
)
end
def description=(val)
self[:description] = val if val != description
end
def slug
Slug.for(self.display_name, "-")
end
def manually_grantable?
!system?
end
def i18n_name
@i18n_name ||= self.class.i18n_name(name)
end
def image_url
upload_cdn_path(image_upload.url) if image_upload_id.present?
end
def for_beginners?
id == Welcome || (badge_grouping_id == BadgeGrouping::GettingStarted && id != NewUserOfTheMonth)
end
protected
def ensure_not_system
self.id = [Badge.maximum(:id) + 1, 100].max unless id
end
def sanitize_description
self.description = sanitize_field(self.description) if description_changed?
end
end
# == Schema Information
#
# Table name: badges
#
# id :integer not null, primary key
# name :string not null
# description :text
# badge_type_id :integer not null
# grant_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# allow_title :boolean default(FALSE), not null
# multiple_grant :boolean default(FALSE), not null
# icon :string default("certificate")
# listable :boolean default(TRUE)
# target_posts :boolean default(FALSE)
# query :text
# enabled :boolean default(TRUE), not null
# auto_revoke :boolean default(TRUE), not null
# badge_grouping_id :integer default(5), not null
# trigger :integer
# show_posts :boolean default(FALSE), not null
# system :boolean default(FALSE), not null
# long_description :text
# image_upload_id :integer
# show_in_post_header :boolean default(FALSE), not null
#
# Indexes
#
# index_badges_on_badge_type_id (badge_type_id)
# index_badges_on_name (name) UNIQUE
#