FEATURE: navigate to first post and auto bump category settings

### navigate_to_first_post_after_read setting for categories

When enabled on categories logged on users will return to OP after
reading the entire category. (useful for documentation categories)

### num_auto_bump_daily

Set a number of topics that will automatically bump daily on a category.

- Every 15 minutes we will check if any category has this setting
- Categories with the setting are shuffled
- We exclude pinned, closed, category description and archived topics
- Maximum of 1 topic for the list of categories is bumped till limit reached per category
- We always try to bump oldest first
- Limit is elastic using a RateLimiter that ensures that we only bump N per day

Also some minor organisation on category settings

Froze strings on category.rb
This commit is contained in:
Sam 2018-07-16 18:10:22 +10:00
parent 259d16a781
commit ac0053f491
13 changed files with 241 additions and 92 deletions

View File

@ -115,7 +115,10 @@ const Category = RestModel.extend({
default_view: this.get("default_view"), default_view: this.get("default_view"),
subcategory_list_style: this.get("subcategory_list_style"), subcategory_list_style: this.get("subcategory_list_style"),
default_top_period: this.get("default_top_period"), default_top_period: this.get("default_top_period"),
minimum_required_tags: this.get("minimum_required_tags") minimum_required_tags: this.get("minimum_required_tags"),
navigate_to_first_post_after_read: this.get(
"navigate_to_first_post_after_read"
)
}, },
type: id ? "PUT" : "POST" type: id ? "PUT" : "POST"
}); });

View File

@ -212,11 +212,18 @@ const Topic = RestModel.extend({
}.property("url", "last_read_post_number"), }.property("url", "last_read_post_number"),
lastUnreadUrl: function() { lastUnreadUrl: function() {
const postNumber = Math.min( const highest = this.get("highest_post_number");
this.get("last_read_post_number") + 1, const lastRead = this.get("last_read_post_number");
this.get("highest_post_number")
); if (highest <= lastRead) {
return this.urlForPostNumber(postNumber); if (this.get("category.navigate_to_first_post_after_read")) {
return this.urlForPostNumber(1);
} else {
return this.urlForPostNumber(lastRead + 1);
}
} else {
return this.urlForPostNumber(lastRead + 1);
}
}.property("url", "last_read_post_number", "highest_post_number"), }.property("url", "last_read_post_number", "highest_post_number"),
lastPostUrl: function() { lastPostUrl: function() {

View File

@ -1,3 +1,19 @@
{{#if showPositionInput}}
<section class='field position-fields'>
<label>
{{i18n 'category.position'}}
{{text-field value=category.position class="position-input" type="number"}}
</label>
</section>
{{/if}}
{{#unless showPositionInput}}
<section class='field'>
{{i18n 'category.position_disabled'}}
<a href="/admin/site_settings/category/basic">{{i18n 'category.position_disabled_click'}}</a>
</section>
{{/unless}}
<section class='field'> <section class='field'>
<div class="control-group"> <div class="control-group">
<label> <label>
@ -74,14 +90,21 @@
</div> </div>
</section> </section>
<section class="field num-featured-topics-fields"> <section class="field">
<label> <label>
{{#if category.parent_category_id}} {{#if category.parent_category_id}}
{{i18n "category.subcategory_num_featured_topics"}} {{i18n "category.subcategory_num_featured_topics"}}
{{else}} {{else}}
{{i18n "category.num_featured_topics"}} {{i18n "category.num_featured_topics"}}
{{/if}} {{/if}}
{{text-field value=category.num_featured_topics}} {{text-field value=category.num_featured_topics type="number"}}
</label>
</section>
<section class="field">
<label>
{{i18n "category.num_auto_bump_daily"}}
{{text-field value=category.custom_fields.num_auto_bump_daily type="number"}}
</label> </label>
</section> </section>
@ -92,6 +115,13 @@
</label> </label>
</section> </section>
<section class="field">
<label>
{{input type="checkbox" checked=category.navigate_to_first_post_after_read}}
{{i18n "category.navigate_to_first_post_after_read"}}
</label>
</section>
{{#if siteSettings.topic_featured_link_enabled}} {{#if siteSettings.topic_featured_link_enabled}}
<section class='field'> <section class='field'>
<div class="allowed-topic-featured-link-category"> <div class="allowed-topic-featured-link-category">
@ -129,29 +159,6 @@
{{plugin-outlet name="category-email-in" args=(hash category=category)}} {{plugin-outlet name="category-email-in" args=(hash category=category)}}
{{/if}} {{/if}}
{{#if showPositionInput}}
<section class='field position-fields'>
<label>
{{i18n 'category.position'}}
{{text-field value=category.position class="position-input"}}
</label>
</section>
{{/if}}
{{#unless emailInEnabled}}
<section class='field'>
{{i18n 'category.email_in_disabled'}}
<a href="/admin/site_settings/category/email">{{i18n 'category.email_in_disabled_click'}}</a>
</section>
{{/unless}}
{{#unless showPositionInput}}
<section class='field'>
{{i18n 'category.position_disabled'}}
<a href="/admin/site_settings/category/basic">{{i18n 'category.position_disabled_click'}}</a>
</section>
{{/unless}}
{{#if siteSettings.tagging_enabled}} {{#if siteSettings.tagging_enabled}}
<section class='field minimum-required-tags'> <section class='field minimum-required-tags'>
<label> <label>
@ -176,3 +183,10 @@
</section> </section>
{{plugin-outlet name="category-custom-settings" args=(hash category=category)}} {{plugin-outlet name="category-custom-settings" args=(hash category=category)}}
{{#unless emailInEnabled}}
<section class='field'>
{{i18n 'category.email_in_disabled'}}
<a href="/admin/site_settings/category/email">{{i18n 'category.email_in_disabled_click'}}</a>
</section>
{{/unless}}

View File

@ -369,12 +369,8 @@
} }
.edit-category-modal { .edit-category-modal {
.future-date-input, input[type="number"] {
.num-featured-topics-fields, width: 50px;
.position-fields {
input[type="text"] {
width: 50px;
}
} }
.subcategory-list-style-field { .subcategory-list-style-field {

View File

@ -283,6 +283,7 @@ class CategoriesController < ApplicationController
:subcategory_list_style, :subcategory_list_style,
:default_top_period, :default_top_period,
:minimum_required_tags, :minimum_required_tags,
:navigate_to_first_post_after_read,
custom_fields: [params[:custom_fields].try(:keys)], custom_fields: [params[:custom_fields].try(:keys)],
permissions: [*p.try(:keys)], permissions: [*p.try(:keys)],
allowed_tags: [], allowed_tags: [],

View File

@ -49,6 +49,14 @@ module Jobs
SiteSetting.min_new_topics_time = last_new_topic.created_at.to_i SiteSetting.min_new_topics_time = last_new_topic.created_at.to_i
end end
auto_bumps = CategoryCustomField.where(name: Category::NUM_AUTO_BUMP_DAILY).pluck(:id)
if (auto_bumps.length > 0)
auto_bumps.shuffle.each do |category_id|
break if Category.find_by(id: category_id)&.auto_bump_topic!
end
end
nil nil
end end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_dependency 'distributed_cache' require_dependency 'distributed_cache'
class Category < ActiveRecord::Base class Category < ActiveRecord::Base
@ -9,9 +11,11 @@ class Category < ActiveRecord::Base
REQUIRE_TOPIC_APPROVAL = 'require_topic_approval' REQUIRE_TOPIC_APPROVAL = 'require_topic_approval'
REQUIRE_REPLY_APPROVAL = 'require_reply_approval' REQUIRE_REPLY_APPROVAL = 'require_reply_approval'
NUM_AUTO_BUMP_DAILY = 'num_auto_bump_daily'
register_custom_field_type(REQUIRE_TOPIC_APPROVAL, :boolean) register_custom_field_type(REQUIRE_TOPIC_APPROVAL, :boolean)
register_custom_field_type(REQUIRE_REPLY_APPROVAL, :boolean) register_custom_field_type(REQUIRE_REPLY_APPROVAL, :boolean)
register_custom_field_type(NUM_AUTO_BUMP_DAILY, :integer)
belongs_to :topic, dependent: :destroy belongs_to :topic, dependent: :destroy
belongs_to :topic_only_relative_url, belongs_to :topic_only_relative_url,
@ -365,6 +369,51 @@ class Category < ActiveRecord::Base
custom_fields[REQUIRE_REPLY_APPROVAL] custom_fields[REQUIRE_REPLY_APPROVAL]
end end
def num_auto_bump_daily
custom_fields[NUM_AUTO_BUMP_DAILY]
end
def num_auto_bump_daily=(v)
custom_fields[NUM_AUTO_BUMP_DAILY] = v
end
def auto_bump_limiter
RateLimiter.new(nil, "auto_bump_limit_#{self.id}", num_auto_bump_daily.to_i, 86400)
end
def clear_auto_bump_cache!
auto_bump_limiter.clear!
end
# will automatically bump a single topic
# if number of automatically bumped topics is smaller than threshold
def auto_bump_topic!
return false if num_auto_bump_daily.blank?
limiter = auto_bump_limiter
return false if !limiter.can_perform?
id = Topic
.visible
.listable_topics
.where(category_id: self.id)
.where('id <> ?', self.topic_id)
.where('bumped_at < ?', 1.day.ago)
.where('pinned_at IS NULL AND NOT closed AND NOT archived')
.order('bumped_at ASC')
.limit(1)
.pluck(:id).first
if id
Topic.where(id: id).update_all(bumped_at: Time.zone.now)
limiter.performed!
true
else
false
end
end
def allowed_tags=(tag_names_arg) def allowed_tags=(tag_names_arg)
DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true) DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true)
end end
@ -459,12 +508,10 @@ class Category < ActiveRecord::Base
def url def url
url = @@url_cache[self.id] url = @@url_cache[self.id]
unless url unless url
url = "#{Discourse.base_uri}/c" url = +"#{Discourse.base_uri}/c"
url << "/#{parent_category.slug}" if parent_category_id url << "/#{parent_category.slug}" if parent_category_id
url << "/#{slug}" url << "/#{slug}"
url.freeze @@url_cache[self.id] = -url
@@url_cache[self.id] = url
end end
url url
@ -545,53 +592,54 @@ end
# #
# Table name: categories # Table name: categories
# #
# id :integer not null, primary key # id :integer not null, primary key
# name :string(50) not null # name :string(50) not null
# color :string(6) default("AB9364"), not null # color :string(6) default("AB9364"), not null
# topic_id :integer # topic_id :integer
# topic_count :integer default(0), not null # topic_count :integer default(0), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# user_id :integer not null # user_id :integer not null
# topics_year :integer default(0) # topics_year :integer default(0)
# topics_month :integer default(0) # topics_month :integer default(0)
# topics_week :integer default(0) # topics_week :integer default(0)
# slug :string not null # slug :string not null
# description :text # description :text
# text_color :string(6) default("FFFFFF"), not null # text_color :string(6) default("FFFFFF"), not null
# read_restricted :boolean default(FALSE), not null # read_restricted :boolean default(FALSE), not null
# auto_close_hours :float # auto_close_hours :float
# post_count :integer default(0), not null # post_count :integer default(0), not null
# latest_post_id :integer # latest_post_id :integer
# latest_topic_id :integer # latest_topic_id :integer
# position :integer # position :integer
# parent_category_id :integer # parent_category_id :integer
# posts_year :integer default(0) # posts_year :integer default(0)
# posts_month :integer default(0) # posts_month :integer default(0)
# posts_week :integer default(0) # posts_week :integer default(0)
# email_in :string # email_in :string
# email_in_allow_strangers :boolean default(FALSE) # email_in_allow_strangers :boolean default(FALSE)
# topics_day :integer default(0) # topics_day :integer default(0)
# posts_day :integer default(0) # posts_day :integer default(0)
# allow_badges :boolean default(TRUE), not null # allow_badges :boolean default(TRUE), not null
# name_lower :string(50) not null # name_lower :string(50) not null
# auto_close_based_on_last_post :boolean default(FALSE) # auto_close_based_on_last_post :boolean default(FALSE)
# topic_template :text # topic_template :text
# contains_messages :boolean # contains_messages :boolean
# sort_order :string # sort_order :string
# sort_ascending :boolean # sort_ascending :boolean
# uploaded_logo_id :integer # uploaded_logo_id :integer
# uploaded_background_id :integer # uploaded_background_id :integer
# topic_featured_link_allowed :boolean default(TRUE) # topic_featured_link_allowed :boolean default(TRUE)
# all_topics_wiki :boolean default(FALSE), not null # all_topics_wiki :boolean default(FALSE), not null
# show_subcategory_list :boolean default(FALSE) # show_subcategory_list :boolean default(FALSE)
# num_featured_topics :integer default(3) # num_featured_topics :integer default(3)
# default_view :string(50) # default_view :string(50)
# subcategory_list_style :string(50) default("rows_with_featured_topics") # subcategory_list_style :string(50) default("rows_with_featured_topics")
# default_top_period :string(20) default("all") # default_top_period :string(20) default("all")
# mailinglist_mirror :boolean default(FALSE), not null # mailinglist_mirror :boolean default(FALSE), not null
# suppress_from_latest :boolean default(FALSE) # suppress_from_latest :boolean default(FALSE)
# minimum_required_tags :integer default(0) # minimum_required_tags :integer default(0)
# navigate_to_first_post_after_read :boolean default(FALSE), not null
# #
# Indexes # Indexes
# #

View File

@ -25,7 +25,8 @@ class BasicCategorySerializer < ApplicationSerializer
:default_view, :default_view,
:subcategory_list_style, :subcategory_list_style,
:default_top_period, :default_top_period,
:minimum_required_tags :minimum_required_tags,
:navigate_to_first_post_after_read
has_one :uploaded_logo, embed: :object, serializer: CategoryUploadSerializer has_one :uploaded_logo, embed: :object, serializer: CategoryUploadSerializer
has_one :uploaded_background, embed: :object, serializer: CategoryUploadSerializer has_one :uploaded_background, embed: :object, serializer: CategoryUploadSerializer

View File

@ -2275,7 +2275,7 @@ en:
show_subcategory_list: "Show subcategory list above topics in this category." show_subcategory_list: "Show subcategory list above topics in this category."
num_featured_topics: "Number of topics shown on the categories page:" num_featured_topics: "Number of topics shown on the categories page:"
subcategory_num_featured_topics: "Number of featured topics on parent category's page:" subcategory_num_featured_topics: "Number of featured topics on parent category's page:"
all_topics_wiki: "Make new topics wikis by default." all_topics_wiki: "Make new topics wikis by default"
subcategory_list_style: "Subcategory List Style:" subcategory_list_style: "Subcategory List Style:"
sort_order: "Topic List Sort By:" sort_order: "Topic List Sort By:"
default_view: "Default Topic List:" default_view: "Default Topic List:"
@ -2286,12 +2286,14 @@ en:
require_topic_approval: "Require moderator approval of all new topics" require_topic_approval: "Require moderator approval of all new topics"
require_reply_approval: "Require moderator approval of all new replies" require_reply_approval: "Require moderator approval of all new replies"
this_year: "this year" this_year: "this year"
position: "position" position: "Position:"
default_position: "Default Position" default_position: "Default Position"
position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, " position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, "
position_disabled_click: 'enable the "fixed category positions" setting.' position_disabled_click: 'enable the "fixed category positions" setting.'
minimum_required_tags: 'Minimum number of tags required in a topic:' minimum_required_tags: 'Minimum number of tags required in a topic:'
parent: "Parent Category" parent: "Parent Category"
num_auto_bump_daily: 'Number of open topics to automatically bump daily:'
navigate_to_first_post_after_read: 'Navigate to first post after topics are read'
notifications: notifications:
watching: watching:
title: "Watching" title: "Watching"

View File

@ -0,0 +1,5 @@
class AddNavigateToFirstPostAfterReadToCategories < ActiveRecord::Migration[5.2]
def change
add_column :categories, :navigate_to_first_post_after_read, :bool, null: false, default: false
end
end

View File

@ -684,4 +684,44 @@ describe Category do
it { expect(category.reload.require_reply_approval?).to eq(true) } it { expect(category.reload.require_reply_approval?).to eq(true) }
end end
end end
describe 'auto bump' do
before do
RateLimiter.enable
end
after do
RateLimiter.disable
end
it 'should correctly automatically bump topics' do
freeze_time 1.second.ago
category = Fabricate(:category)
category.clear_auto_bump_cache!
_post1 = create_post(category: category)
_post2 = create_post(category: category)
_post3 = create_post(category: category)
time = 1.month.from_now
freeze_time time
expect(category.auto_bump_topic!).to eq(false)
expect(Topic.where(bumped_at: time).count).to eq(0)
category.num_auto_bump_daily = 2
category.save!
expect(category.auto_bump_topic!).to eq(true)
expect(Topic.where(bumped_at: time).count).to eq(1)
expect(category.auto_bump_topic!).to eq(true)
expect(Topic.where(bumped_at: time).count).to eq(2)
expect(category.auto_bump_topic!).to eq(false)
expect(Topic.where(bumped_at: time).count).to eq(2)
end
end
end end

View File

@ -297,24 +297,31 @@ describe CategoriesController do
expect(UserHistory.count).to eq(5) # 2 + 3 (bootstrap mode) expect(UserHistory.count).to eq(5) # 2 + 3 (bootstrap mode)
end end
it 'updates per-category approval settings correctly' do it 'updates per-category settings correctly' do
category.custom_fields[Category::REQUIRE_TOPIC_APPROVAL] = false category.custom_fields[Category::REQUIRE_TOPIC_APPROVAL] = false
category.custom_fields[Category::REQUIRE_REPLY_APPROVAL] = false category.custom_fields[Category::REQUIRE_REPLY_APPROVAL] = false
category.custom_fields[Category::NUM_AUTO_BUMP_DAILY] = 0
category.navigate_to_first_post_after_read = false
category.save! category.save!
put "/categories/#{category.id}.json", params: { put "/categories/#{category.id}.json", params: {
name: category.name, name: category.name,
color: category.color, color: category.color,
text_color: category.text_color, text_color: category.text_color,
navigate_to_first_post_after_read: true,
custom_fields: { custom_fields: {
require_reply_approval: true, require_reply_approval: true,
require_topic_approval: true, require_topic_approval: true,
num_auto_bump_daily: 10
} }
} }
category.reload category.reload
expect(category.require_topic_approval?).to eq(true) expect(category.require_topic_approval?).to eq(true)
expect(category.require_reply_approval?).to eq(true) expect(category.require_reply_approval?).to eq(true)
expect(category.num_auto_bump_daily).to eq(10)
expect(category.navigate_to_first_post_after_read).to eq(true)
end end
end end
end end

View File

@ -32,6 +32,23 @@ QUnit.test("visited", assert => {
); );
}); });
QUnit.test("lastUnreadUrl", assert => {
const category = Em.Object.create({
navigate_to_first_post_after_read: true
});
const topic = Topic.create({
id: 101,
highest_post_number: 10,
last_read_post_number: 10,
slug: "hello"
});
topic.set("category", category);
assert.equal(topic.get("lastUnreadUrl"), "/t/hello/101/1");
});
QUnit.test("has details", assert => { QUnit.test("has details", assert => {
const topic = Topic.create({ id: 1234 }); const topic = Topic.create({ id: 1234 });
const topicDetails = topic.get("details"); const topicDetails = topic.get("details");