-
Notifications
You must be signed in to change notification settings - Fork 832
AO3-7461 Change send_data to a background job powered workflow #5950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
5cc9b1e
edd5075
d4ffd42
6cc60af
1d8ee6e
8ae0859
5b92d08
cd63056
7cdf698
7ca6efa
2f83b39
1701b83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class GeneratedDownloadsController < ApplicationController | ||
| def show | ||
| @generated_download = GeneratedDownload.find_by!(token: params[:token]) | ||
|
|
||
| if @generated_download.expired? | ||
| @generated_download.file.purge_later if @generated_download.file.attached? | ||
| head :gone | ||
| elsif @generated_download.status == "ready" && @generated_download.file.attached? | ||
| blob = @generated_download.file.blob | ||
| redirect_to blob.service.url( | ||
Check noticeCode scanning / Brakeman Possible unprotected redirect. Note generated
Possible unprotected redirect.
|
||
| blob.key, | ||
| expires_in: ActiveStorage.service_urls_expire_in, | ||
| filename: blob.filename, | ||
| content_type: blob.content_type, | ||
| disposition: :attachment | ||
| ), allow_other_host: true | ||
| elsif @generated_download.status == "failed" | ||
| render :show, status: :unprocessable_content | ||
| else | ||
| render :show, status: :accepted | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,33 @@ | ||
| # frozen_string_literal: true | ||
| module ExportsHelper | ||
|
|
||
| def send_csv_data(content_array, filename) | ||
| send_data(export_csv(content_array), filename: filename, type: :csv) | ||
| module ExportsHelper | ||
| def queue_csv_download(kind:, arguments:, filename:) | ||
| generated_download = GeneratedDownload.create!( | ||
| kind: kind, | ||
| arguments: arguments, | ||
| filename: filename | ||
| ) | ||
| GeneratedDownloadJob.perform_later(generated_download) | ||
| redirect_to generated_download_path(token: generated_download.token), status: :see_other | ||
| end | ||
|
|
||
| # Tab-separated CSV with utf-16le encoding (unicode) and byte order | ||
| # mark. This seems to be the only variant Excel can get | ||
| # automatically into proper table format. OpenOffice handles it | ||
| # well, too. | ||
| def export_csv(content_array) | ||
| csv_data = content_array.map { |x| x.to_csv(col_sep: "\t", encoding: "utf-8") }.join | ||
| byte_order_mark = "\uFEFF" | ||
| (byte_order_mark + csv_data).encode("utf-16le", "utf-8", invalid: :replace, undef: :replace, replace: "") | ||
| io = StringIO.new("".b) | ||
| ExportsHelper.write_csv(io, content_array) | ||
| io.string.force_encoding("utf-16le") | ||
| end | ||
|
|
||
| def self.write_csv(io, rows) | ||
| io.write("\uFEFF".encode("utf-16le")) | ||
| rows.each do |row| | ||
| encoded_row = row.to_csv(col_sep: "\t", encoding: "utf-8").encode( | ||
| "utf-16le", "utf-8", invalid: :replace, undef: :replace, replace: "" | ||
| ) | ||
| io.write(encoded_row) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class GeneratedDownloadJob < ApplicationJob | ||
| queue_as :utilities | ||
|
|
||
| def perform(generated_download) | ||
| return if generated_download.expired? | ||
|
|
||
| generated_download.update!(status: "processing", error: nil) | ||
|
|
||
| case generated_download.kind | ||
| when "work" | ||
| attach_work(generated_download) | ||
| when "challenge_signups", "tag_wrangler", "bulk_user_search" | ||
| attach_csv(generated_download) | ||
| else | ||
| raise ArgumentError, "Unknown generated download kind: #{generated_download.kind}" | ||
| end | ||
|
|
||
| generated_download.update!(status: "ready") | ||
| rescue StandardError => e | ||
| generated_download.update_columns(status: "failed", error: e.message, updated_at: Time.current) | ||
| raise | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def attach_work(generated_download) | ||
| arguments = generated_download.arguments | ||
| download = Download.new( | ||
| Work.find(arguments.fetch("work_id")), | ||
| format: arguments.fetch("format") | ||
| ).generate | ||
| raise "Download generation failed" unless download.exists? | ||
|
|
||
| file_path = File.join(download.dir, File.basename(download.file_path)) | ||
| File.open(file_path, "rb") do |file| | ||
Check noticeCode scanning / Brakeman Model attribute used in file name. Note generated
Model attribute used in file name.
|
||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the path is built inside a per-download temp directory, the filename is sanitized by |
||
| attach_file(generated_download, file, download.mime_type) | ||
| end | ||
| ensure | ||
| download&.remove | ||
| end | ||
|
|
||
| def attach_csv(generated_download) | ||
| tempfile = Tempfile.new(["generated-download", ".csv"]) | ||
| tempfile.binmode | ||
| ExportsHelper.write_csv(tempfile, csv_rows(generated_download)) | ||
| tempfile.rewind | ||
| attach_file(generated_download, tempfile, "text/csv; charset=utf-16le") | ||
| ensure | ||
| tempfile&.close! | ||
| end | ||
|
|
||
| def attach_file(generated_download, io, content_type) | ||
| blob = ActiveStorage::Blob.create_after_unfurling!( | ||
| io: io, | ||
| filename: generated_download.filename, | ||
| content_type: content_type, | ||
| identify: false | ||
| ) | ||
| io.rewind | ||
| blob.service.upload( | ||
| blob.key, | ||
| io, | ||
| checksum: blob.checksum, | ||
| filename: blob.filename, | ||
| content_type: content_type, | ||
| disposition: :attachment | ||
| ) | ||
| generated_download.file.attach(blob) | ||
| rescue StandardError | ||
| blob&.purge | ||
| raise | ||
| end | ||
|
|
||
| def csv_rows(generated_download) | ||
| arguments = generated_download.arguments | ||
| case generated_download.kind | ||
| when "tag_wrangler" | ||
| tag_wrangler_rows(arguments.fetch("user_id")) | ||
| when "bulk_user_search" | ||
| bulk_user_search_rows(arguments.fetch("emails")) | ||
| when "challenge_signups" | ||
| challenge_signup_rows(arguments.fetch("collection_id")) | ||
| end | ||
| end | ||
|
|
||
| def tag_wrangler_rows(user_id) | ||
| wrangler = User.find(user_id) | ||
| rows = [["Name", "Last Updated", "Type", "Merger", "Fandoms", "Unwrangleable"]] | ||
| Tag.where(last_wrangler: wrangler) | ||
| .limit(ArchiveConfig.WRANGLING_REPORT_LIMIT) | ||
| .includes(:merger, :parents) | ||
| .find_each(order: :desc) do |tag| | ||
| fandoms = tag.parents | ||
| .filter_map { |parent| parent.name if parent.is_a?(Fandom) } | ||
| .join(", ") | ||
| rows << [tag.name, tag.updated_at, tag.type, tag.merger&.name || "", fandoms, tag.unwrangleable] | ||
| end | ||
| rows | ||
| end | ||
|
|
||
| def bulk_user_search_rows(emails) | ||
| found_users, not_found_emails = User.search_multiple_by_email(emails) | ||
| [%w[Email Username]] + | ||
| found_users.map { |user| [user.email, user.login] } + | ||
| not_found_emails.map { |email| [email, ""] } | ||
| end | ||
|
|
||
| def challenge_signup_rows(collection_id) | ||
| collection = Collection.find(collection_id) | ||
| controller = ChallengeSignupsController.new | ||
| controller.instance_variable_set(:@collection, collection) | ||
| controller.instance_variable_set(:@challenge, collection.challenge) | ||
| controller.define_singleton_method(:collection_signup_url) do |requested_collection, signup| | ||
| Rails.application.routes.url_helpers.collection_signup_url( | ||
| requested_collection, | ||
| signup, | ||
| host: ArchiveConfig.APP_HOST, | ||
| protocol: "https" | ||
| ) | ||
| end | ||
| controller.send("#{collection.challenge.class.name.underscore}_to_csv") | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class GeneratedDownload < ApplicationRecord | ||
| EXPIRATION = 1.day | ||
| STATUSES = %w[pending processing ready failed].freeze | ||
|
|
||
| has_one_attached :file | ||
|
|
||
| serialize :arguments, coder: JSON | ||
|
|
||
| validates :token, :kind, :filename, :expires_at, presence: true | ||
| validates :token, uniqueness: true | ||
| validates :status, inclusion: { in: STATUSES } | ||
|
|
||
| before_validation :set_defaults, on: :create | ||
|
|
||
| def self.cleanup | ||
| where(expires_at: ...Time.current).find_each do |download| | ||
| download.file.purge if download.file.attached? | ||
| download.destroy! | ||
| end | ||
| end | ||
|
|
||
| def expired? | ||
| expires_at.past? | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def set_defaults | ||
| self.token ||= SecureRandom.urlsafe_base64(32) | ||
| self.expires_at ||= EXPIRATION.from_now | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <% content_for :head do %> | ||
| <% unless @generated_download.status == "failed" %> | ||
| <meta http-equiv="refresh" content="5" /> | ||
| <% end %> | ||
| <% end %> | ||
|
|
||
| <h2 class="heading"><%= t(".heading") %></h2> | ||
|
|
||
| <% if @generated_download.status == "failed" %> | ||
| <p class="error"><%= t(".failed") %></p> | ||
| <% else %> | ||
| <p class="notice"> | ||
| <%= t(".processing") %> | ||
| </p> | ||
| <p><%= link_to t(".check_status"), generated_download_path(token: @generated_download.token) %></p> | ||
| <% end %> |
Uh oh!
There was an error while loading. Please reload this page.