From f72afdf446e1705afdc28cada3f0747f9761e48d Mon Sep 17 00:00:00 2001 From: kmorin Date: Sun, 5 Oct 2025 22:04:33 +0200 Subject: [PATCH 01/19] Get elevation from a remote service when missing in the import #910 - poc --- .env.docker.dev | 1 + .env.docker.example | 2 ++ .env.example | 1 + fittrackee/config.py | 1 + .../workout_from_file/workout_fit_service.py | 35 +++++++++++++++++++ 5 files changed, 40 insertions(+) diff --git a/.env.docker.dev b/.env.docker.dev index a08999055..99bc8f565 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -42,6 +42,7 @@ export SENDER_EMAIL=fittrackee@example.com # export STATICMAP_SUBDOMAINS= # export MAP_ATTRIBUTION= # export DEFAULT_STATICMAP=False +export OPEN_ELEVATION_API_URL=http://192.168.1.20:8888 # Geospatial features # export NOMINATIM_URL= diff --git a/.env.docker.example b/.env.docker.example index 132fb0d5f..1ac80a61c 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -39,11 +39,13 @@ export UI_URL= export EMAIL_URL= export SENDER_EMAIL= + # Workouts # export TILE_SERVER_URL= # export STATICMAP_SUBDOMAINS= # export MAP_ATTRIBUTION= # export DEFAULT_STATICMAP=False +# export OPEN_ELEVATION_API_URL= # Geospatial features # export NOMINATIM_URL= diff --git a/.env.example b/.env.example index 58cc7ee32..84b867bbf 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ export SENDER_EMAIL= # export STATICMAP_SUBDOMAINS= # export MAP_ATTRIBUTION= # export DEFAULT_STATICMAP=False +# export OPEN_ELEVATION_API_URL= # Geospatial features # export NOMINATIM_URL= diff --git a/fittrackee/config.py b/fittrackee/config.py index 17da22a13..f5dfb5589 100644 --- a/fittrackee/config.py +++ b/fittrackee/config.py @@ -51,6 +51,7 @@ class BaseConfig: ), "STATICMAP_SUBDOMAINS": os.environ.get("STATICMAP_SUBDOMAINS", ""), } + OPEN_ELEVATION_API_URL = os.environ.get("OPEN_ELEVATION_API_URL") DRAMATIQ_BROKER = broker diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index f858ecebb..c6293ddd0 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -2,12 +2,25 @@ import fitdecode import gpxpy.gpx +import requests +import logging +import sys +from flask import current_app from ...constants import NSMAP from ...exceptions import WorkoutFileException from .constants import GARMIN_DEVICES from .workout_gpx_service import WorkoutGpxService +log_format = logging.Formatter('[%(asctime)s] [%(levelname)s] - %(message)s') +log = logging.getLogger('test') +log.setLevel('DEBUG') + +# writing to stdout +handler = logging.StreamHandler(sys.stdout) +handler.setLevel('DEBUG') +handler.setFormatter(log_format) +log.addHandler(handler) class WorkoutFitService(WorkoutGpxService): @staticmethod @@ -83,6 +96,8 @@ def parse_file( ), ) + elevation_needed = False + for frame in event_and_record_frames: # create a new segment after 'stop_all' event if ( @@ -127,6 +142,9 @@ def parse_file( if frame.has_field("enhanced_altitude") else None ) + if not frame.has_field("enhanced_altitude"): + elevation_needed = True + heart_rate = ( frame.get_value("heart_rate") if frame.has_field("heart_rate") @@ -166,6 +184,23 @@ def parse_file( if gpx_segment.points: gpx_track.segments.append(gpx_segment) + if elevation_needed and current_app.config['OPEN_ELEVATION_API_URL']: + url = str(current_app.config['OPEN_ELEVATION_API_URL']) + '/api/v1/lookup' + data = [] + for segment in gpx_track.segments: + for point in segment.points: + data.append({'latitude': point.latitude, 'longitude': point.longitude}) + elevations = requests.post(url, json = { 'locations': data }) + results = elevations.json().get('results') + index = 0 + for segment in gpx_track.segments: + for point in segment.points: + elevation_point = results[index] + index += 1 + if not point.elevation and 'elevation' in elevation_point: + point.elevation = float(elevation_point.get('elevation')) + + if not gpx_track.segments: raise WorkoutFileException( "error", "no valid segments with GPS found in fit file" From e81b80926e9732bcde690997e22775194ce71227 Mon Sep 17 00:00:00 2001 From: kmorin Date: Tue, 7 Oct 2025 12:06:09 +0200 Subject: [PATCH 02/19] override elevation --- .../workouts/services/workout_from_file/workout_fit_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index c6293ddd0..435d92f52 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -197,8 +197,7 @@ def parse_file( for point in segment.points: elevation_point = results[index] index += 1 - if not point.elevation and 'elevation' in elevation_point: - point.elevation = float(elevation_point.get('elevation')) + point.elevation = float(elevation_point.get('elevation')) if not gpx_track.segments: From 9b1e23a4b08bf880e52e186fde22598fe0ebf3e7 Mon Sep 17 00:00:00 2001 From: kmorin Date: Tue, 7 Oct 2025 13:12:44 +0200 Subject: [PATCH 03/19] remove open elevation url from config --- .env.docker.dev | 2 +- .../workouts/services/workout_from_file/workout_fit_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.docker.dev b/.env.docker.dev index 99bc8f565..1f7b82c63 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -42,7 +42,7 @@ export SENDER_EMAIL=fittrackee@example.com # export STATICMAP_SUBDOMAINS= # export MAP_ATTRIBUTION= # export DEFAULT_STATICMAP=False -export OPEN_ELEVATION_API_URL=http://192.168.1.20:8888 +# export OPEN_ELEVATION_API_URL= # Geospatial features # export NOMINATIM_URL= diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index 435d92f52..3e8e93935 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -196,8 +196,8 @@ def parse_file( for segment in gpx_track.segments: for point in segment.points: elevation_point = results[index] - index += 1 point.elevation = float(elevation_point.get('elevation')) + index += 1 if not gpx_track.segments: From e9925c42f77c9f49fe0ca405a888e07392e4ea1a Mon Sep 17 00:00:00 2001 From: kmorin Date: Tue, 7 Oct 2025 13:24:30 +0200 Subject: [PATCH 04/19] add timeout to request to open elevation --- .../workouts/services/workout_from_file/workout_fit_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index 3e8e93935..2abd21928 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -190,7 +190,7 @@ def parse_file( for segment in gpx_track.segments: for point in segment.points: data.append({'latitude': point.latitude, 'longitude': point.longitude}) - elevations = requests.post(url, json = { 'locations': data }) + elevations = requests.post(url, json = { 'locations': data }, timeout=(3.05, 27)) results = elevations.json().get('results') index = 0 for segment in gpx_track.segments: From 75168869531151ec52511da4cf9ac76abea4fb00 Mon Sep 17 00:00:00 2001 From: kmorin Date: Tue, 7 Oct 2025 13:38:29 +0200 Subject: [PATCH 05/19] fix linter errors --- .../workout_from_file/workout_fit_service.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index 2abd21928..4da8c56d9 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -1,10 +1,10 @@ +import logging +import sys from typing import IO, Optional import fitdecode import gpxpy.gpx import requests -import logging -import sys from flask import current_app from ...constants import NSMAP @@ -181,16 +181,24 @@ def parse_file( "error", "error when parsing fit file" ) from e - if gpx_segment.points: + if gpx_segment.points: gpx_track.segments.append(gpx_segment) if elevation_needed and current_app.config['OPEN_ELEVATION_API_URL']: - url = str(current_app.config['OPEN_ELEVATION_API_URL']) + '/api/v1/lookup' + url = str(current_app.config['OPEN_ELEVATION_API_URL']) \ + + '/api/v1/lookup' data = [] for segment in gpx_track.segments: for point in segment.points: - data.append({'latitude': point.latitude, 'longitude': point.longitude}) - elevations = requests.post(url, json = { 'locations': data }, timeout=(3.05, 27)) + data.append({ + 'latitude': point.latitude, + 'longitude': point.longitude + }) + elevations = requests.post( + url, + json = { 'locations': data }, + timeout=(3.05, 27) + ) results = elevations.json().get('results') index = 0 for segment in gpx_track.segments: From 8153fc734c6283991e29306fdbce209b17247fe9 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 11 Oct 2025 12:46:30 +0200 Subject: [PATCH 06/19] API - init OpenElevation service --- fittrackee/tests/fixtures/fixtures_app.py | 10 + .../test_open_elevation_service.py | 201 ++++++++++++++++++ .../workouts/services/elevation/__init__.py | 0 .../elevation/open_elevation_service.py | 68 ++++++ 4 files changed, 279 insertions(+) create mode 100644 fittrackee/tests/workouts/test_services/test_open_elevation_service.py create mode 100644 fittrackee/workouts/services/elevation/__init__.py create mode 100644 fittrackee/workouts/services/elevation/open_elevation_service.py diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index 2da0e6ddc..7f8ef551e 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -115,6 +115,8 @@ def app(monkeypatch: pytest.MonkeyPatch) -> Generator: monkeypatch.delenv("DEFAULT_STATICMAP") if os.getenv("NOMINATIM_URL"): monkeypatch.delenv("NOMINATIM_URL") + if os.getenv("OPEN_ELEVATION_API_URL"): + monkeypatch.delenv("OPEN_ELEVATION_API_URL") yield from get_app(with_config=True) @@ -198,6 +200,14 @@ def app_with_nominatim_url(monkeypatch: pytest.MonkeyPatch) -> Generator: yield from get_app(with_config=True) +@pytest.fixture +def app_with_open_elevation_url(monkeypatch: pytest.MonkeyPatch) -> Generator: + monkeypatch.setenv( + "OPEN_ELEVATION_API_URL", "https://api.open-elevation.example.com" + ) + yield from get_app(with_config=True) + + @pytest.fixture def app_with_global_map_workouts_limit_equal_to_1( monkeypatch: pytest.MonkeyPatch, diff --git a/fittrackee/tests/workouts/test_services/test_open_elevation_service.py b/fittrackee/tests/workouts/test_services/test_open_elevation_service.py new file mode 100644 index 000000000..d065fe74e --- /dev/null +++ b/fittrackee/tests/workouts/test_services/test_open_elevation_service.py @@ -0,0 +1,201 @@ +from typing import TYPE_CHECKING +from unittest.mock import patch + +import requests +from gpxpy.gpx import GPXTrackPoint + +from fittrackee.workouts.services.elevation.open_elevation_service import ( + OpenElevationService, +) + +from ...mixins import ResponseMockMixin + +if TYPE_CHECKING: + from flask import Flask + +POINTS_WITHOUT_ELEVATION = [ + GPXTrackPoint(latitude=44.68095, longitude=6.07367), + GPXTrackPoint(latitude=44.68091, longitude=6.07367), + GPXTrackPoint(latitude=44.6808, longitude=6.07364), +] +OPEN_ELEVATION_RESPONSE = { + "results": [ + { + "elevation": 998.0, + "latitude": 44.68095, + "longitude": 6.07367, + }, + { + "elevation": 998.0, + "latitude": 44.68091, + "longitude": 6.07367, + }, + { + "elevation": 994.0, + "latitude": 44.6808, + "longitude": 6.07364, + }, + ] +} + + +class TestOpenElevationServiceInstantiation: + def test_it_instantiates_service_when_no_open_api_url_set_in_env_var( + self, app: "Flask" + ) -> None: + service = OpenElevationService() + + assert service.url is None + assert service.is_enabled is False + + def test_it_instantiates_service_when_nominatim_url_is_set_in_env_var( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + assert ( + service.url + == "https://api.open-elevation.example.com/api/v1/lookup" + ) + assert service.is_enabled is True + + +class TestOpenElevationServiceGetElevation(ResponseMockMixin): + def test_it_does_not_call_open_elevation_api_when_no_url_set( + self, app: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response({}), + ) as post_mock: + service.get_elevations(POINTS_WITHOUT_ELEVATION) + + post_mock.assert_not_called() + + def test_it_calls_open_elevation_api_with_given_points( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response(OPEN_ELEVATION_RESPONSE), + ) as post_mock: + service.get_elevations(POINTS_WITHOUT_ELEVATION) + + post_mock.assert_called_once_with( + service.url, + json={ + "locations": [ + { + "latitude": 44.68095, + "longitude": 6.07367, + }, + { + "latitude": 44.68091, + "longitude": 6.07367, + }, + { + "latitude": 44.6808, + "longitude": 6.07364, + }, + ] + }, + timeout=30, + ) + + def test_it_returns_elevations( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response(OPEN_ELEVATION_RESPONSE), + ): + result = service.get_elevations(POINTS_WITHOUT_ELEVATION) + + assert result == OPEN_ELEVATION_RESPONSE["results"] + + def test_it_returns_empty_list_when_open_elevation_api_raises_exception( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + side_effect=requests.exceptions.HTTPError, + ): + result = service.get_elevations(POINTS_WITHOUT_ELEVATION) + + assert result == [] + + def test_it_logs_error_when_open_elevation_api_raises_exception( + self, + app_with_open_elevation_url: "Flask", + ) -> None: + service = OpenElevationService() + + with ( + patch( + "fittrackee.workouts.services.elevation." + "open_elevation_service.appLog" + ) as logger_mock, + patch.object( + requests, + "post", + side_effect=requests.exceptions.HTTPError, + ), + ): + service.get_elevations(POINTS_WITHOUT_ELEVATION) + + logger_mock.exception.assert_called_once_with( + "Open Elevation API: error when getting missing elevations" + ) + + def test_it_returns_empty_list_when_number_of_elevation_returned_open_elevation_does_not_match( # noqa + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response( + {"results": OPEN_ELEVATION_RESPONSE["results"][:-1]} + ), + ): + result = service.get_elevations(POINTS_WITHOUT_ELEVATION) + + assert result == [] + + def test_it_logs_error_when_number_of_elevation_returned_open_elevation_does_not_match( # noqa + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with ( + patch( + "fittrackee.workouts.services.elevation." + "open_elevation_service.appLog" + ) as logger_mock, + patch.object( + requests, + "post", + return_value=self.get_response( + {"results": OPEN_ELEVATION_RESPONSE["results"][:-1]} + ), + ), + ): + service.get_elevations(POINTS_WITHOUT_ELEVATION) + + logger_mock.error.assert_called_once_with( + "Open Elevation API: mismatch between number of points in " + "results, ignoring results" + ) diff --git a/fittrackee/workouts/services/elevation/__init__.py b/fittrackee/workouts/services/elevation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/workouts/services/elevation/open_elevation_service.py b/fittrackee/workouts/services/elevation/open_elevation_service.py new file mode 100644 index 000000000..0c3aefa1f --- /dev/null +++ b/fittrackee/workouts/services/elevation/open_elevation_service.py @@ -0,0 +1,68 @@ +from typing import TYPE_CHECKING, Dict, List, Union + +import requests +from flask import current_app + +from fittrackee import appLog + +if TYPE_CHECKING: + from gpxpy.gpx import GPXTrackPoint + + +class OpenElevationService: + """ + Documentation: + https://github.com/Jorl17/open-elevation/blob/master/docs/api.md + """ + + def __init__(self) -> None: + self.url = self._get_api_url() + + @property + def is_enabled(self) -> bool: + return self.url is not None + + @staticmethod + def _get_api_url() -> Union[str, None]: + base_url = current_app.config["OPEN_ELEVATION_API_URL"] + if not base_url: + return None + return f"{base_url}/api/v1/lookup" + + def get_elevations(self, points: List["GPXTrackPoint"]) -> List[Dict]: + if not self.url: + return [] + + appLog.debug("Open Elevation API: getting missing elevations") + + try: + r = requests.post( + self.url, + json={ + "locations": [ + { + "latitude": point.latitude, + "longitude": point.longitude, + } + for point in points + ] + }, + timeout=30, + ) + r.raise_for_status() + except requests.exceptions.HTTPError: + appLog.exception( + "Open Elevation API: error when getting missing elevations" + ) + return [] + + results = r.json().get("results", []) + + # Should not happen + if len(results) != len(points): + appLog.error( + "Open Elevation API: mismatch between number of points in " + "results, ignoring results" + ) + return [] + return results From ed0134ecdd88c4681ac25c57619597cf10edbd0b Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 11 Oct 2025 13:35:43 +0200 Subject: [PATCH 07/19] API - move elevations update in WorkoutGpxService --- .../from_file/test_workout_gpx_service.py | 305 +++++++++++++++++- ...est_workouts_from_file_creation_service.py | 41 +++ .../workout_from_file/workout_fit_service.py | 44 +-- .../workout_from_file/workout_gpx_service.py | 15 + 4 files changed, 358 insertions(+), 47 deletions(-) diff --git a/fittrackee/tests/workouts/test_services/from_file/test_workout_gpx_service.py b/fittrackee/tests/workouts/test_services/from_file/test_workout_gpx_service.py index b9fc1fa7e..35ebe1146 100644 --- a/fittrackee/tests/workouts/test_services/from_file/test_workout_gpx_service.py +++ b/fittrackee/tests/workouts/test_services/from_file/test_workout_gpx_service.py @@ -1,9 +1,11 @@ +import re from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Dict from unittest.mock import MagicMock, call, patch import gpxpy import pytest +import requests from geoalchemy2.shape import to_shape from shapely import LineString, Point @@ -15,7 +17,7 @@ track_points_part_1_coordinates, track_points_part_2_coordinates, ) -from fittrackee.tests.mixins import RandomMixin +from fittrackee.tests.mixins import RandomMixin, ResponseMockMixin from fittrackee.tests.workouts.mixins import ( WorkoutAssertMixin, WorkoutFileMixin, @@ -33,6 +35,9 @@ WorkoutSegment, ) from fittrackee.workouts.services import WorkoutGpxService +from fittrackee.workouts.services.elevation.open_elevation_service import ( + OpenElevationService, +) from fittrackee.workouts.services.workout_from_file.workout_point import ( WorkoutPoint, ) @@ -42,6 +47,136 @@ from fittrackee.users.models import User +OPEN_ELEVATION_RESPONSE = { + "results": [ + { + "elevation": 998.0, + "latitude": 44.68095, + "longitude": 6.07367, + }, + { + "elevation": 998.0, + "latitude": 44.68091, + "longitude": 6.07367, + }, + { + "elevation": 994.0, + "latitude": 44.6808, + "longitude": 6.07364, + }, + { + "elevation": 994.0, + "latitude": 44.68075, + "longitude": 6.07364, + }, + { + "elevation": 994.0, + "latitude": 44.68071, + "longitude": 6.07364, + }, + { + "elevation": 993.0, + "latitude": 44.68049, + "longitude": 6.07361, + }, + { + "elevation": 992.0, + "latitude": 44.68019, + "longitude": 6.07356, + }, + { + "elevation": 992.0, + "latitude": 44.68014, + "longitude": 6.07355, + }, + { + "elevation": 987.0, + "latitude": 44.67995, + "longitude": 6.07358, + }, + { + "elevation": 987.0, + "latitude": 44.67977, + "longitude": 6.07364, + }, + { + "elevation": 987.0, + "latitude": 44.67972, + "longitude": 6.07367, + }, + { + "elevation": 987.0, + "latitude": 44.67966, + "longitude": 6.07368, + }, + { + "elevation": 986.0, + "latitude": 44.67961, + "longitude": 6.0737, + }, + { + "elevation": 986.0, + "latitude": 44.67938, + "longitude": 6.07377, + }, + { + "elevation": 986.0, + "latitude": 44.67933, + "longitude": 6.07381, + }, + { + "elevation": 985.0, + "latitude": 44.67922, + "longitude": 6.07385, + }, + { + "elevation": 980.0, + "latitude": 44.67911, + "longitude": 6.0739, + }, + { + "elevation": 980.0, + "latitude": 44.679, + "longitude": 6.07399, + }, + { + "elevation": 980.0, + "latitude": 44.67896, + "longitude": 6.07402, + }, + { + "elevation": 979.0, + "latitude": 44.67884, + "longitude": 6.07408, + }, + { + "elevation": 981.0, + "latitude": 44.67863, + "longitude": 6.07423, + }, + { + "elevation": 980.0, + "latitude": 44.67858, + "longitude": 6.07425, + }, + { + "elevation": 979.0, + "latitude": 44.67842, + "longitude": 6.07434, + }, + { + "elevation": 979.0, + "latitude": 44.67837, + "longitude": 6.07435, + }, + { + "elevation": 975.0, + "latitude": 44.67822, + "longitude": 6.07442, + }, + ] +} + class TestWorkoutGpxServiceParseFile(RandomMixin, WorkoutFileMixin): def test_it_raises_error_when_gpx_file_is_invalid( @@ -132,7 +267,10 @@ def test_it_calls_weather_service( @pytest.mark.disable_autouse_update_records_patch class TestWorkoutGpxServiceProcessFile( - WorkoutGpxInfoMixin, WorkoutFileMixin, WorkoutAssertMixin + WorkoutGpxInfoMixin, + WorkoutFileMixin, + WorkoutAssertMixin, + ResponseMockMixin, ): @staticmethod def assert_workout_records(workout: "Workout") -> None: @@ -571,6 +709,29 @@ def test_it_creates_workout_and_segment_when_raw_speed_is_true( "time": "2018-03-13 12:48:55+00:00", } + def test_it_does_not_call_open_elevation_service_when_file_has_no_missing_elevations( # noqa + self, + app: "Flask", + sport_1_cycling: Sport, + user_1: "User", + gpx_file: str, + ) -> None: + service = self.init_service_with_gpx(user_1, sport_1_cycling, gpx_file) + + with ( + patch.object( + OpenElevationService, + "_get_api_url", + return_value="https://api.open-elevation.example.com/api/v1/lookup", + ), + patch.object( + OpenElevationService, "get_elevations" + ) as get_elevations_mock, + ): + service.process_workout() + + get_elevations_mock.assert_not_called() + def test_it_creates_workout_and_segment_when_gpx_file_has_no_elevation( self, app: "Flask", @@ -578,13 +739,18 @@ def test_it_creates_workout_and_segment_when_gpx_file_has_no_elevation( user_1: "User", gpx_file_without_elevation: str, ) -> None: + # Open Elevation API URL is not set service = self.init_service_with_gpx( user_1, sport_1_cycling, gpx_file_without_elevation ) - service.process_workout() - db.session.commit() + with patch.object( + OpenElevationService, "get_elevations" + ) as get_elevations_mock: + service.process_workout() + db.session.commit() + get_elevations_mock.assert_not_called() assert service.workout_description is None assert service.workout_name == "just a workout" # workout @@ -647,6 +813,137 @@ def test_it_creates_workout_and_segment_when_gpx_file_has_no_elevation( "time": "2018-03-13 12:48:55+00:00", } + def test_it_creates_workout_and_segment_when_gpx_file_has_no_elevation_and_open_elevation_api_is_set( # noqa + self, + app: "Flask", + sport_1_cycling: Sport, + user_1: "User", + gpx_file_without_elevation: str, + ) -> None: + service = self.init_service_with_gpx( + user_1, sport_1_cycling, gpx_file_without_elevation + ) + + with ( + patch.object( + OpenElevationService, + "_get_api_url", + return_value="https://api.open-elevation.example.com/api/v1/lookup", + ), + patch.object( + requests, + "post", + return_value=self.get_response(OPEN_ELEVATION_RESPONSE), + ), + ): + service.process_workout() + db.session.commit() + + workout = Workout.query.one() + self.assert_workout(user_1, sport_1_cycling, workout) + self.assert_workout_segment(workout) + self.assert_workout_records(workout) + workout_segment = workout.segments[0] + coordinates = ( + track_points_part_1_coordinates + track_points_part_2_coordinates + ) + assert len(workout_segment.points) == len(coordinates) + assert workout_segment.points[0] == { + "distance": 0.0, + "duration": 0, + "elevation": 998.0, + "latitude": 44.68095, + "longitude": 6.07367, + "speed": 3.21, + "time": "2018-03-13 12:44:45+00:00", + } + assert workout_segment.points[-1] == { + "distance": 320.12787035769946, + "duration": 250, + "elevation": 975.0, + "latitude": 44.67822, + "longitude": 6.07442, + "speed": 4.33, + "time": "2018-03-13 12:48:55+00:00", + } + + def test_it_creates_workout_and_segment_when_open_elevation_api_returns_empty_list( # noqa + self, + app: "Flask", + sport_1_cycling: Sport, + user_1: "User", + gpx_file_without_elevation: str, + ) -> None: + service = self.init_service_with_gpx( + user_1, sport_1_cycling, gpx_file_without_elevation + ) + + with ( + patch.object( + OpenElevationService, + "_get_api_url", + return_value="https://api.open-elevation.example.com/api/v1/lookup", + ), + patch.object( + OpenElevationService, + "get_elevations", + return_value=[], + ), + ): + service.process_workout() + db.session.commit() + + workout = Workout.query.one() + workout_segment = workout.segments[0] + assert workout_segment.points[0] == { + "distance": 0.0, + "duration": 0, + "elevation": None, + "latitude": 44.68095, + "longitude": 6.07367, + "speed": 3.21, + "time": "2018-03-13 12:44:45+00:00", + } + assert workout_segment.points[-1] == { + "distance": 317.15294405358054, + "duration": 250, + "elevation": None, + "latitude": 44.67822, + "longitude": 6.07442, + "speed": 4.22, + "time": "2018-03-13 12:48:55+00:00", + } + + def test_it_calls_open_elevation_for_each_segment( + self, + app: "Flask", + sport_1_cycling: Sport, + user_1: "User", + gpx_file_with_3_segments: str, + ) -> None: + regex = re.compile("(.*)") + gpx_file = regex.sub("", gpx_file_with_3_segments) + service = self.init_service_with_gpx(user_1, sport_1_cycling, gpx_file) + + with ( + patch.object( + OpenElevationService, + "_get_api_url", + return_value="https://api.open-elevation.example.com/api/v1/lookup", + ), + patch.object( + OpenElevationService, + "get_elevations", + return_value=[], + ) as get_elevations_mock, + ): + service.process_workout() + db.session.commit() + + workout = Workout.query.one() + assert len(workout.segments) == 3 + assert get_elevations_mock.call_count == 3 + def test_it_creates_workout_and_segments_when_gpx_file_contains_3_segments( self, app: "Flask", diff --git a/fittrackee/tests/workouts/test_services/test_workouts_from_file_creation_service.py b/fittrackee/tests/workouts/test_services/test_workouts_from_file_creation_service.py index 597d17458..90dab7ab4 100644 --- a/fittrackee/tests/workouts/test_services/test_workouts_from_file_creation_service.py +++ b/fittrackee/tests/workouts/test_services/test_workouts_from_file_creation_service.py @@ -1,4 +1,5 @@ import os +import re import zipfile from datetime import datetime, timedelta, timezone from io import BytesIO @@ -34,6 +35,9 @@ WorkoutGpxService, WorkoutsFromFileCreationService, ) +from fittrackee.workouts.services.elevation.open_elevation_service import ( + OpenElevationService, +) from fittrackee.workouts.services.workouts_from_file_creation_service import ( WorkoutsData, ) @@ -1111,6 +1115,43 @@ def test_it_creates_file_in_user_directory_when_extension_is_tcx( with open(get_absolute_file_path(new_workout.original_file)) as f: assert f.read() == tcx_with_one_lap_and_one_track + def test_it_calls_open_elevation_when_elevations_are_missing_in_file_other_than_gpx( # noqa + self, + app: "Flask", + user_1: "User", + tcx_with_one_lap_and_one_track: str, + sport_1_cycling: "Sport", + ) -> None: + regex = re.compile("(.*)") + tcx_without_elevation = regex.sub("", tcx_with_one_lap_and_one_track) + kml_file_storage = FileStorage( + filename="file.tcx", + stream=BytesIO(str.encode(tcx_without_elevation)), + ) + service = WorkoutsFromFileCreationService( + auth_user=user_1, + file=kml_file_storage, + workouts_data={"sport_id": sport_1_cycling.id}, + ) + + with ( + patch.object( + OpenElevationService, + "_get_api_url", + return_value="https://api.open-elevation.example.com/api/v1/lookup", + ), + patch.object( + OpenElevationService, + "get_elevations", + return_value=[], + ) as get_elevations_mock, + ): + service.create_workout_from_file(extension="tcx", equipments=None) + db.session.commit() + + assert Workout.query.one() is not None + get_elevations_mock.assert_called_once() + def test_it_creates_workout_when_extension_is_fit( self, app: "Flask", diff --git a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py index 4da8c56d9..f858ecebb 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_fit_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_fit_service.py @@ -1,26 +1,13 @@ -import logging -import sys from typing import IO, Optional import fitdecode import gpxpy.gpx -import requests -from flask import current_app from ...constants import NSMAP from ...exceptions import WorkoutFileException from .constants import GARMIN_DEVICES from .workout_gpx_service import WorkoutGpxService -log_format = logging.Formatter('[%(asctime)s] [%(levelname)s] - %(message)s') -log = logging.getLogger('test') -log.setLevel('DEBUG') - -# writing to stdout -handler = logging.StreamHandler(sys.stdout) -handler.setLevel('DEBUG') -handler.setFormatter(log_format) -log.addHandler(handler) class WorkoutFitService(WorkoutGpxService): @staticmethod @@ -96,8 +83,6 @@ def parse_file( ), ) - elevation_needed = False - for frame in event_and_record_frames: # create a new segment after 'stop_all' event if ( @@ -142,9 +127,6 @@ def parse_file( if frame.has_field("enhanced_altitude") else None ) - if not frame.has_field("enhanced_altitude"): - elevation_needed = True - heart_rate = ( frame.get_value("heart_rate") if frame.has_field("heart_rate") @@ -181,33 +163,9 @@ def parse_file( "error", "error when parsing fit file" ) from e - if gpx_segment.points: + if gpx_segment.points: gpx_track.segments.append(gpx_segment) - if elevation_needed and current_app.config['OPEN_ELEVATION_API_URL']: - url = str(current_app.config['OPEN_ELEVATION_API_URL']) \ - + '/api/v1/lookup' - data = [] - for segment in gpx_track.segments: - for point in segment.points: - data.append({ - 'latitude': point.latitude, - 'longitude': point.longitude - }) - elevations = requests.post( - url, - json = { 'locations': data }, - timeout=(3.05, 27) - ) - results = elevations.json().get('results') - index = 0 - for segment in gpx_track.segments: - for point in segment.points: - elevation_point = results[index] - point.elevation = float(elevation_point.get('elevation')) - index += 1 - - if not gpx_track.segments: raise WorkoutFileException( "error", "no valid segments with GPS found in fit file" diff --git a/fittrackee/workouts/services/workout_from_file/workout_gpx_service.py b/fittrackee/workouts/services/workout_from_file/workout_gpx_service.py index 9032741dc..11c3f09d8 100644 --- a/fittrackee/workouts/services/workout_from_file/workout_gpx_service.py +++ b/fittrackee/workouts/services/workout_from_file/workout_gpx_service.py @@ -11,6 +11,7 @@ from ...exceptions import WorkoutExceedingValueException, WorkoutFileException from ...models import WORKOUT_VALUES_LIMIT, Workout, WorkoutSegment +from ..elevation.open_elevation_service import OpenElevationService from .base_workout_with_segment_service import ( BaseWorkoutWithSegmentsCreationService, ) @@ -250,6 +251,17 @@ def _process_segment_points( segment_points: List[Dict] = [] coordinates = [] + # Add elevation if OpenElevation is set and at least one value is + # missing: + elevations = [] + update_missing_elevation = False + open_elevation_service = OpenElevationService() + if open_elevation_service.is_enabled and any( + point.elevation is None for point in points + ): + elevations = open_elevation_service.get_elevations(points) + update_missing_elevation = len(elevations) > 0 + for point_idx, point in enumerate(points): if point_idx == 0: if not point.time: @@ -264,6 +276,9 @@ def _process_segment_points( point.time - previous_segment_last_point_time ) + if update_missing_elevation: + point.elevation = elevations[point_idx]["elevation"] + distance = ( point.distance_3d(previous_point) # type: ignore[arg-type] if ( From 87fe9286ec17d0489f92c3a5579afcff1c7da03c Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 9 Nov 2025 18:57:22 +0100 Subject: [PATCH 08/19] API - add method to smooth elevations --- .../test_open_elevation_service.py | 147 +++++++++++++----- .../elevation/open_elevation_service.py | 39 ++++- 2 files changed, 150 insertions(+), 36 deletions(-) diff --git a/fittrackee/tests/workouts/test_services/test_open_elevation_service.py b/fittrackee/tests/workouts/test_services/test_open_elevation_service.py index d065fe74e..17103c6d0 100644 --- a/fittrackee/tests/workouts/test_services/test_open_elevation_service.py +++ b/fittrackee/tests/workouts/test_services/test_open_elevation_service.py @@ -1,3 +1,4 @@ +import copy from typing import TYPE_CHECKING from unittest.mock import patch @@ -17,26 +18,24 @@ GPXTrackPoint(latitude=44.68095, longitude=6.07367), GPXTrackPoint(latitude=44.68091, longitude=6.07367), GPXTrackPoint(latitude=44.6808, longitude=6.07364), + GPXTrackPoint(latitude=44.68075, longitude=6.07364), + GPXTrackPoint(latitude=44.68071, longitude=6.07364), + GPXTrackPoint(latitude=44.68049, longitude=6.07361), + GPXTrackPoint(latitude=44.68019, longitude=6.07356), + GPXTrackPoint(latitude=44.68014, longitude=6.07355), + GPXTrackPoint(latitude=44.67995, longitude=6.07358), +] +OPEN_ELEVATION_RESPONSE = [ + {"elevation": 998.0, "latitude": 44.68095, "longitude": 6.07367}, + {"elevation": 998.0, "latitude": 44.68091, "longitude": 6.07367}, + {"elevation": 994.0, "latitude": 44.6808, "longitude": 6.07364}, + {"elevation": 994.0, "latitude": 44.68075, "longitude": 6.07364}, + {"elevation": 994.0, "latitude": 44.68071, "longitude": 6.07364}, + {"elevation": 1124.0, "latitude": 44.68049, "longitude": 6.07361}, + {"elevation": 1124.0, "latitude": 44.68019, "longitude": 6.07356}, + {"elevation": 1124.0, "latitude": 44.68014, "longitude": 6.07355}, + {"elevation": 1124.0, "latitude": 44.67995, "longitude": 6.07358}, ] -OPEN_ELEVATION_RESPONSE = { - "results": [ - { - "elevation": 998.0, - "latitude": 44.68095, - "longitude": 6.07367, - }, - { - "elevation": 998.0, - "latitude": 44.68091, - "longitude": 6.07367, - }, - { - "elevation": 994.0, - "latitude": 44.6808, - "longitude": 6.07364, - }, - ] -} class TestOpenElevationServiceInstantiation: @@ -83,7 +82,9 @@ def test_it_calls_open_elevation_api_with_given_points( with patch.object( requests, "post", - return_value=self.get_response(OPEN_ELEVATION_RESPONSE), + return_value=self.get_response( + {"results": OPEN_ELEVATION_RESPONSE} + ), ) as post_mock: service.get_elevations(POINTS_WITHOUT_ELEVATION) @@ -92,17 +93,10 @@ def test_it_calls_open_elevation_api_with_given_points( json={ "locations": [ { - "latitude": 44.68095, - "longitude": 6.07367, - }, - { - "latitude": 44.68091, - "longitude": 6.07367, - }, - { - "latitude": 44.6808, - "longitude": 6.07364, - }, + "latitude": point.latitude, + "longitude": point.longitude, + } + for point in POINTS_WITHOUT_ELEVATION ] }, timeout=30, @@ -116,11 +110,13 @@ def test_it_returns_elevations( with patch.object( requests, "post", - return_value=self.get_response(OPEN_ELEVATION_RESPONSE), + return_value=self.get_response( + {"results": copy.deepcopy(OPEN_ELEVATION_RESPONSE)} + ), ): result = service.get_elevations(POINTS_WITHOUT_ELEVATION) - assert result == OPEN_ELEVATION_RESPONSE["results"] + assert result == OPEN_ELEVATION_RESPONSE def test_it_returns_empty_list_when_open_elevation_api_raises_exception( self, app_with_open_elevation_url: "Flask" @@ -168,7 +164,7 @@ def test_it_returns_empty_list_when_number_of_elevation_returned_open_elevation_ requests, "post", return_value=self.get_response( - {"results": OPEN_ELEVATION_RESPONSE["results"][:-1]} + {"results": OPEN_ELEVATION_RESPONSE[:-1]} ), ): result = service.get_elevations(POINTS_WITHOUT_ELEVATION) @@ -189,7 +185,7 @@ def test_it_logs_error_when_number_of_elevation_returned_open_elevation_does_not requests, "post", return_value=self.get_response( - {"results": OPEN_ELEVATION_RESPONSE["results"][:-1]} + {"results": OPEN_ELEVATION_RESPONSE[:-1]} ), ), ): @@ -199,3 +195,84 @@ def test_it_logs_error_when_number_of_elevation_returned_open_elevation_does_not "Open Elevation API: mismatch between number of points in " "results, ignoring results" ) + + def test_it_does_not_call_smooth_elevations_when_flag_is_false( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with ( + patch.object( + requests, + "post", + return_value=self.get_response( + {"results": OPEN_ELEVATION_RESPONSE} + ), + ), + patch.object( + OpenElevationService, "smooth_elevations" + ) as smooth_elevations_mock, + ): + service.get_elevations(POINTS_WITHOUT_ELEVATION) + + smooth_elevations_mock.assert_not_called() + + def test_it_returns_smoothed_elevations_when_flag_is_true( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response( + {"results": copy.deepcopy(OPEN_ELEVATION_RESPONSE)} + ), + ): + result = service.get_elevations( + [ + GPXTrackPoint( + latitude=point["latitude"], + longitude=point["longitude"], + ) + for point in OPEN_ELEVATION_RESPONSE + ], + smooth=True, + ) + + assert result == [ + {"elevation": 1009, "latitude": 44.68095, "longitude": 6.07367}, + {"elevation": 1024, "latitude": 44.68091, "longitude": 6.07367}, + {"elevation": 1038, "latitude": 44.6808, "longitude": 6.07364}, + {"elevation": 1052, "latitude": 44.68075, "longitude": 6.07364}, + {"elevation": 1066, "latitude": 44.68071, "longitude": 6.07364}, + {"elevation": 1080, "latitude": 44.68049, "longitude": 6.07361}, + {"elevation": 1095, "latitude": 44.68019, "longitude": 6.07356}, + {"elevation": 1095, "latitude": 44.68014, "longitude": 6.07355}, + {"elevation": 1095, "latitude": 44.67995, "longitude": 6.07358}, + ] + + def test_it_returns_elevations_unchanged_when_length_below_3( + self, app_with_open_elevation_url: "Flask" + ) -> None: + service = OpenElevationService() + + with patch.object( + requests, + "post", + return_value=self.get_response( + {"results": copy.deepcopy(OPEN_ELEVATION_RESPONSE[:2])} + ), + ): + result = service.get_elevations( + [ + GPXTrackPoint( + latitude=point["latitude"], + longitude=point["longitude"], + ) + for point in OPEN_ELEVATION_RESPONSE[:2] + ], + smooth=True, + ) + + assert result == OPEN_ELEVATION_RESPONSE[:2] diff --git a/fittrackee/workouts/services/elevation/open_elevation_service.py b/fittrackee/workouts/services/elevation/open_elevation_service.py index 0c3aefa1f..52d5828e9 100644 --- a/fittrackee/workouts/services/elevation/open_elevation_service.py +++ b/fittrackee/workouts/services/elevation/open_elevation_service.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Dict, List, Union +import numpy as np import requests from flask import current_app @@ -8,6 +9,8 @@ if TYPE_CHECKING: from gpxpy.gpx import GPXTrackPoint +WINDOW_LEN = 51 + class OpenElevationService: """ @@ -29,7 +32,38 @@ def _get_api_url() -> Union[str, None]: return None return f"{base_url}/api/v1/lookup" - def get_elevations(self, points: List["GPXTrackPoint"]) -> List[Dict]: + @staticmethod + def smooth_elevations(points: List[Dict]) -> List[Dict]: + """ + smooth elevations using 'flat' window + + based on SciPy Cookbook: + https://scipy-cookbook.readthedocs.io/items/SignalSmooth.html + """ + if len(points) < 3: + return points + + points_array = np.array([point["elevation"] for point in points]) + window_len = len(points) if len(points) < WINDOW_LEN else WINDOW_LEN + + s = np.r_[ + points_array[window_len - 1 : 0 : -1], + points_array, + points_array[-2 : -window_len - 1 : -1], + ] + w = np.ones(window_len, "d") + y = np.convolve(w / w.sum(), s, mode="valid") + start = window_len // 2 + 1 + end = start + len(points_array) + smooth_array = y[start:end] + + for index in range(len(points)): + points[index]["elevation"] = int(smooth_array[index]) + return points + + def get_elevations( + self, points: List["GPXTrackPoint"], smooth: bool = False + ) -> List[Dict]: if not self.url: return [] @@ -65,4 +99,7 @@ def get_elevations(self, points: List["GPXTrackPoint"]) -> List[Dict]: "results, ignoring results" ) return [] + + if smooth: + return self.smooth_elevations(results) return results From a3237ec5598040e2c30b88ffce6d9840ac315d3f Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 9 Nov 2025 19:43:59 +0100 Subject: [PATCH 09/19] API - init user preferences for missing elevations processing (WIP) --- fittrackee/constants.py | 7 +++ ...77851_add_missing_elevations_processing.py | 58 +++++++++++++++++++ fittrackee/tests/users/test_auth_api.py | 6 ++ fittrackee/tests/users/test_users_model.py | 4 ++ fittrackee/users/auth.py | 14 +++++ fittrackee/users/models.py | 9 +++ fittrackee/workouts/models.py | 6 ++ .../User/ProfileDisplay/UserPreferences.vue | 10 ++++ .../ProfileEdition/UserPreferencesEdition.vue | 25 +++++++- fittrackee_client/src/locales/en/user.json | 12 +++- fittrackee_client/src/locales/fr/user.json | 6 ++ fittrackee_client/src/types/user.ts | 6 ++ 12 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 fittrackee/migrations/versions/65_c59425f77851_add_missing_elevations_processing.py diff --git a/fittrackee/constants.py b/fittrackee/constants.py index aad1904e3..2b52ad7cf 100644 --- a/fittrackee/constants.py +++ b/fittrackee/constants.py @@ -8,3 +8,10 @@ class TaskPriority(IntEnum): LOW = 100 MEDIUM = 50 HIGH = 0 + + +ELEVATIONS_PROCESSING = [ + "none", + "open_elevation", + "open_elevation_smooth", +] diff --git a/fittrackee/migrations/versions/65_c59425f77851_add_missing_elevations_processing.py b/fittrackee/migrations/versions/65_c59425f77851_add_missing_elevations_processing.py new file mode 100644 index 000000000..3ee83694a --- /dev/null +++ b/fittrackee/migrations/versions/65_c59425f77851_add_missing_elevations_processing.py @@ -0,0 +1,58 @@ +"""add missing_elevations_processing to 'users' and 'workouts' tables + +Revision ID: c59425f77851 +Revises: e63433a1d62e +Create Date: 2025-11-09 19:09:16.579739 + +""" + +from alembic import op +import sqlalchemy as sa + +from fittrackee.constants import ELEVATIONS_PROCESSING + +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "c59425f77851" +down_revision = "e63433a1d62e" +branch_labels = None +depends_on = None + + +def upgrade(): + elevations_processing = postgresql.ENUM( + *ELEVATIONS_PROCESSING, name="elevations_processing" + ) + elevations_processing.create(op.get_bind(), checkfirst=True) + + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "missing_elevations_processing", + elevations_processing, + server_default="none", + nullable=False, + ) + ) + + with op.batch_alter_table("workouts", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "missing_elevations_processing", + elevations_processing, + server_default="none", + nullable=False, + ) + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("workouts", schema=None) as batch_op: + batch_op.drop_column("missing_elevations_processing") + + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("missing_elevations_processing") + + # ### end Alembic commands ### diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 8464f466f..6cb53cbe3 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -1526,6 +1526,7 @@ def test_it_updates_user_preferences( hr_visibility="followers_only", segments_creation_event="none", split_workout_charts=True, + missing_elevations_processing="open_elevation", ) ), headers=dict(Authorization=f"Bearer {auth_token}"), @@ -1549,6 +1550,9 @@ def test_it_updates_user_preferences( assert data["data"]["hr_visibility"] == VisibilityLevel.FOLLOWERS assert data["data"]["segments_creation_event"] == "none" assert data["data"]["split_workout_charts"] is True + assert ( + data["data"]["missing_elevations_processing"] == "open_elevation" + ) @pytest.mark.parametrize( "input_map_visibility,input_analysis_visibility,input_workout_visibility,expected_map_visibility,expected_analysis_visibility", @@ -1619,6 +1623,7 @@ def test_it_updates_user_preferences_with_valid_map_visibility( hr_visibility=input_workout_visibility.value, segments_creation_event="none", split_workout_charts=False, + missing_elevations_processing="none", ) ), headers=dict(Authorization=f"Bearer {auth_token}"), @@ -1667,6 +1672,7 @@ def test_it_updates_user_preferences_when_user_is_suspended( hr_visibility=VisibilityLevel.PUBLIC.value, segments_creation_event="none", split_workout_charts=False, + missing_elevations_processing="none", ) ), headers=dict(Authorization=f"Bearer {auth_token}"), diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 533d0d08b..449121943 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -216,6 +216,7 @@ def test_it_returns_user_preferences( == user_1.split_workout_charts ) assert serialized_user["messages_preferences"] == {} + assert serialized_user["missing_elevations_processing"] == "none" def test_it_returns_empty_dict_when_notification_preferences_are_none( self, app: Flask, user_1: User @@ -357,6 +358,7 @@ def test_it_does_not_return_user_preferences( assert "segments_creation_event" not in serialized_user assert "split_workout_charts" not in serialized_user assert "messages_preferences" not in serialized_user + assert "missing_elevations_processing" not in serialized_user def test_it_returns_workouts_infos( self, app: Flask, user_1_admin: User, user_2: User @@ -447,6 +449,7 @@ def test_it_does_not_return_user_preferences( assert "segments_creation_event" not in serialized_user assert "split_workout_charts" not in serialized_user assert "messages_preferences" not in serialized_user + assert "missing_elevations_processing" not in serialized_user def test_it_returns_workouts_infos( self, app: Flask, user_1_moderator: User, user_2: User @@ -530,6 +533,7 @@ def test_it_does_not_return_user_preferences( assert "segments_creation_event" not in serialized_user assert "split_workout_charts" not in serialized_user assert "messages_preferences" not in serialized_user + assert "missing_elevations_processing" not in serialized_user def test_it_returns_workouts_infos( self, app: Flask, user_1: User, user_2: User diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index babb6be5f..fef4f3055 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -348,6 +348,7 @@ def get_authenticated_user_profile( "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { @@ -492,6 +493,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { @@ -684,6 +686,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { @@ -942,6 +945,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { @@ -1038,6 +1042,9 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: are automatically approved : Union[Dict, HttpResponse]: "language", "manually_approves_followers", "map_visibility", + "missing_elevations_processing", "segments_creation_event", "split_workout_charts", "start_elevation_at_zero", @@ -1106,6 +1114,9 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: hr_visibility = post_data.get("hr_visibility") segments_creation_event = post_data.get("segments_creation_event") split_workout_charts = post_data.get("split_workout_charts") + missing_elevations_processing = post_data.get( + "missing_elevations_processing" + ) try: auth_user.date_format = date_format @@ -1133,6 +1144,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: auth_user.hr_visibility = VisibilityLevel(hr_visibility) auth_user.segments_creation_event = segments_creation_event auth_user.split_workout_charts = split_workout_charts + auth_user.missing_elevations_processing = missing_elevations_processing db.session.commit() return { @@ -1365,6 +1377,7 @@ def edit_user_notifications_preferences( "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { @@ -1545,6 +1558,7 @@ def edit_user_messages_preferences( "messages_preferences": { "warning_about_large_number_of_workouts_on_map": true }, + "missing_elevations_processing": "none", "nb_sports": 3, "nb_workouts": 6, "notification_preferences": { diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 7c002b97b..4c5a18fdc 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -21,6 +21,7 @@ from fittrackee import BaseModel, appLog, bcrypt, db from fittrackee.comments.models import Comment +from fittrackee.constants import ELEVATIONS_PROCESSING from fittrackee.database import TZDateTime from fittrackee.dates import aware_utc_now from fittrackee.files import get_absolute_file_path @@ -376,6 +377,11 @@ class User(BaseModel): messages_preferences: Mapped[Optional[Dict]] = mapped_column( postgresql.JSONB, nullable=True ) + missing_elevations_processing: Mapped[Optional[str]] = mapped_column( + Enum(*ELEVATIONS_PROCESSING, name="elevations_processing"), + server_default="none", + nullable=False, + ) workouts: Mapped[List["Workout"]] = relationship( "Workout", lazy=True, back_populates="user" @@ -941,6 +947,9 @@ def serialize( if self.messages_preferences else {} ), + "missing_elevations_processing": ( + self.missing_elevations_processing + ), } return serialized_user diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index 701f88b3d..ea81161d3 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -17,6 +17,7 @@ from sqlalchemy.types import JSON, Enum from fittrackee import BaseModel, appLog, db +from fittrackee.constants import ELEVATIONS_PROCESSING from fittrackee.database import PSQL_INTEGER_LIMIT, TZDateTime from fittrackee.dates import aware_utc_now from fittrackee.equipments.models import WorkoutEquipment @@ -345,6 +346,11 @@ class Workout(BaseModel): Geometry(geometry_type="POINT", srid=WGS84_CRS, spatial_index=True), nullable=True, ) + missing_elevations_processing: Mapped[Optional[str]] = mapped_column( + Enum(*ELEVATIONS_PROCESSING, name="elevations_processing"), + server_default="none", + nullable=False, + ) user: Mapped["User"] = relationship( "User", lazy="select", single_parent=True diff --git a/fittrackee_client/src/components/User/ProfileDisplay/UserPreferences.vue b/fittrackee_client/src/components/User/ProfileDisplay/UserPreferences.vue index bceebd1d0..08c81286f 100644 --- a/fittrackee_client/src/components/User/ProfileDisplay/UserPreferences.vue +++ b/fittrackee_client/src/components/User/ProfileDisplay/UserPreferences.vue @@ -86,6 +86,16 @@
+
{{ $t('user.PROFILE.MISSING_ELEVATIONS_PROCESSING.LABEL') }}:
+
+ {{ + $t( + `user.PROFILE.MISSING_ELEVATIONS_PROCESSING.${ + user.missing_elevations_processing + }` + ) + }} +
{{ $t('visibility_levels.WORKOUTS_VISIBILITY') }}:
{{ $t(`visibility_levels.LEVELS.${user.workouts_visibility}`) }} diff --git a/fittrackee_client/src/components/User/ProfileEdition/UserPreferencesEdition.vue b/fittrackee_client/src/components/User/ProfileEdition/UserPreferencesEdition.vue index 594ff9cf3..9f3593cb3 100644 --- a/fittrackee_client/src/components/User/ProfileEdition/UserPreferencesEdition.vue +++ b/fittrackee_client/src/components/User/ProfileEdition/UserPreferencesEdition.vue @@ -251,6 +251,22 @@ +