Merge pull request #286 from SamR1/add-elevation-to-workout-wo-gpx

Add ascent/descent to workout w/o gpx
This commit is contained in:
Sam 2022-12-14 20:49:01 +01:00 committed by GitHub
commit 00ec3c99e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 611 additions and 59 deletions

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.cea3a5ee.js"></script><script defer="defer" src="/static/js/app.226b5cbf.js"></script><link href="/static/css/app.7cddaab1.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.cea3a5ee.js"></script><script defer="defer" src="/static/js/app.26448a02.js"></script><link href="/static/css/app.2461bfc8.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,78 @@
"""update elevation precision
Revision ID: 0f375c44e659
Revises: a8cc0adfe1d3
Create Date: 2022-12-14 18:01:54.662987
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0f375c44e659'
down_revision = 'a8cc0adfe1d3'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
'workouts',
'descent',
existing_type=sa.NUMERIC(precision=7, scale=2),
type_=sa.Numeric(precision=8, scale=3),
existing_nullable=True,
)
op.alter_column(
'workouts',
'ascent',
existing_type=sa.NUMERIC(precision=7, scale=3),
type_=sa.Numeric(precision=8, scale=3),
existing_nullable=True,
)
op.alter_column(
'workout_segments',
'descent',
existing_type=sa.NUMERIC(precision=7, scale=2),
type_=sa.Numeric(precision=8, scale=3),
existing_nullable=True,
)
op.alter_column(
'workout_segments',
'ascent',
existing_type=sa.NUMERIC(precision=7, scale=3),
type_=sa.Numeric(precision=8, scale=3),
existing_nullable=True,
)
def downgrade():
op.alter_column(
'workout_segments',
'ascent',
existing_type=sa.NUMERIC(precision=8, scale=3),
type_=sa.Numeric(precision=7, scale=2),
existing_nullable=True,
)
op.alter_column(
'workout_segments',
'descent',
existing_type=sa.NUMERIC(precision=8, scale=3),
type_=sa.Numeric(precision=7, scale=2),
existing_nullable=True,
)
op.alter_column(
'workouts',
'ascent',
existing_type=sa.NUMERIC(precision=8, scale=3),
type_=sa.Numeric(precision=7, scale=2),
existing_nullable=True,
)
op.alter_column(
'workouts',
'descent',
existing_type=sa.NUMERIC(precision=8, scale=3),
type_=sa.Numeric(precision=7, scale=2),
existing_nullable=True,
)

View File

@ -886,6 +886,49 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
assert len(data['data']['workouts']) == 1 assert len(data['data']['workouts']) == 1
assert_workout_data_wo_gpx(data) assert_workout_data_wo_gpx(data)
@pytest.mark.parametrize(
'input_ascent, input_descent',
[
(100, 150),
(0, 150),
(100, 0),
],
)
def test_it_adds_workout_with_ascent_and_descent_when_provided(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
input_ascent: int,
input_descent: int,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/workouts/no_gpx',
content_type='application/json',
data=json.dumps(
dict(
sport_id=1,
duration=3600,
workout_date='2018-05-15 14:05',
distance=10,
ascent=input_ascent,
descent=input_descent,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 201
assert 'created' in data['status']
assert len(data['data']['workouts']) == 1
assert data['data']['workouts'][0]['ascent'] == input_ascent
assert data['data']['workouts'][0]['descent'] == input_descent
@pytest.mark.parametrize( @pytest.mark.parametrize(
'description,input_data', 'description,input_data',
[ [
@ -940,6 +983,82 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
self.assert_400(response) self.assert_400(response)
@pytest.mark.parametrize(
'description,input_data',
[
("only ascent", {"ascent": 100}),
("only descent", {"descent": 150}),
],
)
def test_it_returns_400_when_ascent_or_descent_are_missing(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
description: str,
input_data: Dict,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/workouts/no_gpx',
content_type='application/json',
data=json.dumps(
{
'sport_id': 1,
'duration': 3600,
'workout_date': '2018-05-15 14:05',
'distance': 10,
**input_data,
}
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)
@pytest.mark.parametrize(
'description,input_data',
[
("ascent is below 0", {"ascent": -100, "descent": 100}),
("descent is below 0", {"ascent": 150, "descent": -100}),
("ascent is None", {"ascent": None, "descent": 100}),
("descent is None", {"ascent": 150, "descent": None}),
("ascent is invalid", {"ascent": "a", "descent": 100}),
("descent is invalid", {"ascent": 150, "descent": "b"}),
],
)
def test_it_returns_400_when_ascent_or_descent_are_invalid(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
description: str,
input_data: Dict,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/workouts/no_gpx',
content_type='application/json',
data=json.dumps(
{
'sport_id': 1,
'duration': 3600,
'workout_date': '2018-05-15 14:05',
'distance': 10,
**input_data,
}
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)
def test_it_returns_500_if_workout_date_format_is_invalid( def test_it_returns_500_if_workout_date_format_is_invalid(
self, app: Flask, user_1: User, sport_1_cycling: Sport self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None: ) -> None:

View File

@ -578,6 +578,93 @@ class TestEditWorkoutWithoutGpx(ApiTestCaseMixin):
assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[3]['value'] == 20.0 assert records[3]['value'] == 20.0
def test_it_updates_elevation_values(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
) -> None:
workout_short_id = workout_cycling_user_1.short_id
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
ascent = 10
descent = 0
response = client.patch(
f'/api/workouts/{workout_short_id}',
content_type='application/json',
data=json.dumps(dict(ascent=ascent, descent=descent)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['workouts']) == 1
assert data['data']['workouts'][0]['ascent'] == ascent
assert data['data']['workouts'][0]['descent'] == descent
records = data['data']['workouts'][0]['records']
assert len(records) == 5
assert records[0]['sport_id'] == sport_1_cycling.id
assert records[0]['workout_id'] == workout_short_id
assert records[0]['record_type'] == 'HA'
assert records[0]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[0]['value'] == ascent
assert records[1]['sport_id'] == sport_1_cycling.id
assert records[1]['workout_id'] == workout_short_id
assert records[1]['record_type'] == 'MS'
assert records[1]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[1]['value'] == 10.0
assert records[2]['sport_id'] == sport_1_cycling.id
assert records[2]['workout_id'] == workout_short_id
assert records[2]['record_type'] == 'LD'
assert records[2]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[2]['value'] == '1:00:00'
assert records[3]['sport_id'] == sport_1_cycling.id
assert records[3]['workout_id'] == workout_short_id
assert records[3]['record_type'] == 'FD'
assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[3]['value'] == 10.0
assert records[4]['sport_id'] == sport_1_cycling.id
assert records[4]['workout_id'] == workout_short_id
assert records[4]['record_type'] == 'AS'
assert records[4]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[4]['value'] == 10.0
def test_it_empties_elevation_values(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
) -> None:
workout_short_id = workout_cycling_user_1.short_id
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
workout_cycling_user_1.ascent = 100
workout_cycling_user_1.descent = 150
response = client.patch(
f'/api/workouts/{workout_short_id}',
content_type='application/json',
data=json.dumps(dict(ascent=None, descent=None)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['workouts']) == 1
assert data['data']['workouts'][0]['ascent'] is None
assert data['data']['workouts'][0]['descent'] is None
records = data['data']['workouts'][0]['records']
assert len(records) == 4
assert 'HA' not in [record['record_type'] for record in records]
def test_it_returns_400_if_payload_is_empty( def test_it_returns_400_if_payload_is_empty(
self, self,
app: Flask, app: Flask,
@ -646,3 +733,64 @@ class TestEditWorkoutWithoutGpx(ApiTestCaseMixin):
data = self.assert_404(response) data = self.assert_404(response)
assert len(data['data']['workouts']) == 0 assert len(data['data']['workouts']) == 0
@pytest.mark.parametrize(
'input_ascent, input_descent',
[
(100, None),
(None, 150),
(100, -10),
(-100, 150),
(100, 'O'),
('O', 150),
],
)
def test_it_returns_400_if_elevation_is_invalid(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
input_ascent: int,
input_descent: int,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.patch(
f'/api/workouts/{workout_cycling_user_1.short_id}',
content_type='application/json',
data=json.dumps(
dict(
ascent=input_ascent,
descent=input_descent,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)
@pytest.mark.parametrize(
'input_key',
['ascent', 'descent'],
)
def test_it_returns_400_if_only_one_elevation_value_is_provided(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
workout_cycling_user_1: Workout,
input_key: str,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.patch(
f'/api/workouts/{workout_cycling_user_1.short_id}',
content_type='application/json',
data=json.dumps({input_key: 100}),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)

View File

@ -75,6 +75,48 @@ class TestWorkoutModel:
assert serialized_workout['with_gpx'] is False assert serialized_workout['with_gpx'] is False
assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00' assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00'
def test_serialize_for_workout_without_gpx_and_with_elevation(
self,
app: Flask,
sport_1_cycling: Sport,
user_1: User,
workout_cycling_user_1: Workout,
) -> None:
workout = workout_cycling_user_1
workout.ascent = 0
workout.descent = 10
serialized_workout = workout.serialize()
assert serialized_workout['ascent'] == workout.ascent
assert serialized_workout['ave_speed'] == float(workout.ave_speed)
assert serialized_workout['bounds'] == []
assert 'creation_date' in serialized_workout
assert serialized_workout['descent'] == workout.descent
assert serialized_workout['distance'] == float(workout.distance)
assert serialized_workout['duration'] == str(workout.duration)
assert serialized_workout['id'] == workout.short_id
assert serialized_workout['map'] is None
assert serialized_workout['max_alt'] is None
assert serialized_workout['max_speed'] == float(workout.max_speed)
assert serialized_workout['min_alt'] is None
assert serialized_workout['modification_date'] is not None
assert serialized_workout['moving'] == str(workout.moving)
assert serialized_workout['next_workout'] is None
assert serialized_workout['notes'] is None
assert serialized_workout['pauses'] is None
assert serialized_workout['previous_workout'] is None
assert serialized_workout['records'] == [
record.serialize() for record in workout.records
]
assert serialized_workout['segments'] == []
assert serialized_workout['sport_id'] == workout.sport_id
assert serialized_workout['title'] == workout.title
assert serialized_workout['user'] == workout.user.username
assert serialized_workout['weather_end'] is None
assert serialized_workout['weather_start'] is None
assert serialized_workout['with_gpx'] is False
assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00'
def test_serialize_for_workout_with_gpx( def test_serialize_for_workout_with_gpx(
self, self,
app: Flask, app: Flask,

View File

@ -148,8 +148,8 @@ class Workout(BaseModel):
distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers
min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
descent = db.Column(db.Numeric(7, 2), nullable=True) # meters descent = db.Column(db.Numeric(8, 3), nullable=True) # meters
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters ascent = db.Column(db.Numeric(8, 3), nullable=True) # meters
max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
bounds = db.Column(postgresql.ARRAY(db.Float), nullable=True) bounds = db.Column(postgresql.ARRAY(db.Float), nullable=True)
@ -296,8 +296,10 @@ class Workout(BaseModel):
'distance': float(self.distance) if self.distance else None, 'distance': float(self.distance) if self.distance else None,
'min_alt': float(self.min_alt) if self.min_alt else None, 'min_alt': float(self.min_alt) if self.min_alt else None,
'max_alt': float(self.max_alt) if self.max_alt else None, 'max_alt': float(self.max_alt) if self.max_alt else None,
'descent': float(self.descent) if self.descent else None, 'descent': float(self.descent)
'ascent': float(self.ascent) if self.ascent else None, if self.descent is not None
else None,
'ascent': float(self.ascent) if self.ascent is not None else None,
'max_speed': float(self.max_speed) if self.max_speed else None, 'max_speed': float(self.max_speed) if self.max_speed else None,
'ave_speed': float(self.ave_speed) if self.ave_speed else None, 'ave_speed': float(self.ave_speed) if self.ave_speed else None,
'with_gpx': self.gpx is not None, 'with_gpx': self.gpx is not None,
@ -413,8 +415,8 @@ class WorkoutSegment(BaseModel):
distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers
min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
descent = db.Column(db.Numeric(7, 2), nullable=True) # meters descent = db.Column(db.Numeric(8, 3), nullable=True) # meters
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters ascent = db.Column(db.Numeric(8, 3), nullable=True) # meters
max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h

View File

@ -160,6 +160,8 @@ def create_workout(
else float(new_workout.distance) / (duration.seconds / 3600) else float(new_workout.distance) / (duration.seconds / 3600)
) )
new_workout.max_speed = new_workout.ave_speed new_workout.max_speed = new_workout.ave_speed
new_workout.ascent = workout_data.get('ascent')
new_workout.descent = workout_data.get('descent')
return new_workout return new_workout
@ -239,6 +241,12 @@ def edit_workout(
else float(workout.distance) / (workout.duration.seconds / 3600) else float(workout.distance) / (workout.duration.seconds / 3600)
) )
workout.max_speed = workout.ave_speed workout.max_speed = workout.ave_speed
if 'ascent' in workout_data:
workout.ascent = workout_data.get('ascent')
if 'descent' in workout_data:
workout.descent = workout_data.get('descent')
return workout return workout

View File

@ -1132,13 +1132,15 @@ def post_workout_no_gpx(
"status": "success" "status": "success"
} }
:<json string workout_date: workout date, in user timezone :<json float ascent: workout ascent (not mandatory)
(format: ``%Y-%m-%d %H:%M``) :<json float descent: workout descent (not mandatory)
:<json float distance: workout distance in km :<json float distance: workout distance in km
:<json integer duration: workout duration in seconds :<json integer duration: workout duration in seconds
:<json string notes: notes (not mandatory) :<json string notes: notes (not mandatory)
:<json integer sport_id: workout sport id :<json integer sport_id: workout sport id
:<json string title: workout title :<json string title: workout title (not mandatory)
:<json string workout_date: workout date, in user timezone
(format: ``%Y-%m-%d %H:%M``)
:reqheader Authorization: OAuth 2.0 Bearer Token :reqheader Authorization: OAuth 2.0 Bearer Token
@ -1161,6 +1163,20 @@ def post_workout_no_gpx(
): ):
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()
ascent = workout_data.get('ascent')
descent = workout_data.get('descent')
try:
if (
(ascent is None and descent is not None)
or (ascent is not None and descent is None)
or (
(ascent is not None and descent is not None)
and (float(ascent) < 0 or float(descent) < 0)
)
):
return InvalidPayloadErrorResponse()
except ValueError:
return InvalidPayloadErrorResponse()
try: try:
new_workout = create_workout(auth_user, workout_data) new_workout = create_workout(auth_user, workout_data)
db.session.add(new_workout) db.session.add(new_workout)
@ -1285,8 +1301,9 @@ def update_workout(
:param string workout_short_id: workout short id :param string workout_short_id: workout short id
:<json string workout_date: workout date in user timezone :<json float ascent: workout ascent
(format: ``%Y-%m-%d %H:%M``) (only for workout without gpx)
:<json float descent: workout descent
(only for workout without gpx) (only for workout without gpx)
:<json float distance: workout distance in km :<json float distance: workout distance in km
(only for workout without gpx) (only for workout without gpx)
@ -1295,6 +1312,9 @@ def update_workout(
:<json string notes: notes :<json string notes: notes
:<json integer sport_id: workout sport id :<json integer sport_id: workout sport id
:<json string title: workout title :<json string title: workout title
:<json string workout_date: workout date in user timezone
(format: ``%Y-%m-%d %H:%M``)
(only for workout without gpx)
:reqheader Authorization: OAuth 2.0 Bearer Token :reqheader Authorization: OAuth 2.0 Bearer Token
@ -1322,6 +1342,33 @@ def update_workout(
if response_object: if response_object:
return response_object return response_object
if not workout.gpx:
try:
# for workout without gpx file, both elevation values must be
# provided.
if (
(
'ascent' in workout_data
and 'descent' not in workout_data
)
or (
'ascent' not in workout_data
and 'descent' in workout_data
)
) or (
not (
workout_data.get('ascent') is None
and workout_data.get('descent') is None
)
and (
float(workout_data.get('ascent')) < 0
or float(workout_data.get('descent')) < 0
)
):
return InvalidPayloadErrorResponse()
except (TypeError, ValueError):
return InvalidPayloadErrorResponse()
workout = edit_workout(workout, workout_data, auth_user) workout = edit_workout(workout, workout_data, auth_user)
db.session.commit() db.session.commit()
return { return {

View File

@ -114,7 +114,7 @@
/> />
</div> </div>
</div> </div>
<div class="data altitude" v-if="workout && workout.with_gpx"> <div class="data altitude" v-if="hasElevation(workout)">
<i class="fa fa-location-arrow" aria-hidden="true" /> <i class="fa fa-location-arrow" aria-hidden="true" />
<div class="data-values"> <div class="data-values">
+<Distance +<Distance
@ -167,6 +167,10 @@
const locale: ComputedRef<Locale> = computed( const locale: ComputedRef<Locale> = computed(
() => store.getters[ROOT_STORE.GETTERS.LOCALE] () => store.getters[ROOT_STORE.GETTERS.LOCALE]
) )
function hasElevation(workout: IWorkout): boolean {
return workout && workout.ascent !== null && workout.descent !== null
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -37,7 +37,7 @@
</div> </div>
</div> </div>
<div class="form-item"> <div class="form-item">
<label> {{ $t('workouts.SPORT', 1) }}: </label> <label> {{ $t('workouts.SPORT', 1) }}*: </label>
<select <select
id="sport" id="sport"
required required
@ -57,7 +57,7 @@
<div class="form-item" v-if="isCreation && withGpx"> <div class="form-item" v-if="isCreation && withGpx">
<label for="gpxFile"> <label for="gpxFile">
{{ $t('workouts.GPX_FILE') }} {{ $t('workouts.GPX_FILE') }}
{{ $t('workouts.ZIP_ARCHIVE_DESCRIPTION') }}: {{ $t('workouts.ZIP_ARCHIVE_DESCRIPTION') }}*:
</label> </label>
<input <input
id="gpxFile" id="gpxFile"
@ -105,7 +105,7 @@
<div v-if="!withGpx"> <div v-if="!withGpx">
<div class="workout-date-duration"> <div class="workout-date-duration">
<div class="form-item"> <div class="form-item">
<label>{{ $t('workouts.WORKOUT_DATE') }}:</label> <label>{{ $t('workouts.WORKOUT_DATE') }}*:</label>
<div class="workout-date-time"> <div class="workout-date-time">
<input <input
id="workout-date" id="workout-date"
@ -129,7 +129,7 @@
</div> </div>
</div> </div>
<div class="form-item"> <div class="form-item">
<label>{{ $t('workouts.DURATION') }}:</label> <label>{{ $t('workouts.DURATION') }}*:</label>
<div> <div>
<input <input
id="workout-duration-hour" id="workout-duration-hour"
@ -181,23 +181,59 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-item"> <div class="workout-data">
<label> <div class="form-item">
{{ $t('workouts.DISTANCE') }} ({{ <label>
authUser.imperial_units ? 'mi' : 'km' {{ $t('workouts.DISTANCE') }} ({{
}}): authUser.imperial_units ? 'mi' : 'km'
</label> }})*:
<input </label>
:class="{ errored: isDistanceInvalid() }" <input
name="workout-distance" :class="{ errored: isDistanceInvalid() }"
type="number" name="workout-distance"
min="0" type="number"
step="0.001" min="0"
required step="0.001"
@invalid="invalidateForm" required
:disabled="loading" @invalid="invalidateForm"
v-model="workoutForm.workoutDistance" :disabled="loading"
/> v-model="workoutForm.workoutDistance"
/>
</div>
<div class="form-item">
<label>
{{ $t('workouts.ASCENT') }} ({{
authUser.imperial_units ? 'ft' : 'm'
}}):
</label>
<input
:class="{ errored: isElevationInvalid() }"
name="workout-ascent"
type="number"
min="0"
step="0.01"
@invalid="invalidateForm"
:disabled="loading"
v-model="workoutForm.workoutAscent"
/>
</div>
<div class="form-item">
<label>
{{ $t('workouts.DESCENT') }} ({{
authUser.imperial_units ? 'ft' : 'm'
}}):
</label>
<input
:class="{ errored: isElevationInvalid() }"
name="workout-descent"
type="number"
min="0"
step="0.01"
@invalid="invalidateForm"
:disabled="loading"
v-model="workoutForm.workoutDescent"
/>
</div>
</div> </div>
</div> </div>
<div class="form-item"> <div class="form-item">
@ -305,6 +341,8 @@
workoutDurationMinutes: '', workoutDurationMinutes: '',
workoutDurationSeconds: '', workoutDurationSeconds: '',
workoutDistance: '', workoutDistance: '',
workoutAscent: '',
workoutDescent: '',
}) })
const withGpx = ref( const withGpx = ref(
props.workout.id ? props.workout.with_gpx : props.isCreation props.workout.id ? props.workout.with_gpx : props.isCreation
@ -343,14 +381,30 @@
const duration = workout.duration.split(':') const duration = workout.duration.split(':')
workoutForm.workoutDistance = `${ workoutForm.workoutDistance = `${
authUser.value.imperial_units authUser.value.imperial_units
? convertDistance(workout.distance, 'km', 'mi', 2) ? convertDistance(workout.distance, 'km', 'mi', 3)
: parseFloat(workout.distance.toFixed(2)) : parseFloat(workout.distance.toFixed(3))
}` }`
workoutForm.workoutDate = workoutDateTime.workout_date workoutForm.workoutDate = workoutDateTime.workout_date
workoutForm.workoutTime = workoutDateTime.workout_time workoutForm.workoutTime = workoutDateTime.workout_time
workoutForm.workoutDurationHour = duration[0] workoutForm.workoutDurationHour = duration[0]
workoutForm.workoutDurationMinutes = duration[1] workoutForm.workoutDurationMinutes = duration[1]
workoutForm.workoutDurationSeconds = duration[2] workoutForm.workoutDurationSeconds = duration[2]
workoutForm.workoutAscent =
workout.ascent === null
? ''
: `${
authUser.value.imperial_units
? convertDistance(workout.ascent, 'm', 'ft', 2)
: parseFloat(workout.ascent.toFixed(2))
}`
workoutForm.workoutDescent =
workout.descent === null
? ''
: `${
authUser.value.imperial_units
? convertDistance(workout.descent, 'm', 'ft', 2)
: parseFloat(workout.descent.toFixed(2))
}`
} }
} }
function isDistanceInvalid() { function isDistanceInvalid() {
@ -359,6 +413,11 @@
function isDurationInvalid() { function isDurationInvalid() {
return payloadErrorMessages.value.includes('workouts.INVALID_DURATION') return payloadErrorMessages.value.includes('workouts.INVALID_DURATION')
} }
function isElevationInvalid() {
return payloadErrorMessages.value.includes(
'workouts.INVALID_ASCENT_OR_DESCENT'
)
}
function formatPayload(payload: IWorkoutForm) { function formatPayload(payload: IWorkoutForm) {
payloadErrorMessages.value = [] payloadErrorMessages.value = []
payload.title = workoutForm.title payload.title = workoutForm.title
@ -376,6 +435,24 @@
payloadErrorMessages.value.push('workouts.INVALID_DISTANCE') payloadErrorMessages.value.push('workouts.INVALID_DISTANCE')
} }
payload.workout_date = `${workoutForm.workoutDate} ${workoutForm.workoutTime}` payload.workout_date = `${workoutForm.workoutDate} ${workoutForm.workoutTime}`
payload.ascent =
workoutForm.workoutAscent === ''
? null
: authUser.value.imperial_units
? convertDistance(+workoutForm.workoutAscent, 'ft', 'm', 3)
: +workoutForm.workoutAscent
payload.descent =
workoutForm.workoutDescent === ''
? null
: authUser.value.imperial_units
? convertDistance(+workoutForm.workoutDescent, 'ft', 'm', 3)
: +workoutForm.workoutDescent
if (
(payload.ascent !== null && payload.descent === null) ||
(payload.ascent === null && payload.descent !== null)
) {
payloadErrorMessages.value.push('workouts.INVALID_ASCENT_OR_DESCENT')
}
} }
function updateWorkout() { function updateWorkout() {
const payload: IWorkoutForm = { const payload: IWorkoutForm = {
@ -388,10 +465,17 @@
} else { } else {
formatPayload(payload) formatPayload(payload)
} }
store.dispatch(WORKOUTS_STORE.ACTIONS.EDIT_WORKOUT, { if (payloadErrorMessages.value.length > 0) {
workoutId: props.workout.id, store.commit(
data: payload, ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES,
}) payloadErrorMessages.value
)
} else {
store.dispatch(WORKOUTS_STORE.ACTIONS.EDIT_WORKOUT, {
workoutId: props.workout.id,
data: payload,
})
}
} else { } else {
if (withGpx.value) { if (withGpx.value) {
if (!gpxFile) { if (!gpxFile) {
@ -536,6 +620,22 @@
} }
} }
} }
.workout-data {
display: flex;
flex-direction: row;
justify-content: space-between;
.form-item {
width: 30%;
}
@media screen and (max-width: $medium-limit) {
flex-direction: column;
.form-item {
width: initial;
}
}
}
} }
} }
} }

View File

@ -134,7 +134,7 @@
{{ $t('workouts.ASCENT') }} {{ $t('workouts.ASCENT') }}
</span> </span>
<Distance <Distance
v-if="workout.with_gpx" v-if="workout.ascent !== null"
:distance="workout.ascent" :distance="workout.ascent"
unitFrom="m" unitFrom="m"
:useImperialUnits="user.imperial_units" :useImperialUnits="user.imperial_units"
@ -145,7 +145,7 @@
{{ $t('workouts.DESCENT') }} {{ $t('workouts.DESCENT') }}
</span> </span>
<Distance <Distance
v-if="workout.with_gpx" v-if="workout.descent !== null"
:distance="workout.descent" :distance="workout.descent"
unitFrom="m" unitFrom="m"
:useImperialUnits="user.imperial_units" :useImperialUnits="user.imperial_units"

View File

@ -16,6 +16,7 @@
"FROM": "from", "FROM": "from",
"GPX_FILE": ".gpx file", "GPX_FILE": ".gpx file",
"HIDE_FILTERS": "hide filters", "HIDE_FILTERS": "hide filters",
"INVALID_ASCENT_OR_DESCENT": "Both elevation values must be provided and be greater than or equal to 0.",
"INVALID_DISTANCE": "The distance must be greater than 0", "INVALID_DISTANCE": "The distance must be greater than 0",
"INVALID_DURATION": "The duration must be greater than 0 seconds", "INVALID_DURATION": "The duration must be greater than 0 seconds",
"LATEST_WORKOUTS": "Latest workouts", "LATEST_WORKOUTS": "Latest workouts",

View File

@ -16,6 +16,7 @@
"FROM": "à partir de", "FROM": "à partir de",
"GPX_FILE": "fichier .gpx", "GPX_FILE": "fichier .gpx",
"HIDE_FILTERS": "masquer les filtres", "HIDE_FILTERS": "masquer les filtres",
"INVALID_ASCENT_OR_DESCENT": "Les 2 valeurs pour l'élévation doivent être renseignées et être supérieures ou égales à 0.",
"INVALID_DISTANCE": "La distance doit être supérieure à 0", "INVALID_DISTANCE": "La distance doit être supérieure à 0",
"INVALID_DURATION": "La durée doit être supérieure à 0 secondes", "INVALID_DURATION": "La durée doit être supérieure à 0 secondes",
"LATEST_WORKOUTS": "Séances récentes", "LATEST_WORKOUTS": "Séances récentes",

View File

@ -116,6 +116,8 @@ export interface IWorkoutForm {
distance?: number distance?: number
duration?: number duration?: number
file?: Blob file?: Blob
ascent?: number | null
descent?: number | null
} }
export interface IWorkoutPayload { export interface IWorkoutPayload {