mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
commit
3875429c46
38 changed files with 502 additions and 128 deletions
|
|
@ -1 +1 @@
|
||||||
0.5.3
|
0.6.0
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -26,6 +26,8 @@
|
||||||
!/tmp/storage/.keep
|
!/tmp/storage/.keep
|
||||||
|
|
||||||
/public/assets
|
/public/assets
|
||||||
|
/public/exports
|
||||||
|
/public/imports
|
||||||
|
|
||||||
# Ignore master key for decrypting credentials and more.
|
# Ignore master key for decrypting credentials and more.
|
||||||
/config/master.key
|
/config/master.key
|
||||||
|
|
|
||||||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -5,6 +5,51 @@ 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/).
|
||||||
|
|
||||||
|
## [0.6.0] — 2024-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exports page to list existing exports download them or delete them
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page.
|
||||||
|
|
||||||
|
ℹ️ Deleting Export file will only delete the file, not the points in the database. ℹ️
|
||||||
|
|
||||||
|
⚠️ BREAKING CHANGES: ⚠️
|
||||||
|
|
||||||
|
Volume, exposed to the host machine for placing files to import was changed. See the changes below.
|
||||||
|
|
||||||
|
Path for placing files to import was changed from `tmp/imports` to `public/imports`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
...
|
||||||
|
|
||||||
|
dawarich_app:
|
||||||
|
image: freikin/dawarich:latest
|
||||||
|
container_name: dawarich_app
|
||||||
|
volumes:
|
||||||
|
- gem_cache:/usr/local/bundle/gems
|
||||||
|
- - tmp:/var/app/tmp
|
||||||
|
+ - public:/var/app/public/imports
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
...
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
gem_cache:
|
||||||
|
shared_data:
|
||||||
|
- tmp:
|
||||||
|
+ public:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.5.3] — 2024-06-10
|
## [0.5.3] — 2024-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ You can see the number of countries and cities visited, the distance traveled, a
|
||||||
|
|
||||||
You can import your Google Maps Timeline data into Dawarich as well as Owntracks data.
|
You can import your Google Maps Timeline data into Dawarich as well as Owntracks data.
|
||||||
|
|
||||||
|
⚠️ **Note**: Import of huge Google Maps Timeline files may take a long time and consume a lot of memory. It also might temporarily consume a lot of disk space due to logs. Please make sure you have enough resources before starting the import. After import is completed, you can restart your docker container and logs will be removed.
|
||||||
|
|
||||||
## How to start the app locally
|
## How to start the app locally
|
||||||
|
|
||||||
`docker-compose up` to start the app. The app will be available at `http://localhost:3000`.
|
`docker-compose up` to start the app. The app will be available at `http://localhost:3000`.
|
||||||
|
|
|
||||||
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
|
|
||||||
39
app/controllers/exports_controller.rb
Normal file
39
app/controllers/exports_controller.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def export_params
|
||||||
|
params.require(:export).permit(:name, :url, :status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -104,4 +104,9 @@ module ApplicationHelper
|
||||||
def active_class?(link_path)
|
def active_class?(link_path)
|
||||||
'btn-active' if current_page?(link_path)
|
'btn-active' if current_page?(link_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def full_title(page_title = '')
|
||||||
|
base_title = 'Dawarich'
|
||||||
|
page_title.empty? ? base_title : "#{page_title} | #{base_title}"
|
||||||
|
end
|
||||||
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 :points, through: :imports
|
||||||
has_many :stats, dependent: :destroy
|
has_many :stats, dependent: :destroy
|
||||||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||||
|
has_many :exports, dependent: :destroy
|
||||||
|
|
||||||
after_create :create_api_key
|
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
|
def countries_visited
|
||||||
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
||||||
end
|
end
|
||||||
|
|
@ -59,16 +54,4 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
save
|
save
|
||||||
end
|
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
|
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
|
||||||
47
app/views/exports/index.html.erb
Normal file
47
app/views/exports/index.html.erb
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<% content_for :title, "Exports" %>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex justify-between items-center mb-5">
|
||||||
|
<h1 class="font-bold text-4xl">Exports</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exports" class="min-w-full">
|
||||||
|
<% if @exports.empty? %>
|
||||||
|
<div class="hero min-h-80 bg-base-200">
|
||||||
|
<div class="hero-content text-center">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||||
|
<p class="py-6">
|
||||||
|
Here you'll find your exports, created on <%= link_to 'Points', points_url, class: 'link' %> page. But now there are none.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<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>
|
||||||
|
<% if export.completed? %>
|
||||||
|
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
|
||||||
|
<% end %>
|
||||||
|
<%= 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>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, 'Imports' %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="font-bold text-4xl">Imports</h1>
|
<h1 class="font-bold text-4xl">Imports</h1>
|
||||||
|
|
@ -5,33 +7,46 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="imports" class="min-w-full">
|
<div id="imports" class="min-w-full">
|
||||||
<div class="overflow-x-auto">
|
<% if @imports.empty? %>
|
||||||
<table class="table">
|
<div class="hero min-h-80 bg-base-200">
|
||||||
<!-- head -->
|
<div class="hero-content text-center">
|
||||||
<thead>
|
<div class="max-w-md">
|
||||||
<tr>
|
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||||
<th>Name</th>
|
<p class="py-6">
|
||||||
<th>Processed</th>
|
Here you'll find your imports, But now there are none. Let's <%= link_to 'create one', new_import_path, class: 'link' %>!
|
||||||
<th>Doubles</th>
|
</p>
|
||||||
<th>Created at</th>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<% else %>
|
||||||
<% @imports.each do |import| %>
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<!-- head -->
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<th>Name</th>
|
||||||
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
|
<th>Processed</th>
|
||||||
</td>
|
<th>Doubles</th>
|
||||||
<td>
|
<th>Created at</th>
|
||||||
<%= "✅" if import.processed == import.raw_points %>
|
|
||||||
<%= "#{import.processed}/#{import.raw_points}" %>
|
|
||||||
</td>
|
|
||||||
<td><%= import.doubles %></td>
|
|
||||||
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<% @imports.each do |import| %>
|
||||||
</div>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>)
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= "✅" if import.processed == import.raw_points %>
|
||||||
|
<%= "#{import.processed}/#{import.raw_points}" %>
|
||||||
|
</td>
|
||||||
|
<td><%= import.doubles %></td>
|
||||||
|
<td><%= import.created_at.strftime("%d.%m.%Y, %H:%M") %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, 'New Import' %>
|
||||||
|
|
||||||
<div class="mx-auto md:w-2/3 w-full">
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
<h1 class="font-bold text-4xl">New import</h1>
|
<h1 class="font-bold text-4xl">New import</h1>
|
||||||
|
|
||||||
|
|
@ -8,11 +10,24 @@
|
||||||
|
|
||||||
<p class='mb-3'>Import takes a while to finish, so you might want to run it in <code>screen</code> session.</p>
|
<p class='mb-3'>Import takes a while to finish, so you might want to run it in <code>screen</code> session.</p>
|
||||||
|
|
||||||
<p>1. Upload your Records.json file to your server</p>
|
<p class='mt-5 mb-2'>1. Upload your Records.json file to your server</p>
|
||||||
<p>2. Copy you Records.json to the <code>tmp</code> folder: <code>$ docker cp Records.json dawarich_app:/var/app/tmp/Records.json</code></p>
|
<p class='mt-5 mb-2'>2. Copy you Records.json to the <code>tmp</code> folder:
|
||||||
<p>3. Attach to the docker container: <code>$ docker exec -it dawarich_app sh</code></p>
|
<div class="mockup-code">
|
||||||
<p>4. Run the rake task: <code>$ bundle exec rake import:big_file['tmp/Records.json','user@example.com']</code></p>
|
<pre data-prefix="$"><code>docker cp Records.json dawarich_app:/var/app/public/imports/Records.json</code></pre>
|
||||||
<p>5. Wait patiently for process to finish</p>
|
</div>
|
||||||
|
</p>
|
||||||
|
<p class='mt-5 mb-2'>3. Attach to the docker container:
|
||||||
|
<div class="mockup-code">
|
||||||
|
<pre data-prefix="$"><code>docker exec -it dawarich_app sh</code></pre>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<p class='mt-5 mb-2'>4. Run the rake task:
|
||||||
|
<div class="mockup-code">
|
||||||
|
<pre data-prefix="$"><code>bundle exec rake import:big_file['public/imports/Records.json','user@example.com']</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<p class='mt-5 mb-2'>5. Wait patiently for process to finish</p>
|
||||||
|
|
||||||
<p class='mt-3'>You can monitor progress in <a href="/sidekiq" class="underline">Sidekiq UI</a></p>
|
<p class='mt-3'>You can monitor progress in <a href="/sidekiq" class="underline">Sidekiq UI</a></p>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, 'Import' %>
|
||||||
|
|
||||||
<div class="mx-auto md:w-2/3 w-full flex">
|
<div class="mx-auto md:w-2/3 w-full flex">
|
||||||
<div class="mx-auto">
|
<div class="mx-auto">
|
||||||
<% if notice.present? %>
|
<% if notice.present? %>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="<%= app_theme %>">
|
<html data-theme="<%= app_theme %>">
|
||||||
<head>
|
<head>
|
||||||
<title>DaWarIch</title>
|
<title><%= full_title(yield(:title)) %></title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, 'Map' %>
|
||||||
|
|
||||||
<div class='w-4/5 mt-10'>
|
<div class='w-4/5 mt-10'>
|
||||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||||
<%= form_with url: map_path, method: :get do |f| %>
|
<%= form_with url: map_path, method: :get do |f| %>
|
||||||
|
|
@ -21,27 +23,22 @@
|
||||||
</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 "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
<%= link_to "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||||
</div>
|
</div>
|
||||||
</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 "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||||
</div>
|
</div>
|
||||||
</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 "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div role="alert" class="alert alert-warning">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
|
||||||
<span>Warning: Starting release 0.4.0 it's HIGHLY RECOMMENDED to switch from <code>/api/v1/points</code> to <code>/api/v1/owntracks/points</code> API endpoint. Please read more at <a href="https://github.com/Freika/dawarich/releases/tag/0.4.0" class='underline hover:no-underline'>0.4.0 release notes</a></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full"
|
class="w-full"
|
||||||
data-controller="maps"
|
data-controller="maps"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<% content_for :title, "Points" %>
|
<% content_for :title, 'Points' %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<%= form_with url: points_path, method: :get do |f| %>
|
<%= form_with url: points_path, method: :get do |f| %>
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-2/6">
|
<div class="w-full md:w-2/6">
|
||||||
<div class="flex flex-col space-y-2 text-center">
|
<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}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
<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 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
<%= 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 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_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 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||||
|
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, 'Statistics' %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||||
<div class="stat text-center">
|
<div class="stat text-center">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<% content_for :title, "Statistics for #{@year} year" %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
|
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Rails.application.routes.draw do
|
||||||
get 'export/download', to: 'export#download'
|
get 'export/download', to: 'export#download'
|
||||||
|
|
||||||
resources :imports
|
resources :imports
|
||||||
|
resources :exports, only: %i[index create destroy]
|
||||||
resources :points, only: %i[index] do
|
resources :points, only: %i[index] do
|
||||||
collection do
|
collection do
|
||||||
delete :bulk_destroy
|
delete :bulk_destroy
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
:queues:
|
:queues:
|
||||||
- default
|
- default
|
||||||
- imports
|
- imports
|
||||||
|
- exports
|
||||||
- stats
|
- stats
|
||||||
- reverse_geocoding
|
- 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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
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
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
end
|
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|
|
create_table "imports", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ services:
|
||||||
container_name: dawarich_app
|
container_name: dawarich_app
|
||||||
volumes:
|
volumes:
|
||||||
- gem_cache:/usr/local/bundle/gems
|
- gem_cache:/usr/local/bundle/gems
|
||||||
- tmp:/var/app/tmp
|
- public:/var/app/public
|
||||||
networks:
|
networks:
|
||||||
- dawarich
|
- dawarich
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -78,4 +78,4 @@ volumes:
|
||||||
db_data:
|
db_data:
|
||||||
gem_cache:
|
gem_cache:
|
||||||
shared_data:
|
shared_data:
|
||||||
tmp:
|
public:
|
||||||
|
|
|
||||||
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(:points).through(:imports) }
|
||||||
it { is_expected.to have_many(:stats) }
|
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(:tracked_points).class_name('Point').dependent(:destroy) }
|
||||||
|
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'callbacks' do
|
describe 'callbacks' do
|
||||||
|
|
@ -23,19 +24,6 @@ RSpec.describe User, type: :model do
|
||||||
describe 'methods' do
|
describe 'methods' do
|
||||||
let(:user) { create(:user) }
|
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
|
describe '#countries_visited' do
|
||||||
subject { user.countries_visited }
|
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
|
lat: 52.502397
|
||||||
lon: 13.356718
|
lon: 13.356718
|
||||||
tid: Swagger
|
tid: Swagger
|
||||||
tst: 1717877268
|
tst: 1718219019
|
||||||
servers:
|
servers:
|
||||||
- url: http://{defaultHost}
|
- url: http://{defaultHost}
|
||||||
variables:
|
variables:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue