mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Implement Exporting points to a file
This commit is contained in:
parent
e736f66049
commit
3f68ce5e37
26 changed files with 359 additions and 84 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,6 +26,8 @@
|
|||
!/tmp/storage/.keep
|
||||
|
||||
/public/assets
|
||||
/public/exports
|
||||
/public/imports
|
||||
|
||||
# Ignore master key for decrypting credentials and more.
|
||||
/config/master.key
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,32 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def download
|
||||
export = current_user.export_data(start_at:, end_at:)
|
||||
|
||||
send_data export, filename:, type: 'applocation/json', disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filename
|
||||
first_point_datetime = Time.zone.at(start_at).to_s
|
||||
last_point_datetime = Time.zone.at(end_at).to_s
|
||||
|
||||
"dawarich-export-#{first_point_datetime}-#{last_point_datetime}.json".gsub(' ', '_')
|
||||
end
|
||||
|
||||
def start_at
|
||||
first_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.first&.timestamp
|
||||
|
||||
@start_at ||= first_point_timestamp || 1.month.ago.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
last_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.last&.timestamp
|
||||
|
||||
@end_at ||= last_point_timestamp || Time.current.to_i
|
||||
end
|
||||
end
|
||||
40
app/controllers/exports_controller.rb
Normal file
40
app/controllers/exports_controller.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_export, only: %i[destroy]
|
||||
|
||||
def index
|
||||
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
|
||||
end
|
||||
|
||||
def create
|
||||
export_name = "#{params[:start_at].to_date}_#{params[:end_at].to_date}"
|
||||
export = current_user.exports.create(name: export_name, status: :created)
|
||||
|
||||
ExportJob.perform_later(export.id, params[:start_at], params[:end_at])
|
||||
|
||||
redirect_to exports_url, notice: 'Export was successfully initiated. Please wait until it\'s finished.'
|
||||
rescue StandardError => e
|
||||
export&.destroy
|
||||
|
||||
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@export.destroy
|
||||
|
||||
redirect_to exports_url, notice: 'Export was successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_export
|
||||
@export = current_user.exports.find(params[:id])
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def export_params
|
||||
params.require(:export).permit(:name, :url, :status)
|
||||
end
|
||||
end
|
||||
2
app/helpers/exports_helper.rb
Normal file
2
app/helpers/exports_helper.rb
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
module ExportsHelper
|
||||
end
|
||||
11
app/jobs/export_job.rb
Normal file
11
app/jobs/export_job.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ExportJob < ApplicationJob
|
||||
queue_as :exports
|
||||
|
||||
def perform(export_id, start_at, end_at)
|
||||
export = Export.find(export_id)
|
||||
|
||||
Exports::Create.new(export:, start_at:, end_at:).call
|
||||
end
|
||||
end
|
||||
19
app/models/export.rb
Normal file
19
app/models/export.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Export < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
enum status: { created: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
before_destroy :delete_export_file
|
||||
|
||||
private
|
||||
|
||||
def delete_export_file
|
||||
file_path = Rails.root.join('public', 'exports', "#{name}.json")
|
||||
|
||||
File.delete(file_path) if File.exist?(file_path)
|
||||
end
|
||||
end
|
||||
|
|
@ -10,15 +10,10 @@ class User < ApplicationRecord
|
|||
has_many :points, through: :imports
|
||||
has_many :stats, dependent: :destroy
|
||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||
has_many :exports, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
|
||||
def export_data(start_at: nil, end_at: nil)
|
||||
geopoints = time_framed_points(start_at, end_at)
|
||||
|
||||
::ExportSerializer.new(geopoints, email).call
|
||||
end
|
||||
|
||||
def countries_visited
|
||||
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
||||
end
|
||||
|
|
@ -59,16 +54,4 @@ class User < ApplicationRecord
|
|||
|
||||
save
|
||||
end
|
||||
|
||||
def time_framed_points(start_at, end_at)
|
||||
return tracked_points.without_raw_data if start_at.nil? && end_at.nil?
|
||||
|
||||
if start_at && end_at
|
||||
tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
|
||||
elsif start_at
|
||||
tracked_points.without_raw_data.where('timestamp >= ?', start_at)
|
||||
elsif end_at
|
||||
tracked_points.without_raw_data.where('timestamp <= ?', end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
34
app/services/exports/create.rb
Normal file
34
app/services/exports/create.rb
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Exports::Create
|
||||
def initialize(export:, start_at:, end_at:)
|
||||
@export = export
|
||||
@user = export.user
|
||||
@start_at = start_at
|
||||
@end_at = end_at
|
||||
end
|
||||
|
||||
def call
|
||||
export.update!(status: :processing)
|
||||
|
||||
points = time_framed_points(start_at, end_at, user)
|
||||
data = ::ExportSerializer.new(points, user.email).call
|
||||
file_path = Rails.root.join('public', 'exports', "#{export.name}.json")
|
||||
|
||||
File.open(file_path, 'w') { |file| file.write(data) }
|
||||
|
||||
export.update!(status: :completed, url: "exports/#{export.name}.json")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("====Export failed to create: #{e.message}")
|
||||
|
||||
export.update!(status: :failed)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :export, :start_at, :end_at
|
||||
|
||||
def time_framed_points(start_at, end_at, user)
|
||||
user.tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at.to_i, end_at.to_i)
|
||||
end
|
||||
end
|
||||
32
app/views/exports/index.html.erb
Normal file
32
app/views/exports/index.html.erb
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<% content_for :title, "Exports" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Exports</h1>
|
||||
</div>
|
||||
|
||||
<div id="imports" class="min-w-full">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @exports.each do |export| %>
|
||||
<tr>
|
||||
<td><%= export.name %></td>
|
||||
<td><%= export.status %></td>
|
||||
<td>
|
||||
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
|
||||
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
<div class="w-full md:w-2/6">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to 'Download JSON', export_download_path(start_at: @start_at, end_at: @end_at), data: { turbo: false }, class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
<%= link_to 'Export points', exports_path(start_at: @start_at, end_at: @end_at), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points withing timeframe, selected between 'Start at' and 'End at' inputs", turbo_method: :post }, class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Rails.application.routes.draw do
|
|||
get 'export/download', to: 'export#download'
|
||||
|
||||
resources :imports
|
||||
resources :exports, only: %i[index create destroy]
|
||||
resources :points, only: %i[index] do
|
||||
collection do
|
||||
delete :bulk_destroy
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@
|
|||
:queues:
|
||||
- default
|
||||
- imports
|
||||
- exports
|
||||
- stats
|
||||
- reverse_geocoding
|
||||
|
|
|
|||
16
db/migrate/20240612152451_create_exports.rb
Normal file
16
db/migrate/20240612152451_create_exports.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateExports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :exports do |t|
|
||||
t.string :name, null: false
|
||||
t.string :url
|
||||
t.integer :status, default: 0, null: false
|
||||
t.bigint :user_id, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :exports, :status
|
||||
add_index :exports, :user_id
|
||||
end
|
||||
end
|
||||
16
db/schema.rb
generated
16
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_06_12_152451) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -42,6 +42,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
|
|||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["status"], name: "index_exports_on_status"
|
||||
t.index ["user_id"], name: "index_exports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "imports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
|
|
|
|||
0
public/exports/.keep
Normal file
0
public/exports/.keep
Normal file
0
public/imports/.keep
Normal file
0
public/imports/.keep
Normal file
10
spec/factories/exports.rb
Normal file
10
spec/factories/exports.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :export do
|
||||
name { 'export' }
|
||||
url { 'exports/export.json' }
|
||||
status { 1 }
|
||||
user
|
||||
end
|
||||
end
|
||||
15
spec/jobs/export_job_spec.rb
Normal file
15
spec/jobs/export_job_spec.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ExportJob, type: :job do
|
||||
let(:export) { create(:export) }
|
||||
let(:start_at) { 1.day.ago }
|
||||
let(:end_at) { Time.zone.now }
|
||||
|
||||
it 'calls the Exports::Create service class' do
|
||||
expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:).and_call_original
|
||||
|
||||
described_class.perform_now(export.id, start_at, end_at)
|
||||
end
|
||||
end
|
||||
13
spec/models/export_spec.rb
Normal file
13
spec/models/export_spec.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Export, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:status).with_values(created: 0, processing: 1, completed: 2, failed: 3) }
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:points).through(:imports) }
|
||||
it { is_expected.to have_many(:stats) }
|
||||
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
|
||||
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
|
|
@ -23,19 +24,6 @@ RSpec.describe User, type: :model do
|
|||
describe 'methods' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
xdescribe '#export_data' do
|
||||
subject { user.export_data }
|
||||
|
||||
let(:import) { create(:import, user:) }
|
||||
let(:point) { create(:point, import:) }
|
||||
|
||||
it 'returns json' do
|
||||
expect(subject).to include(user.email)
|
||||
expect(subject).to include('dawarich-export')
|
||||
expect(subject).to include(point.attributes.except('raw_data', 'id', 'created_at', 'updated_at', 'country', 'city', 'import_id').to_json)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#countries_visited' do
|
||||
subject { user.countries_visited }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Exports', type: :request do
|
||||
describe 'GET /download' do
|
||||
before do
|
||||
sign_in create(:user)
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get '/export/download'
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
98
spec/requests/exports_spec.rb
Normal file
98
spec/requests/exports_spec.rb
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/exports', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:params) { { start_at: 1.day.ago, end_at: Time.zone.now } }
|
||||
|
||||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
end
|
||||
|
||||
describe 'GET /index' do
|
||||
context 'when user is not logged in' do
|
||||
it 'redirects to the login page' do
|
||||
get exports_url
|
||||
|
||||
expect(response).to redirect_to(new_user_session_url)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is logged in' do
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'renders a successful response' do
|
||||
get exports_url
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /create' do
|
||||
before { sign_in user }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:points) { create_list(:point, 10, user: user, timestamp: 1.day.ago) }
|
||||
|
||||
it 'creates a new Export' do
|
||||
expect { post exports_url, params: params }.to change(Export, :count).by(1)
|
||||
end
|
||||
|
||||
it 'redirects to the exports index page' do
|
||||
post exports_url, params: params
|
||||
|
||||
expect(response).to redirect_to(exports_url)
|
||||
end
|
||||
|
||||
it 'enqeuues a job to process the export' do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
|
||||
expect { post exports_url, params: params }.to have_enqueued_job(ExportJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:params) { { start_at: nil, end_at: nil } }
|
||||
|
||||
it 'does not create a new Export' do
|
||||
expect { post exports_url, params: params }.to change(Export, :count).by(0)
|
||||
end
|
||||
|
||||
it 'renders a response with 422 status (i.e. to display the "new" template)' do
|
||||
post exports_url, params: params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /destroy' do
|
||||
let!(:export) { create(:export, user:, url: 'exports/export.json') }
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
it 'destroys the requested export' do
|
||||
expect { delete export_url(export) }.to change(Export, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'redirects to the exports list' do
|
||||
delete export_url(export)
|
||||
|
||||
expect(response).to redirect_to(exports_url)
|
||||
end
|
||||
|
||||
it 'remove the export file from the disk' do
|
||||
export_file = Rails.root.join('public', export.url)
|
||||
FileUtils.touch(export_file)
|
||||
|
||||
delete export_url(export)
|
||||
|
||||
expect(File.exist?(export_file)).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
43
spec/services/exports/create_spec.rb
Normal file
43
spec/services/exports/create_spec.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Exports::Create do
|
||||
describe '#call' do
|
||||
subject(:create_export) { described_class.new(export:, start_at:, end_at:).call }
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:start_at) { DateTime.new(2021, 1, 1) }
|
||||
let(:end_at) { DateTime.new(2021, 1, 2) }
|
||||
let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
|
||||
let(:export) { create(:export, user:, name: export_name, status: :created) }
|
||||
let(:export_content) { ExportSerializer.new(points, user.email).call }
|
||||
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_i) }
|
||||
|
||||
it 'writes the data to a file' do
|
||||
create_export
|
||||
|
||||
file_path = Rails.root.join('public', 'exports', "#{export_name}.json")
|
||||
|
||||
expect(File.read(file_path)).to eq(export_content)
|
||||
end
|
||||
|
||||
it 'updates the export url' do
|
||||
create_export
|
||||
|
||||
expect(export.reload.url).to eq("exports/#{export.name}.json")
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
before do
|
||||
allow(File).to receive(:open).and_raise(StandardError)
|
||||
end
|
||||
|
||||
it 'updates the export status to failed' do
|
||||
create_export
|
||||
|
||||
expect(export.reload.failed?).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -180,7 +180,7 @@ paths:
|
|||
lat: 52.502397
|
||||
lon: 13.356718
|
||||
tid: Swagger
|
||||
tst: 1717877268
|
||||
tst: 1718216894
|
||||
servers:
|
||||
- url: http://{defaultHost}
|
||||
variables:
|
||||
|
|
|
|||
Loading…
Reference in a new issue