2014-11-12 10:01:41 +11:00
require_dependency 'distributed_cache'
require_dependency 'sass/discourse_stylesheets'
2013-02-05 14:16:51 -05:00
class Category < ActiveRecord :: Base
2013-10-18 18:09:30 +11:00
2014-04-19 12:00:40 +08:00
include Positionable
2014-04-28 10:31:51 +02:00
include HasCustomFields
2016-01-12 16:40:36 +08:00
include CategoryHashtag
2013-10-18 18:09:30 +11:00
2013-02-28 21:54:12 +03:00
belongs_to :topic , dependent : :destroy
2014-02-17 17:44:28 +01:00
belongs_to :topic_only_relative_url ,
- > { select " id, title, slug " } ,
class_name : " Topic " ,
foreign_key : " topic_id "
2013-08-15 17:52:18 +02:00
2013-02-05 14:16:51 -05:00
belongs_to :user
2013-10-17 17:44:56 +11:00
belongs_to :latest_post , class_name : " Post "
2013-02-05 14:16:51 -05:00
has_many :topics
2016-07-06 15:56:40 -04:00
has_many :category_users
2013-02-07 16:45:24 +01:00
has_many :category_featured_topics
2013-02-05 14:16:51 -05:00
has_many :featured_topics , through : :category_featured_topics , source : :topic
has_many :category_featured_users
has_many :featured_users , through : :category_featured_users , source : :user
2014-08-31 22:10:38 +02:00
has_many :category_groups , dependent : :destroy
2013-04-29 16:33:24 +10:00
has_many :groups , through : :category_groups
2013-03-02 11:57:02 +03:00
validates :user_id , presence : true
2014-08-11 16:55:26 -04:00
validates :name , if : Proc . new { | c | c . new_record? || c . name_changed? } ,
presence : true ,
uniqueness : { scope : :parent_category_id , case_sensitive : false } ,
length : { in : 1 .. 50 }
2013-10-23 12:58:11 -04:00
validate :parent_category_validator
2013-02-05 14:16:51 -05:00
2016-02-24 19:47:58 +01:00
validate :email_in_validator
2014-12-03 16:23:59 -08:00
validate :ensure_slug
2013-07-14 11:24:16 +10:00
before_save :apply_permissions
2014-07-14 10:16:24 -04:00
before_save :downcase_email
2014-08-18 11:07:32 -04:00
before_save :downcase_name
2013-03-02 11:57:02 +03:00
after_create :create_category_definition
2015-07-10 12:09:43 +10:00
after_save :publish_category
after_destroy :publish_category_deletion
2014-07-18 13:59:54 -04:00
after_update :rename_category_definition , if : :name_changed?
2013-02-05 14:16:51 -05:00
2016-04-27 16:34:44 +05:30
after_create :delete_category_permalink
after_update :create_category_permalink , if : :slug_changed?
2014-11-11 15:32:44 +11:00
after_save :publish_discourse_stylesheet
2013-05-22 15:33:33 -04:00
has_one :category_search_data
2013-10-23 12:58:11 -04:00
belongs_to :parent_category , class_name : 'Category'
2014-02-05 18:39:26 -05:00
has_many :subcategories , class_name : 'Category' , foreign_key : 'parent_category_id'
2013-05-22 15:33:33 -04:00
2016-06-07 13:08:59 -04:00
has_many :category_tags , dependent : :destroy
2016-05-30 16:37:06 -04:00
has_many :tags , through : :category_tags
2016-06-07 13:08:59 -04:00
has_many :category_tag_groups , dependent : :destroy
has_many :tag_groups , through : :category_tag_groups
2016-05-30 16:37:06 -04:00
2016-06-26 19:25:45 +02:00
scope :latest , - > { order ( 'topic_count DESC' ) }
2013-03-02 11:57:02 +03:00
2016-06-26 19:25:45 +02:00
scope :secured , - > ( guardian = nil ) {
2013-05-13 18:04:03 +10:00
ids = guardian . secure_category_ids if guardian
if ids . present?
2016-06-26 19:25:45 +02:00
where ( " NOT categories.read_restricted OR categories.id IN (:cats) " , cats : ids ) . references ( :categories )
2013-05-13 18:04:03 +10:00
else
2013-08-25 23:18:11 +02:00
where ( " NOT categories.read_restricted " ) . references ( :categories )
2013-05-13 18:04:03 +10:00
end
}
2016-06-26 19:25:45 +02:00
TOPIC_CREATION_PERMISSIONS || = [ :full ]
POST_CREATION_PERMISSIONS || = [ :create_post , :full ]
scope :topic_create_allowed , - > ( guardian ) { scoped_to_permissions ( guardian , TOPIC_CREATION_PERMISSIONS ) }
scope :post_create_allowed , - > ( guardian ) { scoped_to_permissions ( guardian , POST_CREATION_PERMISSIONS ) }
2016-01-12 12:06:51 +01:00
2013-03-02 11:57:02 +03:00
delegate :post_template , to : 'self.class'
2013-07-16 15:44:07 +10:00
# permission is just used by serialization
# we may consider wrapping this in another spot
2015-09-02 23:46:04 +02:00
attr_accessor :displayable_topics , :permission , :subcategory_ids , :notification_level , :has_children
2013-06-05 16:10:26 +10:00
2014-06-27 17:06:59 -04:00
def self . last_updated_at
order ( 'updated_at desc' ) . limit ( 1 ) . pluck ( :updated_at ) . first . to_i
end
2013-07-14 11:24:16 +10:00
def self . scoped_to_permissions ( guardian , permission_types )
2016-06-26 19:25:45 +02:00
if guardian . try ( :is_admin? )
2014-02-17 17:44:28 +01:00
all
2015-05-14 12:19:22 +10:00
elsif ! guardian || guardian . anonymous?
if permission_types . include? ( :readonly )
where ( " NOT categories.read_restricted " )
else
where ( " 1 = 0 " )
end
2013-07-14 11:24:16 +10:00
else
2016-06-26 19:25:45 +02:00
permissions = permission_types . map { | p | CategoryGroup . permission_types [ p ] }
where ( " (:staged AND LENGTH(COALESCE(email_in, '')) > 0 AND email_in_allow_strangers)
OR categories . id NOT IN ( SELECT category_id FROM category_groups )
OR categories . id IN (
SELECT category_id
FROM category_groups
WHERE permission_type IN ( :permissions )
AND ( group_id = :everyone OR group_id IN ( SELECT group_id FROM group_users WHERE user_id = :user_id ) )
) " ,
staged : guardian . is_staged? ,
permissions : permissions ,
user_id : guardian . user . id ,
everyone : Group [ :everyone ] . id )
2013-07-14 11:24:16 +10:00
end
end
2014-03-19 10:14:05 -04:00
def self . update_stats
topics_with_post_count = Topic
. select ( " topics.category_id, COUNT(*) topic_count, SUM(topics.posts_count) post_count " )
. where ( " topics.id NOT IN (select cc.topic_id from categories cc WHERE topic_id IS NOT NULL) " )
. group ( " topics.category_id " )
. visible . to_sql
2016-06-26 19:25:45 +02:00
Category . exec_sql <<-SQL
2014-03-19 10:14:05 -04:00
UPDATE categories c
2016-06-26 19:25:45 +02:00
SET topic_count = x . topic_count ,
post_count = x . post_count
FROM ( #{topics_with_post_count}) x
WHERE x . category_id = c . id
AND ( c . topic_count < > x . topic_count OR c . post_count < > x . post_count )
2014-03-19 10:14:05 -04:00
SQL
# Yes, there are a lot of queries happening below.
# Performing a lot of queries is actually faster than using one big update
# statement with sub-selects on large databases with many categories,
# topics, and posts.
#
# The old method with the one query is here:
# https://github.com/discourse/discourse/blob/5f34a621b5416a53a2e79a145e927fca7d5471e8/app/models/category.rb
#
# If you refactor this, test performance on a large database.
Category . all . each do | c |
2014-08-27 15:58:05 -04:00
topics = c . topics . visible
topics = topics . where ( [ 'topics.id <> ?' , c . topic_id ] ) if c . topic_id
2014-03-19 10:14:05 -04:00
c . topics_year = topics . created_since ( 1 . year . ago ) . count
c . topics_month = topics . created_since ( 1 . month . ago ) . count
c . topics_week = topics . created_since ( 1 . week . ago ) . count
c . topics_day = topics . created_since ( 1 . day . ago ) . count
posts = c . visible_posts
c . posts_year = posts . created_since ( 1 . year . ago ) . count
c . posts_month = posts . created_since ( 1 . month . ago ) . count
c . posts_week = posts . created_since ( 1 . week . ago ) . count
c . posts_day = posts . created_since ( 1 . day . ago ) . count
c . save if c . changed?
end
end
2013-12-13 15:15:51 -05:00
def visible_posts
query = Post . joins ( :topic )
. where ( [ 'topics.category_id = ?' , self . id ] )
. where ( 'topics.visible = true' )
. where ( 'posts.deleted_at IS NULL' )
. where ( 'posts.user_deleted = false' )
self . topic_id ? query . where ( [ 'topics.id <> ?' , self . topic_id ] ) : query
end
2013-04-29 16:33:24 +10:00
# Internal: Generate the text of post prompting to enter category
# description.
def self . post_template
I18n . t ( " category.post_template " , replace_paragraph : I18n . t ( " category.replace_paragraph " ) )
end
2013-03-02 11:57:02 +03:00
def create_category_definition
2013-10-24 10:05:51 +11:00
t = Topic . new ( title : I18n . t ( " category.topic_prefix " , category : name ) , user : user , pinned_at : Time . now , category_id : id )
t . skip_callbacks = true
2014-10-10 18:21:44 +02:00
t . ignore_category_auto_close = true
t . set_auto_close ( nil )
2014-05-26 15:33:51 -04:00
t . save! ( validate : false )
2013-10-24 10:05:51 +11:00
update_column ( :topic_id , t . id )
t . posts . create ( raw : post_template , user : user )
2013-03-02 11:57:02 +03:00
end
def topic_url
2015-09-28 16:43:38 +10:00
if has_attribute? ( " topic_slug " )
2015-10-02 12:27:38 +10:00
Topic . relative_url ( topic_id , read_attribute ( :topic_slug ) )
2015-09-28 16:43:38 +10:00
else
topic_only_relative_url . try ( :relative_url )
end
2013-03-02 11:57:02 +03:00
end
2014-10-22 15:48:18 +11:00
def description_text
return nil unless description
2015-09-28 16:41:16 +10:00
@@cache || = LruRedux :: ThreadSafeCache . new ( 1000 )
2014-10-22 15:48:18 +11:00
@@cache . getset ( self . description ) do
Nokogiri :: HTML ( self . description ) . text
end
end
2014-12-03 16:23:59 -08:00
def duplicate_slug?
Category . where ( slug : self . slug , parent_category_id : parent_category_id ) . where . not ( id : id ) . any?
end
2013-03-02 11:57:02 +03:00
def ensure_slug
2014-12-20 22:07:29 +08:00
return unless name . present?
self . name . strip!
if slug . present?
# santized custom slug
2015-05-13 16:52:48 +08:00
self . slug = Slug . sanitize ( slug )
2014-12-20 22:07:29 +08:00
errors . add ( :slug , 'is already in use' ) if duplicate_slug?
else
# auto slug
2015-04-13 22:50:41 +08:00
self . slug = Slug . for ( name , '' )
2014-12-20 22:07:29 +08:00
self . slug = '' if duplicate_slug?
2013-04-01 12:26:51 -04:00
end
2015-04-13 22:50:41 +08:00
# only allow to use category itself id. new_record doesn't have a id.
unless new_record?
2016-01-07 12:06:45 +05:30
match_id = / ^( \ d+)-category / . match ( self . slug )
2015-04-13 22:50:41 +08:00
errors . add ( :slug , :invalid ) if match_id && match_id [ 1 ] && match_id [ 1 ] != self . id . to_s
end
2013-03-02 11:57:02 +03:00
end
2014-03-24 13:36:23 -04:00
def slug_for_url
slug . present? ? self . slug : " #{ self . id } -category "
end
2015-07-10 12:09:43 +10:00
def publish_category
group_ids = self . groups . pluck ( :id ) if self . read_restricted
MessageBus . publish ( '/categories' , { categories : ActiveModel :: ArraySerializer . new ( [ self ] ) . as_json } , group_ids : group_ids )
end
def publish_category_deletion
MessageBus . publish ( '/categories' , { deleted_categories : [ self . id ] } )
2013-04-10 15:53:36 -04:00
end
2013-10-23 12:58:11 -04:00
def parent_category_validator
if parent_category_id
2014-07-15 15:19:17 -04:00
errors . add ( :base , I18n . t ( " category.errors.self_parent " ) ) if parent_category_id == id
errors . add ( :base , I18n . t ( " category.errors.uncategorized_parent " ) ) if uncategorized?
2013-10-23 12:58:11 -04:00
grandfather_id = Category . where ( id : parent_category_id ) . pluck ( :parent_category_id ) . first
2013-10-24 17:03:28 -04:00
errors . add ( :base , I18n . t ( " category.errors.depth " ) ) if grandfather_id
2013-10-23 12:58:11 -04:00
end
end
2013-05-10 16:47:47 +10:00
def group_names = ( names )
# this line bothers me, destroying in AR can not seem to be queued, thinking of extending it
category_groups . destroy_all unless new_record?
2013-05-17 15:11:37 -04:00
ids = Group . where ( name : names . split ( " , " ) ) . pluck ( :id )
2013-05-10 16:47:47 +10:00
ids . each do | id |
category_groups . build ( group_id : id )
end
end
2013-07-14 11:24:16 +10:00
# will reset permission on a topic to a particular
# set.
#
# Available permissions are, :full, :create_post, :readonly
# hash can be:
#
# :everyone => :full - everyone has everything
# :everyone => :readonly, :staff => :full
# 7 => 1 # you can pass a group_id and permission id
def set_permissions ( permissions )
self . read_restricted , @permissions = Category . resolve_permissions ( permissions )
# Ideally we can just call .clear here, but it runs SQL, we only want to run it
# on save.
2013-02-05 14:16:51 -05:00
end
2013-07-16 15:44:07 +10:00
def permissions = ( permissions )
set_permissions ( permissions )
end
2015-09-17 15:51:32 +08:00
def permissions_params
hash = { }
category_groups . includes ( :group ) . each do | category_group |
hash [ category_group . group_name ] = category_group . permission_type
end
hash
end
2013-07-14 11:24:16 +10:00
def apply_permissions
if @permissions
category_groups . destroy_all
@permissions . each do | group_id , permission_type |
category_groups . build ( group_id : group_id , permission_type : permission_type )
end
@permissions = nil
2013-04-29 16:33:24 +10:00
end
2013-02-21 18:09:56 -05:00
end
2013-04-29 16:33:24 +10:00
2016-06-01 17:05:15 -04:00
def allowed_tags = ( tag_names_arg )
2016-06-06 14:18:15 -04:00
DiscourseTagging . add_or_create_tags_by_name ( self , tag_names_arg )
2016-05-30 16:37:06 -04:00
end
2016-06-07 13:08:59 -04:00
def allowed_tag_groups = ( group_names )
self . tag_groups = TagGroup . where ( name : group_names ) . all . to_a
end
2014-07-14 10:16:24 -04:00
def downcase_email
2016-02-24 19:47:58 +01:00
self . email_in = ( email_in || " " ) . strip . downcase . presence
end
def email_in_validator
return if self . email_in . blank?
email_in . split ( " | " ) . each do | email |
2016-03-08 20:52:04 +01:00
if ! Email . is_valid? ( email )
self . errors . add ( :base , I18n . t ( 'category.errors.invalid_email_in' , email : email ) )
elsif group = Group . find_by_email ( email )
self . errors . add ( :base , I18n . t ( 'category.errors.email_already_used_in_group' , email : email , group_name : group . name ) )
elsif category = Category . where . not ( id : self . id ) . find_by_email ( email )
self . errors . add ( :base , I18n . t ( 'category.errors.email_already_used_in_category' , email : email , category_name : category . name ) )
2016-02-24 19:47:58 +01:00
end
end
2014-07-14 10:16:24 -04:00
end
2014-08-18 11:07:32 -04:00
def downcase_name
self . name_lower = name . downcase if self . name
end
2013-05-29 18:11:04 +10:00
def secure_group_ids
2013-07-14 11:24:16 +10:00
if self . read_restricted?
2013-05-29 18:11:04 +10:00
groups . pluck ( " groups.id " )
end
end
2013-10-17 17:44:56 +11:00
def update_latest
latest_post_id = Post
. order ( " posts.created_at desc " )
. where ( " NOT hidden " )
. joins ( " join topics on topics.id = topic_id " )
. where ( " topics.category_id = :id " , id : self . id )
. limit ( 1 )
. pluck ( " posts.id " )
. first
latest_topic_id = Topic
. order ( " topics.created_at desc " )
. where ( " visible " )
. where ( " topics.category_id = :id " , id : self . id )
. limit ( 1 )
. pluck ( " topics.id " )
. first
self . update_attributes ( latest_topic_id : latest_topic_id , latest_post_id : latest_post_id )
end
2013-07-14 11:24:16 +10:00
def self . resolve_permissions ( permissions )
read_restricted = true
everyone = Group :: AUTO_GROUPS [ :everyone ]
full = CategoryGroup . permission_types [ :full ]
mapped = permissions . map do | group , permission |
2014-03-26 12:20:41 -07:00
group = group . id if group . is_a? ( Group )
2013-07-14 11:24:16 +10:00
# subtle, using Group[] ensures the group exists in the DB
2014-03-26 12:20:41 -07:00
group = Group [ group . to_sym ] . id unless group . is_a? ( Fixnum )
permission = CategoryGroup . permission_types [ permission ] unless permission . is_a? ( Fixnum )
2013-07-14 11:24:16 +10:00
[ group , permission ]
end
mapped . each do | group , permission |
if group == everyone && permission == full
return [ false , [ ] ]
end
read_restricted = false if group == everyone
end
[ read_restricted , mapped ]
end
2013-12-17 15:36:15 -05:00
2014-02-08 14:10:48 -08:00
def self . query_parent_category ( parent_slug )
2014-08-13 15:24:28 -04:00
self . where ( slug : parent_slug , parent_category_id : nil ) . pluck ( :id ) . first ||
2014-02-08 14:10:48 -08:00
self . where ( id : parent_slug . to_i ) . pluck ( :id ) . first
end
2015-02-12 18:21:07 +01:00
def self . query_category ( slug_or_id , parent_category_id )
self . where ( slug : slug_or_id , parent_category_id : parent_category_id ) . includes ( :featured_users ) . first ||
self . where ( id : slug_or_id . to_i , parent_category_id : parent_category_id ) . includes ( :featured_users ) . first
2014-02-08 14:10:48 -08:00
end
2014-02-27 13:44:21 +01:00
def self . find_by_email ( email )
2016-03-08 20:52:04 +01:00
self . where ( " string_to_array(email_in, '|') @> ARRAY[?] " , Email . downcase ( email ) ) . first
2014-02-27 13:44:21 +01:00
end
2014-02-12 17:24:25 -05:00
def has_children?
2015-10-02 12:35:47 +10:00
@has_children || = ( id && Category . where ( parent_category_id : id ) . exists? ) ? :true : :false
@has_children == :true
2014-02-12 17:24:25 -05:00
end
2014-01-15 14:11:19 -05:00
def uncategorized?
2013-12-17 15:36:15 -05:00
id == SiteSetting . uncategorized_category_id
end
2014-02-16 12:45:00 -05:00
2014-11-12 10:01:41 +11:00
@@url_cache = DistributedCache . new ( 'category_url' )
after_save do
# parent takes part in url calculation
# any change could invalidate multiples
@@url_cache . clear
end
2016-01-12 16:40:36 +08:00
def full_slug ( separator = " - " )
url [ 3 .. - 1 ] . gsub ( " / " , separator )
2015-02-12 18:21:07 +01:00
end
2014-02-16 12:45:00 -05:00
def url
2014-11-12 10:01:41 +11:00
url = @@url_cache [ self . id ]
unless url
2015-04-30 12:46:19 -04:00
url = " #{ Discourse . base_uri } /c "
2014-11-12 10:01:41 +11:00
url << " / #{ parent_category . slug } " if parent_category_id
url << " / #{ slug } "
url . freeze
@@url_cache [ self . id ] = url
end
url
2014-02-16 12:45:00 -05:00
end
2014-07-18 13:59:54 -04:00
2015-12-28 14:28:16 +08:00
def url_with_id
self . parent_category ? " #{ url } / #{ self . id } " : " #{ Discourse . base_uri } /c/ #{ self . id } - #{ self . slug } "
end
2014-07-18 13:59:54 -04:00
# If the name changes, try and update the category definition topic too if it's
# an exact match
def rename_category_definition
old_name = changed_attributes [ " name " ]
2014-07-25 16:36:16 -04:00
return unless topic . present?
2014-07-18 13:59:54 -04:00
if topic . title == I18n . t ( " category.topic_prefix " , category : old_name )
topic . update_column ( :title , I18n . t ( " category.topic_prefix " , category : name ) )
end
end
2014-11-11 15:32:44 +11:00
2016-04-27 16:34:44 +05:30
def create_category_permalink
old_slug = changed_attributes [ " slug " ]
if self . parent_category
Permalink . create ( url : " c/ #{ self . parent_category . slug } / #{ old_slug } " , category_id : id )
else
Permalink . create ( url : " c/ #{ old_slug } " , category_id : id )
end
end
def delete_category_permalink
if self . parent_category
permalink = Permalink . find_by_url ( " c/ #{ self . parent_category . slug } / #{ slug } " )
else
permalink = Permalink . find_by_url ( " c/ #{ slug } " )
end
permalink . destroy if permalink
end
2014-11-11 15:32:44 +11:00
def publish_discourse_stylesheet
2014-11-12 10:01:41 +11:00
DiscourseStylesheets . cache . clear
2014-11-11 15:32:44 +11:00
end
2016-03-14 22:08:29 +05:30
def self . find_by_slug ( category_slug , parent_category_slug = nil )
if parent_category_slug
parent_category_id = self . where ( slug : parent_category_slug , parent_category_id : nil ) . pluck ( :id ) . first
self . where ( slug : category_slug , parent_category_id : parent_category_id ) . first
else
self . where ( slug : category_slug , parent_category_id : nil ) . first
end
end
2013-02-05 14:16:51 -05:00
end
2013-05-24 12:48:32 +10:00
# == Schema Information
#
# Table name: categories
#
2014-11-20 14:53:15 +11:00
# id :integer not null, primary key
# name :string(50) not null
# color :string(6) default("AB9364"), not null
# topic_id :integer
# topic_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
# topics_year :integer default(0)
# topics_month :integer default(0)
# topics_week :integer default(0)
2016-02-23 10:33:53 +11:00
# slug :string not null
2014-11-20 14:53:15 +11:00
# description :text
# text_color :string(6) default("FFFFFF"), not null
# read_restricted :boolean default(FALSE), not null
# auto_close_hours :float
# post_count :integer default(0), not null
# latest_post_id :integer
# latest_topic_id :integer
# position :integer
# parent_category_id :integer
# posts_year :integer default(0)
# posts_month :integer default(0)
# posts_week :integer default(0)
2016-02-23 10:33:53 +11:00
# email_in :string
2014-11-20 14:53:15 +11:00
# email_in_allow_strangers :boolean default(FALSE)
# topics_day :integer default(0)
# posts_day :integer default(0)
2016-02-23 10:33:53 +11:00
# logo_url :string
# background_url :string
2014-11-20 14:53:15 +11:00
# allow_badges :boolean default(TRUE), not null
# name_lower :string(50) not null
# auto_close_based_on_last_post :boolean default(FALSE)
2015-09-18 10:41:10 +10:00
# topic_template :text
# suppress_from_homepage :boolean default(FALSE)
2016-02-23 10:33:53 +11:00
# contains_messages :boolean
2013-05-24 12:48:32 +10:00
#
# Indexes
#
2014-07-03 17:29:44 +10:00
# index_categories_on_email_in (email_in) UNIQUE
# index_categories_on_topic_count (topic_count)
# unique_index_categories_on_name (name) UNIQUE
2013-05-24 12:48:32 +10:00
#