From 942c84fb0750913304e8c859dec8653005446de4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 2 Sep 2024 21:35:08 +0200 Subject: [PATCH] Implement GPX export --- CHANGELOG.md | 16 +++++++++++ Gemfile | 1 + Gemfile.lock | 4 +++ app/jobs/export_job.rb | 2 +- app/serializers/points/gpx_serializer.rb | 17 ++++++++++++ app/services/exports/create.rb | 35 ++++++++++++------------ app/views/points/index.html.erb | 2 +- spec/fixtures/files/geojson/export.json | 1 + spec/jobs/export_job_spec.rb | 2 +- spec/services/exports/create_spec.rb | 25 +++++++---------- 10 files changed, 70 insertions(+), 35 deletions(-) create mode 100644 app/serializers/points/gpx_serializer.rb create mode 100644 spec/fixtures/files/geojson/export.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 99feeae7..5cb57e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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 ### Added diff --git a/Gemfile b/Gemfile index e4e3c97f..1da98ac5 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem 'chartkick' gem 'data_migrate' gem 'devise' gem 'geocoder' +gem 'gpx' gem 'httparty' gem 'importmap-rails' gem 'kaminari' diff --git a/Gemfile.lock b/Gemfile.lock index c4a61ebf..279e4099 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,6 +137,9 @@ GEM csv (>= 3.0.0) globalid (1.2.1) activesupport (>= 6.1) + gpx (1.1.1) + nokogiri (~> 1.7) + rake hashdiff (1.1.0) httparty (0.22.0) csv @@ -433,6 +436,7 @@ DEPENDENCIES ffaker foreman geocoder + gpx httparty importmap-rails kaminari diff --git a/app/jobs/export_job.rb b/app/jobs/export_job.rb index e2ff3d26..6f65a25b 100644 --- a/app/jobs/export_job.rb +++ b/app/jobs/export_job.rb @@ -3,7 +3,7 @@ class ExportJob < ApplicationJob 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) Exports::Create.new(export:, start_at:, end_at:, format:).call diff --git a/app/serializers/points/gpx_serializer.rb b/app/serializers/points/gpx_serializer.rb new file mode 100644 index 00000000..c52c1e9b --- /dev/null +++ b/app/serializers/points/gpx_serializer.rb @@ -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 diff --git a/app/services/exports/create.rb b/app/services/exports/create.rb index 19c6d7a3..fa9143ba 100644 --- a/app/services/exports/create.rb +++ b/app/services/exports/create.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Exports::Create - def initialize(export:, start_at:, end_at:, format: :geojson) + def initialize(export:, start_at:, end_at:, format: :json) @export = export @user = export.user @start_at = start_at.to_datetime @@ -12,29 +12,15 @@ class Exports::Create def call export.update!(status: :processing) - Rails.logger.debug "====Exporting data for #{user.email} from #{start_at} to #{end_at}" - points = time_framed_points + data = points_data(points) - Rails.logger.debug "====Exporting #{points.size} points" - - 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) } + create_export_file(data) export.update!(status: :completed, url: "exports/#{export.name}.#{format}") create_export_finished_notification rescue StandardError => e - Rails.logger.error("====Export failed to create: #{e.message}") - create_failed_export_notification(e) export.update!(status: :failed) @@ -68,10 +54,25 @@ class Exports::Create ).call 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) Points::GeojsonSerializer.new(points).call end 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 diff --git a/app/views/points/index.html.erb b/app/views/points/index.html.erb index 06707058..2f21b667 100644 --- a/app/views/points/index.html.erb +++ b/app/views/points/index.html.erb @@ -22,7 +22,7 @@
- <%= 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" %>
<%#
%> diff --git a/spec/fixtures/files/geojson/export.json b/spec/fixtures/files/geojson/export.json new file mode 100644 index 00000000..b4b978a4 --- /dev/null +++ b/spec/fixtures/files/geojson/export.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}},{"type":"Feature","geometry":{"type":"Point","coordinates":["0.0","0.0"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"0.0","velocity":"MyString","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"0.0","mode":1,"inrids":[],"in_regions":[],"raw_data":"","city":null,"country":null,"geodata":{}}}]} \ No newline at end of file diff --git a/spec/jobs/export_job_spec.rb b/spec/jobs/export_job_spec.rb index cbc75964..da895fd6 100644 --- a/spec/jobs/export_job_spec.rb +++ b/spec/jobs/export_job_spec.rb @@ -8,7 +8,7 @@ RSpec.describe ExportJob, type: :job do 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 + 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) end diff --git a/spec/services/exports/create_spec.rb b/spec/services/exports/create_spec.rb index f7ec82be..e52723b2 100644 --- a/spec/services/exports/create_spec.rb +++ b/spec/services/exports/create_spec.rb @@ -4,20 +4,21 @@ require 'rails_helper' RSpec.describe Exports::Create 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(:start_at) { DateTime.new(2021, 1, 1).to_s } - let(:end_at) { DateTime.new(2021, 1, 2).to_s } - 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_datetime.to_i) } + let(:format) { :json } + let(:user) { create(:user) } + let(:start_at) { DateTime.new(2021, 1, 1).to_s } + let(:end_at) { DateTime.new(2021, 1, 2).to_s } + let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" } + let(:export) { create(:export, user:, name: export_name, status: :created) } + 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 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) end @@ -49,12 +50,6 @@ RSpec.describe Exports::Create do expect(export.reload.failed?).to be_truthy 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 expect { create_export }.to change { Notification.count }.by(1) end