From 9828c875252f2d83cf715dfd78b9e991fae6ffb0 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 7 May 2013 14:25:41 -0400 Subject: [PATCH] Topic Auto-Close: admins and mods can set a topic to automatically close after a number of days --- Gemfile | 1 + Gemfile.lock | 2 + .../topic_admin_menu_controller.js | 10 ++ .../javascripts/discourse/models/composer.js | 16 ++- .../javascripts/discourse/models/post.js | 3 +- .../templates/composer.js.handlebars | 84 +++++++----- .../templates/modal/auto_close.js.handlebars | 15 +++ .../discourse/templates/topic.js.handlebars | 3 + .../templates/topic_admin_menu.js.handlebars | 3 +- .../discourse/views/composer_view.js | 13 ++ .../views/modal/edit_topic_auto_close_view.js | 44 ++++++ .../discourse/views/topic_closing_view.js | 47 +++++++ .../views/topic_footer_buttons_view.js | 2 +- .../stylesheets/application/compose.css.scss | 28 ++++ .../application/topic-admin-menu.css.scss | 5 - .../stylesheets/application/topic.css.scss | 4 + .../stylesheets/components/buttons.css.scss | 6 + app/controllers/posts_controller.rb | 3 +- app/controllers/topics_controller.rb | 13 +- app/models/topic.rb | 26 +++- app/serializers/topic_view_serializer.rb | 3 +- config/locales/client.en.yml | 23 ++++ config/locales/server.en.yml | 5 + config/routes.rb | 1 + ...30506185042_add_auto_close_at_to_topics.rb | 6 + lib/jobs.rb | 28 ++++ lib/jobs/close_topic.rb | 15 +++ lib/post_creator.rb | 5 + spec/components/jobs/close_topic_spec.rb | 35 +++++ spec/components/jobs_spec.rb | 36 +++++ spec/components/post_creator_spec.rb | 8 ++ spec/controllers/topics_controller_spec.rb | 36 +++++ spec/models/topic_spec.rb | 126 +++++++++++++++++- 33 files changed, 600 insertions(+), 55 deletions(-) create mode 100644 app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars create mode 100644 app/assets/javascripts/discourse/views/modal/edit_topic_auto_close_view.js create mode 100644 app/assets/javascripts/discourse/views/topic_closing_view.js create mode 100644 db/migrate/20130506185042_add_auto_close_at_to_topics.rb create mode 100644 lib/jobs/close_topic.rb create mode 100644 spec/components/jobs/close_topic_spec.rb diff --git a/Gemfile b/Gemfile index 353764973..12dc4db09 100644 --- a/Gemfile +++ b/Gemfile @@ -107,6 +107,7 @@ group :test, :development do gem 'shoulda', require: false gem 'simplecov', require: false gem 'terminal-notifier-guard', require: false + gem 'timecop' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index b2291f355..0865b8032 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -432,6 +432,7 @@ GEM rack (>= 1.0.0) thor (0.18.1) tilt (1.3.7) + timecop (0.6.1) timers (1.1.0) treetop (1.4.12) polyglot @@ -529,6 +530,7 @@ DEPENDENCIES terminal-notifier-guard therubyracer thin + timecop turbo-sprockets-rails3 uglifier vestal_versions! diff --git a/app/assets/javascripts/discourse/controllers/topic_admin_menu_controller.js b/app/assets/javascripts/discourse/controllers/topic_admin_menu_controller.js index 17b74a336..f1ff706e8 100644 --- a/app/assets/javascripts/discourse/controllers/topic_admin_menu_controller.js +++ b/app/assets/javascripts/discourse/controllers/topic_admin_menu_controller.js @@ -8,6 +8,7 @@ **/ Discourse.TopicAdminMenuController = Discourse.ObjectController.extend({ visible: false, + needs: ['modal'], show: function() { this.set('visible', true); @@ -15,6 +16,15 @@ Discourse.TopicAdminMenuController = Discourse.ObjectController.extend({ hide: function() { this.set('visible', false); + }, + + autoClose: function() { + var modalController = this.get('controllers.modal'); + if (modalController) { + var v = Discourse.EditTopicAutoCloseView.create(); + v.set('topic', this.get('content')); + modalController.show(v); + } } }); diff --git a/app/assets/javascripts/discourse/models/composer.js b/app/assets/javascripts/discourse/models/composer.js index 0d82a8473..101cb883d 100644 --- a/app/assets/javascripts/discourse/models/composer.js +++ b/app/assets/javascripts/discourse/models/composer.js @@ -65,6 +65,11 @@ Discourse.Composer = Discourse.Model.extend({ return false; }.property('editingPost', 'creatingTopic', 'post.post_number'), + showAdminOptions: function() { + if (this.get('creatingTopic') && Discourse.get('currentUser.staff')) return true; + return false; + }.property('editTitle'), + togglePreview: function() { this.toggleProperty('showPreview'); Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: this.get('showPreview') }); @@ -354,7 +359,8 @@ Discourse.Composer = Discourse.Model.extend({ actions_summary: Em.A(), moderator: currentUser.get('moderator'), yours: true, - newPost: true + newPost: true, + auto_close_days: this.get('auto_close_days') }); // If we're in a topic, we can append the post instantly. @@ -532,7 +538,13 @@ Discourse.Composer = Discourse.Model.extend({ var reply = this.get('reply') || ""; while (Discourse.BBCode.QUOTE_REGEXP.test(reply)) { reply = reply.replace(Discourse.BBCode.QUOTE_REGEXP, ""); } return reply.replace(/\s+/img, " ").trim().length; - }.property('reply') + }.property('reply'), + + autoCloseChanged: function() { + if( this.get('auto_close_days') && this.get('auto_close_days').length > 0 ) { + this.set('auto_close_days', this.get('auto_close_days').replace(/[^\d]/g, '') ) + } + }.observes('auto_close_days') }); diff --git a/app/assets/javascripts/discourse/models/post.js b/app/assets/javascripts/discourse/models/post.js index d9ac55fb6..cbd839c66 100644 --- a/app/assets/javascripts/discourse/models/post.js +++ b/app/assets/javascripts/discourse/models/post.js @@ -168,7 +168,8 @@ Discourse.Post = Discourse.Model.extend({ archetype: this.get('archetype'), title: this.get('title'), image_sizes: this.get('imageSizes'), - target_usernames: this.get('target_usernames') + target_usernames: this.get('target_usernames'), + auto_close_days: this.get('auto_close_days') }; // Put the metaData into the request diff --git a/app/assets/javascripts/discourse/templates/composer.js.handlebars b/app/assets/javascripts/discourse/templates/composer.js.handlebars index 2e77f1a6e..db0ce21fc 100644 --- a/app/assets/javascripts/discourse/templates/composer.js.handlebars +++ b/app/assets/javascripts/discourse/templates/composer.js.handlebars @@ -23,48 +23,60 @@ {{#if content.viewOpen}}
-
{{{content.actionTitle}}}:
+
{{{content.actionTitle}}}:
- {{#if content.editTitle}} -
- {{#if content.creatingPrivateMessage}} - {{view Discourse.UserSelector topicIdBinding="controller.controllers.topic.content.id" excludeCurrentUser="true" id="private-message-users" class="span8" placeholderKey="composer.users_placeholder" tabindex="1" usernamesBinding="content.targetUsernames"}} - {{/if}} - {{view Discourse.TextField valueBinding="content.title" tabindex="2" id="reply-title" maxlength="255" class="span8" placeholderKey="composer.title_placeholder"}} - {{#unless content.creatingPrivateMessage}} - {{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="Discourse.site.categories" valueBinding="content.categoryName"}} - {{#if content.archetype.hasOptions}} - - {{/if}} - {{/unless}} -
- {{/if}} - -
-
-
- {{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="content.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}} -
-
-
-
- {{#if Discourse.currentUser}} - {{{content.toggleText}}} -
- {{#if view.loadingImage}} -
- {{i18n image_selector.uploading_image}} {{view.uploadProgress}}% {{i18n cancel}} -
- {{/if}} + {{#if content.editTitle}} +
+ {{#if content.creatingPrivateMessage}} + {{view Discourse.UserSelector topicIdBinding="controller.controllers.topic.content.id" excludeCurrentUser="true" id="private-message-users" class="span8" placeholderKey="composer.users_placeholder" tabindex="1" usernamesBinding="content.targetUsernames"}} {{/if}} + {{view Discourse.TextField valueBinding="content.title" tabindex="2" id="reply-title" maxlength="255" class="span8" placeholderKey="composer.title_placeholder"}} + {{#unless content.creatingPrivateMessage}} + {{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="Discourse.site.categories" valueBinding="content.categoryName"}} + {{#if content.archetype.hasOptions}} + + {{/if}} + {{#if content.showAdminOptions}} + + {{/if}} + {{/unless}}
- {{#if Discourse.currentUser}} -
- - {{i18n cancel}} +
+
+ + {{i18n composer.auto_close_label}} + {{view Discourse.TextField valueBinding="content.auto_close_days" maxlength="5"}} + {{i18n composer.auto_close_units}}
+
+ {{/if}} + +
+
+
+ {{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="content.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}} +
+
+
+
+ {{#if Discourse.currentUser}} + {{{content.toggleText}}} +
+ {{#if view.loadingImage}} +
+ {{i18n image_selector.uploading_image}} {{view.uploadProgress}}% {{i18n cancel}} +
+ {{/if}} {{/if}} +
+ + {{#if Discourse.currentUser}} +
+ + {{i18n cancel}} +
+ {{/if}}
{{else}} diff --git a/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars b/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars new file mode 100644 index 000000000..dc3229eaa --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/auto_close.js.handlebars @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars index 0773abaad..fb4cc606f 100644 --- a/app/assets/javascripts/discourse/templates/topic.js.handlebars +++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars @@ -72,6 +72,9 @@ {{/unless}} {{else}} {{#if view.fullyLoaded}} + + {{view Discourse.TopicClosingView topicBinding="controller.content"}} + {{view Discourse.TopicFooterButtonsView topicBinding="controller.content"}} {{#if controller.content.suggested_topics.length}} diff --git a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars index 97eca39fb..c0909bf14 100644 --- a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars +++ b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars @@ -18,6 +18,7 @@ {{else}} + {{/if}} @@ -57,5 +58,5 @@
{{else}} - + {{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/composer_view.js b/app/assets/javascripts/discourse/views/composer_view.js index d4201e510..7f341843d 100644 --- a/app/assets/javascripts/discourse/views/composer_view.js +++ b/app/assets/javascripts/discourse/views/composer_view.js @@ -354,6 +354,19 @@ Discourse.ComposerView = Discourse.View.extend({ childDidInsertElement: function(e) { return this.initEditor(); + }, + + toggleAdminOptions: function() { + var $adminOpts = $('.admin-options-form'), + $wmd = $('.wmd-controls'), + wmdTop = parseInt($wmd.css('top'),10); + if( $adminOpts.is(':visible') ) { + $wmd.css('top', wmdTop - parseInt($adminOpts.css('height'),10) + 'px' ); + $adminOpts.hide(); + } else { + $adminOpts.show(); + $wmd.css('top', wmdTop + parseInt($adminOpts.css('height'),10) + 'px' ); + } } }); diff --git a/app/assets/javascripts/discourse/views/modal/edit_topic_auto_close_view.js b/app/assets/javascripts/discourse/views/modal/edit_topic_auto_close_view.js new file mode 100644 index 000000000..db903fd8a --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/edit_topic_auto_close_view.js @@ -0,0 +1,44 @@ +/** + This view handles a modal to set, edit, and remove a topic's auto-close time. + + @class EditTopicAutoCloseView + @extends Discourse.ModalBodyView + @namespace Discourse + @module Discourse +**/ +Discourse.EditTopicAutoCloseView = Discourse.ModalBodyView.extend({ + templateName: 'modal/auto_close', + title: Em.String.i18n('topic.auto_close_title'), + modalClass: 'edit-auto-close-modal', + + setDays: function() { + if( this.get('topic.auto_close_at') ) { + var closeTime = Date.create( this.get('topic.auto_close_at') ); + if (closeTime.isFuture()) { + this.set('auto_close_days', closeTime.daysSince()); + } + } + }.observes('topic'), + + saveAutoClose: function() { + this.setAutoClose( parseFloat(this.get('auto_close_days')) ); + }, + + removeAutoClose: function() { + this.setAutoClose(null); + }, + + setAutoClose: function(days) { + Discourse.ajax({ + url: "/t/" + this.get('topic.id') + "/autoclose", + type: 'PUT', + dataType: 'json', + data: { auto_close_days: days > 0 ? days : null } + }).then(function(){ + window.location.reload(); + }, function (error) { + bootbox.alert(Em.String.i18n('generic_error')); + }); + } + +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/topic_closing_view.js b/app/assets/javascripts/discourse/views/topic_closing_view.js new file mode 100644 index 000000000..3a566af75 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_closing_view.js @@ -0,0 +1,47 @@ +/** + This view is used for rendering the notification that a topic will + automatically close. + + @class TopicClosingView + @extends Ember.ContainerView + @namespace Discourse + @module Discourse +**/ +Discourse.TopicClosingView = Discourse.View.extend({ + elementId: 'topic-closing-info', + templateName: 'topic_closing', + + render: function(buffer) { + if (!this.present('topic.auto_close_at')) return; + + var autoCloseAt = Date.create(this.get('topic.auto_close_at')); + + if (autoCloseAt.isPast()) return; + + var timeLeftString, reRenderDelay, minutesLeft = autoCloseAt.minutesSince(); + + if (minutesLeft > 1440) { + timeLeftString = Em.String.i18n('in_n_days', {count: autoCloseAt.daysSince()}); + if( minutesLeft > 2160 ) { + reRenderDelay = 12 * 60 * 60000; + } else { + reRenderDelay = 60 * 60000; + } + } else if (minutesLeft > 90) { + timeLeftString = Em.String.i18n('in_n_hours', {count: autoCloseAt.hoursSince()}); + reRenderDelay = 30 * 60000; + } else if (minutesLeft > 2) { + timeLeftString = Em.String.i18n('in_n_minutes', {count: autoCloseAt.minutesSince()}); + reRenderDelay = 60000; + } else { + timeLeftString = Em.String.i18n('in_n_seconds', {count: autoCloseAt.secondsSince()}); + reRenderDelay = 1000; + } + + buffer.push('

'); + buffer.push( Em.String.i18n('topic.auto_close_notice', {timeLeft: timeLeftString}) ); + buffer.push('

'); + + this.rerender.bind(this).delay(reRenderDelay); + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js index b3cca6649..f278a1821 100644 --- a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js +++ b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js @@ -2,7 +2,7 @@ This view is used for rendering the buttons at the footer of the topic @class TopicFooterButtonsView - @extends Discourse.View + @extends Ember.ContainerView @namespace Discourse @module Discourse **/ diff --git a/app/assets/stylesheets/application/compose.css.scss b/app/assets/stylesheets/application/compose.css.scss index 5d816216e..c0cefe544 100644 --- a/app/assets/stylesheets/application/compose.css.scss +++ b/app/assets/stylesheets/application/compose.css.scss @@ -234,6 +234,7 @@ margin: 6px 10px 3px 0; } .wmd-controls { + @include transition(top 0.3s ease); top: 100px; } } @@ -365,6 +366,7 @@ div.ac-wrap { #reply-control.edit-title.private-message { .wmd-controls { + @include transition(top 0.3s ease); top: 140px; } } @@ -466,3 +468,29 @@ div.ac-wrap { } } } + +.admin-options-form { + margin-top: 8px; + display: none; +} + +.auto-close-fields { + input { + width: 50px; + } +} + +.edit-auto-close-modal { + form { + margin: 0; + } + .auto-close-fields { + i.icon-time { + font-size: 16px; + line-height: 8px; + } + input { + margin: 0; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/topic-admin-menu.css.scss b/app/assets/stylesheets/application/topic-admin-menu.css.scss index 427d65efb..78d0f7c93 100644 --- a/app/assets/stylesheets/application/topic-admin-menu.css.scss +++ b/app/assets/stylesheets/application/topic-admin-menu.css.scss @@ -8,11 +8,6 @@ top: 70px; right: 10px; z-index: 1000; - - i { - margin: 0px; - line-height: 10px; - } } .topic-admin-menu { diff --git a/app/assets/stylesheets/application/topic.css.scss b/app/assets/stylesheets/application/topic.css.scss index 02cc7485e..972d38978 100644 --- a/app/assets/stylesheets/application/topic.css.scss +++ b/app/assets/stylesheets/application/topic.css.scss @@ -329,6 +329,10 @@ } } } + + #topic-closing-info { + margin-left: 103px; + } } kbd { diff --git a/app/assets/stylesheets/components/buttons.css.scss b/app/assets/stylesheets/components/buttons.css.scss index 0f0520945..d65cd88dc 100644 --- a/app/assets/stylesheets/components/buttons.css.scss +++ b/app/assets/stylesheets/components/buttons.css.scss @@ -29,6 +29,12 @@ .icon { margin-right: 7px; } + &.no-text { + .icon { + margin-right: 0; + line-height: 10px; + } + } } // Default button diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index c4fb6c7b8..b6c78eb38 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -36,7 +36,8 @@ class PostsController < ApplicationController target_usernames: params[:target_usernames], reply_to_post_number: params[:post][:reply_to_post_number], image_sizes: params[:image_sizes], - meta_data: params[:meta_data]) + meta_data: params[:meta_data], + auto_close_days: params[:auto_close_days]) post = post_creator.create if post_creator.errors.present? diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index e1ca6a65f..145e19939 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -15,7 +15,8 @@ class TopicsController < ApplicationController :unmute, :set_notifications, :move_posts, - :clear_pin] + :clear_pin, + :autoclose] before_filter :consider_user_for_promotion, only: :show @@ -97,6 +98,16 @@ class TopicsController < ApplicationController toggle_mute(false) end + def autoclose + requires_parameter(:auto_close_days) + @topic = Topic.where(id: params[:topic_id].to_i).first + guardian.ensure_can_moderate!(@topic) + @topic.auto_close_days = params[:auto_close_days] + @topic.auto_close_user = current_user + @topic.save + render nothing: true + end + def destroy topic = Topic.where(id: params[:id]).first guardian.ensure_can_delete!(topic) diff --git a/app/models/topic.rb b/app/models/topic.rb index fb96ceb64..27a3d1f13 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -61,6 +61,7 @@ class Topic < ActiveRecord::Base belongs_to :featured_user2, class_name: 'User', foreign_key: :featured_user2_id belongs_to :featured_user3, class_name: 'User', foreign_key: :featured_user3_id belongs_to :featured_user4, class_name: 'User', foreign_key: :featured_user4_id + belongs_to :auto_close_user, class_name: 'User', foreign_key: :auto_close_user_id has_many :topic_users has_many :topic_links @@ -108,6 +109,18 @@ class Topic < ActiveRecord::Base end end + before_save do + if (auto_close_at_changed? and !auto_close_at_was.nil?) or (auto_close_user_id_changed? and auto_close_at) + Jobs.cancel_scheduled_job(:close_topic, {topic_id: id}) + end + end + + after_save do + if auto_close_at and (auto_close_at_changed? or auto_close_user_id_changed?) + Jobs.enqueue_at(auto_close_at, :close_topic, {topic_id: id, user_id: auto_close_user_id || user_id}) + end + end + # all users (in groups or directly targetted) that are going to get the pm def all_allowed_users # TODO we should probably change this from 3 queries to 1 @@ -264,7 +277,7 @@ class Topic < ActiveRecord::Base update_pinned(status) else # otherwise update the column - update_column(property, status) + update_column(property == 'autoclosed' ? 'closed' : property, status) end key = "topic_statuses.#{property}_" @@ -273,9 +286,11 @@ class Topic < ActiveRecord::Base opts = {} # We don't bump moderator posts except for the re-open post. - opts[:bump] = true if property == 'closed' and (!status) + opts[:bump] = true if (property == 'closed' or property == 'autoclosed') and (!status) - add_moderator_post(user, I18n.t(key), opts) + message = property != 'autoclosed' ? I18n.t(key) : I18n.t(key, count: (((self.auto_close_at||Time.zone.now) - self.created_at) / 86_400).round ) + + add_moderator_post(user, message, opts) end end @@ -712,4 +727,9 @@ class Topic < ActiveRecord::Base def notify_muted!(user) TopicUser.change(user, id, notification_level: TopicUser.notification_levels[:muted]) end + + def auto_close_days=(num_days) + self.auto_close_at = (num_days and num_days.to_i > 0.0 ? num_days.to_i.days.from_now : nil) + end + end diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index ef3753a66..843fdb44a 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -18,7 +18,8 @@ class TopicViewSerializer < ApplicationSerializer :moderator_posts_count, :has_best_of, :archetype, - :slug] + :slug, + :auto_close_at] end def self.guardian_attributes diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a67f0d455..7c5ff5c5b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -32,6 +32,19 @@ en: now: "just now" read_more: 'read more' + in_n_seconds: + one: "in 1 second" + other: "in {{count}} seconds" + in_n_minutes: + one: "in 1 minute" + other: "in {{count}} minutes" + in_n_hours: + one: "in 1 hour" + other: "in {{count}} hours" + in_n_days: + one: "in 1 day" + other: "in {{count}} days" + suggested_topics: title: "Suggested Topics" @@ -370,6 +383,9 @@ en: help: "Markdown Editing Help" toggler: "hide or show the composer panel" + auto_close_label: "Auto-close topic after:" + auto_close_units: "days" + notifications: title: "notifications of @name mentions, replies to your posts and topics, private messages, etc" none: "You have no notifications right now." @@ -479,6 +495,12 @@ en: jump_reply_down: jump to later reply deleted: "The topic has been deleted" + auto_close_notice: "This topic will close %{timeLeft}" + auto_close_title: 'Auto-Close Settings' + auto_close_save: "Save" + auto_close_cancel: "Cancel" + auto_close_remove: "Don't Auto-Close This Topic" + progress: title: topic progress jump_top: jump to first post @@ -516,6 +538,7 @@ en: delete: "Delete Topic" open: "Open Topic" close: "Close Topic" + auto_close: "Auto Close" unpin: "Un-Pin Topic" pin: "Pin Topic" unarchive: "Unarchive Topic" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 4e3ba5544..7c02e0ebc 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -607,6 +607,11 @@ en: archived_disabled: "This topic is now unarchived. It is no longer frozen, and can be changed." closed_enabled: "This topic is now closed. New replies are no longer allowed." closed_disabled: "This topic is now opened. New replies are allowed." + autoclosed_enabled: + zero: "This topic was automatically closed after 1 day. New replies are no longer allowed." + one: "This topic was automatically closed after 1 day. New replies are no longer allowed." + other: "This topic was automatically closed after %{count} days. New replies are no longer allowed." + autoclosed_disabled: "This topic is now opened. New replies are allowed." pinned_enabled: "This topic is now pinned. It will appear at the top of its category until it is either unpinned by a moderator, or the Clear Pin button is pressed." pinned_disabled: "This topic is now unpinned. It will no longer appear at the top of its category." visible_enabled: "This topic is now visible. It will be displayed in topic lists." diff --git a/config/routes.rb b/config/routes.rb index f70bb870c..e8c8b4e0a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -203,6 +203,7 @@ Discourse::Application.routes.draw do put 't/:topic_id/clear-pin' => 'topics#clear_pin', constraints: {topic_id: /\d+/} put 't/:topic_id/mute' => 'topics#mute', constraints: {topic_id: /\d+/} put 't/:topic_id/unmute' => 'topics#unmute', constraints: {topic_id: /\d+/} + put 't/:topic_id/autoclose' => 'topics#autoclose', constraints: {topic_id: /\d+/} get 't/:topic_id/:post_number' => 'topics#show', constraints: {topic_id: /\d+/, post_number: /\d+/} get 't/:slug/:topic_id.rss' => 'topics#feed', format: :rss, constraints: {topic_id: /\d+/} diff --git a/db/migrate/20130506185042_add_auto_close_at_to_topics.rb b/db/migrate/20130506185042_add_auto_close_at_to_topics.rb new file mode 100644 index 000000000..c4f9987f5 --- /dev/null +++ b/db/migrate/20130506185042_add_auto_close_at_to_topics.rb @@ -0,0 +1,6 @@ +class AddAutoCloseAtToTopics < ActiveRecord::Migration + def change + add_column :topics, :auto_close_at, :datetime + add_column :topics, :auto_close_user_id, :integer + end +end diff --git a/lib/jobs.rb b/lib/jobs.rb index 340d5a552..f413cf1e5 100644 --- a/lib/jobs.rb +++ b/lib/jobs.rb @@ -99,6 +99,34 @@ module Jobs enqueue(job_name, opts.merge!(delay_for: secs)) end + def self.enqueue_at(datetime, job_name, opts={}) + enqueue_in( [(datetime - Time.zone.now).to_i, 0].max, job_name, opts ) + end + + # TODO: should take job_name like enqueue methods + def self.cancel_scheduled_job(job_name, params={}) + job_class = "Jobs::#{job_name.to_s.camelcase}" + matched = true + Sidekiq::ScheduledSet.new.each do |scheduled_job| + if scheduled_job.klass == 'Sidekiq::Extensions::DelayedClass' + job_args = YAML.load(scheduled_job.args[0]) + if job_args[0] == job_class + next unless job_args[2] and job_args[2][0] + matched = true + params.each do |key, value| + unless job_args[2][0][key] == value + matched = false + break + end + end + next unless matched + end + scheduled_job.delete + break + end + end + matched + end end # Require all jobs diff --git a/lib/jobs/close_topic.rb b/lib/jobs/close_topic.rb new file mode 100644 index 000000000..61c8d42b5 --- /dev/null +++ b/lib/jobs/close_topic.rb @@ -0,0 +1,15 @@ +module Jobs + class CloseTopic < Jobs::Base + + def execute(args) + topic = Topic.find(args[:topic_id]) + if topic.auto_close_at + closer = User.find(args[:user_id]) + if Guardian.new(closer).can_moderate?(topic) + topic.update_status('autoclosed', true, closer) + end + end + end + + end +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index c48073f59..beb927ece 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -53,6 +53,11 @@ class PostCreator topic = Topic.new(topic_params) + if @opts[:auto_close_days] + guardian.ensure_can_moderate!(topic) + topic.auto_close_days = @opts[:auto_close_days] + end + if @opts[:archetype] == Archetype.private_message topic.subtype = TopicSubtype.user_to_user unless topic.subtype diff --git a/spec/components/jobs/close_topic_spec.rb b/spec/components/jobs/close_topic_spec.rb new file mode 100644 index 000000000..3850917ba --- /dev/null +++ b/spec/components/jobs/close_topic_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::CloseTopic do + + let(:admin) { Fabricate.build(:admin) } + + it 'closes a topic that is set to auto-close' do + topic = Fabricate.build(:topic, auto_close_at: Time.zone.now, user: admin) + topic.expects(:update_status).with('autoclosed', true, admin) + Topic.stubs(:find).returns(topic) + User.stubs(:find).returns(admin) + Jobs::CloseTopic.new.execute( topic_id: 123, user_id: 234 ) + end + + it 'does nothing if the topic is not set to auto-close' do + topic = Fabricate.build(:topic, auto_close_at: nil, user: admin) + topic.expects(:update_status).never + Topic.stubs(:find).returns(topic) + User.stubs(:find).returns(admin) + Jobs::CloseTopic.new.execute( topic_id: 123, user_id: 234 ) + end + + it 'does nothing if the user is not authorized to close the topic' do + topic = Fabricate.build(:topic, auto_close_at: Time.zone.now, user: admin) + topic.expects(:update_status).never + Topic.stubs(:find).returns(topic) + User.stubs(:find).returns(admin) + Guardian.any_instance.stubs(:can_moderate?).returns(false) + Jobs::CloseTopic.new.execute( topic_id: 123, user_id: 234 ) + end + + it 'does nothing if the topic is already closed' + +end \ No newline at end of file diff --git a/spec/components/jobs_spec.rb b/spec/components/jobs_spec.rb index b769c4c1f..cfde22148 100644 --- a/spec/components/jobs_spec.rb +++ b/spec/components/jobs_spec.rb @@ -75,5 +75,41 @@ describe Jobs do end + describe 'cancel_scheduled_job' do + it 'deletes the matching job' do + job_to_delete = stub_everything(klass: 'Sidekiq::Extensions::DelayedClass', args: [YAML.dump(['Jobs::DrinkBeer', :delayed_perform, [{beer_id: 42}]])]) + job_to_delete.expects(:delete) + job_to_keep1 = stub_everything(klass: 'Sidekiq::Extensions::DelayedClass', args: [YAML.dump(['Jobs::DrinkBeer', :delayed_perform, [{beer_id: 43}]])]) + job_to_keep1.expects(:delete).never + job_to_keep2 = stub_everything(klass: 'Sidekiq::Extensions::DelayedClass', args: [YAML.dump(['Jobs::DrinkBeer', :delayed_perform, [{beer_id: 44}]])]) + job_to_keep2.expects(:delete).never + Sidekiq::ScheduledSet.stubs(:new).returns( [job_to_keep1, job_to_delete, job_to_keep2] ) + Jobs.cancel_scheduled_job(:drink_beer, {beer_id: 42}).should be_true + end + + it 'returns false when no matching job is scheduled' do + job_to_keep = stub_everything(klass: 'Sidekiq::Extensions::DelayedClass', args: [YAML.dump(['Jobs::DrinkBeer', :delayed_perform, [{beer_id: 43}]])]) + job_to_keep.expects(:delete).never + Sidekiq::ScheduledSet.stubs(:new).returns( [job_to_keep] ) + Jobs.cancel_scheduled_job(:drink_beer, {beer_id: 42}).should be_false + end + end + + describe 'enqueue_at' do + it 'calls enqueue_in for you' do + Timecop.freeze(Time.zone.now) do + Jobs.expects(:enqueue_in).with(3 * 60 * 60, :eat_lunch, {}).returns(true) + Jobs.enqueue_at(3.hours.from_now, :eat_lunch, {}) + end + end + + it 'handles datetimes that are in the past' do + Timecop.freeze(Time.zone.now) do + Jobs.expects(:enqueue_in).with(0, :eat_lunch, {}).returns(true) + Jobs.enqueue_at(3.hours.ago, :eat_lunch, {}) + end + end + end + end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index d159e5f7e..c152a5add 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -98,6 +98,14 @@ describe PostCreator do end end + context 'when auto-close param is given' do + it 'ensures the user can auto-close the topic' do + Guardian.any_instance.stubs(:can_moderate?).returns(false) + expect { + PostCreator.new(user, basic_topic_params.merge(auto_close_days: 2)).create + }.to raise_error(Discourse::InvalidAccess) + end + end end context 'uniqueness' do diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 3c824e764..a7409a1d7 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -516,4 +516,40 @@ describe TopicsController do end + describe 'autoclose' do + + it 'needs you to be logged in' do + lambda { xhr :put, :autoclose, topic_id: 99, auto_close_days: 3}.should raise_error(Discourse::NotLoggedIn) + end + + it 'needs you to be an admin or mod' do + user = log_in + xhr :put, :autoclose, topic_id: 99, auto_close_days: 3 + response.should be_forbidden + end + + describe 'when logged in' do + before do + @admin = log_in(:admin) + @topic = Fabricate(:topic, user: @admin) + end + + it "can set a topic's auto close time" do + Topic.any_instance.expects(:auto_close_days=).with { |arg| arg.to_i == 3 } + xhr :put, :autoclose, topic_id: @topic.id, auto_close_days: 3 + end + + it "can remove a topic's auto close time" do + Topic.any_instance.expects(:auto_close_days=).with(nil) + xhr :put, :autoclose, topic_id: @topic.id, auto_close_days: nil + end + + it "sets the topic closer to the current user" do + Topic.any_instance.expects(:auto_close_user=).with(@admin) + xhr :put, :autoclose, topic_id: @topic.id, auto_close_days: nil + end + end + + end + end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 90028e25c..6cb35ef8c 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -584,10 +584,10 @@ describe Topic do end end - context 'closed' do + shared_examples_for 'a status that closes a topic' do context 'disable' do before do - @topic.update_status('closed', false, @user) + @topic.update_status(status, false, @user) @topic.reload end @@ -602,7 +602,7 @@ describe Topic do context 'enable' do before do @topic.update_attribute :closed, false - @topic.update_status('closed', true, @user) + @topic.update_status(status, true, @user) @topic.reload end @@ -614,6 +614,16 @@ describe Topic do end end + context 'closed' do + let(:status) { 'closed' } + it_should_behave_like 'a status that closes a topic' + end + + context 'autoclosed' do + let(:status) { 'autoclosed' } + it_should_behave_like 'a status that closes a topic' + end + end @@ -943,4 +953,114 @@ describe Topic do end end + describe 'auto-close' do + context 'a new topic' do + it 'when auto_close_at is not present, it does not queue a job to close the topic' do + Jobs.expects(:enqueue_at).never + Fabricate(:topic) + end + + context 'auto_close_at is set' do + it 'queues a job to close the topic' do + Timecop.freeze(Time.zone.now) do + Jobs.expects(:enqueue_at).with(7.days.from_now, :close_topic, all_of( has_key(:topic_id), has_key(:user_id) )) + Fabricate(:topic, auto_close_at: 7.days.from_now, user: Fabricate(:admin)) + end + end + + it 'when auto_close_user_id is nil, it will use the topic creator as the topic closer' do + topic_creator = Fabricate(:admin) + Jobs.expects(:enqueue_at).with do |datetime, job_name, job_args| + job_args[:user_id] == topic_creator.id + end + Fabricate(:topic, auto_close_at: 7.days.from_now, user: topic_creator) + end + + it 'when auto_close_user_id is set, it will use it as the topic closer' do + topic_creator = Fabricate(:admin) + topic_closer = Fabricate(:user, admin: true) + Jobs.expects(:enqueue_at).with do |datetime, job_name, job_args| + job_args[:user_id] == topic_closer.id + end + Fabricate(:topic, auto_close_at: 7.days.from_now, auto_close_user: topic_closer, user: topic_creator) + end + end + end + + context 'an existing topic' do + it 'when auto_close_at is set, it queues a job to close the topic' do + Timecop.freeze(Time.zone.now) do + topic = Fabricate(:topic) + Jobs.expects(:enqueue_at).with(12.hours.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: topic.user_id)) + topic.auto_close_at = 12.hours.from_now + topic.save.should be_true + end + end + + it 'when auto_close_at and auto_closer_user_id are set, it queues a job to close the topic' do + Timecop.freeze(Time.zone.now) do + topic = Fabricate(:topic) + closer = Fabricate(:admin) + Jobs.expects(:enqueue_at).with(12.hours.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: closer.id)) + topic.auto_close_at = 12.hours.from_now + topic.auto_close_user = closer + topic.save.should be_true + end + end + + it 'when auto_close_at is removed, it cancels the job to close the topic' do + Jobs.stubs(:enqueue_at).returns(true) + topic = Fabricate(:topic, auto_close_at: 1.day.from_now) + Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) + topic.auto_close_at = nil + topic.save.should be_true + topic.auto_close_user.should be_nil + end + + it 'when auto_close_user is removed, it updates the job' do + Timecop.freeze(Time.zone.now) do + Jobs.stubs(:enqueue_at).with(1.day.from_now, :close_topic, anything).returns(true) + topic = Fabricate(:topic, auto_close_at: 1.day.from_now, auto_close_user: Fabricate(:admin)) + Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) + Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: topic.user_id)) + topic.auto_close_user = nil + topic.save.should be_true + end + end + + it 'when auto_close_at value is changed, it reschedules the job' do + Timecop.freeze(Time.zone.now) do + Jobs.stubs(:enqueue_at).returns(true) + topic = Fabricate(:topic, auto_close_at: 1.day.from_now) + Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) + Jobs.expects(:enqueue_at).with(3.days.from_now, :close_topic, has_entry(topic_id: topic.id)) + topic.auto_close_at = 3.days.from_now + topic.save.should be_true + end + end + + it 'when auto_close_user_id is changed, it updates the job' do + Timecop.freeze(Time.zone.now) do + admin = Fabricate(:admin) + Jobs.stubs(:enqueue_at).returns(true) + topic = Fabricate(:topic, auto_close_at: 1.day.from_now) + Jobs.expects(:cancel_scheduled_job).with(:close_topic, {topic_id: topic.id}) + Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_entries(topic_id: topic.id, user_id: admin.id)) + topic.auto_close_user = admin + topic.save.should be_true + end + end + + it 'when auto_close_at and auto_close_user_id are not changed, it should not schedule another CloseTopic job' do + Timecop.freeze(Time.zone.now) do + Jobs.expects(:enqueue_at).with(1.day.from_now, :close_topic, has_key(:topic_id)).once.returns(true) + Jobs.expects(:cancel_scheduled_job).never + topic = Fabricate(:topic, auto_close_at: 1.day.from_now) + topic.title = 'A new title that is long enough' + topic.save.should be_true + end + end + end + end + end