mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 17:51:39 -05:00
Merge 8f24fd89ab into 001d294885
This commit is contained in:
commit
354cecde05
24 changed files with 166 additions and 59 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Source of imports is now being detected automatically.
|
||||
|
||||
|
||||
# [0.30.10] - 2025-08-19
|
||||
|
||||
|
||||
## Added
|
||||
|
||||
- Internal data structure for separate devices in a single user account.
|
||||
- Geodata from Immich and Photoprism now will also write `tracker_id` to the points table. This will allow to group points by device. It's a good idea to delete your existing imports from Photoprism and Immich and import them again. This will remove existing points and re-import them as long as photos are still available.
|
||||
- [ ] Add tracker_id index to points table
|
||||
|
||||
|
||||
|
||||
# [0.30.9] - 2025-08-19
|
||||
|
||||
## Changed
|
||||
|
|
@ -41,7 +52,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Scratch map is now working correctly.
|
||||
|
||||
|
||||
|
||||
# [0.30.7] - 2025-08-01
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ module PointValidation
|
|||
Point.where(
|
||||
lonlat: params[:lonlat],
|
||||
timestamp: params[:timestamp].to_i,
|
||||
tracker_id: params[:tracker_id],
|
||||
user_id:
|
||||
).exists?
|
||||
end
|
||||
|
|
|
|||
6
app/models/device.rb
Normal file
6
app/models/device.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class Device < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
validates :name, presence: true
|
||||
validates :identifier, presence: true, uniqueness: { scope: :user_id }
|
||||
end
|
||||
|
|
@ -9,10 +9,11 @@ class Point < ApplicationRecord
|
|||
belongs_to :user
|
||||
belongs_to :country, optional: true
|
||||
belongs_to :track, optional: true
|
||||
belongs_to :device, optional: true
|
||||
|
||||
validates :timestamp, :lonlat, presence: true
|
||||
validates :lonlat, uniqueness: {
|
||||
scope: %i[timestamp user_id],
|
||||
scope: %i[timestamp user_id device_id],
|
||||
message: 'already has a point at this location and time for this user',
|
||||
index: true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
has_many :tracks, dependent: :destroy
|
||||
has_many :devices, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
class PointSerializer
|
||||
EXCLUDED_ATTRIBUTES = %w[
|
||||
created_at updated_at visit_id id import_id user_id raw_data lonlat
|
||||
reverse_geocoded_at country_id
|
||||
reverse_geocoded_at country_id device_id
|
||||
].freeze
|
||||
|
||||
def initialize(point)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ class Immich::ImportGeodata
|
|||
latitude: asset['exifInfo']['latitude'],
|
||||
longitude: asset['exifInfo']['longitude'],
|
||||
lonlat: "SRID=4326;POINT(#{asset['exifInfo']['longitude']} #{asset['exifInfo']['latitude']})",
|
||||
timestamp: Time.zone.parse(asset['exifInfo']['dateTimeOriginal']).to_i
|
||||
timestamp: Time.zone.parse(asset['exifInfo']['dateTimeOriginal']).to_i,
|
||||
tracker_id: asset['deviceId']
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ class Photoprism::ImportGeodata
|
|||
latitude: asset['Lat'],
|
||||
longitude: asset['Lng'],
|
||||
lonlat: "SRID=4326;POINT(#{asset['Lng']} #{asset['Lat']})",
|
||||
timestamp: Time.zone.parse(asset['TakenAt']).to_i
|
||||
timestamp: Time.zone.parse(asset['TakenAt']).to_i,
|
||||
tracker_id: "#{asset['CameraMake']} #{asset['CameraModel']}"
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class Photos::Importer
|
|||
|
||||
def create_point(point, index)
|
||||
return 0 unless valid?(point)
|
||||
return 0 if point_exists?(point, point['timestamp'])
|
||||
return 0 if point_exists?(point, user_id)
|
||||
|
||||
Point.create(
|
||||
lonlat: point['lonlat'],
|
||||
|
|
@ -29,6 +29,7 @@ class Photos::Importer
|
|||
timestamp: point['timestamp'].to_i,
|
||||
raw_data: point,
|
||||
import_id: import.id,
|
||||
tracker_id: point['tracker_id'],
|
||||
user_id:
|
||||
)
|
||||
|
||||
|
|
|
|||
14
db/migrate/20250805184854_create_devices.rb
Normal file
14
db/migrate/20250805184854_create_devices.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateDevices < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :devices do |t|
|
||||
t.string :name, null: false
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :identifier, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :devices, :identifier
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250805184855_add_device_id_to_points.rb
Normal file
9
db/migrate/20250805184855_add_device_id_to_points.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDeviceIdToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_reference :points, :device, null: true, index: { algorithm: :concurrently }
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
class AddUniqueIndexToPointsWithDeviceId < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_index :points, [:lonlat, :timestamp, :user_id, :device_id],
|
||||
name: "index_points_on_lonlat_timestamp_user_id_device_id",
|
||||
unique: true,
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :points, name: "index_points_on_lonlat_timestamp_user_id_device_id", algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
14
db/migrate/20250810110943_add_index_to_points_tracker_id.rb
Normal file
14
db/migrate/20250810110943_add_index_to_points_tracker_id.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class AddIndexToPointsTrackerId < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_index :points, :tracker_id,
|
||||
name: "index_points_on_tracker_id",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :points, name: "index_points_on_tracker_id", algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
class RemoveOldUniqueIndexFromPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
remove_index :points, name: "index_points_on_lonlat_timestamp_user_id", algorithm: :concurrently
|
||||
end
|
||||
|
||||
def down
|
||||
add_index :points, [:lonlat, :timestamp, :user_id],
|
||||
name: "index_points_on_lonlat_timestamp_user_id",
|
||||
unique: true,
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
generated
18
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_08_10_111002) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -80,6 +80,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
create_table "devices", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.string "identifier", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["identifier"], name: "index_devices_on_identifier"
|
||||
t.index ["user_id"], name: "index_devices_on_user_id"
|
||||
end
|
||||
|
||||
create_table "exports", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "url"
|
||||
|
|
@ -187,6 +197,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
t.bigint "country_id"
|
||||
t.bigint "track_id"
|
||||
t.string "country_name"
|
||||
t.bigint "device_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"
|
||||
|
|
@ -195,15 +206,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
t.index ["country"], name: "index_points_on_country"
|
||||
t.index ["country_id"], name: "index_points_on_country_id"
|
||||
t.index ["country_name"], name: "index_points_on_country_name"
|
||||
t.index ["device_id"], name: "index_points_on_device_id"
|
||||
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"], name: "index_points_on_latitude_and_longitude"
|
||||
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
|
||||
t.index ["lonlat", "timestamp", "user_id", "device_id"], name: "index_points_on_lonlat_timestamp_user_id_device_id", unique: true
|
||||
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
|
||||
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
|
||||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
t.index ["track_id"], name: "index_points_on_track_id"
|
||||
t.index ["tracker_id"], name: "index_points_on_tracker_id"
|
||||
t.index ["trigger"], name: "index_points_on_trigger"
|
||||
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
|
||||
t.index ["user_id"], name: "index_points_on_user_id"
|
||||
|
|
@ -300,6 +313,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
|
|||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "devices", "users"
|
||||
add_foreign_key "notifications", "users"
|
||||
add_foreign_key "place_visits", "places"
|
||||
add_foreign_key "place_visits", "visits"
|
||||
|
|
|
|||
9
spec/factories/devices.rb
Normal file
9
spec/factories/devices.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :device do
|
||||
name { SecureRandom.uuid }
|
||||
user
|
||||
identifier { SecureRandom.uuid }
|
||||
end
|
||||
end
|
||||
|
|
@ -31,6 +31,7 @@ FactoryBot.define do
|
|||
lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" }
|
||||
user
|
||||
country_id { nil }
|
||||
device
|
||||
|
||||
# Add transient attribute to handle country strings
|
||||
transient do
|
||||
|
|
|
|||
2
spec/fixtures/files/immich/response.json
vendored
2
spec/fixtures/files/immich/response.json
vendored
|
|
@ -3,6 +3,7 @@
|
|||
{
|
||||
"assets": [
|
||||
{
|
||||
"deviceId": "MyString",
|
||||
"exifInfo": {
|
||||
"dateTimeOriginal": "2022-12-31T23:17:06.170Z",
|
||||
"latitude": 52.0000,
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"deviceId": "MyString",
|
||||
"exifInfo": {
|
||||
"dateTimeOriginal": "2022-12-31T23:21:53.140Z",
|
||||
"latitude": 52.0000,
|
||||
|
|
|
|||
|
|
@ -104,56 +104,13 @@ RSpec.describe PointValidation do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with integration tests', :db do
|
||||
# These tests require a database with PostGIS support
|
||||
# Only run them if using real database integration
|
||||
|
||||
let(:existing_timestamp) { 1_650_000_000 }
|
||||
let(:existing_point_params) do
|
||||
{
|
||||
lonlat: 'POINT(10.5 50.5)',
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
}
|
||||
context 'with point existing in device scope' do
|
||||
let(:existing_point) do
|
||||
create(:point, lonlat: 'POINT(10.5 50.5)', timestamp: Time.now.to_i, tracker_id: '123', user_id: user.id)
|
||||
end
|
||||
|
||||
before do
|
||||
# Skip this context if not in integration mode
|
||||
skip 'Skipping integration tests' unless ENV['RUN_INTEGRATION_TESTS']
|
||||
|
||||
# Create a point in the database
|
||||
existing_point = Point.create!(
|
||||
lonlat: "POINT(#{existing_point_params[:longitude]} #{existing_point_params[:latitude]})",
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns true when a point with same coordinates and timestamp exists' do
|
||||
params = {
|
||||
lonlat: 'POINT(10.5 50.5)',
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when a point with different coordinates exists' do
|
||||
params = {
|
||||
lonlat: 'POINT(10.6 50.5)',
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when a point with different timestamp exists' do
|
||||
params = {
|
||||
lonlat: 'POINT(10.5 50.5)',
|
||||
timestamp: existing_timestamp + 1
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
it 'returns true' do
|
||||
expect(validator.point_exists?(existing_point, user.id)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
13
spec/models/device_spec.rb
Normal file
13
spec/models/device_spec.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Device, type: :model do
|
||||
describe 'validations' do
|
||||
subject { build(:device) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:identifier) }
|
||||
it { is_expected.to validate_uniqueness_of(:identifier).scoped_to(:user_id) }
|
||||
end
|
||||
end
|
||||
|
|
@ -9,11 +9,15 @@ RSpec.describe Point, type: :model do
|
|||
it { is_expected.to belong_to(:country).optional }
|
||||
it { is_expected.to belong_to(:visit).optional }
|
||||
it { is_expected.to belong_to(:track).optional }
|
||||
it { is_expected.to belong_to(:device).optional }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { build(:point, timestamp: Time.current, lonlat: 'POINT(1.0 2.0)') }
|
||||
|
||||
it { is_expected.to validate_presence_of(:timestamp) }
|
||||
it { is_expected.to validate_presence_of(:lonlat) }
|
||||
it { is_expected.to validate_uniqueness_of(:lonlat).scoped_to(%i[timestamp user_id device_id]).with_message('already has a point at this location and time for this user') }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:places).through(:visits) }
|
||||
it { is_expected.to have_many(:trips).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:tracks).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:devices).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Api::V1::Countries::Borders', type: :request do
|
||||
describe 'GET /index' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns http unauthorized' do
|
||||
get '/api/v1/countries/borders'
|
||||
|
|
@ -22,6 +20,8 @@ RSpec.describe 'Api::V1::Countries::Borders', type: :request do
|
|||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'returns a list of countries with borders' do
|
||||
get '/api/v1/countries/borders', headers: { 'Authorization' => "Bearer #{user.api_key}" }
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ RSpec.describe Photos::Importer do
|
|||
let(:user) do
|
||||
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
|
||||
end
|
||||
let(:device) { create(:device, user:) }
|
||||
|
||||
let(:immich_data) do
|
||||
JSON.parse(File.read(Rails.root.join('spec/fixtures/files/immich/geodata.json')))
|
||||
|
|
@ -44,12 +45,28 @@ RSpec.describe Photos::Importer do
|
|||
|
||||
context 'when there are points with the same coordinates' do
|
||||
let!(:existing_point) do
|
||||
create(:point, lonlat: 'POINT(30.0000 59.0000)', timestamp: 978_296_400, user:)
|
||||
create(:point,
|
||||
lonlat: 'SRID=4326;POINT(30.0000 59.0000)',
|
||||
timestamp: 978_296_400,
|
||||
user: user,
|
||||
device: device,
|
||||
tracker_id: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates only new points' do
|
||||
expect { service }.to change { Point.count }.by(1)
|
||||
end
|
||||
|
||||
it 'does not create duplicate points' do
|
||||
service
|
||||
points = Point.where(
|
||||
lonlat: 'SRID=4326;POINT(30.0000 59.0000)',
|
||||
timestamp: 978_296_400,
|
||||
user_id: user.id
|
||||
)
|
||||
expect(points.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue