From 7ac08f936e24572842bd93c122a794dd9c0ec3d5 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 15 Oct 2018 09:12:54 +0100 Subject: [PATCH] FEATURE: Upload tags from CSV (#6484) --- .../admin/components/tags-uploader.js.es6 | 19 ++++++++ .../templates/components/tags-uploader.hbs | 6 +++ .../components/tags-admin-dropdown.js.es6 | 9 +++- .../discourse/controllers/tags-index.js.es6 | 5 ++ .../discourse/routes/tags-index.js.es6 | 4 ++ .../discourse/templates/modal/tag-upload.hbs | 3 ++ app/controllers/tags_controller.rb | 29 ++++++++++++ config/locales/client.en.yml | 5 +- config/locales/server.en.yml | 1 + config/routes.rb | 1 + spec/fixtures/csv/tags.csv | 6 +++ spec/fixtures/csv/tags_invalid.csv | 5 ++ spec/requests/tags_controller_spec.rb | 46 +++++++++++++++++++ 13 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/admin/components/tags-uploader.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/tags-uploader.hbs create mode 100644 app/assets/javascripts/discourse/templates/modal/tag-upload.hbs create mode 100644 spec/fixtures/csv/tags.csv create mode 100644 spec/fixtures/csv/tags_invalid.csv diff --git a/app/assets/javascripts/admin/components/tags-uploader.js.es6 b/app/assets/javascripts/admin/components/tags-uploader.js.es6 new file mode 100644 index 00000000000..621045b7316 --- /dev/null +++ b/app/assets/javascripts/admin/components/tags-uploader.js.es6 @@ -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"); + }); + } +}); diff --git a/app/assets/javascripts/admin/templates/components/tags-uploader.hbs b/app/assets/javascripts/admin/templates/components/tags-uploader.hbs new file mode 100644 index 00000000000..eca270f3695 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/tags-uploader.hbs @@ -0,0 +1,6 @@ + + {{i18n 'tagging.upload_instructions'}} diff --git a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 index e599f3d9b51..03b5a3c4713 100644 --- a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 +++ b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 @@ -16,6 +16,12 @@ export default DropdownSelectBoxComponent.extend({ name: I18n.t("tagging.manage_groups"), description: I18n.t("tagging.manage_groups_description"), 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: { - manageGroups: "showTagGroups" + manageGroups: "showTagGroups", + uploadTags: "showUploader" }, mutateValue(id) { diff --git a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 index 1baa4315dd3..8f4cf5ba29f 100644 --- a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 @@ -1,4 +1,5 @@ import computed from "ember-addons/ember-computed-decorators"; +import showModal from "discourse/lib/show-modal"; export default Ember.Controller.extend({ sortProperties: ["totalCount:desc", "id"], @@ -33,6 +34,10 @@ export default Ember.Controller.extend({ sortedByCount: false, sortedByName: true }); + }, + + showUploader() { + showModal("tag-upload"); } } }); diff --git a/app/assets/javascripts/discourse/routes/tags-index.js.es6 b/app/assets/javascripts/discourse/routes/tags-index.js.es6 index 1ab8c6fb1c0..c9436e876dc 100644 --- a/app/assets/javascripts/discourse/routes/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/tags-index.js.es6 @@ -41,6 +41,10 @@ export default Discourse.Route.extend({ showTagGroups() { this.transitionTo("tagGroups"); return true; + }, + + refresh() { + this.refresh(); } } }); diff --git a/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs b/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs new file mode 100644 index 00000000000..d79cb8de400 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs @@ -0,0 +1,3 @@ +{{#d-modal-body title='tagging.upload'}} + {{tags-uploader closeModal=(action "closeModal") refresh=(route-action "refresh")}} +{{/d-modal-body}} diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 8c7c854e7c6..a493d9c25ca 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -117,6 +117,35 @@ class TagsController < ::ApplicationController 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 guardian.ensure_can_admin_tags! tag_name = params[:tag_id] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 806b24ad8cf..a02309cc0e4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2700,7 +2700,10 @@ en: sort_by_name: "name" manage_groups: "Manage Tag Groups" 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: without_category: "%{filter} %{tag} topics" with_category: "%{filter} %{tag} topics in %{category}" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2606cee026f..adadf4da713 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -3903,6 +3903,7 @@ en: 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." 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}" finish_installation: diff --git a/config/routes.rb b/config/routes.rb index f4769f67a20..a65c0f02973 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -775,6 +775,7 @@ Discourse::Application.routes.draw do get '/filter/search' => 'tags#search' get '/check' => 'tags#check_hashtag' get '/personal_messages/:username' => 'tags#personal_messages' + post '/upload' => 'tags#upload' constraints(tag_id: /[^\/]+?/, format: /json|rss/) do get '/:tag_id.rss' => 'tags#tag_feed' get '/:tag_id' => 'tags#show', as: 'tag_show' diff --git a/spec/fixtures/csv/tags.csv b/spec/fixtures/csv/tags.csv new file mode 100644 index 00000000000..9d44199dd88 --- /dev/null +++ b/spec/fixtures/csv/tags.csv @@ -0,0 +1,6 @@ +tag1 +Capitaltag2 +spaced tag +tag1 +tag3,taggroup1 +tag4,taggroup1 diff --git a/spec/fixtures/csv/tags_invalid.csv b/spec/fixtures/csv/tags_invalid.csv new file mode 100644 index 00000000000..e1bae581997 --- /dev/null +++ b/spec/fixtures/csv/tags_invalid.csv @@ -0,0 +1,5 @@ +tag1 +tag2 +tag3,taggroup1 +tag4,taggroup2 +tag5,with,too,many,columns diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index 9be7b71022c..c24712daff7 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -369,4 +369,50 @@ describe TagsController do 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