Merge pull request #699 from Freika/feature/api/points

This commit is contained in:
Evgenii Burmakin 2025-01-21 10:44:55 +01:00 committed by GitHub
commit 8bf69e1e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 687 additions and 47 deletions

View file

@ -1 +1 @@
0.22.5
0.23.0

View file

@ -5,10 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.22.5 - 2025-01-20
# 0.23.0 - 2025-01-20
## ⚠️ IMPORTANT ⚠️
This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version.
### Added
- `POST /api/v1/points/create` endpoint added.
- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations.
- `GET /api/v1/users/me` endpoint added to get current user.
# 0.22.4 - 2025-01-20

View file

@ -21,6 +21,12 @@ class Api::V1::PointsController < ApiController
render json: serialized_points
end
def create
Points::CreateJob.perform_later(batch_params, current_api_user.id)
render json: { message: 'Points are being processed' }
end
def update
point = current_api_user.tracked_points.find(params[:id])
@ -42,6 +48,10 @@ class Api::V1::PointsController < ApiController
params.require(:point).permit(:latitude, :longitude)
end
def batch_params
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
end
def point_serializer
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
end

View file

@ -6,7 +6,6 @@ class MapController < ApplicationController
def index
@points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
@countries_and_cities = CountriesAndCities.new(@points).call
@coordinates =
@points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country)
.map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Points::CreateJob < ApplicationJob
queue_as :default
def perform(params, user_id)
data = Points::Params.new(params, user_id).call
data.each_slice(1000) do |location_batch|
Point.upsert_all(
location_batch,
unique_by: %i[latitude longitude timestamp user_id],
returning: false
)
end
end
end

View file

@ -8,7 +8,11 @@ class Point < ApplicationRecord
belongs_to :user
validates :latitude, :longitude, :timestamp, presence: true
validates :timestamp, uniqueness: {
scope: %i[latitude longitude user_id],
message: 'already has a point at this location and time for this user',
index: true
}
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class Points::Params
attr_reader :data, :points, :user_id
def initialize(json, user_id)
@data = json.with_indifferent_access
@points = @data[:locations]
@user_id = user_id
end
def call
points.map do |point|
next unless params_valid?(point)
{
latitude: point[:geometry][:coordinates][1],
longitude: point[:geometry][:coordinates][0],
battery_status: point[:properties][:battery_state],
battery: battery_level(point[:properties][:battery_level]),
timestamp: DateTime.parse(point[:properties][:timestamp]),
altitude: point[:properties][:altitude],
tracker_id: point[:properties][:device_id],
velocity: point[:properties][:speed],
ssid: point[:properties][:wifi],
accuracy: point[:properties][:horizontal_accuracy],
vertical_accuracy: point[:properties][:vertical_accuracy],
course_accuracy: point[:properties][:course_accuracy],
course: point[:properties][:course],
raw_data: point,
user_id: user_id
}
end.compact
end
private
def battery_level(level)
value = (level.to_f * 100).to_i
value.positive? ? value : nil
end
def params_valid?(point)
point[:geometry].present? &&
point[:geometry][:coordinates].present? &&
point.dig(:properties, :timestamp).present?
end
end

View file

@ -68,7 +68,7 @@ Rails.application.routes.draw do
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[index destroy update]
resources :points, only: %i[index create update destroy]
resources :visits, only: %i[update]
resources :stats, only: :index

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class RemoveDuplicatePoints < ActiveRecord::Migration[8.0]
def up
# Find duplicate groups using a subquery
duplicate_groups =
Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count')
.group('latitude, longitude, timestamp, user_id')
.having('COUNT(*) > 1')
puts "Duplicate groups found: #{duplicate_groups.length}"
duplicate_groups.each do |group|
points = Point.where(
latitude: group.latitude,
longitude: group.longitude,
timestamp: group.timestamp,
user_id: group.user_id
).order(id: :asc)
# Keep the latest record and destroy all others
latest = points.last
points.where.not(id: latest.id).destroy_all
end
end
def down
# This migration cannot be reversed
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20250104204852)
DataMigrate::Data.define(version: 20250120154554)

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddCourseAndCourseAccuracyToPoints < ActiveRecord::Migration[8.0]
def change
add_column :points, :course, :decimal, precision: 8, scale: 5
add_column :points, :course_accuracy, :decimal, precision: 8, scale: 5
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddExternalTrackIdToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_column :points, :external_track_id, :string
add_index :points, :external_track_id, algorithm: :concurrently
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
add_index :points, %i[latitude longitude timestamp user_id],
unique: true,
name: 'unique_points_index',
algorithm: :concurrently
end
def down
remove_index :points, name: 'unique_points_index'
end
end

7
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do
ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -156,14 +156,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do
t.jsonb "geodata", default: {}, null: false
t.bigint "visit_id"
t.datetime "reverse_geocoded_at"
t.decimal "course", precision: 8, scale: 5
t.decimal "course_accuracy", precision: 8, scale: 5
t.string "external_track_id"
t.index ["altitude"], name: "index_points_on_altitude"
t.index ["battery"], name: "index_points_on_battery"
t.index ["battery_status"], name: "index_points_on_battery_status"
t.index ["city"], name: "index_points_on_city"
t.index ["connection"], name: "index_points_on_connection"
t.index ["country"], name: "index_points_on_country"
t.index ["external_track_id"], name: "index_points_on_external_track_id"
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_index", unique: true
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp"

View file

@ -25,6 +25,10 @@ FactoryBot.define do
import_id { '' }
city { nil }
country { nil }
reverse_geocoded_at { nil }
course { nil }
course_accuracy { nil }
external_track_id { nil }
user
trait :with_known_location do

View file

@ -10,11 +10,15 @@ FactoryBot.define do
trait :with_points do
after(:build) do |trip|
create_list(
:point, 25,
user: trip.user,
timestamp: trip.started_at + (1..1000).to_a.sample.minutes
)
(1..25).map do |i|
create(
:point,
:with_geodata,
:reverse_geocoded,
timestamp: trip.started_at + i.minutes,
user: trip.user
)
end
end
end
end

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,136 @@
{
"locations" : [
{
"type" : "Feature",
"geometry" : {
"type" : "Point",
"coordinates" : [
-122.40530871,
37.744304130000003
]
},
"properties" : {
"horizontal_accuracy" : 5,
"track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F",
"speed_accuracy" : 0,
"vertical_accuracy" : -1,
"course_accuracy" : 0,
"altitude" : 0,
"speed" : 92.087999999999994,
"course" : 27.07,
"timestamp" : "2025-01-17T21:03:01Z",
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46"
}
},
{
"type" : "Feature",
"properties" : {
"timestamp" : "2025-01-17T21:03:02Z",
"horizontal_accuracy" : 5,
"course" : 24.260000000000002,
"speed_accuracy" : 0,
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
"vertical_accuracy" : -1,
"altitude" : 0,
"track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F",
"speed" : 92.448000000000008,
"course_accuracy" : 0
},
"geometry" : {
"type" : "Point",
"coordinates" : [
-122.40518926999999,
37.744513759999997
]
}
},
{
"type" : "Feature",
"properties" : {
"altitude" : 0,
"horizontal_accuracy" : 5,
"speed" : 123.76800000000001,
"course_accuracy" : 0,
"speed_accuracy" : 0,
"course" : 309.73000000000002,
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
"timestamp" : "2025-01-17T21:18:38Z",
"vertical_accuracy" : -1
},
"geometry" : {
"type" : "Point",
"coordinates" : [
-122.28487643,
37.454486080000002
]
}
},
{
"type" : "Feature",
"properties" : {
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
"speed_accuracy" : 0,
"course_accuracy" : 0,
"speed" : 123.3,
"horizontal_accuracy" : 5,
"course" : 309.38,
"altitude" : 0,
"timestamp" : "2025-01-17T21:18:39Z",
"vertical_accuracy" : -1
},
"geometry" : {
"coordinates" : [
-122.28517332,
37.454684899999997
],
"type" : "Point"
}
},
{
"geometry" : {
"coordinates" : [
-122.28547306,
37.454883219999999
],
"type" : "Point"
},
"properties" : {
"course_accuracy" : 0,
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
"vertical_accuracy" : -1,
"course" : 309.73000000000002,
"speed_accuracy" : 0,
"timestamp" : "2025-01-17T21:18:40Z",
"horizontal_accuracy" : 5,
"speed" : 125.06400000000001,
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
"altitude" : 0
},
"type" : "Feature"
},
{
"geometry" : {
"type" : "Point",
"coordinates" : [
-122.28577665,
37.455080109999997
]
},
"properties" : {
"course_accuracy" : 0,
"speed_accuracy" : 0,
"speed" : 124.05600000000001,
"track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888",
"course" : 309.73000000000002,
"device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46",
"altitude" : 0,
"horizontal_accuracy" : 5,
"vertical_accuracy" : -1,
"timestamp" : "2025-01-17T21:18:41Z"
},
"type" : "Feature"
}
]
}

View file

@ -9,8 +9,17 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) }
let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) }
let!(:points1) do
(1..10).map do |i|
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
end
end
let!(:points2) do
(1..10).map do |i|
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
end
end
it 'enqueues Stats::CalculatingJob for each user' do
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Points::CreateJob, type: :job do
describe '#perform' do
subject(:perform) { described_class.new.perform(json, user.id) }
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:user) { create(:user) }
it 'creates a point' do
expect { perform }.to change { Point.count }.by(6)
end
end
end

View file

@ -26,7 +26,11 @@ RSpec.describe Import, type: :model do
describe '#years_and_months_tracked' do
let(:import) { create(:import) }
let(:timestamp) { Time.zone.local(2024, 11, 1) }
let!(:points) { create_list(:point, 3, import:, timestamp:) }
let!(:points) do
(1..3).map do |i|
create(:point, import:, timestamp: timestamp + i.minutes)
end
end
it 'returns years and months tracked' do
expect(import.years_and_months_tracked).to eq([[2024, 11]])

View file

@ -89,8 +89,14 @@ RSpec.describe Stat, type: :model do
subject { stat.points.to_a }
let(:stat) { create(:stat, year:, month: 1, user:) }
let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
let!(:points) { create_list(:point, 3, user:, timestamp:) }
let(:base_timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
let!(:points) do
[
create(:point, user:, timestamp: base_timestamp),
create(:point, user:, timestamp: base_timestamp + 1.hour),
create(:point, user:, timestamp: base_timestamp + 2.hours)
]
end
it 'returns points' do
expect(subject).to eq(points)

View file

@ -115,7 +115,11 @@ RSpec.describe User, type: :model do
end
describe '#years_tracked' do
let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
let!(:points) do
(1..3).map do |i|
create(:point, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0) + i.minutes)
end
end
it 'returns years tracked' do
expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }])

View file

@ -4,7 +4,11 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Points', type: :request do
let!(:user) { create(:user) }
let!(:points) { create_list(:point, 150, user:) }
let!(:points) do
(1..15).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
describe 'GET /index' do
context 'when regular version of points is requested' do
@ -21,7 +25,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(100)
expect(json_response.size).to eq(15)
end
it 'returns a list of points with pagination' do
@ -31,7 +35,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(10)
expect(json_response.size).to eq(5)
end
it 'returns a list of points with pagination headers' do
@ -40,7 +44,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
expect(response).to have_http_status(:ok)
expect(response.headers['X-Current-Page']).to eq('2')
expect(response.headers['X-Total-Pages']).to eq('15')
expect(response.headers['X-Total-Pages']).to eq('2')
end
end
@ -58,7 +62,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(100)
expect(json_response.size).to eq(15)
end
it 'returns a list of points with pagination' do
@ -68,7 +72,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
json_response = JSON.parse(response.body)
expect(json_response.size).to eq(10)
expect(json_response.size).to eq(5)
end
it 'returns a list of points with pagination headers' do
@ -77,7 +81,7 @@ RSpec.describe 'Api::V1::Points', type: :request do
expect(response).to have_http_status(:ok)
expect(response.headers['X-Current-Page']).to eq('2')
expect(response.headers['X-Total-Pages']).to eq('15')
expect(response.headers['X-Total-Pages']).to eq('2')
end
it 'returns a list of points with slim attributes' do

View file

@ -10,14 +10,20 @@ RSpec.describe 'Api::V1::Stats', type: :request do
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) do
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) do
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
let(:expected_json) do
{
totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
totalPointsTracked: points_in_2020.count + points_in_2021.count,
totalReverseGeocodedPoints: points_in_2020.count,
totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count,
totalCountriesVisited: 1,
totalCitiesVisited: 1,
yearlyStats: [

View file

@ -37,7 +37,11 @@ RSpec.describe '/exports', type: :request do
before { sign_in user }
context 'with valid parameters' do
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
it 'creates a new Export' do
expect { post exports_url, params: }.to change(Export, :count).by(1)

View file

@ -11,7 +11,11 @@ RSpec.describe 'Map', type: :request do
describe 'GET /index' do
context 'when user signed in' do
let(:user) { create(:user) }
let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
before { sign_in user }

View file

@ -7,7 +7,12 @@ RSpec.describe ExportSerializer do
subject(:serializer) { described_class.new(points, user_email).call }
let(:user_email) { 'ab@cd.com' }
let(:points) { create_list(:point, 2) }
let(:points) do
(1..2).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
let(:expected_json) do
{
user_email => {

View file

@ -6,7 +6,12 @@ RSpec.describe Points::GeojsonSerializer do
describe '#call' do
subject(:serializer) { described_class.new(points).call }
let(:points) { create_list(:point, 3) }
let(:points) do
(1..3).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
let(:expected_json) do
{
type: 'FeatureCollection',

View file

@ -6,7 +6,11 @@ RSpec.describe Points::GpxSerializer do
describe '#call' do
subject(:serializer) { described_class.new(points, 'some_name').call }
let(:points) { create_list(:point, 3) }
let(:points) do
(1..3).map do |i|
create(:point, timestamp: 1.day.ago + i.minutes)
end
end
it 'returns GPX file' do
expect(serializer).to be_a(GPX::GPXFile)

View file

@ -29,16 +29,20 @@ RSpec.describe StatsSerializer do
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) do
create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:)
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:)
end
end
let!(:points_in_2021) do
create_list(:point, 95, timestamp: Time.zone.local(2021), user:)
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:)
end
end
let(:expected_json) do
{
"totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum,
"totalPointsTracked": points_in_2020.count + points_in_2021.count,
"totalReverseGeocodedPoints": points_in_2020.count,
"totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count,
"totalCountriesVisited": 1,
"totalCitiesVisited": 1,
"yearlyStats": [

View file

@ -15,7 +15,12 @@ RSpec.describe Exports::Create do
let(:export_content) { Points::GeojsonSerializer.new(points).call }
let(:reverse_geocoded_at) { Time.zone.local(2021, 1, 1) }
let!(:points) do
create_list(:point, 10, :with_known_location, user:, timestamp: start_at.to_datetime.to_i, reverse_geocoded_at:)
10.times.map do |i|
create(:point, :with_known_location,
user: user,
timestamp: start_at.to_datetime.to_i + i,
reverse_geocoded_at: reverse_geocoded_at)
end
end
before do

View file

@ -7,7 +7,7 @@ RSpec.describe GoogleMaps::RecordsParser do
subject(:parser) { described_class.new(import).call(json) }
let(:import) { create(:import) }
let(:time) { Time.zone.now }
let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) }
let(:json) do
{
'latitudeE7' => 123_456_789,
@ -31,7 +31,7 @@ RSpec.describe GoogleMaps::RecordsParser do
before do
create(
:point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789,
timestamp: Time.zone.now.to_i
timestamp: time.to_i
)
end

View file

@ -8,7 +8,12 @@ RSpec.describe Jobs::Create do
context 'when job_name is start_reverse_geocoding' do
let(:user) { create(:user) }
let(:points) { create_list(:point, 4, user:) }
let(:points) do
(1..4).map do |i|
create(:point, user:, timestamp: 1.day.ago + i.minutes)
end
end
let(:job_name) { 'start_reverse_geocoding' }
it 'enqueues reverse geocoding for all user points' do
@ -24,8 +29,17 @@ RSpec.describe Jobs::Create do
context 'when job_name is continue_reverse_geocoding' do
let(:user) { create(:user) }
let(:points_without_address) { create_list(:point, 4, user:, country: nil, city: nil) }
let(:points_with_address) { create_list(:point, 5, user:, country: 'Country', city: 'City') }
let(:points_without_address) do
(1..4).map do |i|
create(:point, user:, country: nil, city: nil, timestamp: 1.day.ago + i.minutes)
end
end
let(:points_with_address) do
(1..5).map do |i|
create(:point, user:, country: 'Country', city: 'City', timestamp: 1.day.ago + i.minutes)
end
end
let(:job_name) { 'continue_reverse_geocoding' }

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Points::Params do
describe '#call' do
let(:user) { create(:user) }
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:expected_json) do
{
latitude: 37.74430413,
longitude: -122.40530871,
battery_status: nil,
battery: nil,
timestamp: DateTime.parse('2025-01-17T21:03:01Z'),
altitude: 0,
tracker_id: '8D5D4197-245B-4619-A88B-2049100ADE46',
velocity: 92.088,
ssid: nil,
accuracy: 5,
vertical_accuracy: -1,
course_accuracy: 0,
course: 27.07,
raw_data: {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.40530871, 37.74430413]
},
properties: {
horizontal_accuracy: 5,
track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F',
speed_accuracy: 0,
vertical_accuracy: -1,
course_accuracy: 0,
altitude: 0,
speed: 92.088,
course: 27.07,
timestamp: '2025-01-17T21:03:01Z',
device_id: '8D5D4197-245B-4619-A88B-2049100ADE46'
}
}.with_indifferent_access,
user_id: user.id
}
end
subject(:params) { described_class.new(json, user.id).call }
it 'returns an array of points' do
expect(params).to be_an(Array)
expect(params.first).to eq(expected_json)
end
it 'returns the correct number of points' do
expect(params.size).to eq(6)
end
it 'returns correct keys' do
expect(params.first.keys).to eq(expected_json.keys)
end
it 'returns the correct values' do
expect(params.first).to eq(expected_json)
end
end
end

View file

@ -58,7 +58,92 @@ describe 'Points API', type: :request do
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) }
let(:points) do
(1..10).map do |i|
create(:point, user:, timestamp: 2.hours.ago + i.minutes)
end
end
run_test!
end
end
post 'Creates a batch of points' do
request_body_example value: {
locations: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.40530871, 37.74430413]
},
properties: {
timestamp: '2025-01-17T21:03:01Z',
horizontal_accuracy: 5,
vertical_accuracy: -1,
altitude: 0,
speed: 92.088,
speed_accuracy: 0,
course: 27.07,
course_accuracy: 0,
track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F',
device_id: '8D5D4197-245B-4619-A88B-2049100ADE46'
}
}
]
}
tags 'Batches'
consumes 'application/json'
parameter name: :locations, in: :body, schema: {
type: :object,
properties: {
type: { type: :string },
geometry: {
type: :object,
properties: {
type: { type: :string },
coordinates: { type: :array, items: { type: :number } }
}
},
properties: {
type: :object,
properties: {
timestamp: { type: :string },
horizontal_accuracy: { type: :number },
vertical_accuracy: { type: :number },
altitude: { type: :number },
speed: { type: :number },
speed_accuracy: { type: :number },
course: { type: :number },
course_accuracy: { type: :number },
track_id: { type: :string },
device_id: { type: :string }
}
}
},
required: %w[geometry properties]
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'Batch of points being processed' do
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:params) { json }
let(:locations) { params['locations'] }
let(:api_key) { create(:user).api_key }
run_test!
end
response '401', 'Unauthorized' do
let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:params) { json }
let(:locations) { params['locations'] }
let(:api_key) { 'invalid_api_key' }
run_test!
end

View file

@ -57,8 +57,18 @@ describe 'Stats API', type: :request do
let!(:user) { create(:user) }
let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) }
let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) }
let!(:points_in_2020) { create_list(:point, 85, :with_geodata, timestamp: Time.zone.local(2020), user:) }
let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) }
let!(:points_in_2020) do
(1..85).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours,
user:)
end
end
let!(:points_in_2021) do
(1..95).map do |i|
create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours,
user:)
end
end
let(:api_key) { user.api_key }
run_test!

View file

@ -696,6 +696,87 @@ paths:
type: string
visit_id:
type: string
post:
summary: Creates a batch of points
tags:
- Batches
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: Batch of points being processed
'401':
description: Unauthorized
requestBody:
content:
application/json:
schema:
type: object
properties:
type:
type: string
geometry:
type: object
properties:
type:
type: string
coordinates:
type: array
items:
type: number
properties:
type: object
properties:
timestamp:
type: string
horizontal_accuracy:
type: number
vertical_accuracy:
type: number
altitude:
type: number
speed:
type: number
speed_accuracy:
type: number
course:
type: number
course_accuracy:
type: number
track_id:
type: string
device_id:
type: string
required:
- geometry
- properties
examples:
'0':
summary: Creates a batch of points
value:
locations:
- type: Feature
geometry:
type: Point
coordinates:
- -122.40530871
- 37.74430413
properties:
timestamp: '2025-01-17T21:03:01Z'
horizontal_accuracy: 5
vertical_accuracy: -1
altitude: 0
speed: 92.088
speed_accuracy: 0
course: 27.07
course_accuracy: 0
track_id: 799F32F5-89BB-45FB-A639-098B1B95B09F
device_id: 8D5D4197-245B-4619-A88B-2049100ADE46
"/api/v1/points/{id}":
delete:
summary: Deletes a point