Skip to content

Commit

Permalink
Merge pull request otwcode#2442 from ariana-paris/AO3-4359-Mass-impor…
Browse files Browse the repository at this point in the history
…t-bookmarks

AO3-4359 Mass import bookmarks
  • Loading branch information
sarken committed Jul 10, 2016
2 parents d90f2bf + d631c11 commit af3a2ec
Show file tree
Hide file tree
Showing 11 changed files with 744 additions and 413 deletions.
22 changes: 22 additions & 0 deletions app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ def restrict_access
ApiKey.exists?(access_token: token) && !ApiKey.find_by_access_token(token).banned?
end
end

# Top-level error handling: returns a 403 forbidden if a valid archivist isn't supplied and a 400
# if no works are supplied. If there is neither a valid archivist nor valid works, a 400 is returned
# with both errors as a message
def batch_errors(archivist, import_items)
status = :bad_request
errors = []

unless archivist && archivist.is_archivist?
status = :forbidden
errors << "The 'archivist' field must specify the name of an Archive user with archivist privileges."
end

if import_items.nil? || import_items.empty?
errors << "No items to import were provided."
elsif import_items.size >= ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST
errors << "This request contains too many items to import. A maximum of #{ ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST }" +
"items can be imported at one time by an archivist."
end
status = :ok if errors.empty?
[status, errors]
end
end
end
end
142 changes: 142 additions & 0 deletions app/controllers/api/v1/bookmarks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
class Api::V1::BookmarksController < Api::V1::BaseController
respond_to :json

def create
archivist = User.find_by_login(params[:archivist])
bookmarks = params[:bookmarks]
bookmarks_responses = []
@bookmarks = []

# check for top-level errors (not an archivist, no bookmarks...)
status, messages = batch_errors(archivist, bookmarks)

if status == :ok
# Flag error and successes
@some_errors = @some_success = false

# Process the works, updating the flags
bookmarks.each do |bookmark|
bookmarks_responses << import_bookmark(archivist, bookmark)
end

# set final response code and message depending on the flags
messages = response_message(messages)
end

render status: status, json: { status: status, messages: messages, bookmarks: bookmarks_responses }
end

private

# Set messages based on success and error flags
def response_message(messages)
if @some_success && @some_errors
messages << "At least one bookmark was not created. Please check the individual bookmark results for further information."
elsif !@some_success && @some_errors
messages << "None of the bookmarks were created. Please check the individual bookmark results for further information."
else
messages << "All bookmarks were successfully created."
end
messages
end

# Returns a hash
def import_bookmark(archivist, params)
bookmark_request = bookmark_request(archivist, params)
bookmark_status, bookmark_messages = bookmark_errors(archivist, bookmark_request)
bookmark_url = ""
original_url = ""
@some_errors = true
if bookmark_status == :ok
begin
bookmark = Bookmark.new(bookmark_request)
bookmarkable = bookmark.bookmarkable
if bookmarkable.save && bookmark.save
@bookmarks << bookmark
@some_success = true
@some_errors = false
bookmark_status = :created
bookmark_url = bookmark_url(bookmark)
bookmark_messages << "Successfully created bookmark for \"" + bookmarkable.title + "\"."
else
bookmark_status = :unprocessable_entity
bookmark_messages << bookmarkable.errors.full_messages + bookmark.errors.full_messages
end
rescue => exception
bookmark_status = :unprocessable_entity
bookmark_messages << exception.message
end
original_url = bookmarkable.url if bookmarkable
end

{
status: bookmark_status,
archive_url: bookmark_url,
original_id: params[:id],
original_url: original_url,
messages: bookmark_messages.flatten
}
end

# Handling for requests that are incomplete
def bookmark_errors(archivist, bookmark_request)
status = :bad_request
errors = []

# Perform basic validation which the ExternalWork model doesn't do or returns strange messages for
# (title is validated correctly in the model and so isn't checked here)
external_work = bookmark_request[:external]
url = external_work[:url]
author = external_work[:author]
fandom = external_work[:fandom_string]

if url.nil?
# Unreachable and AO3 URLs are handled in the ExternalWork model
errors << "This bookmark does not contain a URL to an external site. Please specify a valid, non-AO3 URL."
end

if author.nil? || author == ""
errors << "This bookmark does not contain an external author name. Please specify an author."
end

if fandom.nil? || fandom == ""
errors << "This bookmark does not contain a fandom. Please specify a fandom."
end

archivist_bookmarks = Bookmark.find_all_by_pseud_id(archivist.default_pseud.id)

unless archivist_bookmarks.empty?
archivist_bookmarks.each do |bookmark|
if bookmark.bookmarkable_type == "ExternalWork" && ExternalWork.find(bookmark.bookmarkable_id).url == url
errors << "There is already a bookmark for this archivist and the URL #{url}"
end
end
end

status = :ok if errors.empty?
[status, errors]
end

# Map Json request to Bookmark request for external work
def bookmark_request(archivist, params)
{
pseud_id: archivist.default_pseud.id,
external: {
url: params[:url],
author: params[:author],
title: params[:title],
summary: params[:summary],
fandom_string: params[:fandom_string],
rating_string: params[:rating_string],
category_string: params[:category_string].to_s.split(","), # category is actually an array on bookmarks
relationship_string: params[:relationship_string],
character_string: params[:character_string]
},
notes: params[:notes],
tag_string: params[:tag_string],
collection_names: params[:collection_names],
private: params[:private].blank? ? false : params[:private],
rec: params[:recommendation].blank? ? false : params[:recommendation]
}
end
end
179 changes: 0 additions & 179 deletions app/controllers/api/v1/import_controller.rb
Original file line number Diff line number Diff line change
@@ -1,183 +1,4 @@
class Api::V1::ImportController < Api::V1::BaseController
respond_to :json

# Imports multiple works with their meta from external URLs
# Params:
# +params+:: a JSON object containing the following:
# - archivist: username of an existing archivist
# - post_without_preview: false = import as drafts, true = import and post
# - send_claim_emails: false = don't send emails (for testing), true = send emails
# - array of works to import
def create
archivist = User.find_by_login(params[:archivist])
external_works = params[:works]
works_responses = []
@works = []

# check for top-level errors (not an archivist, no works...)
status, messages = batch_errors(archivist, external_works)

if status == :ok
# Flag error and successes
@some_errors = @some_success = false

# Process the works, updating the flags
external_works.each do |external_work|
works_responses << import_work(archivist, external_work.merge(params))
end

# Send claim notification emails if required
if params[:send_claim_emails] && !@works.empty?
send_external_invites(@works, archivist)
end

# set final response code and message depending on the flags
status, messages = response_code(messages)
end
render status: status, json: { status: status, messages: messages, works: works_responses }
end

private

# Set HTTP responses based on success and error flags
def response_code(messages)
if @some_success && @some_errors
status = :multi_status
messages << "At least one work was not imported. Please check the works array for further information."
elsif !@some_success && @some_errors
status = :unprocessable_entity
messages << "None of the works were imported. Please check the works array for further information."
else
status = :created
messages << "All works were successfully imported."
end
[status, messages]
end

# Use the story parser to import works from the chapter URLs,
# and set success or error flag depending on the outcome
# Returns a hash
def import_work(archivist, external_work)
work_status, work_messages = work_errors(external_work)
work_url = ""
original_url = []
work = nil
if work_status == :ok
urls = external_work[:chapter_urls]
original_url = urls.first
storyparser = StoryParser.new
options = options(archivist, external_work)
begin
work = storyparser.download_and_parse_chapters_into_story(urls, options)
work.save
if work.persisted? && work.chapters.all? { |c| c.errors.empty? }
@works << work
@some_success = true
work_status = :created
work_url = work_url(work)
work_messages << "Successfully created work \"" + work.title + "\"."
else
@some_errors = true
work_status = :unprocessable_entity
work_messages << work.errors.messages.values.flatten if work

# Extract work chapter errors and append the chapter number to them for readability
if work.chapters
chapter_errors = work.chapters.map(&:errors).each_with_index
chapter_messages = chapter_errors.map do |e, i|
e.messages.values.flatten.map { |s| s.prepend("Chapter #{i + 1} ") }
end
work_messages << chapter_messages.flatten
end
end
rescue => exception
@some_errors = true
work_status = :unprocessable_entity
work_messages << exception.message.to_json
work_messages << work.errors if work
end
end

{
status: work_status,
url: work_url,
original_url: original_url,
messages: work_messages.flatten
}
end

# Top-level error handling: returns a 403 forbidden if a valid archivist isn't supplied and a 400
# if no works are supplied. If there is neither a valid archivist nor valid works, a 400 is returned
# ith both errors as a message
def batch_errors(archivist, external_works)
status = :bad_request
errors = []

unless archivist && archivist.is_archivist?
status = :forbidden
errors << "The 'archivist' field must specify the name of an Archive user with archivist privileges."
end

if external_works.nil? || external_works.empty?
errors << "No work URLs were provided."
elsif external_works.size >= ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST
errors << "This request contains too many works. A maximum of #{ ArchiveConfig.IMPORT_MAX_WORKS_BY_ARCHIVIST }" +
"works can be imported in one go by an archivist."
end
status = :ok if errors.empty?
[status, errors]
end

# Work-level error handling for requests that are incomplete or too large
def work_errors(work)
status = :bad_request
errors = []
urls = work[:chapter_urls]
if urls.nil?
errors << "This work doesn't contain chapter_urls. Works can only be imported from publicly-accessible URLs."
elsif urls.length >= ArchiveConfig.IMPORT_MAX_CHAPTERS
errors << "This work contains too many chapter URLs. A maximum of #{ ArchiveConfig.IMPORT_MAX_CHAPTERS }" +
"chapters can be imported per work."
end
status = :ok if errors.empty?
[status, errors]
end

# send invitations to external authors for a given set of works
def send_external_invites(works, archivist)
external_authors = works.collect(&:external_authors).flatten.uniq
unless external_authors.empty?
external_authors.each do |external_author|
external_author.find_or_invite(archivist)
end
end
end

def options(archivist, params)
{
archivist: archivist,
import_multiple: "chapters",
importing_for_others: true,
do_not_set_current_author: true,
post_without_preview: params[:post_without_preview].blank? ? true : params[:post_without_preview],
restricted: params[:restricted],
override_tags: params[:override_tags].blank? ? true : params[:override_tags],
collection_names: params[:collection_names],
title: params[:title],
fandom: params[:fandoms],
warning: params[:warnings],
character: params[:characters],
rating: params[:rating],
relationship: params[:relationships],
category: params[:categories],
freeform: params[:additional_tags],
summary: params[:summary],
notes: params[:notes],
encoding: params[:encoding],
external_author_name: params[:external_author_name],
external_author_email: params[:external_author_email],
external_coauthor_name: params[:external_coauthor_name],
external_coauthor_email: params[:external_coauthor_email]
}
end
end
Loading

0 comments on commit af3a2ec

Please sign in to comment.