Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .app_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.30.9
0.30.10
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


# [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
Expand All @@ -28,7 +39,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Scratch map is now working correctly.



# [0.30.7] - 2025-08-01

## Fixed
Expand Down
1 change: 1 addition & 0 deletions app/models/concerns/point_validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def point_exists?(params, user_id)
Point.where(
lonlat: params[:lonlat],
timestamp: params[:timestamp].to_i,
tracker_id: params[:tracker_id],
user_id:
).exists?
end
Expand Down
6 changes: 6 additions & 0 deletions app/models/device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Device < ApplicationRecord
belongs_to :user

validates :name, presence: true
validates :identifier, presence: true, uniqueness: { scope: :user_id }
end
3 changes: 2 additions & 1 deletion app/models/point.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Expand Down
2 changes: 1 addition & 1 deletion app/serializers/point_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/services/immich/import_geodata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def extract_geodata(asset)
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

Expand Down
3 changes: 2 additions & 1 deletion app/services/photoprism/import_geodata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ def extract_geodata(asset)
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

Expand Down
3 changes: 2 additions & 1 deletion app/services/photos/importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def call

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'],
Expand All @@ -28,6 +28,7 @@ def create_point(point, index)
timestamp: point['timestamp'].to_i,
raw_data: point,
import_id: import.id,
tracker_id: point['tracker_id'],
user_id:
)

Expand Down
14 changes: 14 additions & 0 deletions db/migrate/20250805184854_create_devices.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions db/migrate/20250805184855_add_device_id_to_points.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions db/migrate/20250810110943_add_index_to_points_tracker_id.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions db/migrate/20250810111002_remove_old_unique_index_from_points.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions spec/factories/devices.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

FactoryBot.define do
factory :device do
name { SecureRandom.uuid }
user
identifier { SecureRandom.uuid }
end
end
1 change: 1 addition & 0 deletions spec/factories/points.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" }
user
country_id { nil }
device

# Add transient attribute to handle country strings
transient do
Expand Down
2 changes: 2 additions & 0 deletions spec/fixtures/files/immich/response.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
{
"assets": [
{
"deviceId": "MyString",
"exifInfo": {
"dateTimeOriginal": "2022-12-31T23:17:06.170Z",
"latitude": 52.0000,
"longitude": 13.0000
}
},
{
"deviceId": "MyString",
"exifInfo": {
"dateTimeOriginal": "2022-12-31T23:21:53.140Z",
"latitude": 52.0000,
Expand Down
53 changes: 5 additions & 48 deletions spec/models/concerns/point_validation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,56 +104,13 @@
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
}
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
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

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
Expand Down
13 changes: 13 additions & 0 deletions spec/models/device_spec.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions spec/models/point_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
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
Expand Down
1 change: 1 addition & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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
Expand Down
4 changes: 2 additions & 2 deletions spec/requests/api/v1/countries/borders_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

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'
Expand All @@ -22,6 +20,8 @@
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}" }

Expand Down
Loading