diff --git a/app/controllers/family_exports_controller.rb b/app/controllers/family_exports_controller.rb index ab3b4c76..992d68b3 100644 --- a/app/controllers/family_exports_controller.rb +++ b/app/controllers/family_exports_controller.rb @@ -1,8 +1,8 @@ class FamilyExportsController < ApplicationController include StreamExtensions - + before_action :require_admin - before_action :set_export, only: [:download] + before_action :set_export, only: [ :download ] def new # Modal view for initiating export @@ -11,10 +11,10 @@ class FamilyExportsController < ApplicationController def create @export = Current.family.family_exports.create! FamilyDataExportJob.perform_later(@export) - + respond_to do |format| format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." } - format.turbo_stream { + format.turbo_stream { stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." } end @@ -24,7 +24,7 @@ class FamilyExportsController < ApplicationController @exports = Current.family.family_exports.ordered.limit(10) render layout: false # For turbo frame end - + def download if @export.downloadable? redirect_to @export.export_file, allow_other_host: true @@ -34,14 +34,14 @@ class FamilyExportsController < ApplicationController end private - - def set_export - @export = Current.family.family_exports.find(params[:id]) - end - def require_admin - unless Current.user.admin? - redirect_to root_path, alert: "Access denied" + def set_export + @export = Current.family.family_exports.find(params[:id]) end - end -end \ No newline at end of file + + def require_admin + unless Current.user.admin? + redirect_to root_path, alert: "Access denied" + end + end +end diff --git a/app/jobs/family_data_export_job.rb b/app/jobs/family_data_export_job.rb index 3496aba8..1b62bd66 100644 --- a/app/jobs/family_data_export_job.rb +++ b/app/jobs/family_data_export_job.rb @@ -3,20 +3,20 @@ class FamilyDataExportJob < ApplicationJob def perform(family_export) family_export.update!(status: :processing) - + exporter = Family::DataExporter.new(family_export.family) zip_file = exporter.generate_export - + family_export.export_file.attach( io: zip_file, filename: family_export.filename, content_type: "application/zip" ) - + family_export.update!(status: :completed) rescue => e Rails.logger.error "Family export failed: #{e.message}" Rails.logger.error e.backtrace.join("\n") family_export.update!(status: :failed) end -end \ No newline at end of file +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index aba036d9..93546cd6 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -1,238 +1,238 @@ -require 'zip' -require 'csv' +require "zip" +require "csv" class Family::DataExporter def initialize(family) @family = family end - + def generate_export # Create a StringIO to hold the zip data in memory zip_data = Zip::OutputStream.write_buffer do |zipfile| # Add accounts.csv zipfile.put_next_entry("accounts.csv") zipfile.write generate_accounts_csv - + # Add transactions.csv zipfile.put_next_entry("transactions.csv") zipfile.write generate_transactions_csv - + # Add trades.csv zipfile.put_next_entry("trades.csv") zipfile.write generate_trades_csv - + # Add categories.csv zipfile.put_next_entry("categories.csv") zipfile.write generate_categories_csv - + # Add all.ndjson zipfile.put_next_entry("all.ndjson") zipfile.write generate_ndjson end - + # Rewind and return the StringIO zip_data.rewind zip_data end - + private - - def generate_accounts_csv - CSV.generate do |csv| - csv << ["id", "name", "type", "subtype", "balance", "currency", "created_at"] - - # Only export accounts belonging to this family + + def generate_accounts_csv + CSV.generate do |csv| + csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ] + + # Only export accounts belonging to this family + @family.accounts.includes(:accountable).find_each do |account| + csv << [ + account.id, + account.name, + account.accountable_type, + account.subtype, + account.balance.to_s, + account.currency, + account.created_at.iso8601 + ] + end + end + end + + def generate_transactions_csv + CSV.generate do |csv| + csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ] + + # Only export transactions from accounts belonging to this family + @family.transactions + .includes(:category, :tags, entry: :account) + .find_each do |transaction| + csv << [ + transaction.entry.date.iso8601, + transaction.entry.account.name, + transaction.entry.amount.to_s, + transaction.entry.name, + transaction.category&.name, + transaction.tags.pluck(:name).join(","), + transaction.entry.notes, + transaction.entry.currency + ] + end + end + end + + def generate_trades_csv + CSV.generate do |csv| + csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ] + + # Only export trades from accounts belonging to this family + @family.trades + .includes(:security, entry: :account) + .find_each do |trade| + csv << [ + trade.entry.date.iso8601, + trade.entry.account.name, + trade.security.ticker, + trade.qty.to_s, + trade.price.to_s, + trade.entry.amount.to_s, + trade.currency + ] + end + end + end + + def generate_categories_csv + CSV.generate do |csv| + csv << [ "name", "color", "parent_category", "classification" ] + + # Only export categories belonging to this family + @family.categories.includes(:parent).find_each do |category| + csv << [ + category.name, + category.color, + category.parent&.name, + category.classification + ] + end + end + end + + def generate_ndjson + lines = [] + + # Export accounts with full accountable data @family.accounts.includes(:accountable).find_each do |account| - csv << [ - account.id, - account.name, - account.accountable_type, - account.subtype, - account.balance.to_s, - account.currency, - account.created_at.iso8601 - ] + lines << { + type: "Account", + data: account.as_json( + include: { + accountable: {} + } + ) + }.to_json end - end - end - - def generate_transactions_csv - CSV.generate do |csv| - csv << ["date", "account_name", "amount", "name", "category", "tags", "notes", "currency"] - - # Only export transactions from accounts belonging to this family - @family.transactions - .includes(:category, :tags, entry: :account) - .find_each do |transaction| - csv << [ - transaction.entry.date.iso8601, - transaction.entry.account.name, - transaction.entry.amount.to_s, - transaction.entry.name, - transaction.category&.name, - transaction.tags.pluck(:name).join(","), - transaction.entry.notes, - transaction.entry.currency - ] - end - end - end - - def generate_trades_csv - CSV.generate do |csv| - csv << ["date", "account_name", "ticker", "quantity", "price", "amount", "currency"] - - # Only export trades from accounts belonging to this family - @family.trades - .includes(:security, entry: :account) - .find_each do |trade| - csv << [ - trade.entry.date.iso8601, - trade.entry.account.name, - trade.security.ticker, - trade.qty.to_s, - trade.price.to_s, - trade.entry.amount.to_s, - trade.currency - ] - end - end - end - - def generate_categories_csv - CSV.generate do |csv| - csv << ["name", "color", "parent_category", "classification"] - - # Only export categories belonging to this family - @family.categories.includes(:parent).find_each do |category| - csv << [ - category.name, - category.color, - category.parent&.name, - category.classification - ] + + # Export categories + @family.categories.find_each do |category| + lines << { + type: "Category", + data: category.as_json + }.to_json end - end - end - - def generate_ndjson - lines = [] - - # Export accounts with full accountable data - @family.accounts.includes(:accountable).find_each do |account| - lines << { - type: "Account", - data: account.as_json( - include: { - accountable: {} + + # Export tags + @family.tags.find_each do |tag| + lines << { + type: "Tag", + data: tag.as_json + }.to_json + end + + # Export merchants (only family merchants) + @family.merchants.find_each do |merchant| + lines << { + type: "Merchant", + data: merchant.as_json + }.to_json + end + + # Export transactions with full data + @family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| + lines << { + type: "Transaction", + data: { + id: transaction.id, + entry_id: transaction.entry.id, + account_id: transaction.entry.account_id, + date: transaction.entry.date, + amount: transaction.entry.amount, + currency: transaction.entry.currency, + name: transaction.entry.name, + notes: transaction.entry.notes, + excluded: transaction.entry.excluded, + category_id: transaction.category_id, + merchant_id: transaction.merchant_id, + tag_ids: transaction.tag_ids, + kind: transaction.kind, + created_at: transaction.created_at, + updated_at: transaction.updated_at } - ) - }.to_json + }.to_json + end + + # Export trades with full data + @family.trades.includes(:security, entry: :account).find_each do |trade| + lines << { + type: "Trade", + data: { + id: trade.id, + entry_id: trade.entry.id, + account_id: trade.entry.account_id, + security_id: trade.security_id, + ticker: trade.security.ticker, + date: trade.entry.date, + qty: trade.qty, + price: trade.price, + amount: trade.entry.amount, + currency: trade.currency, + created_at: trade.created_at, + updated_at: trade.updated_at + } + }.to_json + end + + # Export valuations + @family.entries.valuations.includes(:account, :entryable).find_each do |entry| + lines << { + type: "Valuation", + data: { + id: entry.entryable.id, + entry_id: entry.id, + account_id: entry.account_id, + date: entry.date, + amount: entry.amount, + currency: entry.currency, + name: entry.name, + created_at: entry.created_at, + updated_at: entry.updated_at + } + }.to_json + end + + # Export budgets + @family.budgets.find_each do |budget| + lines << { + type: "Budget", + data: budget.as_json + }.to_json + end + + # Export budget categories + @family.budget_categories.includes(:budget, :category).find_each do |budget_category| + lines << { + type: "BudgetCategory", + data: budget_category.as_json + }.to_json + end + + lines.join("\n") end - - # Export categories - @family.categories.find_each do |category| - lines << { - type: "Category", - data: category.as_json - }.to_json - end - - # Export tags - @family.tags.find_each do |tag| - lines << { - type: "Tag", - data: tag.as_json - }.to_json - end - - # Export merchants (only family merchants) - @family.merchants.find_each do |merchant| - lines << { - type: "Merchant", - data: merchant.as_json - }.to_json - end - - # Export transactions with full data - @family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| - lines << { - type: "Transaction", - data: { - id: transaction.id, - entry_id: transaction.entry.id, - account_id: transaction.entry.account_id, - date: transaction.entry.date, - amount: transaction.entry.amount, - currency: transaction.entry.currency, - name: transaction.entry.name, - notes: transaction.entry.notes, - excluded: transaction.entry.excluded, - category_id: transaction.category_id, - merchant_id: transaction.merchant_id, - tag_ids: transaction.tag_ids, - kind: transaction.kind, - created_at: transaction.created_at, - updated_at: transaction.updated_at - } - }.to_json - end - - # Export trades with full data - @family.trades.includes(:security, entry: :account).find_each do |trade| - lines << { - type: "Trade", - data: { - id: trade.id, - entry_id: trade.entry.id, - account_id: trade.entry.account_id, - security_id: trade.security_id, - ticker: trade.security.ticker, - date: trade.entry.date, - qty: trade.qty, - price: trade.price, - amount: trade.entry.amount, - currency: trade.currency, - created_at: trade.created_at, - updated_at: trade.updated_at - } - }.to_json - end - - # Export valuations - @family.entries.valuations.includes(:account, :entryable).find_each do |entry| - lines << { - type: "Valuation", - data: { - id: entry.entryable.id, - entry_id: entry.id, - account_id: entry.account_id, - date: entry.date, - amount: entry.amount, - currency: entry.currency, - name: entry.name, - created_at: entry.created_at, - updated_at: entry.updated_at - } - }.to_json - end - - # Export budgets - @family.budgets.find_each do |budget| - lines << { - type: "Budget", - data: budget.as_json - }.to_json - end - - # Export budget categories - @family.budget_categories.includes(:budget, :category).find_each do |budget_category| - lines << { - type: "BudgetCategory", - data: budget_category.as_json - }.to_json - end - - lines.join("\n") - end -end \ No newline at end of file +end diff --git a/app/models/family_export.rb b/app/models/family_export.rb index 86abb77c..292ab9e0 100644 --- a/app/models/family_export.rb +++ b/app/models/family_export.rb @@ -1,21 +1,21 @@ class FamilyExport < ApplicationRecord belongs_to :family - + has_one_attached :export_file - + enum :status, { pending: "pending", processing: "processing", completed: "completed", failed: "failed" }, default: :pending, validate: true - + scope :ordered, -> { order(created_at: :desc) } - + def filename "maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip" end - + def downloadable? completed? && export_file.attached? end diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb index 68a9b006..f4e979d8 100644 --- a/app/views/family_exports/_list.html.erb +++ b/app/views/family_exports/_list.html.erb @@ -1,7 +1,7 @@ -<%= turbo_frame_tag "family_exports", - data: exports.any? { |e| e.pending? || e.processing? } ? { - turbo_refresh_url: family_exports_path, - turbo_refresh_interval: 3000 +<%= turbo_frame_tag "family_exports", + data: exports.any? { |e| e.pending? || e.processing? } ? { + turbo_refresh_url: family_exports_path, + turbo_refresh_interval: 3000 } : {} do %>
Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %>
<%= export.filename %>
No exports yet
<% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb index a5a7d4c0..530b8151 100644 --- a/app/views/family_exports/index.html.erb +++ b/app/views/family_exports/index.html.erb @@ -1 +1 @@ -<%= render "list", exports: @exports %> \ No newline at end of file +<%= render "list", exports: @exports %> diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb index de936562..5bf02352 100644 --- a/app/views/family_exports/new.html.erb +++ b/app/views/family_exports/new.html.erb @@ -39,4 +39,4 @@ <% end %> <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 9a51357b..1dfe2c15 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -135,7 +135,7 @@ data: { turbo_frame: :modal } ) %> - + <%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>