Compare commits

...

5 commits

Author SHA1 Message Date
Lukas Kühne
e3ec194d78
Merge ced62253c9 into 699504f4e9 2025-07-15 00:43:32 +02:00
Evgenii Burmakin
699504f4e9
Merge pull request #1517 from Freika/fix/api-user-serializer
Add user serializer and update CHANGELOG.md
2025-07-14 21:23:48 +02:00
Eugene Burmakin
878d863569 Fix some tests 2025-07-14 21:15:45 +02:00
Eugene Burmakin
24378b150d Add user serializer and update CHANGELOG.md 2025-07-13 12:50:24 +02:00
eyko139
ced62253c9
Transform search dates to ISO 8601 UTC format for immich photo search 2025-06-26 18:09:05 +02:00
21 changed files with 226 additions and 52 deletions

View file

@ -19,6 +19,39 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Notification about Photon API load is now disabled. - Notification about Photon API load is now disabled.
- All distance values are now stored in the database in meters. Conversion to user's preferred unit is done on the fly. - All distance values are now stored in the database in meters. Conversion to user's preferred unit is done on the fly.
- Every night, Dawarich will try to fetch names for places and visits that don't have them. #1281 #902 #583 #212 - Every night, Dawarich will try to fetch names for places and visits that don't have them. #1281 #902 #583 #212
- ⚠️ User settings are now being serialized in a more consistent way ⚠. `GET /api/v1/users/me` now returns the following data structure:
```json
{
"user": {
"email": "test@example.com",
"theme": "light",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"settings": {
"maps": {
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"name": "Custom OpenStreetMap",
"distance_unit": "km"
},
"fog_of_war_meters": 51,
"meters_between_routes": 500,
"preferred_map_layer": "Light",
"speed_colored_routes": false,
"points_rendering_mode": "raw",
"minutes_between_routes": 30,
"time_threshold_minutes": 30,
"merge_threshold_minutes": 15,
"live_map_enabled": false,
"route_opacity": 0.3,
"immich_url": "https://persistence-test-1752264458724.com",
"photoprism_url": "",
"visits_suggestions_enabled": true,
"speed_color_scale": "0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300",
"fog_of_war_threshold": 5
}
}
}
```
## Fixed ## Fixed

View file

@ -2,6 +2,6 @@
class Api::V1::UsersController < ApiController class Api::V1::UsersController < ApiController
def me def me
render json: { user: current_api_user } render json: Api::UserSerializer.new(current_api_user).call
end end
end end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
class Api::UserSerializer
def initialize(user)
@user = user
end
def call
{
user: {
email: user.email,
theme: user.theme,
created_at: user.created_at,
updated_at: user.updated_at,
settings: settings,
}
}
end
private
attr_reader :user
def settings
{
maps: user.safe_settings.maps,
fog_of_war_meters: user.safe_settings.fog_of_war_meters.to_i,
meters_between_routes: user.safe_settings.meters_between_routes.to_i,
preferred_map_layer: user.safe_settings.preferred_map_layer,
speed_colored_routes: user.safe_settings.speed_colored_routes,
points_rendering_mode: user.safe_settings.points_rendering_mode,
minutes_between_routes: user.safe_settings.minutes_between_routes.to_i,
time_threshold_minutes: user.safe_settings.time_threshold_minutes.to_i,
merge_threshold_minutes: user.safe_settings.merge_threshold_minutes.to_i,
live_map_enabled: user.safe_settings.live_map_enabled,
route_opacity: user.safe_settings.route_opacity.to_f,
immich_url: user.safe_settings.immich_url,
photoprism_url: user.safe_settings.photoprism_url,
visits_suggestions_enabled: user.safe_settings.visits_suggestions_enabled?,
speed_color_scale: user.safe_settings.speed_color_scale,
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
}
end
end

View file

@ -7,8 +7,8 @@ class Immich::RequestPhotos
@user = user @user = user
@immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata") @immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata")
@immich_api_key = user.safe_settings.immich_api_key @immich_api_key = user.safe_settings.immich_api_key
@start_date = start_date @start_date = normalize_date(start_date)
@end_date = end_date @end_date = normalize_date(end_date)
end end
def call def call
@ -22,6 +22,15 @@ class Immich::RequestPhotos
private private
def normalize_date(raw_date)
return nil if raw_date.nil?
time = Time.zone.parse(raw_date.to_s)
time.utc.iso8601
rescue ArgumentError => e
raise ArgumentError, "Invalid date format for '#{raw_date}': #{e.message}"
end
def retrieve_immich_data def retrieve_immich_data
page = 1 page = 1
data = [] data = []

View file

@ -45,7 +45,9 @@ class Users::SafeSettings
photoprism_api_key: photoprism_api_key, photoprism_api_key: photoprism_api_key,
maps: maps, maps: maps,
distance_unit: distance_unit, distance_unit: distance_unit,
visits_suggestions_enabled: visits_suggestions_enabled? visits_suggestions_enabled: visits_suggestions_enabled?,
speed_color_scale: speed_color_scale,
fog_of_war_threshold: fog_of_war_threshold
} }
end end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength
@ -118,4 +120,12 @@ class Users::SafeSettings
def visits_suggestions_enabled? def visits_suggestions_enabled?
settings['visits_suggestions_enabled'] == 'true' settings['visits_suggestions_enabled'] == 'true'
end end
def speed_color_scale
settings['speed_color_scale']
end
def fog_of_war_threshold
settings['fog_of_war_threshold']
end
end end

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Visits::Suggest class Visits::Suggest
include Rails.application.routes.url_helpers
attr_reader :points, :user, :start_at, :end_at attr_reader :points, :user, :start_at, :end_at
def initialize(user, start_at:, end_at:) def initialize(user, start_at:, end_at:)
@ -14,6 +12,7 @@ class Visits::Suggest
def call def call
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
create_visits_notification(user) if visits.any? create_visits_notification(user) if visits.any?
return nil unless DawarichSettings.reverse_geocoding_enabled? return nil unless DawarichSettings.reverse_geocoding_enabled?
@ -35,7 +34,7 @@ class Visits::Suggest
def create_visits_notification(user) def create_visits_notification(user)
content = <<~CONTENT content = <<~CONTENT
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="#{visits_path}" class="link">Visits</a> page. New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="/visits" class="link">Visits</a> page.
CONTENT CONTENT
user.notifications.create!( user.notifications.create!(

View file

@ -7,12 +7,28 @@ RSpec.describe 'Api::V1::Users', type: :request do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
it 'returns http success' do it 'returns success response' do
get '/api/v1/users/me', headers: headers get '/api/v1/users/me', headers: headers
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include(user.email) end
expect(response.body).to include(user.id.to_s)
it 'returns only the keys and values stated in the serializer' do
get '/api/v1/users/me', headers: headers
json = JSON.parse(response.body, symbolize_names: true)
expect(json.keys).to eq([:user])
expect(json[:user].keys).to match_array(
%i[email theme created_at updated_at settings]
)
expect(json[:user][:settings].keys).to match_array(%i[
maps fog_of_war_meters meters_between_routes preferred_map_layer
speed_colored_routes points_rendering_mode minutes_between_routes
time_threshold_minutes merge_threshold_minutes live_map_enabled
route_opacity immich_url photoprism_url visits_suggestions_enabled
speed_color_scale fog_of_war_threshold
])
end end
end end
end end

View file

@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::UserSerializer do
describe '#call' do
subject(:serializer) { described_class.new(user).call }
let(:user) { create(:user, email: 'test@example.com', theme: 'dark') }
it 'returns JSON with correct user attributes' do
expect(serializer[:user][:email]).to eq(user.email)
expect(serializer[:user][:theme]).to eq(user.theme)
expect(serializer[:user][:created_at]).to eq(user.created_at)
expect(serializer[:user][:updated_at]).to eq(user.updated_at)
end
it 'returns settings with expected keys and types' do
settings = serializer[:user][:settings]
expect(settings).to include(
:maps,
:fog_of_war_meters,
:meters_between_routes,
:preferred_map_layer,
:speed_colored_routes,
:points_rendering_mode,
:minutes_between_routes,
:time_threshold_minutes,
:merge_threshold_minutes,
:live_map_enabled,
:route_opacity,
:immich_url,
:photoprism_url,
:visits_suggestions_enabled,
:speed_color_scale,
:fog_of_war_threshold
)
end
context 'with custom settings' do
let(:custom_settings) do
{
'fog_of_war_meters' => 123,
'meters_between_routes' => 456,
'preferred_map_layer' => 'Satellite',
'speed_colored_routes' => true,
'points_rendering_mode' => 'cluster',
'minutes_between_routes' => 42,
'time_threshold_minutes' => 99,
'merge_threshold_minutes' => 77,
'live_map_enabled' => false,
'route_opacity' => 0.75,
'immich_url' => 'https://immich.example.com',
'photoprism_url' => 'https://photoprism.example.com',
'visits_suggestions_enabled' => 'false',
'speed_color_scale' => 'rainbow',
'fog_of_war_threshold' => 5,
'maps' => { 'distance_unit' => 'mi' }
}
end
let(:user) { create(:user, settings: custom_settings) }
it 'serializes custom settings correctly' do
settings = serializer[:user][:settings]
expect(settings[:fog_of_war_meters]).to eq(123)
expect(settings[:meters_between_routes]).to eq(456)
expect(settings[:preferred_map_layer]).to eq('Satellite')
expect(settings[:speed_colored_routes]).to eq(true)
expect(settings[:points_rendering_mode]).to eq('cluster')
expect(settings[:minutes_between_routes]).to eq(42)
expect(settings[:time_threshold_minutes]).to eq(99)
expect(settings[:merge_threshold_minutes]).to eq(77)
expect(settings[:live_map_enabled]).to eq(false)
expect(settings[:route_opacity]).to eq(0.75)
expect(settings[:immich_url]).to eq('https://immich.example.com')
expect(settings[:photoprism_url]).to eq('https://photoprism.example.com')
expect(settings[:visits_suggestions_enabled]).to eq(false)
expect(settings[:speed_color_scale]).to eq('rainbow')
expect(settings[:fog_of_war_threshold]).to eq(5)
expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' })
end
end
end
end

View file

@ -27,7 +27,9 @@ RSpec.describe Users::SafeSettings do
photoprism_api_key: nil, photoprism_api_key: nil,
maps: { "distance_unit" => "km" }, maps: { "distance_unit" => "km" },
distance_unit: 'km', distance_unit: 'km',
visits_suggestions_enabled: true visits_suggestions_enabled: true,
speed_color_scale: nil,
fog_of_war_threshold: nil
} }
) )
end end
@ -98,7 +100,9 @@ RSpec.describe Users::SafeSettings do
photoprism_api_key: "photoprism-key", photoprism_api_key: "photoprism-key",
maps: { "name" => "custom", "url" => "https://custom.example.com" }, maps: { "name" => "custom", "url" => "https://custom.example.com" },
distance_unit: nil, distance_unit: nil,
visits_suggestions_enabled: false visits_suggestions_enabled: false,
speed_color_scale: nil,
fog_of_war_threshold: nil
} }
) )
end end

View file

@ -81,7 +81,7 @@ RSpec.describe Visits::Suggest do
before do before do
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
# Create points for reverse geocoding test in a separate time range
create_visit_points(user, reverse_geocoding_start_at) create_visit_points(user, reverse_geocoding_start_at)
clear_enqueued_jobs clear_enqueued_jobs
end end

View file

@ -29,19 +29,22 @@ describe 'Users API', type: :request do
settings: { settings: {
type: :object, type: :object,
properties: { properties: {
immich_url: { type: :string }, maps: { type: :object },
route_opacity: { type: :string }, fog_of_war_meters: { type: :integer },
immich_api_key: { type: :string }, meters_between_routes: { type: :integer },
live_map_enabled: { type: :boolean },
fog_of_war_meters: { type: :string },
preferred_map_layer: { type: :string }, preferred_map_layer: { type: :string },
speed_colored_routes: { type: :boolean }, speed_colored_routes: { type: :boolean },
meters_between_routes: { type: :string },
points_rendering_mode: { type: :string }, points_rendering_mode: { type: :string },
minutes_between_routes: { type: :string }, minutes_between_routes: { type: :integer },
time_threshold_minutes: { type: :string }, time_threshold_minutes: { type: :integer },
merge_threshold_minutes: { type: :string }, merge_threshold_minutes: { type: :integer },
speed_colored_polylines: { type: :boolean } live_map_enabled: { type: :boolean },
route_opacity: { type: :number },
immich_url: { type: :string, nullable: true },
photoprism_url: { type: :string, nullable: true },
visits_suggestions_enabled: { type: :boolean },
speed_color_scale: { type: :string, nullable: true },
fog_of_war_threshold: { type: :string, nullable: true }
} }
}, },
admin: { type: :boolean } admin: { type: :boolean }

View file

@ -1,5 +0,0 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
end

View file

@ -1,11 +0,0 @@
require "test_helper"
class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
# test "connects with cookies" do
# cookies.signed[:user_id] = 42
#
# connect
#
# assert_equal connection.user_id, "42"
# end
end

View file

View file

View file

View file

View file

View file

View file

View file

@ -1,13 +0,0 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
end