Staff can enter and view deleted topics

This commit is contained in:
Robin Ward 2013-07-11 16:38:46 -04:00
parent eba662b988
commit 19c169540c
24 changed files with 176 additions and 83 deletions

View file

@ -60,6 +60,8 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
return canDelete;
}.property('selectedPostsCount'),
hasError: Ember.computed.or('errorBodyHtml', 'message'),
streamPercentage: function() {
if (!this.get('postStream.loaded')) { return 0; }
if (this.get('postStream.filteredPostsCount') === 0) { return 0; }
@ -248,12 +250,8 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
}.property('isPrivateMessage'),
deleteTopic: function() {
var topicController = this;
this.unsubscribe();
this.get('content').destroy().then(function() {
topicController.set('message', I18n.t('topic.deleted'));
topicController.set('loaded', false);
});
this.get('content').destroy(Discourse.User.current());
},
toggleVisibility: function() {

View file

@ -258,7 +258,7 @@ Discourse.PostStream = Em.Object.extend({
Discourse.URL.set('queryParams', postStream.get('streamFilters'));
}, function(result) {
postStream.errorLoading(result.status);
postStream.errorLoading(result);
});
},
hasLoadedData: Em.computed.and('hasPosts', 'hasStream'),
@ -612,7 +612,8 @@ Discourse.PostStream = Em.Object.extend({
@param {Integer} status the HTTP status code
@param {Discourse.Topic} topic The topic instance we were trying to load
**/
errorLoading: function(status) {
errorLoading: function(result) {
var status = result.status;
var topic = this.get('topic');
topic.set('loadingFilter', false);
@ -621,7 +622,7 @@ Discourse.PostStream = Em.Object.extend({
// If the result was 404 the post is not found
if (status === 404) {
topic.set('errorTitle', I18n.t('topic.not_found.title'));
topic.set('message', I18n.t('topic.not_found.description'));
topic.set('errorBodyHtml', result.responseText);
return;
}

View file

@ -17,6 +17,7 @@ Discourse.Topic = Discourse.Model.extend({
}.property(),
invisible: Em.computed.not('visible'),
deleted: Em.computed.notEmpty('deleted_at'),
canConvertToRegular: function() {
var a = this.get('archetype');
@ -142,13 +143,13 @@ Discourse.Topic = Discourse.Model.extend({
});
},
favoriteTooltipKey: (function() {
favoriteTooltipKey: function() {
return this.get('starred') ? 'favorite.help.unstar' : 'favorite.help.star';
}).property('starred'),
}.property('starred'),
favoriteTooltip: (function() {
favoriteTooltip: function() {
return I18n.t(this.get('favoriteTooltipKey'));
}).property('favoriteTooltipKey'),
}.property('favoriteTooltipKey'),
toggleStar: function() {
var topic = this;
@ -181,22 +182,26 @@ Discourse.Topic = Discourse.Model.extend({
// Reset our read data for this topic
resetRead: function() {
return Discourse.ajax("/t/" + (this.get('id')) + "/timings", {
return Discourse.ajax("/t/" + this.get('id') + "/timings", {
type: 'DELETE'
});
},
// Invite a user to this topic
inviteUser: function(user) {
return Discourse.ajax("/t/" + (this.get('id')) + "/invite", {
return Discourse.ajax("/t/" + this.get('id') + "/invite", {
type: 'POST',
data: { user: user }
});
},
// Delete this topic
destroy: function() {
return Discourse.ajax("/t/" + (this.get('id')), { type: 'DELETE' });
destroy: function(deleted_by) {
this.setProperties({
deleted_at: new Date(),
deleted_by: deleted_by
});
return Discourse.ajax("/t/" + this.get('id'), { type: 'DELETE' });
},
// Update our attributes from a JSON result

View file

@ -107,15 +107,18 @@
</div>
{{else}}
{{#if message}}
{{#if hasError}}
<div class='container'>
<div class='message'>
{{#if errorBodyHtml}}
{{{errorBodyHtml}}}
{{/if}}
<h2>{{message}}</h2>
{{#if message}}
<div class="message">
<h2>{{message}}</h2>
</div>
{{/if}}
<p>
{{#linkTo list.latest}}{{i18n topic.back_to_list}}{{/linkTo}}
</div>
</div>
{{else}}
<div class='container'>

View file

@ -9,7 +9,7 @@
Discourse.ButtonView = Discourse.View.extend({
tagName: 'button',
classNameBindings: [':btn', ':standard', 'dropDownToggle'],
attributeBindings: ['data-not-implemented', 'title', 'data-toggle', 'data-share-url'],
attributeBindings: ['title', 'data-toggle', 'data-share-url'],
title: function() {
return I18n.t(this.get('helpKey') || this.get('textKey'));

View file

@ -7,9 +7,7 @@
@module Discourse
**/
Discourse.DropdownButtonView = Discourse.View.extend({
classNames: ['btn-group'],
attributeBindings: ['data-not-implemented'],
classNameBindings: [':btn-group', 'hidden'],
shouldRerender: Discourse.View.renderIfChanged('text', 'longDescription'),
didInsertElement: function(e) {

View file

@ -8,9 +8,10 @@
**/
Discourse.FavoriteButton = Discourse.ButtonView.extend({
textKey: 'favorite.title',
helpKeyBinding: 'controller.content.favoriteTooltipKey',
helpKeyBinding: 'controller.favoriteTooltipKey',
attributeBindings: ['disabled'],
shouldRerender: Discourse.View.renderIfChanged('controller.content.starred'),
shouldRerender: Discourse.View.renderIfChanged('controller.starred'),
click: function() {
this.get('controller').toggleStar();
@ -18,7 +19,7 @@ Discourse.FavoriteButton = Discourse.ButtonView.extend({
renderIcon: function(buffer) {
buffer.push("<i class='icon-star " +
(this.get('controller.content.starred') ? ' starred' : '') +
(this.get('controller.starred') ? ' starred' : '') +
"'></i>");
}
});

View file

@ -10,7 +10,7 @@ Discourse.InviteReplyButton = Discourse.ButtonView.extend({
textKey: 'topic.invite_reply.title',
helpKey: 'topic.invite_reply.help',
attributeBindings: ['disabled'],
disabled: Em.computed.or('controller.content.archived', 'controller.content.closed'),
disabled: Em.computed.or('controller.archived', 'controller.closed', 'controller.deleted'),
renderIcon: function(buffer) {
buffer.push("<i class='icon icon-group'></i>");

View file

@ -9,6 +9,8 @@
Discourse.NotificationsButton = Discourse.DropdownButtonView.extend({
title: I18n.t('topic.notifications.title'),
longDescriptionBinding: 'topic.details.notificationReasonText',
topic: Em.computed.alias('controller.model'),
hidden: Em.computed.alias('topic.deleted'),
dropDownContent: [
[Discourse.Topic.NotificationLevel.WATCHING, 'topic.notifications.watching'],

View file

@ -10,6 +10,7 @@ Discourse.ShareButton = Discourse.ButtonView.extend({
textKey: 'topic.share.title',
helpKey: 'topic.share.help',
'data-share-url': Em.computed.alias('topic.shareUrl'),
topic: Em.computed.alias('controller.model'),
renderIcon: function(buffer) {
buffer.push("<i class='icon icon-share'></i>");

View file

@ -26,13 +26,13 @@ Discourse.TopicFooterButtonsView = Discourse.ContainerView.extend({
this.attachViewClass(Discourse.InviteReplyButton);
}
this.attachViewClass(Discourse.FavoriteButton);
this.attachViewWithArgs({topic: topic}, Discourse.ShareButton);
this.attachViewClass(Discourse.ShareButton);
this.attachViewClass(Discourse.ClearPinButton);
}
this.attachViewClass(Discourse.ReplyButton);
if (!topic.get('isPrivateMessage')) {
this.attachViewWithArgs({topic: topic}, Discourse.NotificationsButton);
this.attachViewClass(Discourse.NotificationsButton);
}
this.trigger('additionalButtons', this);
} else {

View file

@ -9,15 +9,17 @@
**/
Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
templateName: 'topic',
topicBinding: 'controller.content',
topicBinding: 'controller.model',
userFiltersBinding: 'controller.userFilters',
classNameBindings: ['controller.multiSelect:multi-select', 'topic.archetype', 'topic.category.secure:secure_category'],
classNameBindings: ['controller.multiSelect:multi-select',
'topic.archetype',
'topic.category.secure:secure_category',
'topic.deleted:deleted-topic'],
menuVisible: true,
SHORT_POST: 1200,
postStream: Em.computed.alias('controller.postStream'),
updateBar: function() {
var $topicProgress = $('#topic-progress');
if (!$topicProgress.length) return;
@ -168,7 +170,6 @@ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, {
// Called for every post seen, returns the post number
postSeen: function($post) {
var post = this.getPost($post);
if (post) {

View file

@ -777,7 +777,7 @@
}
}
// Private messages
// Custom Gutter Glyphs
// --------------------------------------------------
.private_message .gutter {
@ -794,6 +794,20 @@
}
.deleted-topic .gutter {
position: relative;
&:before {
display: block;
position: absolute;
top: 0;
left: 0;
color: rgba($black, 0.05);
font: 90px/1 FontAwesome;
content: "\f05c";
}
}
.secure_category .gutter {
position: relative;
&:before {

View file

@ -74,9 +74,9 @@ class ApplicationController < ActionController::Base
def rescue_discourse_actions(message, error)
if request.format && request.format.json?
render status: error, layout: false, text: message
render status: error, layout: false, text: (error == 404) ? build_not_found_page(error) : message
else
render_not_found_page(error)
render text: build_not_found_page(error, 'no_js')
end
end
@ -123,7 +123,6 @@ class ApplicationController < ActionController::Base
@guardian ||= Guardian.new(current_user)
end
def serialize_data(obj, serializer, opts={})
# If it's an array, apply the serializer as an each_serializer to the elements
serializer_opts = {scope: guardian}.merge!(opts)
@ -261,13 +260,13 @@ class ApplicationController < ActionController::Base
redirect_to :login if SiteSetting.login_required? && !current_user
end
def render_not_found_page(status=404)
def build_not_found_page(status=404, layout=false)
@top_viewed = TopicQuery.top_viewed(10)
@recent = TopicQuery.recent(10)
@slug = params[:slug].class == String ? params[:slug] : ''
@slug = (params[:id].class == String ? params[:id] : '') if @slug.blank?
@slug.gsub!('-',' ')
render status: status, layout: 'no_js', formats: [:html], template: '/exceptions/not_found'
render_to_string status: status, layout: layout, formats: [:html], template: '/exceptions/not_found'
end
protected

View file

@ -25,6 +25,7 @@ class TopicsController < ApplicationController
caches_action :avatar, cache_path: Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" }
def show
# We'd like to migrate the wordpress feed to another url. This keeps up backwards compatibility with
# existing installs.
return wordpress if params[:best].present?
@ -33,6 +34,7 @@ class TopicsController < ApplicationController
begin
@topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts)
rescue Discourse::NotFound
Rails.logger.info ">>>> B"
topic = Topic.where(slug: params[:id]).first if params[:id]
raise Discourse::NotFound unless topic
return redirect_to(topic.relative_url)

View file

@ -19,7 +19,8 @@ class TopicViewSerializer < ApplicationSerializer
:has_best_of,
:archetype,
:slug,
:category_id]
:category_id,
:deleted_at]
end
attributes :draft,

View file

@ -1,31 +1,43 @@
<% local_domain = "#{request.protocol}#{request.host_with_port}" %>
<p><%= t 'page_not_found.title' %></p>
<table>
<tr>
<td style="vertical-align:top; padding:0 20px 20px 0;">
<h2><%= t 'page_not_found.popular_topics' %></h2>
<% @top_viewed.each do |t| %>
<%= link_to t.title, t.relative_url %><br/>
<% end %>
<br/>
<a href="/latest" class="btn"><%= t 'page_not_found.see_more' %>&hellip;</a>
</td>
<td style="vertical-align:top; padding:0 0 20px 0;">
<h2><%= t 'page_not_found.recent_topics' %></h2>
<% @recent.each do |t| %>
<%= link_to t.title, t.relative_url %><br/>
<% end %>
<br/>
<a href="/latest" class="btn"><%= t 'page_not_found.see_more' %>&hellip;</a>
</td>
</tr>
</table>
<h2><%= t 'page_not_found.search_title' %></h2>
<p>
<form action='http://google.com'>
<input type="text" name='q' value="site:<%= local_domain %> <%= @slug %>">
<!--<input type="button" class="btn" value="Search Here" onclick="alert('single page search results not implemented yet');" />-->
<input type="submit" class="btn btn-primary" value="<%= t 'page_not_found.search_google' %>" />
</form>
</p>
<div class="row">
<div class="span8">
<h2><%= t 'page_not_found.popular_topics' %></h2>
<% @top_viewed.each do |t| %>
<%= link_to t.title, t.relative_url %><br/>
<% end %>
<br/>
<a href="/latest" class="btn"><%= t 'page_not_found.see_more' %>&hellip;</a>
</div>
<div class="span8">
<h2><%= t 'page_not_found.recent_topics' %></h2>
<% @recent.each do |t| %>
<%= link_to t.title, t.relative_url %><br/>
<% end %>
<br/>
<a href="/latest" class="btn"><%= t 'page_not_found.see_more' %>&hellip;</a>
</div>
</div>
<div class="row">
<div class="span10" style='padding-top: 20px'>
<h2><%= t 'page_not_found.search_title' %></h2>
<p>
<form action='http://google.com' id='google-search' onsubmit="return google_button_clicked()">
<input type="text" id='user-query' value="<%= @slug %>">
<input type='hidden' id='google-query' name="q">
<button class="btn btn-primary"><%= t 'page_not_found.search_google' %></button>
</form>
</p>
</div>
</div>
<script language="Javascript">
function google_button_clicked(e) {
var searchValue = document.getElementById('user-query').value;
document.getElementById('google-query').value = 'site:<%= local_domain %> ' + searchValue;
return true;
}
</script>

View file

@ -1052,7 +1052,7 @@ en:
access_token_problem: "Tell an admin: Please update the site settings to include the correct discourse_org_access_key."
page_not_found:
title: "The page you requested doesn't exist on this discussion forum. Perhaps we can help find it, or another topic like it:"
title: "The page you requested doesn't exist or may have been deleted by a moderator."
popular_topics: "Popular topics"
recent_topics: "Recent topics"
see_more: "See More"

View file

@ -242,7 +242,11 @@ class Guardian
end
def can_create_post_on_topic?(topic)
is_staff? || (not(topic.closed? || topic.archived?) && can_create_post?(topic))
# No users can create posts on deleted topics
return false if topic.trashed?
is_staff? || (not(topic.closed? || topic.archived? || topic.trashed?) && can_create_post?(topic))
end
# Editing Methods
@ -283,7 +287,9 @@ class Guardian
end
def can_delete_topic?(topic)
is_staff? && not(Category.exists?(topic_id: topic.id))
!topic.trashed? &&
is_staff? &&
!(Category.exists?(topic_id: topic.id))
end
def can_delete_post_action?(post_action)

View file

@ -8,17 +8,18 @@ class TopicView
attr_accessor :draft, :draft_key, :draft_sequence
def initialize(topic_id, user=nil, options={})
@user = user
@topic = find_topic(topic_id)
raise Discourse::NotFound if @topic.blank?
@guardian = Guardian.new(user)
@guardian = Guardian.new(@user)
# Special case: If the topic is private and the user isn't logged in, ask them
# to log in!
if @topic.present? && @topic.private_message? && user.blank?
if @topic.present? && @topic.private_message? && @user.blank?
raise Discourse::NotLoggedIn.new
end
guardian.ensure_can_see!(@topic)
@post_number, @page = options[:post_number], options[:page].to_i
@ -36,14 +37,13 @@ class TopicView
@filtered_posts = @filtered_posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames)
end
@user = user
@initial_load = true
@index_reverse = false
filter_posts(options)
@draft_key = @topic.draft_key
@draft_sequence = DraftSequence.current(user, @draft_key)
@draft_sequence = DraftSequence.current(@user, @draft_key)
end
def canonical_path
@ -317,6 +317,8 @@ class TopicView
end
def find_topic(topic_id)
Topic.where(id: topic_id).includes(:category).first
finder = Topic.where(id: topic_id).includes(:category)
finder = finder.with_deleted if @user.try(:staff?)
finder.first
end
end

View file

@ -5,7 +5,6 @@ module Trashable
default_scope where(with_deleted_scope_sql)
# scope unscoped does not work
belongs_to :deleted_by, class_name: 'User'
end
@ -26,6 +25,10 @@ module Trashable
end
end
def trashed?
deleted_at.present?
end
def trash!(trashed_by=nil)
# note, an argument could be made that the column should probably called trashed_at
# however, deleted_at is the terminology used in the UI

View file

@ -318,7 +318,6 @@ describe Guardian do
end
context 'regular users' do
it "doesn't allow new posts from regular users" do
Guardian.new(coding_horror).can_create?(Post, topic).should be_false
end
@ -326,7 +325,6 @@ describe Guardian do
it 'allows editing of posts' do
Guardian.new(coding_horror).can_edit?(post).should be_false
end
end
it "allows new posts from moderators" do
@ -338,6 +336,26 @@ describe Guardian do
end
end
context "trashed topic" do
before do
topic.deleted_at = Time.now
end
it "doesn't allow new posts from regular users" do
Guardian.new(coding_horror).can_create?(Post, topic).should be_false
end
it "doesn't allow new posts from moderators users" do
Guardian.new(moderator).can_create?(Post, topic).should be_false
end
it "doesn't allow new posts from admins" do
Guardian.new(admin).can_create?(Post, topic).should be_false
end
end
end
end

View file

@ -18,6 +18,14 @@ describe TopicView do
lambda { topic_view }.should raise_error(Discourse::InvalidAccess)
end
it "handles deleted topics" do
topic.trash!(coding_horror)
lambda { TopicView.new(topic.id, coding_horror) }.should raise_error(Discourse::NotFound)
coding_horror.stubs(:staff?).returns(true)
lambda { TopicView.new(topic.id, coding_horror) }.should_not raise_error
end
context "with a few sample posts" do
let!(:p1) { Fabricate(:post, topic: topic, user: first_poster, percent_rank: 1 )}
let!(:p2) { Fabricate(:post, topic: topic, user: coding_horror, percent_rank: 0.5 )}

View file

@ -1,5 +1,11 @@
module("Discourse.Topic");
test("defaults", function() {
var topic = Discourse.Topic.create({id: 1234});
blank(topic.get('deleted_at'), 'deleted_at defaults to blank');
blank(topic.get('deleted_by'), 'deleted_by defaults to blank');
});
test('has details', function() {
var topic = Discourse.Topic.create({id: 1234});
var topicDetails = topic.get('details');
@ -36,4 +42,16 @@ test("updateFromJson", function() {
equal(topic.get('details.hello'), 'world', 'it updates the details');
equal(topic.get('cool'), "property", "it updates other properties");
equal(topic.get('category'), category);
});
test("destroy", function() {
var topic = Discourse.Topic.create({id: 1234});
var user = Discourse.User.create({username: 'eviltrout'});
this.stub(Discourse, 'ajax');
topic.destroy(user);
present(topic.get('deleted_at'), 'deleted at is set');
equal(topic.get('deleted_by'), user, 'deleted by is set');
ok(Discourse.ajax.calledOnce, "it called delete over the wire");
});