Add a bunch of small changes and fixes, see CHANGELOG.md for details

This commit is contained in:
Eugene Burmakin 2024-07-31 19:35:35 +02:00
parent 3b600c1052
commit 04a2150959
32 changed files with 558 additions and 207 deletions

View file

@ -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

View file

@ -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' }

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 } }

View file

@ -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 => {

View file

@ -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: "&copy; <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: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>",
}).addTo(map);
}

View 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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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 %>"

View file

@ -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| %>
<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>

View file

@ -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>

View file

@ -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? %>

View file

@ -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

View 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

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20240625201842)
DataMigrate::Data.define(version: 20240730130922)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -8,10 +8,55 @@ RSpec.describe '/stats', type: :request do
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'when user is not signed in' do
describe 'GET /index' do
it 'renders a successful response' 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

View 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

View file

@ -64,4 +64,22 @@ describe 'Areas API', type: :request do
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

View 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
View 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

View file

@ -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: