FEATURE: Upload tags from CSV (#6484)

This commit is contained in:
David Taylor 2018-10-15 09:12:54 +01:00 committed by GitHub
parent 8fa59f0548
commit 7ac08f936e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 137 additions and 2 deletions

View File

@ -0,0 +1,19 @@
import UploadMixin from "discourse/mixins/upload";
export default Em.Component.extend(UploadMixin, {
type: "csv",
uploadUrl: "/tags/upload",
addDisabled: Em.computed.alias("uploading"),
elementId: "tag-uploader",
validateUploadedFilesOptions() {
return { csvOnly: true };
},
uploadDone() {
bootbox.alert(I18n.t("tagging.upload_successful"), () => {
this.sendAction("refresh");
this.sendAction("closeModal");
});
}
});

View File

@ -0,0 +1,6 @@
<label class="btn {{if addDisabled 'disabled'}}">
{{d-icon "upload"}}
{{i18n 'admin.watched_words.form.upload'}}
<input disabled={{addDisabled}} type="file" accept="text/plain,text/csv" style="visibility: hidden; position: absolute;" />
</label>
<span class="instructions">{{i18n 'tagging.upload_instructions'}}</span>

View File

@ -16,6 +16,12 @@ export default DropdownSelectBoxComponent.extend({
name: I18n.t("tagging.manage_groups"), name: I18n.t("tagging.manage_groups"),
description: I18n.t("tagging.manage_groups_description"), description: I18n.t("tagging.manage_groups_description"),
icon: "wrench" icon: "wrench"
},
{
id: "uploadTags",
name: I18n.t("tagging.upload"),
description: I18n.t("tagging.upload_description"),
icon: "upload"
} }
]; ];
@ -23,7 +29,8 @@ export default DropdownSelectBoxComponent.extend({
}, },
actionNames: { actionNames: {
manageGroups: "showTagGroups" manageGroups: "showTagGroups",
uploadTags: "showUploader"
}, },
mutateValue(id) { mutateValue(id) {

View File

@ -1,4 +1,5 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "ember-addons/ember-computed-decorators";
import showModal from "discourse/lib/show-modal";
export default Ember.Controller.extend({ export default Ember.Controller.extend({
sortProperties: ["totalCount:desc", "id"], sortProperties: ["totalCount:desc", "id"],
@ -33,6 +34,10 @@ export default Ember.Controller.extend({
sortedByCount: false, sortedByCount: false,
sortedByName: true sortedByName: true
}); });
},
showUploader() {
showModal("tag-upload");
} }
} }
}); });

View File

@ -41,6 +41,10 @@ export default Discourse.Route.extend({
showTagGroups() { showTagGroups() {
this.transitionTo("tagGroups"); this.transitionTo("tagGroups");
return true; return true;
},
refresh() {
this.refresh();
} }
} }
}); });

View File

@ -0,0 +1,3 @@
{{#d-modal-body title='tagging.upload'}}
{{tags-uploader closeModal=(action "closeModal") refresh=(route-action "refresh")}}
{{/d-modal-body}}

View File

@ -117,6 +117,35 @@ class TagsController < ::ApplicationController
end end
end end
def upload
guardian.ensure_can_admin_tags!
file = params[:file] || params[:files].first
hijack do
begin
Tag.transaction do
CSV.foreach(file.tempfile) do |row|
raise Discourse::InvalidParameters.new(I18n.t("tags.upload_row_too_long")) if row.length > 2
tag_name = DiscourseTagging.clean_tag(row[0])
tag_group_name = row[1] || nil
tag = Tag.find_by_name(tag_name) || Tag.create!(name: tag_name)
if tag_group_name
tag_group = TagGroup.find_by(name: tag_group_name) || TagGroup.create!(name: tag_group_name)
tag.tag_groups << tag_group unless tag.tag_groups.include?(tag_group)
end
end
end
render json: success_json
rescue Discourse::InvalidParameters => e
render json: failed_json.merge(errors: [e.message]), status: 422
end
end
end
def destroy def destroy
guardian.ensure_can_admin_tags! guardian.ensure_can_admin_tags!
tag_name = params[:tag_id] tag_name = params[:tag_id]

View File

@ -2700,7 +2700,10 @@ en:
sort_by_name: "name" sort_by_name: "name"
manage_groups: "Manage Tag Groups" manage_groups: "Manage Tag Groups"
manage_groups_description: "Define groups to organize tags" manage_groups_description: "Define groups to organize tags"
upload: "Upload Tags"
upload_description: "Upload a text file to create tags in bulk"
upload_instructions: "One per line, optionally with a tag group in the format 'tag_name,tag_group'."
upload_successful: "Tags uploaded successfully"
filters: filters:
without_category: "%{filter} %{tag} topics" without_category: "%{filter} %{tag} topics"
with_category: "%{filter} %{tag} topics in %{category}" with_category: "%{filter} %{tag} topics in %{category}"

View File

@ -3903,6 +3903,7 @@ en:
staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff." staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff."
staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff." staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff."
minimum_required_tags: "You must select at least %{count} tags." minimum_required_tags: "You must select at least %{count} tags."
upload_row_too_long: "The CSV file should have one tag per line. Optionally the tag can be followed by a comma, then the tag group name."
rss_by_tag: "Topics tagged %{tag}" rss_by_tag: "Topics tagged %{tag}"
finish_installation: finish_installation:

View File

@ -775,6 +775,7 @@ Discourse::Application.routes.draw do
get '/filter/search' => 'tags#search' get '/filter/search' => 'tags#search'
get '/check' => 'tags#check_hashtag' get '/check' => 'tags#check_hashtag'
get '/personal_messages/:username' => 'tags#personal_messages' get '/personal_messages/:username' => 'tags#personal_messages'
post '/upload' => 'tags#upload'
constraints(tag_id: /[^\/]+?/, format: /json|rss/) do constraints(tag_id: /[^\/]+?/, format: /json|rss/) do
get '/:tag_id.rss' => 'tags#tag_feed' get '/:tag_id.rss' => 'tags#tag_feed'
get '/:tag_id' => 'tags#show', as: 'tag_show' get '/:tag_id' => 'tags#show', as: 'tag_show'

6
spec/fixtures/csv/tags.csv vendored Normal file
View File

@ -0,0 +1,6 @@
tag1
Capitaltag2
spaced tag
tag1
tag3,taggroup1
tag4,taggroup1
1 tag1
2 Capitaltag2
3 spaced tag
4 tag1
5 tag3,taggroup1
6 tag4,taggroup1

5
spec/fixtures/csv/tags_invalid.csv vendored Normal file
View File

@ -0,0 +1,5 @@
tag1
tag2
tag3,taggroup1
tag4,taggroup2
tag5,with,too,many,columns
1 tag1
2 tag2
3 tag3,taggroup1
4 tag4,taggroup2
5 tag5,with,too,many,columns

View File

@ -369,4 +369,50 @@ describe TagsController do
end end
end end
end end
context '#upload_csv' do
it 'requires you to be logged in' do
post "/tags/upload.json"
expect(response.status).to eq(403)
end
context 'while logged in' do
let(:csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/tags.csv") }
let(:invalid_csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/tags_invalid.csv") }
let(:file) do
Rack::Test::UploadedFile.new(File.open(csv_file))
end
let(:invalid_file) do
Rack::Test::UploadedFile.new(File.open(invalid_csv_file))
end
let(:filename) { 'tags.csv' }
it "fails if you can't manage tags" do
sign_in(Fabricate(:user))
post "/tags/upload.json", params: { file: file, name: filename }
expect(response.status).to eq(403)
end
it "allows staff to bulk upload tags" do
sign_in(Fabricate(:moderator))
post "/tags/upload.json", params: { file: file, name: filename }
expect(response.status).to eq(200)
expect(Tag.pluck(:name)).to contain_exactly("tag1", "capitaltag2", "spaced-tag", "tag3", "tag4")
expect(Tag.find_by_name("tag3").tag_groups.pluck(:name)).to contain_exactly("taggroup1")
expect(Tag.find_by_name("tag4").tag_groups.pluck(:name)).to contain_exactly("taggroup1")
end
it "fails gracefully with invalid input" do
sign_in(Fabricate(:moderator))
expect do
post "/tags/upload.json", params: { file: invalid_file, name: filename }
expect(response.status).to eq(422)
end.not_to change { [Tag.count, TagGroup.count] }
end
end
end
end end