mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Add a bunch of small changes and fixes, see CHANGELOG.md for details
This commit is contained in:
parent
3b600c1052
commit
04a2150959
32 changed files with 558 additions and 207 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -6,7 +6,30 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
## [0.9.7] — 2024-07-27
|
||||
## [0.9.9] — 2024-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Pagination to exports page
|
||||
- Pagination to imports page
|
||||
- GET `/api/v1/points` endpoint to get all points for the user with swagger docs
|
||||
- DELETE `/api/v1/points/:id` endpoint to delete a single point for the user with swagger docs
|
||||
- DELETE `/api/v1/areas/:id` swagger docs
|
||||
- User can now change route opacity in settings
|
||||
- Points on the Points page can now be ordered by oldest or newest points
|
||||
- Visits on the Visits page can now be ordered by oldest or newest visits
|
||||
|
||||
### Changed
|
||||
|
||||
- Point deletion is now being done using an api key instead of CSRF token
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenStreetMap layer is now being selected by default in map controls
|
||||
|
||||
---
|
||||
|
||||
## [0.9.8] — 2024-07-27
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,10 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::PointsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
skip_forgery_protection
|
||||
before_action :authenticate_api_key
|
||||
|
||||
def index
|
||||
start_at = params[:start_at]&.to_datetime&.to_i
|
||||
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
|
||||
|
||||
points = current_api_user.tracked_points.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: points
|
||||
end
|
||||
|
||||
def destroy
|
||||
point = current_user.tracked_points.find(params[:id])
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
point.destroy
|
||||
|
||||
render json: { message: 'Point deleted successfully' }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class ImportsController < ApplicationController
|
|||
before_action :set_import, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@imports = current_user.imports
|
||||
@imports = current_user.imports.order(created_at: :desc).page(params[:page])
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ class PointsController < ApplicationController
|
|||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
order_by = params[:order_by] || 'desc'
|
||||
|
||||
@points =
|
||||
current_user
|
||||
.tracked_points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_at..end_at)
|
||||
.order(timestamp: :desc)
|
||||
.order(timestamp: order_by)
|
||||
.page(params[:page])
|
||||
.per(50)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class SettingsController < ApplicationController
|
|||
def settings_params
|
||||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
:time_threshold_minutes, :merge_threshold_minutes
|
||||
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ class VisitsController < ApplicationController
|
|||
before_action :set_visit, only: %i[update]
|
||||
|
||||
def index
|
||||
order_by = params[:order_by] || 'asc'
|
||||
|
||||
visits = current_user
|
||||
.visits
|
||||
.where(status: :pending)
|
||||
.or(current_user.visits.where(status: :confirmed))
|
||||
.order(started_at: :asc)
|
||||
.order(started_at: order_by)
|
||||
.group_by { |visit| visit.started_at.to_date }
|
||||
.map { |k, v| { date: k, visits: v } }
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { formatDate } from "../maps/helpers";
|
|||
import { haversineDistance } from "../maps/helpers";
|
||||
import { osmMapLayer } from "../maps/layers";
|
||||
import { osmHotMapLayer } from "../maps/layers";
|
||||
import { addTileLayer } from "../maps/layers";
|
||||
import "leaflet-draw";
|
||||
|
||||
export default class extends Controller {
|
||||
|
|
@ -21,18 +20,17 @@ export default class extends Controller {
|
|||
this.markers = JSON.parse(this.element.dataset.coordinates);
|
||||
this.timezone = this.element.dataset.timezone;
|
||||
this.clearFogRadius = this.element.dataset.fog_of_war_meters;
|
||||
this.routeOpacity = parseInt(this.element.dataset.route_opacity) / 100 || 0.6;
|
||||
|
||||
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
|
||||
|
||||
this.map = L.map(this.containerTarget, {
|
||||
layers: [osmMapLayer(), osmHotMapLayer()],
|
||||
}).setView([this.center[0], this.center[1]], 14);
|
||||
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
|
||||
|
||||
this.markersArray = this.createMarkersArray(this.markers);
|
||||
this.markersLayer = L.layerGroup(this.markersArray);
|
||||
this.heatmapMarkers = this.markers.map((element) => [element[0], element[1], 0.3]);
|
||||
this.heatmapMarkers = this.markers.map((element) => [element[0], element[1], 0.2]);
|
||||
|
||||
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone);
|
||||
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity);
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
|
||||
this.fogOverlay = L.layerGroup(); // Initialize fog layer
|
||||
this.areasLayer = L.layerGroup(); // Initialize areas layer
|
||||
|
|
@ -87,7 +85,6 @@ export default class extends Controller {
|
|||
}
|
||||
});
|
||||
|
||||
addTileLayer(this.map);
|
||||
this.addLastMarker(this.map, this.markers);
|
||||
this.addEventListeners();
|
||||
|
||||
|
|
@ -114,7 +111,7 @@ export default class extends Controller {
|
|||
|
||||
baseMaps() {
|
||||
return {
|
||||
OpenStreetMap: osmMapLayer(),
|
||||
OpenStreetMap: osmMapLayer(this.map),
|
||||
"OpenStreetMap.HOT": osmHotMapLayer(),
|
||||
};
|
||||
}
|
||||
|
|
@ -147,18 +144,17 @@ export default class extends Controller {
|
|||
const pointId = event.target.getAttribute('data-id');
|
||||
|
||||
if (confirm('Are you sure you want to delete this point?')) {
|
||||
this.deletePoint(pointId);
|
||||
this.deletePoint(pointId, this.apiKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deletePoint(id) {
|
||||
fetch(`/api/v1/points/${id}`, {
|
||||
deletePoint(id, apiKey) {
|
||||
fetch(`/api/v1/points/${id}?api_key=${apiKey}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
|
|
@ -234,8 +230,8 @@ export default class extends Controller {
|
|||
fog.appendChild(circle);
|
||||
}
|
||||
|
||||
addHighlightOnHover(polyline, map, polylineCoordinates, timezone) {
|
||||
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
|
||||
addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity) {
|
||||
const originalStyle = { color: "blue", opacity: routeOpacity, weight: 3 };
|
||||
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
|
||||
|
||||
polyline.setStyle(originalStyle);
|
||||
|
|
@ -319,7 +315,7 @@ export default class extends Controller {
|
|||
});
|
||||
}
|
||||
|
||||
createPolylinesLayer(markers, map, timezone) {
|
||||
createPolylinesLayer(markers, map, timezone, routeOpacity) {
|
||||
const splitPolylines = [];
|
||||
let currentPolyline = [];
|
||||
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
|
||||
|
|
@ -348,11 +344,11 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
return L.layerGroup(
|
||||
splitPolylines.map((polylineCoordinates, index) => {
|
||||
splitPolylines.map((polylineCoordinates) => {
|
||||
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
|
||||
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
|
||||
|
||||
this.addHighlightOnHover(polyline, map, polylineCoordinates, timezone);
|
||||
this.addHighlightOnHover(polyline, map, polylineCoordinates, timezone, routeOpacity);
|
||||
|
||||
return polyline;
|
||||
})
|
||||
|
|
@ -487,8 +483,7 @@ export default class extends Controller {
|
|||
fetch(`/api/v1/areas/${id}?api_key=${apiKey}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
|
||||
export function osmMapLayer() {
|
||||
export function osmMapLayer(map) {
|
||||
return L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap",
|
||||
});
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
export function osmHotMapLayer() {
|
||||
|
|
@ -12,10 +12,3 @@ export function osmHotMapLayer() {
|
|||
attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France",
|
||||
});
|
||||
}
|
||||
|
||||
export function addTileLayer(map) {
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
|
||||
}).addTo(map);
|
||||
}
|
||||
|
|
|
|||
46
app/services/tasks/imports/google_records.rb
Normal file
46
app/services/tasks/imports/google_records.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# This class is named based on Google Takeout's Records.json file,
|
||||
# the main source of user's location history data.
|
||||
|
||||
class Tasks::Imports::GoogleRecords
|
||||
def initialize(file_path, user_email)
|
||||
@file_path = file_path
|
||||
@user = User.find_by(email: user_email)
|
||||
end
|
||||
|
||||
def call
|
||||
raise 'User not found' unless @user
|
||||
|
||||
import_id = create_import
|
||||
log_start
|
||||
file_content = read_file
|
||||
json_data = Oj.load(file_content)
|
||||
schedule_import_jobs(json_data, import_id)
|
||||
log_success
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_import
|
||||
@user.imports.create(name: @file_path, source: :google_records)
|
||||
end
|
||||
|
||||
def read_file
|
||||
File.read(@file_path)
|
||||
end
|
||||
|
||||
def schedule_import_jobs(json_data, import_id)
|
||||
json_data['locations'].each do |json|
|
||||
ImportGoogleTakeoutJob.perform_later(import_id, json.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
def log_start
|
||||
Rails.logger.debug("Importing #{@file_path} for #{@user.email}, file size is #{File.size(@file_path)}... This might take a while, have patience!")
|
||||
end
|
||||
|
||||
def log_success
|
||||
Rails.logger.info("Imported #{@file_path} for #{@user.email} successfully! Wait for the processing to finish. You can check the status of the import in the Sidekiq UI (http://<your-dawarich-url>/sidekiq).")
|
||||
end
|
||||
end
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Visitcalc
|
||||
class Visit
|
||||
attr_accessor :start_time, :end_time, :points
|
||||
|
||||
def initialize(start_time)
|
||||
@start_time = start_time
|
||||
@end_time = start_time
|
||||
@points = []
|
||||
end
|
||||
|
||||
def add_point(point)
|
||||
@points << point
|
||||
@end_time = point.timestamp if point.timestamp > @end_time
|
||||
end
|
||||
|
||||
def duration_in_minutes
|
||||
(end_time - start_time) / 60.0
|
||||
end
|
||||
|
||||
def valid?
|
||||
@points.size > 1 && duration_in_minutes >= 10
|
||||
end
|
||||
end
|
||||
|
||||
def call
|
||||
# Usage
|
||||
area = Area.last
|
||||
points = Point.near([area.latitude, area.longitude], (area.radius / 1000.0)).order(timestamp: :asc)
|
||||
points_grouped_by_month = points.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') }
|
||||
|
||||
visits_by_month = {}
|
||||
points_grouped_by_month.each do |month, points_in_month|
|
||||
visits_by_month[month] = group_points_into_visits(points_in_month, 30, 15)
|
||||
end
|
||||
|
||||
# Debugging output to check the number of visits and some sample data
|
||||
visits_by_month.each do |month, visits|
|
||||
puts "Month: #{month}, Total visits: #{visits.size}"
|
||||
visits.each do |time_range, visit_points|
|
||||
puts "Visit from #{time_range}, Points: #{visit_points.size}"
|
||||
end
|
||||
end
|
||||
|
||||
visits_by_month.map { |d, v| v.keys }
|
||||
end
|
||||
|
||||
def group_points_into_visits(points, time_threshold_minutes = 30, merge_threshold_minutes = 15)
|
||||
# Ensure points are sorted by timestamp
|
||||
sorted_points = points.sort_by(&:timestamp)
|
||||
visits = []
|
||||
current_visit = nil
|
||||
|
||||
sorted_points.each do |point|
|
||||
point_time = point.timestamp
|
||||
puts "Processing point at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if current_visit.nil?
|
||||
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
current_visit = Visit.new(point_time)
|
||||
current_visit.add_point(point)
|
||||
else
|
||||
time_difference = (point_time - current_visit.end_time) / 60.0 # Convert to minutes
|
||||
puts "Time difference: #{time_difference.round} minutes"
|
||||
|
||||
if time_difference <= time_threshold_minutes
|
||||
current_visit.add_point(point)
|
||||
else
|
||||
if current_visit.valid?
|
||||
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
|
||||
visits << current_visit
|
||||
else
|
||||
puts "Discarding visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
|
||||
end
|
||||
current_visit = Visit.new(point_time)
|
||||
current_visit.add_point(point)
|
||||
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add the last visit to the list if it is valid
|
||||
if current_visit&.valid?
|
||||
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
|
||||
visits << current_visit
|
||||
else
|
||||
puts "Discarding last visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
|
||||
end
|
||||
|
||||
# Merge visits that are not more than merge_threshold_minutes apart
|
||||
merged_visits = []
|
||||
previous_visit = nil
|
||||
|
||||
visits.each do |visit|
|
||||
if previous_visit.nil?
|
||||
previous_visit = visit
|
||||
else
|
||||
time_difference = (visit.start_time - previous_visit.end_time) / 60.0 # Convert to minutes
|
||||
if time_difference <= merge_threshold_minutes
|
||||
previous_visit.points.concat(visit.points)
|
||||
previous_visit.end_time = visit.end_time
|
||||
else
|
||||
merged_visits << previous_visit
|
||||
previous_visit = visit
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
merged_visits << previous_visit if previous_visit
|
||||
|
||||
# Sort visits by start time
|
||||
merged_visits.sort_by!(&:start_time)
|
||||
|
||||
# Convert visits to a hash with human-readable datetime ranges as keys and points as values
|
||||
visits_hash = {}
|
||||
merged_visits.each do |visit|
|
||||
start_time_str = Time.zone.at(visit.start_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
end_time_str = Time.zone.at(visit.end_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
visits_hash["#{start_time_str} - #{end_time_str}"] = visit.points
|
||||
end
|
||||
|
||||
visits_hash
|
||||
end
|
||||
end
|
||||
|
||||
# Run the Visitcalc class
|
||||
# Visitcalc.new.call
|
||||
|
|
@ -5,15 +5,18 @@ class Visits::Calculate
|
|||
@points = points
|
||||
end
|
||||
|
||||
def city_visits
|
||||
normalize_result(city_visits)
|
||||
def call
|
||||
# Only one visit per city per day
|
||||
normalized_visits.flat_map do |country|
|
||||
{
|
||||
country: country[:country],
|
||||
cities: country[:cities].uniq { [_1[:city], Time.zone.at(_1[:timestamp]).to_date] }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def uniq_visits
|
||||
# Only one visit per city per day
|
||||
call.flat_map do |country|
|
||||
{ country: country[:country], cities: country[:cities].uniq { [_1[:city], Time.at(_1[:timestamp]).to_date] } }
|
||||
end
|
||||
def normalized_visits
|
||||
normalize_result(city_visits)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, "Exports" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<div class="flex justify-center my-5">
|
||||
<h1 class="font-bold text-4xl">Exports</h1>
|
||||
</div>
|
||||
|
||||
|
|
@ -18,6 +18,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center my-5">
|
||||
<div class='flex'>
|
||||
<%= paginate @exports %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center my-5">
|
||||
<div class='flex'>
|
||||
<%= paginate @imports %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
<div
|
||||
class="w-full"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-route_opacity="<%= current_user.settings['route_opacity'] %>"
|
||||
data-controller="maps"
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,16 @@
|
|||
<div id="points" class="min-w-full">
|
||||
<div data-controller='checkbox-select-all'>
|
||||
<%= form_with url: bulk_destroy_points_path, method: :delete, id: :bulk_destroy_form do |f| %>
|
||||
<%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" } %>
|
||||
|
||||
<div class="flex justify-between my-5">
|
||||
<%= f.submit "Delete Selected", class: "px-4 py-2 bg-red-500 text-white rounded-md", data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" } %>
|
||||
<div class="flex justify-end">
|
||||
<span class="mr-2">Order by:</span>
|
||||
<%= link_to 'Newest', points_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
<%= link_to 'Oldest', points_path(order_by: :asc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table'>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
<input type="checkbox" id="fog_of_war_meters_info" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Fog of War meters</h3>
|
||||
<h3 class="text-lg font-bold">Fog of War</h3>
|
||||
<p class="py-4">
|
||||
Value in meters.
|
||||
</p>
|
||||
|
|
@ -140,6 +140,30 @@
|
|||
<% end %>
|
||||
<%= f.number_field :merge_threshold_minutes, value: current_user.settings['merge_threshold_minutes'], class: "input input-bordered" %>
|
||||
</div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :route_opacity do %>
|
||||
Route opacity percent
|
||||
|
||||
<!-- The button to open modal -->
|
||||
<label for="route_opacity_info" class="btn">?</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" id="route_opacity_info" class="modal-toggle" />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Route opacity</h3>
|
||||
<p class="py-4">
|
||||
Value in percent.
|
||||
</p>
|
||||
<p class="py-4">
|
||||
This value is the opacity of the route on the map. The value is in percent, and it can be set from 0 to 100. The default value is 100, which means that the route is fully visible. If you set the value to 0, the route will be invisible.
|
||||
</p>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="route_opacity_info">Close</label>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.number_field :route_opacity, value: current_user.settings['route_opacity'], class: "input input-bordered" %>
|
||||
</div>
|
||||
<div class="form-control my-2">
|
||||
<%= f.submit "Update", class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<div class="w-full">
|
||||
<% content_for :title, "Visits" %>
|
||||
|
||||
<div class="flex justify-center my-5">
|
||||
<div class="flex justify-between my-5">
|
||||
<h1 class="font-bold text-4xl">Visits</h1>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">Order by:</span>
|
||||
<%= link_to 'Newest', visits_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
<%= link_to 'Oldest', visits_path(order_by: :asc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @visits.empty? %>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ Rails.application.routes.draw do
|
|||
namespace :api do
|
||||
namespace :v1 do
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[destroy]
|
||||
resources :points, only: %i[index destroy]
|
||||
|
||||
namespace :overland do
|
||||
resources :batches, only: :create
|
||||
|
|
|
|||
14
db/data/20240730130922_add_route_opacity_to_settings.rb
Normal file
14
db/data/20240730130922_add_route_opacity_to_settings.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddRouteOpacityToSettings < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
User.find_each do |user|
|
||||
user.settings = user.settings.merge(route_opacity: 20)
|
||||
user.save!
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
|
|
@ -1 +1 @@
|
|||
DataMigrate::Data.define(version: 20240625201842)
|
||||
DataMigrate::Data.define(version: 20240730130922)
|
||||
|
|
|
|||
|
|
@ -6,22 +6,6 @@ namespace :import do
|
|||
desc 'Accepts a file path and user email and imports the data into the database'
|
||||
|
||||
task :big_file, %i[file_path user_email] => :environment do |_, args|
|
||||
user = User.find_by(email: args[:user_email])
|
||||
|
||||
raise 'User not found' unless user
|
||||
|
||||
import = user.imports.create(name: args[:file_path], source: :google_records)
|
||||
import_id = import.id
|
||||
|
||||
pp "Importing #{args[:file_path]} for #{user.email}, file size is #{File.size(args[:file_path])}... This might take a while, have patience!"
|
||||
|
||||
content = File.read(args[:file_path]); nil
|
||||
data = Oj.load(content); nil
|
||||
|
||||
data['locations'].each do |json|
|
||||
ImportGoogleTakeoutJob.perform_later(import_id, json.to_json)
|
||||
end
|
||||
|
||||
pp "Imported #{args[:file_path]} for #{user.email} successfully! Wait for the processing to finish. You can check the status of the import in the Sidekiq UI (http://<your-dawarich-url>/sidekiq)."
|
||||
Tasks::Imports::GoogleRecords.new(args[:file_path], args[:user_email]).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :stat do
|
||||
year { 1 }
|
||||
month { 1 }
|
||||
distance { 1 }
|
||||
toponyms { "" }
|
||||
toponyms do
|
||||
[
|
||||
{
|
||||
'cities' => [
|
||||
{ 'city' => 'Moscow', 'points' => 7, 'timestamp' => 1_554_317_696, 'stayed_for' => 1831 }
|
||||
],
|
||||
'country' => 'Russia'
|
||||
}, { 'cities' => [], 'country' => nil }
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AreaVisitsCalculatingJob, type: :job do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
describe '#perform' do
|
||||
let(:user) { create(:user) }
|
||||
let(:area) { create(:area, user:) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
expect(Areas::Visits::Create).to receive(:new).with(user, [area]).and_call_original
|
||||
|
||||
described_class.new.perform(user.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
describe '#perform' do
|
||||
let(:area) { create(:area) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'calls the AreaVisitsCalculationService' do
|
||||
expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,4 +33,22 @@ RSpec.describe Point, type: :model do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'methods' do
|
||||
describe '#recorded_at' do
|
||||
let(:point) { create(:point, timestamp: 1_554_317_696) }
|
||||
|
||||
it 'returns recorded at time' do
|
||||
expect(point.recorded_at).to eq(Time.zone.at(1_554_317_696))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#async_reverse_geocode' do
|
||||
let(:point) { build(:point) }
|
||||
|
||||
it 'enqueues ReverseGeocodeJob' do
|
||||
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,10 +8,55 @@ RSpec.describe '/stats', type: :request do
|
|||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
end
|
||||
|
||||
describe 'GET /index' do
|
||||
it 'renders a successful response' do
|
||||
get stats_url
|
||||
expect(response.status).to eq(302)
|
||||
context 'when user is not signed in' do
|
||||
describe 'GET /index' do
|
||||
it 'redirects to the sign in page' do
|
||||
get stats_url
|
||||
|
||||
expect(response.status).to eq(302)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /show' do
|
||||
it 'redirects to the sign in page' do
|
||||
get stats_url(2024)
|
||||
|
||||
expect(response.status).to eq(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is signed in' do
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'GET /index' do
|
||||
it 'renders a successful response' do
|
||||
get stats_url
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /show' do
|
||||
let(:stat) { create(:stat, user:, year: 2024) }
|
||||
|
||||
it 'renders a successful response' do
|
||||
get stats_url(stat.year)
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /update' do
|
||||
let(:stat) { create(:stat, user:, year: 2024) }
|
||||
|
||||
it 'enqueues StatCreatingJob' do
|
||||
expect { post stats_url(stat.year) }.to have_enqueued_job(StatCreatingJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
16
spec/services/tasks/imports/google_records_spec.rb
Normal file
16
spec/services/tasks/imports/google_records_spec.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tasks::Imports::GoogleRecords do
|
||||
describe '#call' do
|
||||
let(:user) { create(:user) }
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') }
|
||||
|
||||
it 'schedules the ImportGoogleTakeoutJob' do
|
||||
expect(ImportGoogleTakeoutJob).to receive(:perform_later).exactly(3).times
|
||||
|
||||
described_class.new(file_path, user.email).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -25,13 +25,13 @@ describe 'Areas API', type: :request do
|
|||
}
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
|
||||
response '201', 'area created' do
|
||||
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060, radius: 100 } }
|
||||
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060, radius: 100 } }
|
||||
let(:api_key) { create(:user).api_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
response '422', 'invalid request' do
|
||||
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060 } }
|
||||
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060 } }
|
||||
let(:api_key) { create(:user).api_key }
|
||||
|
||||
run_test!
|
||||
|
|
@ -56,12 +56,30 @@ describe 'Areas API', type: :request do
|
|||
required: %w[id name latitude longitude radius]
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:areas) { create_list(:area, 3, user:) }
|
||||
let(:user) { create(:user) }
|
||||
let(:areas) { create_list(:area, 3, user:) }
|
||||
let(:api_key) { user.api_key }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/areas/{id}' do
|
||||
delete 'Deletes an area' do
|
||||
tags 'Areas'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'Area ID'
|
||||
|
||||
response '200', 'area deleted' do
|
||||
let(:user) { create(:user) }
|
||||
let(:area) { create(:area, user:) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:id) { area.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
81
spec/swagger/api/v1/points_controller_spec.rb
Normal file
81
spec/swagger/api/v1/points_controller_spec.rb
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'swagger_helper'
|
||||
|
||||
describe 'Points API', type: :request do
|
||||
path '/api/v1/points' do
|
||||
get 'Retrieves all points' do
|
||||
tags 'Points'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
|
||||
parameter name: :start_at, in: :query, type: :string,
|
||||
description: 'Start date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)'
|
||||
parameter name: :end_at, in: :query, type: :string,
|
||||
description: 'End date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)'
|
||||
response '200', 'points found' do
|
||||
schema type: :array,
|
||||
items: {
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer },
|
||||
battery_status: { type: :number },
|
||||
ping: { type: :number },
|
||||
battery: { type: :number },
|
||||
tracker_id: { type: :string },
|
||||
topic: { type: :string },
|
||||
altitude: { type: :number },
|
||||
longitude: { type: :number },
|
||||
velocity: { type: :number },
|
||||
trigger: { type: :string },
|
||||
bssid: { type: :string },
|
||||
ssid: { type: :string },
|
||||
connection: { type: :string },
|
||||
vertical_accuracy: { type: :number },
|
||||
accuracy: { type: :number },
|
||||
timestamp: { type: :number },
|
||||
latitude: { type: :number },
|
||||
mode: { type: :number },
|
||||
inrids: { type: :array },
|
||||
in_regions: { type: :array },
|
||||
raw_data: { type: :string },
|
||||
import_id: { type: :string },
|
||||
city: { type: :string },
|
||||
country: { type: :string },
|
||||
created_at: { type: :string },
|
||||
updated_at: { type: :string },
|
||||
user_id: { type: :integer },
|
||||
geodata: { type: :string },
|
||||
visit_id: { type: :string }
|
||||
}
|
||||
}
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:areas) { create_list(:area, 3, user:) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:start_at) { Time.zone.now - 1.day }
|
||||
let(:end_at) { Time.zone.now }
|
||||
let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/points/{id}' do
|
||||
delete 'Deletes a point' do
|
||||
tags 'Points'
|
||||
produces 'application/json'
|
||||
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'Point ID'
|
||||
|
||||
response '200', 'point deleted' do
|
||||
let(:user) { create(:user) }
|
||||
let(:point) { create(:point, user:) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:id) { point.id }
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
spec/tasks/import_spec.rb
Normal file
14
spec/tasks/import_spec.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'import.rake' do
|
||||
let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'calls importing class' do
|
||||
expect(Tasks::Imports::GoogleRecords).to receive(:new).with(file_path, user.email).and_call_original.once
|
||||
|
||||
Rake::Task['import:big_file'].invoke(file_path, user.email)
|
||||
end
|
||||
end
|
||||
|
|
@ -85,6 +85,27 @@ paths:
|
|||
- latitude
|
||||
- longitude
|
||||
- radius
|
||||
"/api/v1/areas/{id}":
|
||||
delete:
|
||||
summary: Deletes an area
|
||||
tags:
|
||||
- Areas
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Area ID
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: area deleted
|
||||
"/api/v1/overland/batches":
|
||||
post:
|
||||
summary: Creates a batch of points
|
||||
|
|
@ -283,6 +304,117 @@ paths:
|
|||
isorcv: '2024-02-03T13:00:03Z'
|
||||
isotst: '2024-02-03T13:00:03Z'
|
||||
disptst: '2024-02-03 13:00:03'
|
||||
"/api/v1/points":
|
||||
get:
|
||||
summary: Retrieves all points
|
||||
tags:
|
||||
- Points
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
- name: start_at
|
||||
in: query
|
||||
description: Start date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)
|
||||
schema:
|
||||
type: string
|
||||
- name: end_at
|
||||
in: query
|
||||
description: End date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: points found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
battery_status:
|
||||
type: number
|
||||
ping:
|
||||
type: number
|
||||
battery:
|
||||
type: number
|
||||
tracker_id:
|
||||
type: string
|
||||
topic:
|
||||
type: string
|
||||
altitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
velocity:
|
||||
type: number
|
||||
trigger:
|
||||
type: string
|
||||
bssid:
|
||||
type: string
|
||||
ssid:
|
||||
type: string
|
||||
connection:
|
||||
type: string
|
||||
vertical_accuracy:
|
||||
type: number
|
||||
accuracy:
|
||||
type: number
|
||||
timestamp:
|
||||
type: number
|
||||
latitude:
|
||||
type: number
|
||||
mode:
|
||||
type: number
|
||||
inrids:
|
||||
type: array
|
||||
in_regions:
|
||||
type: array
|
||||
raw_data:
|
||||
type: string
|
||||
import_id:
|
||||
type: string
|
||||
city:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
user_id:
|
||||
type: integer
|
||||
geodata:
|
||||
type: string
|
||||
visit_id:
|
||||
type: string
|
||||
"/api/v1/points/{id}":
|
||||
delete:
|
||||
summary: Deletes a point
|
||||
tags:
|
||||
- Points
|
||||
parameters:
|
||||
- name: api_key
|
||||
in: query
|
||||
required: true
|
||||
description: API Key
|
||||
schema:
|
||||
type: string
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Point ID
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: point deleted
|
||||
servers:
|
||||
- url: http://{defaultHost}
|
||||
variables:
|
||||
|
|
|
|||
Loading…
Reference in a new issue