Skip to content
Draft
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
1 change: 1 addition & 0 deletions .env.docker.dev
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export [email protected]
# export STATICMAP_SUBDOMAINS=
# export MAP_ATTRIBUTION=
# export DEFAULT_STATICMAP=False
# export OPEN_ELEVATION_API_URL=

# Geospatial features
# export NOMINATIM_URL=
Expand Down
2 changes: 2 additions & 0 deletions .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions docsrc/source/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Workouts
.. note::
.fit files from Garmin devices may contain product id instead of product name. The mapping between the product id and the product name allows the product name to be displayed instead, if available (*mapping updated in 0.11.0*).

- | If some elevation data are missing and an OpenElevation API URL is set, the missing elevations can be retrieved if the user preference is set (*new in 1.1.0*).
| In this case, all elevations are updated.
- | Some values are only calculated on workout creation.
| The previously uploaded workouts are not updated in the following cases:

Expand Down Expand Up @@ -527,6 +529,11 @@ Account & preferences
- each data displayed on a different chart

- A user can update messages preferences (*new in 1.0.0*).
- A user can set missing elevation processing if an OpenElevation API URL is set (*new in 1.1.0*):

- none
- OpenElevation (raw data)
- OpenElevation (smoothed data)


Equipments
Expand Down
7 changes: 7 additions & 0 deletions docsrc/source/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ deployment method.
:default: ``https://nominatim.openstreetmap.org``


.. envvar:: OPEN_ELEVATION_API_URL

.. versionadded:: 1.1.0

URL of `OpenElevation <https://open-elevation.com/>`__ service (public API or self-hosted instance).


.. envvar:: PORT

**FitTrackee** port.
Expand Down
6 changes: 6 additions & 0 deletions fittrackee/application/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def get_application_config() -> Union[Dict, HttpResponse]:
"data": {
"about": null,
"admin_contact": "[email protected]",
"elevation_services": {
"open_elevation": false
},
"file_sync_limit_import": 10,
"file_limit_import": 10,
"global_map_workouts_limit": 10000,
Expand Down Expand Up @@ -107,6 +110,9 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]:
"data": {
"about": null,
"admin_contact": "[email protected]",
"elevation_services": {
"open_elevation": false
},
"file_sync_limit_import": 10,
"file_limit_import": 10,
"global_map_workouts_limit": 10000,
Expand Down
8 changes: 8 additions & 0 deletions fittrackee/application/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,19 @@ def is_registration_enabled(self) -> bool:
def map_attribution(self) -> str:
return current_app.config["TILE_SERVER"]["ATTRIBUTION"]

@property
def elevation_services(self) -> Dict:
return {
"open_elevation": current_app.config["OPEN_ELEVATION_API_URL"]
is not None
}

def serialize(self) -> Dict:
weather_provider = os.getenv("WEATHER_API_PROVIDER", "").lower()
return {
"about": self.about,
"admin_contact": self.admin_contact,
"elevation_services": self.elevation_services,
"file_limit_import": self.file_limit_import,
"file_sync_limit_import": self.file_sync_limit_import,
"is_email_sending_enabled": current_app.config["CAN_SEND_EMAILS"],
Expand Down
1 change: 1 addition & 0 deletions fittrackee/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion fittrackee/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from enum import IntEnum
from enum import Enum, IntEnum

TASKS_TIME_LIMIT = int(os.environ.get("TASKS_TIME_LIMIT", "1800")) * 1000

Expand All @@ -8,3 +8,9 @@ class TaskPriority(IntEnum):
LOW = 100
MEDIUM = 50
HIGH = 0


class MissingElevationsProcessing(str, Enum): # to make enum serializable
NONE = "none"
OPEN_ELEVATION = "open_elevation"
OPEN_ELEVATION_SMOOTH = "open_elevation_smooth"
4 changes: 2 additions & 2 deletions fittrackee/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
<link rel="stylesheet" href="/static/css/fork-awesome.min.css"/>
<link rel="stylesheet" href="/static/css/leaflet.css"/>
<title>FitTrackee</title>
<script type="module" crossorigin src="/static/index-uiFhYZFF.js"></script>
<script type="module" crossorigin src="/static/index-D-j15cdk.js"></script>
<link rel="modulepreload" crossorigin href="/static/charts-CMSNJWZH.js">
<link rel="modulepreload" crossorigin href="/static/maps-Cx6tPzQM.js">
<link rel="stylesheet" crossorigin href="/static/css/maps-CIGW-MKW.css">
<link rel="stylesheet" crossorigin href="/static/css/index-yNauVWmf.css">
<link rel="stylesheet" crossorigin href="/static/css/index-Xk2zfF8A.css">
</head>
<body>
<div id="app"></div>
Expand Down
1 change: 1 addition & 0 deletions fittrackee/dist/static/css/index-Xk2zfF8A.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion fittrackee/dist/static/css/index-yNauVWmf.css

This file was deleted.

1,009 changes: 1,009 additions & 0 deletions fittrackee/dist/static/index-D-j15cdk.js

Large diffs are not rendered by default.

1,009 changes: 0 additions & 1,009 deletions fittrackee/dist/static/index-uiFhYZFF.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "c59425f77851"
down_revision = "e63433a1d62e"
branch_labels = None
depends_on = None

elevations_processing = postgresql.ENUM(
'NONE', 'OPEN_ELEVATION', 'OPEN_ELEVATION_SMOOTH',
name="elevations_processing"
)
elevations_processing.create(op.get_bind(), checkfirst=True)


def upgrade():
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 ###
elevations_processing.drop(op.get_bind())
1 change: 1 addition & 0 deletions fittrackee/tests/application/test_app_config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def test_it_updates_all_config(
data = json.loads(response.data.decode())
assert "success" in data["status"]
assert data["data"]["admin_contact"] == admin_email
assert data["data"]["elevation_services"] == {"open_elevation": False}
assert data["data"]["file_limit_import"] == 200
assert data["data"]["file_sync_limit_import"] == 20
assert data["data"]["global_map_workouts_limit"] == 7000
Expand Down
26 changes: 26 additions & 0 deletions fittrackee/tests/application/test_app_config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ def test_application_config(

serialized_app_config = config.serialize()
assert serialized_app_config["admin_contact"] == config.admin_contact
assert (
serialized_app_config["elevation_services"]
== config.elevation_services
)
assert (
serialized_app_config["file_limit_import"]
== config.file_limit_import
Expand Down Expand Up @@ -152,3 +156,25 @@ def test_it_returns_global_map_workouts_limit(self, app: Flask) -> None:
serialized_app_config["global_map_workouts_limit"]
== global_map_workouts_limit
)

def test_it_returns_elevation_services_when_open_elevation_is_disabled(
self, app: "Flask"
) -> None:
config = AppConfig.query.one()

serialized_app_config = config.serialize()

assert serialized_app_config["elevation_services"] == {
"open_elevation": False
}

def test_it_returns_elevation_services_when_open_elevation_is_enabled(
self, app_with_open_elevation_url: "Flask"
) -> None:
config = AppConfig.query.one()

serialized_app_config = config.serialize()

assert serialized_app_config["elevation_services"] == {
"open_elevation": True
}
10 changes: 10 additions & 0 deletions fittrackee/tests/fixtures/fixtures_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions fittrackee/tests/users/test_auth_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1495,13 +1495,13 @@ def test_it_returns_error_if_fields_are_missing(
)
def test_it_updates_user_preferences(
self,
app: Flask,
app_with_open_elevation_url: Flask,
user_1: User,
input_language: Optional[str],
expected_language: str,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
app_with_open_elevation_url, user_1.email
)

response = client.post(
Expand All @@ -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}"),
Expand All @@ -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",
Expand Down Expand Up @@ -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}"),
Expand Down Expand Up @@ -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}"),
Expand Down
31 changes: 31 additions & 0 deletions fittrackee/tests/users/test_users_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from time_machine import travel

from fittrackee import db
from fittrackee.constants import MissingElevationsProcessing
from fittrackee.equipments.models import Equipment
from fittrackee.files import get_absolute_file_path
from fittrackee.reports.models import ReportAction
Expand Down Expand Up @@ -216,6 +217,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
Expand Down Expand Up @@ -310,6 +312,32 @@ def test_it_does_return_reports_info_when_user_has_admin_rights(
assert serialized_user["reported_count"] == 0
assert serialized_user["sanctions_count"] == 0

def test_it_returns_missing_elevations_processing_when_no_elevation_service_set( # noqa
self, app: Flask, user_1: User
) -> None:
user_1.missing_elevations_processing = (
MissingElevationsProcessing.OPEN_ELEVATION
)
serialized_user = user_1.serialize(current_user=user_1, light=False)

assert (
serialized_user["missing_elevations_processing"]
== MissingElevationsProcessing.NONE
)

def test_it_returns_missing_elevations_processing_when_elevation_service_set( # noqa
self, app_with_open_elevation_url: Flask, user_1: User
) -> None:
user_1.missing_elevations_processing = (
MissingElevationsProcessing.OPEN_ELEVATION_SMOOTH
)
serialized_user = user_1.serialize(current_user=user_1, light=False)

assert (
serialized_user["missing_elevations_processing"]
== user_1.missing_elevations_processing
)


class TestUserSerializeAsAdmin(UserModelAssertMixin, ReportMixin):
def test_it_returns_user_account_infos(
Expand Down Expand Up @@ -357,6 +385,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
Expand Down Expand Up @@ -447,6 +476,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
Expand Down Expand Up @@ -530,6 +560,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
Expand Down
Empty file.
Loading