Merge branch 'master' of github.com:discourse/discourse

This commit is contained in:
Sam 2015-01-05 09:01:54 +11:00
commit 9aba6ab265
43 changed files with 344 additions and 256 deletions

View File

@ -1,8 +1,19 @@
export default Ember.Component.extend({
classNameBindings: ['containerClass'],
layoutName: 'components/conditional-loading-spinner',
containerClass: function() {
return (this.get('size') === 'small') ? 'inline-spinner' : undefined;
}.property('size')
}.property('size'),
render: function(buffer) {
if (this.get('condition')) {
buffer.push('<div class="spinner ' + this.get('size') + '"}}></div>');
} else {
return this._super();
}
},
_conditionChanged: function() {
this.rerender();
}.observes('condition')
});

View File

@ -146,9 +146,9 @@ export default ObjectController.extend(ModalFunctionality, {
}).catch(function(error) {
if (error && error.responseText) {
self.flash($.parseJSON(error.responseText).errors[0]);
self.flash($.parseJSON(error.responseText).errors[0], 'error');
} else {
self.flash(I18n.t('generic_error'));
self.flash(I18n.t('generic_error'), 'error');
}
self.set('saving', false);
});

View File

@ -1,7 +1,8 @@
import ObjectController from 'discourse/controllers/object';
import BufferedContent from 'discourse/mixins/buffered-content';
import { spinnerHTML } from 'discourse/helpers/loading-spinner';
export default ObjectController.extend(Discourse.SelectedPostsCount, {
export default ObjectController.extend(Discourse.SelectedPostsCount, BufferedContent, {
multiSelect: false,
needs: ['header', 'modal', 'composer', 'quote-button', 'search', 'topic-progress', 'application'],
allPostsSelected: false,
@ -235,11 +236,6 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, {
this.set('allPostsSelected', false);
},
/**
Toggle a participant for filtering
@method toggleParticipant
**/
toggleParticipant: function(user) {
this.get('postStream').toggleParticipant(Em.get(user, 'username'));
},
@ -247,17 +243,13 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, {
editTopic: function() {
if (!this.get('details.can_edit')) return false;
this.setProperties({
editingTopic: true,
newTitle: this.get('title'),
newCategoryId: this.get('category_id')
});
this.set('editingTopic', true);
return false;
},
// close editing mode
cancelEditingTopic: function() {
this.set('editingTopic', false);
this.rollbackBuffer();
},
toggleMultiSelect: function() {
@ -265,39 +257,24 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, {
},
finishedEditingTopic: function() {
if (this.get('editingTopic')) {
if (!this.get('editingTopic')) { return; }
var topic = this.get('model');
// save the modifications
var self = this,
props = this.get('buffered.buffer');
// Topic title hasn't been sanitized yet, so the template shouldn't trust it.
this.set('topicSaving', true);
// manually update the titles & category
var backup = topic.setPropertiesBackup({
title: this.get('newTitle'),
category_id: parseInt(this.get('newCategoryId'), 10),
fancy_title: this.get('newTitle')
});
// save the modifications
var self = this;
topic.save().then(function(result){
// update the title if it has been changed (cleaned up) server-side
topic.setProperties(Em.getProperties(result.basic_topic, 'title', 'fancy_title'));
self.set('topicSaving', false);
}, function(error) {
self.setProperties({ editingTopic: true, topicSaving: false });
topic.setProperties(backup);
if (error && error.responseText) {
bootbox.alert($.parseJSON(error.responseText).errors[0]);
} else {
bootbox.alert(I18n.t('generic_error'));
}
});
// close editing mode
Discourse.Topic.update(this.get('model'), props).then(function() {
// Note we roll back on success here because `update` saves
// the properties to the topic.
self.rollbackBuffer();
self.set('editingTopic', false);
}
}).catch(function(error) {
if (error && error.responseText) {
bootbox.alert($.parseJSON(error.responseText).errors[0]);
} else {
bootbox.alert(I18n.t('generic_error'));
}
});
},
toggledSelectedPost: function(post) {

View File

@ -1,5 +1,6 @@
Handlebars.registerHelper('link-domain', function(property, options) {
var link = Em.get(this, property, options);
import registerUnbound from 'discourse/helpers/register-unbound';
registerUnbound('link-domain', function(link) {
if (link) {
var internal = Em.get(link, 'internal'),
hasTitle = (!Em.isEmpty(Em.get(link, 'title')));

View File

@ -1,46 +0,0 @@
// TODO: Make this a proper ES6 import
var ComposerView = require('discourse/views/composer').default;
ComposerView.on("initWmdEditor", function(){
if (!Discourse.SiteSettings.enable_emoji) { return; }
var template = Handlebars.compile(
"<div class='autocomplete'>" +
"<ul>" +
"{{#each options}}" +
"<li>" +
"<a href='#'><img src='{{src}}' class='emoji'> {{code}}</a>" +
"</li>" +
"{{/each}}" +
"</ul>" +
"</div>"
);
$('#wmd-input').autocomplete({
template: template,
key: ":",
transformComplete: function(v){ return v.code + ":"; },
dataSource: function(term){
return new Ember.RSVP.Promise(function(resolve) {
var full = ":" + term;
term = term.toLowerCase();
if (term === "") {
return resolve(["smile", "smiley", "wink", "sunny", "blush"]);
}
if (Discourse.Emoji.translations[full]) {
return resolve([Discourse.Emoji.translations[full]]);
}
var options = Discourse.Emoji.search(term, {maxResults: 5});
return resolve(options);
}).then(function(list) {
return list.map(function(i) {
return {code: i, src: Discourse.Emoji.urlFor(i)};
});
});
}
});
});

View File

@ -58,6 +58,7 @@ Discourse.Category = Discourse.Model.extend({
return Discourse.ajax(url, {
data: {
name: this.get('name'),
slug: this.get('slug'),
color: this.get('color'),
text_color: this.get('text_color'),
secure: this.get('secure'),

View File

@ -463,13 +463,10 @@ Discourse.Composer = Discourse.Model.extend({
// Update the title if we've changed it
if (this.get('title') && post.get('post_number') === 1) {
var topic = this.get('topic');
topic.setProperties({
Discourse.Topic.update(this.get('topic'), {
title: this.get('title'),
fancy_title: Handlebars.Utils.escapeExpression(this.get('title')),
category_id: parseInt(this.get('categoryId'), 10)
category_id: this.get('categoryId')
});
topic.save();
}
post.setProperties({

View File

@ -30,7 +30,7 @@ Discourse.ExportCsv.reopenClass({
@method export_user_list
**/
exportUserList: function() {
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user'}});
return Discourse.ajax("/export_csv/export_entity.json", {data: {entity_type: 'admin', entity: 'user_list'}});
},
/**

View File

@ -1,12 +1,4 @@
Discourse.Model = Ember.Object.extend(Discourse.Presence, {
// Like `setProperties` but returns the original values in case
// we want to roll back
setPropertiesBackup: function(obj) {
var backup = this.getProperties(Ember.keys(obj));
this.setProperties(obj);
return backup;
}
});
Discourse.Model = Ember.Object.extend(Discourse.Presence);
Discourse.Model.reopenClass({
extractByKey: function(collection, klass) {

View File

@ -202,23 +202,6 @@ Discourse.Topic = Discourse.Model.extend({
});
},
// Save any changes we've made to the model
save: function() {
// Don't save unless we can
if (!this.get('details.can_edit')) return;
var data = { title: this.get('title') };
if(this.get('category')){
data.category_id = this.get('category.id');
}
return Discourse.ajax(this.get('url'), {
type: 'PUT',
data: data
});
},
/**
Invite a user to this topic
@ -373,6 +356,29 @@ Discourse.Topic.reopenClass({
}
},
update: function(topic, props) {
props = JSON.parse(JSON.stringify(props)) || {};
// Annoyingly, empty arrays are not sent across the wire. This
// allows us to make a distinction between arrays that were not
// sent and arrays that we specifically want to be empty.
Object.keys(props).forEach(function(k) {
var v = props[k];
if (v instanceof Array && v.length === 0) {
props[k + '_empty_array'] = true;
}
});
return Discourse.ajax(topic.get('url'), { type: 'PUT', data: props }).then(function(result) {
// The title can be cleaned up server side
props.title = result.basic_topic.title;
props.fancy_title = result.basic_topic.fancy_title;
topic.setProperties(props);
});
},
create: function() {
var result = this._super.apply(this, arguments);
this.createActionSummary(result);

View File

@ -1,5 +0,0 @@
{{#if condition}}
<div {{bind-attr class=":spinner size"}}></div>
{{else}}
{{yield}}
{{/if}}

View File

@ -82,7 +82,7 @@
{{#if showAllLinksControls}}
<div class='link-summary'>
<a href='#' {{action "showAllLinks"}}>{{i18n 'topic_map.links_shown' totalLinks=details.links.length}}</a>
<a href {{action "showAllLinks"}}>{{i18n 'topic_map.links_shown' totalLinks=details.links.length}}</a>
</div>
{{/if}}

View File

@ -0,0 +1,9 @@
<div class='autocomplete'>
<ul>
{{#each options}}
<li>
<a href><img src='{{src}}' class='emoji'> {{code}}</a>
</li>
{{/each}}
</ul>
</div>

View File

@ -1,7 +1,13 @@
<form>
<section class='field'>
<label>{{i18n 'category.name'}}</label>
{{text-field value=name placeholderKey="category.name_placeholder" maxlength="50"}}
<section class="field-item">
<label>{{i18n 'category.name'}}</label>
{{text-field value=name placeholderKey="category.name_placeholder" maxlength="50"}}
</section>
<section class="field-item">
<label>{{i18n 'category.slug'}}</label>
{{text-field value=slug placeholderKey="category.slug_placeholder" maxlength="255"}}
</section>
</section>
{{#if canSelectParentCategory}}

View File

@ -16,11 +16,11 @@
{{#if editingTopic}}
{{#if isPrivateMessage}}
<span class="private-message-glyph">{{fa-icon envelope}}</span>
{{autofocus-text-field id='edit-title' value=newTitle maxLength=maxTitleLength}}
{{autofocus-text-field id='edit-title' value=buffered.title maxLength=maxTitleLength}}
{{else}}
{{autofocus-text-field id='edit-title' value=newTitle maxLength=maxTitleLength}}
{{autofocus-text-field id='edit-title' value=buffered.title maxLength=maxTitleLength}}
</br>
{{category-chooser valueAttribute="id" value=newCategoryId source=category_id}}
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}}
{{/if}}
<button class='btn btn-primary btn-small no-text' {{action "finishedEditingTopic"}}>{{fa-icon check}}</button>
@ -34,11 +34,7 @@
{{#if details.loaded}}
{{topic-status topic=model}}
<a href='{{unbound url}}' {{action "jumpTop"}}>
{{#if topicSaving}}
{{fancy_title}}
{{else}}
{{{fancy_title}}}
{{/if}}
{{{fancy_title}}}
</a>
{{/if}}

View File

@ -7,6 +7,7 @@ export default ComboboxView.extend({
overrideWidths: true,
dataAttributes: ['id', 'description_text'],
valueBinding: Ember.Binding.oneWay('source'),
castInteger: true,
content: function() {
var scopedCategoryId = this.get('scopedCategoryId');

View File

@ -63,7 +63,7 @@ export default Discourse.View.extend({
this.rerender();
}.observes('content.@each'),
didInsertElement: function() {
_initializeCombo: function() {
var $elem = this.$(),
self = this;
@ -75,10 +75,15 @@ export default Discourse.View.extend({
$elem.select2({formatResult: this.template, minimumResultsForSearch: 5, width: 'resolve'});
var castInteger = this.get('castInteger');
$elem.on("change", function (e) {
self.set('value', $(e.target).val());
var val = $(e.target).val();
if (val.length && castInteger) {
val = parseInt(val, 10);
}
self.set('value', val);
});
},
}.on('didInsertElement'),
willClearRender: function() {
var elementId = "s2id_" + this.$().attr('id');

View File

@ -163,6 +163,39 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
this.trigger('previewRefreshed', $wmdPreview);
},
_applyEmojiAutocomplete: function() {
if (!this.siteSettings.enable_emoji) { return; }
var template = this.container.lookup('template:emoji-selector-autocomplete.raw');
$('#wmd-input').autocomplete({
template: template,
key: ":",
transformComplete: function(v){ return v.code + ":"; },
dataSource: function(term){
return new Ember.RSVP.Promise(function(resolve) {
var full = ":" + term;
term = term.toLowerCase();
if (term === "") {
return resolve(["smile", "smiley", "wink", "sunny", "blush"]);
}
if (Discourse.Emoji.translations[full]) {
return resolve([Discourse.Emoji.translations[full]]);
}
var options = Discourse.Emoji.search(term, {maxResults: 5});
return resolve(options);
}).then(function(list) {
return list.map(function(i) {
return {code: i, src: Discourse.Emoji.urlFor(i)};
});
});
}
});
},
initEditor: function() {
// not quite right, need a callback to pass in, meaning this gets called once,
// but if you start replying to another topic it will get the avatars wrong
@ -172,6 +205,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, {
$LAB.script(assetPath('defer/html-sanitizer-bundle'));
ComposerView.trigger("initWmdEditor");
this._applyEmojiAutocomplete();
var template = this.container.lookup('template:user-selector-autocomplete.raw');
$wmdInput.data('init', true);

View File

@ -130,6 +130,10 @@
section.field {
margin-bottom: 20px;
}
section.field .field-item {
display: inline-block;
margin-right: 10px;
}
}
.reply-where-modal {

View File

@ -320,12 +320,15 @@ class Admin::UsersController < Admin::AdminController
user.email_tokens.update_all confirmed: true
email_token = user.email_tokens.create(email: user.email)
Jobs.enqueue(:user_email,
unless params[:send_email] == '0' || params[:send_email] == 'false'
Jobs.enqueue( :user_email,
type: :account_created,
user_id: user.id,
email_token: email_token.token)
end
render json: success_json
render json: success_json.merge!(password_url: "#{Discourse.base_url}/users/password-reset/#{email_token.token}")
end

View File

@ -95,6 +95,19 @@ class CategoriesController < ApplicationController
end
end
def update_slug
@category = Category.find(params[:category_id].to_i)
guardian.ensure_can_edit!(@category)
custom_slug = params[:slug].to_s
if custom_slug.present? && @category.update_attributes(slug: custom_slug)
render json: success_json
else
render_json_error(@category)
end
end
def set_notifications
category_id = params[:category_id].to_i
notification_level = params[:notification_level].to_i

View File

@ -15,7 +15,7 @@ class ExportCsvController < ApplicationController
def show
params.require(:id)
filename = params.fetch(:id)
export_id = filename.split('_')[1].split('.')[0]
export_id = filename.split('-')[2].split('.')[0]
export_initiated_by_user_id = 0
export_initiated_by_user_id = UserExport.where(id: export_id)[0].user_id unless UserExport.where(id: export_id).empty?
export_csv_path = UserExport.get_download_path(filename)

View File

@ -2,6 +2,7 @@ require_dependency 'topic_view'
require_dependency 'promotion'
require_dependency 'url_helper'
require_dependency 'topics_bulk_action'
require_dependency 'discourse_event'
class TopicsController < ApplicationController
include UrlHelper
@ -134,6 +135,8 @@ class TopicsController < ApplicationController
success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false)
end
DiscourseEvent.trigger(:topic_saved, topic, params)
# this is used to return the title to the client as it may have been changed by "TextCleaner"
success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
end

View File

@ -24,6 +24,7 @@ module Jobs
def execute(args)
entity = args[:entity]
@file_name = entity
if entity == "user_archive"
@entity_type = "user"
@ -56,19 +57,25 @@ module Jobs
end
end
def user_export
def user_list_export
query = ::AdminUserIndexQuery.new
user_data = query.find_users_query.to_a
user_data.map do |user|
group_names = get_group_names(user).join(';')
user_array = get_user_fields(user)
user_array = get_user_list_fields(user)
user_array.push(group_names) if group_names != ''
user_array
end
end
def staff_action_export
staff_action_data = UserHistory.order('id DESC').to_a
if @current_user.admin?
staff_action_data = UserHistory.only_staff_actions.order('id DESC').to_a
else
# moderator
staff_action_data = UserHistory.where(admin_only: false).only_staff_actions.order('id DESC').to_a
end
staff_action_data.map do |staff_action|
get_staff_action_fields(staff_action)
end
@ -162,7 +169,7 @@ module Jobs
user_archive_array
end
def get_user_fields(user)
def get_user_list_fields(user)
user_array = []
HEADER_ATTRS_FOR['user'].each do |attr|
@ -265,7 +272,8 @@ module Jobs
def set_file_path
@file = UserExport.create(export_type: @entity_type, user_id: @current_user.id)
@file_name = "export_#{@file.id}.csv"
file_name_prefix = @file_name.split('_').join('-')
@file_name = "#{file_name_prefix}-#{@file.id}.csv"
# ensure directory exists
dir = File.dirname("#{UserExport.base_directory}/#{@file_name}")

View File

@ -201,18 +201,19 @@ SQL
end
def ensure_slug
if name.present?
self.name.strip!
return unless name.present?
if slug.present?
# custom slug
errors.add(:slug, "is already in use") if duplicate_slug?
else
# auto slug
self.slug = Slug.for(name)
return if self.slug.blank?
self.slug = '' if duplicate_slug?
end
self.name.strip!
if slug.present?
# santized custom slug
self.slug = Slug.for(slug)
errors.add(:slug, 'is already in use') if duplicate_slug?
else
# auto slug
self.slug = Slug.for(name)
return if self.slug.blank?
self.slug = '' if duplicate_slug?
end
end

View File

@ -20,7 +20,7 @@ class UserHistory < ActiveRecord::Base
:change_site_setting,
:change_site_customization,
:delete_site_customization,
:checked_for_custom_avatar,
:checked_for_custom_avatar, # not used anymore
:notified_about_avatar,
:notified_about_sequential_replies,
:notified_about_dominating_topic,

View File

@ -1,14 +1,13 @@
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="author" content="">
<meta name="generator" content="Discourse <%= Discourse::VERSION::STRING %> - https://github.com/discourse/discourse version <%= Discourse.git_version %>">
<meta name="author" content="">
<meta name="generator" content="Discourse <%= Discourse::VERSION::STRING %> - https://github.com/discourse/discourse version <%= Discourse.git_version %>">
<link rel="icon" type="image/png" href="<%=SiteSetting.favicon_url%>">
<link rel="apple-touch-icon" type="image/png" href="<%=SiteSetting.apple_touch_icon_url%>">
<link rel="icon" type="image/png" href="<%=SiteSetting.favicon_url%>">
<link rel="apple-touch-icon" type="image/png" href="<%=SiteSetting.apple_touch_icon_url%>">
<%= canonical_link_tag %>
<%= canonical_link_tag %>
<%= render partial: "common/special_font_face" %>
<%= render partial: "common/discourse_stylesheet" %>
<%= render partial: "common/special_font_face" %>
<%= render partial: "common/discourse_stylesheet" %>
<%= discourse_csrf_tags %>
<%= discourse_csrf_tags %>

View File

@ -7,11 +7,11 @@
<%= render partial: "layouts/head" %>
<%- if SiteSetting.enable_escaped_fragments? %>
<meta name="fragment" content="!">
<meta name="fragment" content="!">
<%- end %>
<%- if shared_session_key %>
<meta name="shared_session_key" content="<%= shared_session_key %>">
<meta name="shared_session_key" content="<%= shared_session_key %>">
<%- end %>
<%= script "preload_store" %>

View File

@ -20,13 +20,13 @@
<% if @rss %>
<% content_for :head do %>
<%= auto_discovery_link_tag(:rss, {action: "#{@rss}_feed"}, title: I18n.t("rss_description.#{@rss}")) %>
<%= auto_discovery_link_tag(:rss, { action: "#{@rss}_feed" }, title: I18n.t("rss_description.#{@rss}")) %>
<% end %>
<% end %>
<% if @category %>
<% content_for :head do %>
<%= auto_discovery_link_tag(@category, {action: :category_feed, format: :rss}, title: t('rss_topics_in_category', category: @category.name), type: 'application/rss+xml') %>
<%= auto_discovery_link_tag(:rss, { action: :category_feed }, title: t('rss_topics_in_category', category: @category.name)) %>
<% end %>
<% end %>

View File

@ -1281,6 +1281,8 @@ en:
delete: 'Delete Category'
create: 'New Category'
save: 'Save Category'
slug: 'Category Slug'
slug_placeholder: '(Optional) dashed-words for url'
creation_error: There has been an error during the creation of the category.
save_error: There was an error saving the category.
name: "Category Name"

View File

@ -598,14 +598,14 @@ en:
s3_backup_config_warning: 'The server is configured to upload backups to s3, but at least one the following setting is not set: s3_access_key_id, s3_secret_access_key or s3_backup_bucket. Go to <a href="/admin/site_settings">the Site Settings</a> and update the settings. <a href="http://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">See "How to set up image uploads to S3?" to learn more</a>.'
image_magick_warning: 'The server is configured to create thumbnails of large images, but ImageMagick is not installed. Install ImageMagick using your favorite package manager or <a href="http://www.imagemagick.org/script/binary-releases.php" target="_blank">download the latest release</a>.'
failing_emails_warning: 'There are %{num_failed_jobs} email jobs that failed. Check your config/discourse.conf file and ensure that the mail server settings are correct. <a href="/sidekiq/retries" target="_blank">See the failed jobs in Sidekiq</a>.'
default_logo_warning: "You haven't customized the logo images for your site. Update logo_url, logo_small_url, and favicon_url in the <a href='/admin/site_settings'>Site Settings</a>."
contact_email_missing: "You haven't provided a contact email for your site. Please update contact_email in the <a href='/admin/site_settings'>Site Settings</a>."
contact_email_invalid: "The site contact email is invalid. Please update contact_email in the <a href='/admin/site_settings'>Site Settings</a>."
title_nag: "The title Site Setting is still set to the default value. Please update it with your site's title in the <a href='/admin/site_settings'>Site Settings</a>."
site_description_missing: "The site_description setting is blank. Write a brief description of this forum in the <a href='/admin/site_settings'>Site Settings</a>."
default_logo_warning: "Set the graphic logos for your site. Update logo_url, logo_small_url, and favicon_url in <a href='/admin/site_settings'>Site Settings</a>."
contact_email_missing: "Enter a site contact email address so new users or users who can't log in, as well as other webmasters and system administrators, can reach you for urgent matters. Update it in <a href='/admin/site_settings'>Site Settings</a>."
contact_email_invalid: "The site contact email is invalid. Update it in <a href='/admin/site_settings'>Site Settings</a>."
title_nag: "Enter the name of your site. Update title in <a href='/admin/site_settings'>Site Settings</a>."
site_description_missing: "Enter a one sentence description of your site that will appear in search results. Update site_description in <a href='/admin/site_settings'>Site Settings</a>."
consumer_email_warning: "Your site is configured to use Gmail (or another consumer email service) to send email. <a href='http://support.google.com/a/bin/answer.py?hl=en&answer=166852' target='_blank'>Gmail limits how many emails you can send</a>. Consider using an email service provider like mandrill.com to ensure email deliverability."
access_password_removal: "Your site was using the access_password setting, which has been removed. The login_required and must_approve_users settings have been enabled, which should be used instead. You can change them in the <a href='/admin/site_settings'>Site Settings</a>. Be sure to <a href='/admin/users/list/pending'>approve users in the Pending Users list</a>. (This message will go away after 2 days.)"
site_contact_username_warning: "The site_contact_username setting is blank. Please update it in the <a href='/admin/site_settings'>Site Settings</a>. Set it to the username of an admin user who should be the sender of system messages."
site_contact_username_warning: "Enter the name of a friendly staff user account to send important automated private messages from, such as the new user welcome, flag warnings, etc. Update site_contact_username in <a href='/admin/site_settings'>Site Settings</a>."
notification_email_warning: "The notification_email setting is blank. Please update it in the <a href='/admin/site_settings'>Site Settings</a>."
content_types:
@ -657,9 +657,9 @@ en:
allow_duplicate_topic_titles: "Allow topics with identical, duplicate titles."
unique_posts_mins: "How many minutes before a user can make a post with the same content again"
educate_until_posts: "When the user starts typing their first (n) new posts, show the pop-up new user education panel in the composer."
title: "Brief title of this site, used in the title tag."
site_description: "Describe this site in one sentence, used in the meta description tag."
contact_email: "Email address of key contact for site. Important notices from discourse.org regarding critical updates may be sent to this address."
title: "The name of this site, as used in the title tag."
site_description: "Describe this site in one sentence, as used in the meta description tag."
contact_email: "Email address of key contact responsible for this site. Used for critical notifications only, as well as on the /about contact form for urgent matters."
queue_jobs: "DEVELOPER ONLY! WARNING! By default, queue jobs in sidekiq. If disabled, your site will be broken."
crawl_images: "Retrieve images from remote URLs to insert the correct width and height dimensions."
download_remote_images_to_local: "Convert remote images to local images by downloading them; this prevents broken images."
@ -737,7 +737,7 @@ en:
post_menu_hidden_items: "The menu items to hide by default in the post menu unless an expansion ellipsis is clicked on."
share_links: "Determine which items appear on the share dialog, and in what order."
track_external_right_clicks: "Track external links that are right clicked (eg: open in new tab) disabled by default because it rewrites URLs"
site_contact_username: "All automated private messages will be from this user; if left blank the default System account will be used."
site_contact_username: "A valid staff username to send all automated private messages from. If left blank the default System account will be used."
send_welcome_message: "Send all new users a welcome private message with a quick start guide."
suppress_reply_directly_below: "Don't show the expandable reply count on a post when there is only a single reply directly below this post."
suppress_reply_directly_above: "Don't show the expandable in-reply-to on a post when there is only a single reply directly above this post."

View File

@ -325,6 +325,7 @@ Discourse::Application.routes.draw do
post "category/uploads" => "categories#upload"
post "category/:category_id/move" => "categories#move"
post "category/:category_id/notifications" => "categories#set_notifications"
put "category/:category_id/slug" => "categories#update_slug"
get "c/:id/show" => "categories#show"
get "c/:category.rss" => "list#category_feed", format: :rss

View File

@ -0,0 +1,10 @@
class CleanUpUserHistory < ActiveRecord::Migration
def up
# 'checked_for_custom_avatar' is not used anymore
# was removed in https://github.com/discourse/discourse/commit/6c1c8be79433f87bef9d768da7b8fa4ec9bb18d7
UserHistory.where(action: UserHistory.actions[:checked_for_custom_avatar]).delete_all
end
def down
end
end

View File

@ -26,9 +26,11 @@ class ComposerMessagesFinder
if count < SiteSetting.educate_until_posts
education_posts_text = I18n.t('education.until_posts', count: SiteSetting.educate_until_posts)
return {templateName: 'composer/education',
wait_for_typing: true,
body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text)) }
return {
templateName: 'composer/education',
wait_for_typing: true,
body: PrettyText.cook(SiteText.text_for(education_key, education_posts_text: education_posts_text))
}
end
nil
@ -37,7 +39,11 @@ class ComposerMessagesFinder
# New users have a limited number of replies in a topic
def check_new_user_many_replies
return unless replying? && @user.posted_too_much_in_topic?(@details[:topic_id])
{templateName: 'composer/education', body: PrettyText.cook(I18n.t('education.too_many_replies', newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic)) }
{
templateName: 'composer/education',
body: PrettyText.cook(I18n.t('education.too_many_replies', newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic))
}
end
# Should a user be contacted to update their avatar?
@ -49,14 +55,14 @@ class ComposerMessagesFinder
# We don't notify users who have avatars or who have been notified already.
return if @user.uploaded_avatar_id || UserHistory.exists_for_user?(@user, :notified_about_avatar)
# Finally, we don't check users whose avatars haven't been examined
return unless UserHistory.exists_for_user?(@user, :checked_for_custom_avatar)
# If we got this far, log that we've nagged them about the avatar
UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: @user.id )
# Return the message
{templateName: 'composer/education', body: PrettyText.cook(I18n.t('education.avatar', profile_path: "/users/#{@user.username_lower}")) }
{
templateName: 'composer/education',
body: PrettyText.cook(I18n.t('education.avatar', profile_path: "/users/#{@user.username_lower}"))
}
end
# Is a user replying too much in succession?
@ -87,10 +93,12 @@ class ComposerMessagesFinder
target_user_id: @user.id,
topic_id: @details[:topic_id] )
{templateName: 'composer/education',
wait_for_typing: true,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.sequential_replies')) }
{
templateName: 'composer/education',
wait_for_typing: true,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.sequential_replies'))
}
end
def check_dominating_topic
@ -102,6 +110,7 @@ class ComposerMessagesFinder
!UserHistory.exists_for_user?(@user, :notified_about_dominating_topic, topic_id: @details[:topic_id])
topic = Topic.find_by(id: @details[:topic_id])
return if topic.blank? ||
topic.user_id == @user.id ||
topic.posts_count < SiteSetting.summary_posts_required ||
@ -117,11 +126,12 @@ class ComposerMessagesFinder
target_user_id: @user.id,
topic_id: @details[:topic_id])
{templateName: 'composer/education',
wait_for_typing: true,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.dominating_topic', percent: (ratio * 100).round)) }
{
templateName: 'composer/education',
wait_for_typing: true,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.dominating_topic', percent: (ratio * 100).round))
}
end
def check_reviving_old_topic
@ -136,20 +146,22 @@ class ComposerMessagesFinder
topic.last_posted_at.nil? ||
topic.last_posted_at > SiteSetting.warn_reviving_old_topic_age.days.ago
{templateName: 'composer/education',
wait_for_typing: false,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.reviving_old_topic', days: (Time.zone.now - topic.last_posted_at).round / 1.day)) }
{
templateName: 'composer/education',
wait_for_typing: false,
extraClass: 'urgent',
body: PrettyText.cook(I18n.t('education.reviving_old_topic', days: (Time.zone.now - topic.last_posted_at).round / 1.day))
}
end
private
def creating_topic?
return @details[:composerAction] == "createTopic"
@details[:composerAction] == "createTopic"
end
def replying?
return @details[:composerAction] == "reply"
@details[:composerAction] == "reply"
end
end

View File

@ -83,44 +83,31 @@ describe ComposerMessagesFinder do
let(:finder) { ComposerMessagesFinder.new(user, composerAction: 'createTopic') }
let(:user) { Fabricate(:user) }
context "a user who we haven't checked for an avatar yet" do
it "returns no avatar message" do
finder.check_avatar_notification.should be_blank
context "success" do
let!(:message) { finder.check_avatar_notification }
it "returns an avatar upgrade message" do
message.should be_present
end
it "creates a notified_about_avatar log" do
UserHistory.exists_for_user?(user, :notified_about_avatar).should == true
end
end
context "a user who has been checked for a custom avatar" do
before do
UserHistory.create!(action: UserHistory.actions[:checked_for_custom_avatar], target_user_id: user.id )
end
it "doesn't return notifications for new users" do
user.trust_level = TrustLevel[0]
finder.check_avatar_notification.should be_blank
end
context "success" do
let!(:message) { finder.check_avatar_notification }
it "returns an avatar upgrade message" do
message.should be_present
end
it "creates a notified_about_avatar log" do
UserHistory.exists_for_user?(user, :notified_about_avatar).should == true
end
end
it "doesn't return notifications for new users" do
user.trust_level = TrustLevel[0]
finder.check_avatar_notification.should be_blank
end
it "doesn't return notifications for users who have custom avatars" do
user.uploaded_avatar_id = 1
finder.check_avatar_notification.should be_blank
end
it "doesn't notify users who have been notified already" do
UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: user.id )
finder.check_avatar_notification.should be_blank
end
it "doesn't return notifications for users who have custom avatars" do
user.uploaded_avatar_id = 1
finder.check_avatar_notification.should be_blank
end
it "doesn't notify users who have been notified already" do
UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: user.id )
finder.check_avatar_notification.should be_blank
end
end

View File

@ -111,10 +111,13 @@ describe HasCustomFields do
db_item = CustomFieldsTestItem.find(test_item.id)
db_item.custom_fields.should == {"a" => ["b", "c", "d"]}
db_item.custom_fields["a"] = ["c", "d"]
db_item.custom_fields.update('a' => ['c', 'd'])
db_item.save
db_item.custom_fields.should == {"a" => ["c", "d"]}
db_item.custom_fields.delete('a')
db_item.custom_fields.should == {}
end
it "casts integers in arrays properly without error" do

View File

@ -460,6 +460,7 @@ describe Admin::UsersController do
context ".invite_admin" do
it 'should invite admin' do
Jobs.expects(:enqueue).with(:user_email, anything).returns(true)
xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com'
response.should be_success
@ -468,6 +469,14 @@ describe Admin::UsersController do
u.username.should == "bill22"
u.admin.should == true
end
it "doesn't send the email with send_email falsy" do
Jobs.expects(:enqueue).with(:user_email, anything).never
xhr :post, :invite_admin, name: 'Bill', username: 'bill22', email: 'bill@bill.com', send_email: '0'
response.should be_success
json = ::JSON.parse(response.body)
json["password_url"].should be_present
end
end
end

View File

@ -203,4 +203,42 @@ describe CategoriesController do
end
describe 'update_slug' do
it 'requires the user to be logged in' do
lambda { xhr :put, :update_slug, category_id: 'category'}.should raise_error(Discourse::NotLoggedIn)
end
describe 'logged in' do
let(:valid_attrs) { {id: @category.id, slug: 'fff'} }
before do
@user = log_in(:admin)
@category = Fabricate(:happy_category, user: @user)
end
it 'rejects blank' do
xhr :put, :update_slug, category_id: @category.id, slug: nil
response.status.should == 422
end
it 'accepts valid custom slug' do
xhr :put, :update_slug, category_id: @category.id, slug: 'valid-slug'
response.should be_success
category = Category.find(@category.id)
category.slug.should == 'valid-slug'
end
it 'accepts not well formed custom slug' do
xhr :put, :update_slug, category_id: @category.id, slug: ' valid slug'
response.should be_success
category = Category.find(@category.id)
category.slug.should == 'valid-slug'
end
it 'rejects invalid custom slug' do
xhr :put, :update_slug, category_id: @category.id, slug: ' '
response.status.should == 422
end
end
end
end

View File

@ -1,7 +1,7 @@
require "spec_helper"
describe ExportCsvController do
let(:export_filename) { "export_999.csv" }
let(:export_filename) { "user-archive-999.csv" }
context "while logged in as normal user" do
@ -30,7 +30,7 @@ describe ExportCsvController do
describe ".download" do
it "uses send_file to transmit the export file" do
file = UserExport.create(export_type: "user", user_id: @user.id)
file_name = "export_#{file.id}.csv"
file_name = "user-archive-#{file.id}.csv"
controller.stubs(:render)
export = UserExport.new()
UserExport.expects(:get_download_path).with(file_name).returns(export)
@ -74,7 +74,7 @@ describe ExportCsvController do
describe ".download" do
it "uses send_file to transmit the export file" do
file = UserExport.create(export_type: "admin", user_id: @admin.id)
file_name = "export_#{file.id}.csv"
file_name = "screened-email-#{file.id}.csv"
controller.stubs(:render)
export = UserExport.new()
UserExport.expects(:get_download_path).with(file_name).returns(export)

View File

@ -7,3 +7,9 @@ Fabricator(:diff_category, from: :category) do
name "Different Category"
user
end
Fabricator(:happy_category, from: :category) do
name 'Happy Category'
slug 'happy'
user
end

View File

@ -8,16 +8,16 @@ describe Jobs::ExportCsvFile do
end
end
let :user_header do
let :user_list_header do
Jobs::ExportCsvFile.new.get_header('user')
end
let :user_export do
Jobs::ExportCsvFile.new.user_export
let :user_list_export do
Jobs::ExportCsvFile.new.user_list_export
end
def to_hash(row)
Hash[*user_header.zip(row).flatten]
Hash[*user_list_header.zip(row).flatten]
end
it 'exports sso data' do
@ -25,10 +25,9 @@ describe Jobs::ExportCsvFile do
user = Fabricate(:user)
user.create_single_sign_on_record(external_id: "123", last_payload: "xxx", external_email: 'test@test.com')
user = to_hash(user_export.find{|u| u[0] == user.id})
user = to_hash(user_list_export.find{|u| u[0] == user.id})
user["external_id"].should == "123"
user["external_email"].should == "test@test.com"
end
end

View File

@ -198,6 +198,11 @@ describe Category do
c.slug.should eq("cats-category")
end
it 'and be sanitized' do
c = Fabricate(:category, name: 'Cats', slug: ' invalid slug')
c.slug.should == 'invalid-slug'
end
it 'fails if custom slug is duplicate with existing' do
c1 = Fabricate(:category, name: "Cats", slug: "cats")
c2 = Fabricate.build(:category, name: "More Cats", slug: "cats")

View File

@ -28,8 +28,8 @@ test("editingMode", function() {
topicController.set('model.details.can_edit', true);
topicController.send('editTopic');
ok(topicController.get('editingTopic'), "calling editTopic enables editing if the user can edit");
equal(topicController.get('newTitle'), topic.get('title'));
equal(topicController.get('newCategoryId'), topic.get('category_id'));
equal(topicController.get('buffered.title'), topic.get('title'));
equal(topicController.get('buffered.category_id'), topic.get('category_id'));
topicController.send('cancelEditingTopic');
ok(!topicController.get('editingTopic'), "cancelling edit mode reverts the property value");