Implement GPX export

This commit is contained in:
Eugene Burmakin 2024-09-02 21:35:08 +02:00
parent 80b2f8831d
commit 942c84fb07
10 changed files with 70 additions and 35 deletions

View file

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [UNRELEASED] — 2024-08-28
### Added
- GeoJSON format is now available for exporting data.
- GPX format is now available for exporting data.
### Changed
- Default exporting format is now GeoJSON instead of Owntracks-like JSON. This will allow you to use the exported data in other applications that support GeoJSON format.
### TODO
- [ ] Importing GeoJSON
## [0.12.2] — 2024-08-28 ## [0.12.2] — 2024-08-28
### Added ### Added

View file

@ -9,6 +9,7 @@ gem 'chartkick'
gem 'data_migrate' gem 'data_migrate'
gem 'devise' gem 'devise'
gem 'geocoder' gem 'geocoder'
gem 'gpx'
gem 'httparty' gem 'httparty'
gem 'importmap-rails' gem 'importmap-rails'
gem 'kaminari' gem 'kaminari'

View file

@ -137,6 +137,9 @@ GEM
csv (>= 3.0.0) csv (>= 3.0.0)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gpx (1.1.1)
nokogiri (~> 1.7)
rake
hashdiff (1.1.0) hashdiff (1.1.0)
httparty (0.22.0) httparty (0.22.0)
csv csv
@ -433,6 +436,7 @@ DEPENDENCIES
ffaker ffaker
foreman foreman
geocoder geocoder
gpx
httparty httparty
importmap-rails importmap-rails
kaminari kaminari

View file

@ -3,7 +3,7 @@
class ExportJob < ApplicationJob class ExportJob < ApplicationJob
queue_as :exports queue_as :exports
def perform(export_id, start_at, end_at, format: :geojson) def perform(export_id, start_at, end_at, format: :json)
export = Export.find(export_id) export = Export.find(export_id)
Exports::Create.new(export:, start_at:, end_at:, format:).call Exports::Create.new(export:, start_at:, end_at:, format:).call

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Points::GpxSerializer
def initialize(points)
@points = points
end
def call
geojson_data = Points::GeojsonSerializer.new(points).call
GPX::GeoJSON.convert_to_gpx(geojson_data:)
end
private
attr_reader :points
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Exports::Create class Exports::Create
def initialize(export:, start_at:, end_at:, format: :geojson) def initialize(export:, start_at:, end_at:, format: :json)
@export = export @export = export
@user = export.user @user = export.user
@start_at = start_at.to_datetime @start_at = start_at.to_datetime
@ -12,29 +12,15 @@ class Exports::Create
def call def call
export.update!(status: :processing) export.update!(status: :processing)
Rails.logger.debug "====Exporting data for #{user.email} from #{start_at} to #{end_at}"
points = time_framed_points points = time_framed_points
data = points_data(points)
Rails.logger.debug "====Exporting #{points.size} points" create_export_file(data)
data =
case format
when :geojson then process_geojson_export(points)
when :gpx then process_gpx_export(points)
else raise ArgumentError, "Unsupported format: #{format}"
end
file_path = Rails.root.join('public', 'exports', "#{export.name}.#{format}")
File.open(file_path, 'w') { |file| file.write(data) }
export.update!(status: :completed, url: "exports/#{export.name}.#{format}") export.update!(status: :completed, url: "exports/#{export.name}.#{format}")
create_export_finished_notification create_export_finished_notification
rescue StandardError => e rescue StandardError => e
Rails.logger.error("====Export failed to create: #{e.message}")
create_failed_export_notification(e) create_failed_export_notification(e)
export.update!(status: :failed) export.update!(status: :failed)
@ -68,10 +54,25 @@ class Exports::Create
).call ).call
end end
def points_data(points)
case format
when :json then process_geojson_export(points)
when :gpx then process_gpx_export(points)
else raise ArgumentError, "Unsupported format: #{format}"
end
end
def process_geojson_export(points) def process_geojson_export(points)
Points::GeojsonSerializer.new(points).call Points::GeojsonSerializer.new(points).call
end end
def process_gpx_export(points) def process_gpx_export(points)
Points::GpxSerializer.new(points).call
end
def create_export_file(data)
file_path = Rails.root.join('public', 'exports', "#{export.name}.#{format}")
File.open(file_path, 'w') { |file| file.write(data) }
end end
end end

View file

@ -22,7 +22,7 @@
</div> </div>
<div class="w-full md:w-2/12"> <div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center"> <div class="flex flex-col space-y-2 text-center">
<%= link_to 'Export GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :geojson), 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}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %> <%= link_to 'Export GeoJSON', exports_path(start_at: @start_at, end_at: @end_at, file_format: :json), 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}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md join-item" %>
</div> </div>
</div> </div>
<%# <div class="w-full md:w-2/12"> %> <%# <div class="w-full md:w-2/12"> %>

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,7 @@ RSpec.describe ExportJob, type: :job do
let(:end_at) { Time.zone.now } let(:end_at) { Time.zone.now }
it 'calls the Exports::Create service class' do it 'calls the Exports::Create service class' do
expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:).and_call_original expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:, format: :json).and_call_original
described_class.perform_now(export.id, start_at, end_at) described_class.perform_now(export.id, start_at, end_at)
end end

View file

@ -4,20 +4,21 @@ require 'rails_helper'
RSpec.describe Exports::Create do RSpec.describe Exports::Create do
describe '#call' do describe '#call' do
subject(:create_export) { described_class.new(export:, start_at:, end_at:).call } subject(:create_export) { described_class.new(export:, start_at:, end_at:, format:).call }
let(:user) { create(:user) } let(:format) { :json }
let(:start_at) { DateTime.new(2021, 1, 1).to_s } let(:user) { create(:user) }
let(:end_at) { DateTime.new(2021, 1, 2).to_s } let(:start_at) { DateTime.new(2021, 1, 1).to_s }
let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" } let(:end_at) { DateTime.new(2021, 1, 2).to_s }
let(:export) { create(:export, user:, name: export_name, status: :created) } let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
let(:export_content) { ExportSerializer.new(points, user.email).call } let(:export) { create(:export, user:, name: export_name, status: :created) }
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) } let(:export_content) { Points::GeojsonSerializer.new(points).call }
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_datetime.to_i) }
it 'writes the data to a file' do it 'writes the data to a file' do
create_export create_export
file_path = Rails.root.join('public', 'exports', "#{export_name}.json") file_path = Rails.root.join('spec/fixtures/files/geojson/export.json')
expect(File.read(file_path)).to eq(export_content) expect(File.read(file_path)).to eq(export_content)
end end
@ -49,12 +50,6 @@ RSpec.describe Exports::Create do
expect(export.reload.failed?).to be_truthy expect(export.reload.failed?).to be_truthy
end end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('====Export failed to create: StandardError')
create_export
end
it 'creates a notification' do it 'creates a notification' do
expect { create_export }.to change { Notification.count }.by(1) expect { create_export }.to change { Notification.count }.by(1)
end end