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:
commit
00ec3c99e5
2
fittrackee/dist/index.html
vendored
2
fittrackee/dist/index.html
vendored
@ -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>
|
2
fittrackee/dist/service-worker.js
vendored
2
fittrackee/dist/service-worker.js
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/service-worker.js.map
vendored
2
fittrackee/dist/service-worker.js.map
vendored
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
2
fittrackee/dist/static/js/app.226b5cbf.js
vendored
2
fittrackee/dist/static/js/app.226b5cbf.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/app.26448a02.js
vendored
Normal file
2
fittrackee/dist/static/js/app.26448a02.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/app.26448a02.js.map
vendored
Normal file
1
fittrackee/dist/static/js/app.26448a02.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
9
fittrackee/dist/static/js/workouts.5117e90f.js
vendored
Normal file
9
fittrackee/dist/static/js/workouts.5117e90f.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/workouts.5117e90f.js.map
vendored
Normal file
1
fittrackee/dist/static/js/workouts.5117e90f.js.map
vendored
Normal file
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
@ -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,
|
||||
)
|
@ -886,6 +886,49 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
|
||||
assert len(data['data']['workouts']) == 1
|
||||
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(
|
||||
'description,input_data',
|
||||
[
|
||||
@ -940,6 +983,82 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
|
||||
|
||||
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(
|
||||
self, app: Flask, user_1: User, sport_1_cycling: Sport
|
||||
) -> None:
|
||||
|
@ -578,6 +578,93 @@ class TestEditWorkoutWithoutGpx(ApiTestCaseMixin):
|
||||
assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
|
||||
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(
|
||||
self,
|
||||
app: Flask,
|
||||
@ -646,3 +733,64 @@ class TestEditWorkoutWithoutGpx(ApiTestCaseMixin):
|
||||
|
||||
data = self.assert_404(response)
|
||||
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)
|
||||
|
@ -75,6 +75,48 @@ class TestWorkoutModel:
|
||||
assert serialized_workout['with_gpx'] is False
|
||||
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(
|
||||
self,
|
||||
app: Flask,
|
||||
|
@ -148,8 +148,8 @@ class Workout(BaseModel):
|
||||
distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers
|
||||
min_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
|
||||
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
descent = db.Column(db.Numeric(8, 3), 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
|
||||
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
|
||||
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,
|
||||
'min_alt': float(self.min_alt) if self.min_alt else None,
|
||||
'max_alt': float(self.max_alt) if self.max_alt else None,
|
||||
'descent': float(self.descent) if self.descent else None,
|
||||
'ascent': float(self.ascent) if self.ascent else None,
|
||||
'descent': float(self.descent)
|
||||
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,
|
||||
'ave_speed': float(self.ave_speed) if self.ave_speed else 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
|
||||
min_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
|
||||
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
descent = db.Column(db.Numeric(8, 3), 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
|
||||
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
|
||||
|
||||
|
@ -160,6 +160,8 @@ def create_workout(
|
||||
else float(new_workout.distance) / (duration.seconds / 3600)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@ -239,6 +241,12 @@ def edit_workout(
|
||||
else float(workout.distance) / (workout.duration.seconds / 3600)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
@ -1132,13 +1132,15 @@ def post_workout_no_gpx(
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:<json string workout_date: workout date, in user timezone
|
||||
(format: ``%Y-%m-%d %H:%M``)
|
||||
:<json float ascent: workout ascent (not mandatory)
|
||||
:<json float descent: workout descent (not mandatory)
|
||||
:<json float distance: workout distance in km
|
||||
:<json integer duration: workout duration in seconds
|
||||
:<json string notes: notes (not mandatory)
|
||||
:<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
|
||||
|
||||
@ -1161,6 +1163,20 @@ def post_workout_no_gpx(
|
||||
):
|
||||
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:
|
||||
new_workout = create_workout(auth_user, workout_data)
|
||||
db.session.add(new_workout)
|
||||
@ -1285,8 +1301,9 @@ def update_workout(
|
||||
|
||||
:param string workout_short_id: workout short id
|
||||
|
||||
:<json string workout_date: workout date in user timezone
|
||||
(format: ``%Y-%m-%d %H:%M``)
|
||||
:<json float ascent: workout ascent
|
||||
(only for workout without gpx)
|
||||
:<json float descent: workout descent
|
||||
(only for workout without gpx)
|
||||
:<json float distance: workout distance in km
|
||||
(only for workout without gpx)
|
||||
@ -1295,6 +1312,9 @@ def update_workout(
|
||||
:<json string notes: notes
|
||||
:<json integer sport_id: workout sport id
|
||||
:<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
|
||||
|
||||
@ -1322,6 +1342,33 @@ def update_workout(
|
||||
if 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)
|
||||
db.session.commit()
|
||||
return {
|
||||
|
@ -114,7 +114,7 @@
|
||||
/>
|
||||
</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" />
|
||||
<div class="data-values">
|
||||
+<Distance
|
||||
@ -167,6 +167,10 @@
|
||||
const locale: ComputedRef<Locale> = computed(
|
||||
() => store.getters[ROOT_STORE.GETTERS.LOCALE]
|
||||
)
|
||||
|
||||
function hasElevation(workout: IWorkout): boolean {
|
||||
return workout && workout.ascent !== null && workout.descent !== null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -37,7 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label> {{ $t('workouts.SPORT', 1) }}: </label>
|
||||
<label> {{ $t('workouts.SPORT', 1) }}*: </label>
|
||||
<select
|
||||
id="sport"
|
||||
required
|
||||
@ -57,7 +57,7 @@
|
||||
<div class="form-item" v-if="isCreation && withGpx">
|
||||
<label for="gpxFile">
|
||||
{{ $t('workouts.GPX_FILE') }}
|
||||
{{ $t('workouts.ZIP_ARCHIVE_DESCRIPTION') }}:
|
||||
{{ $t('workouts.ZIP_ARCHIVE_DESCRIPTION') }}*:
|
||||
</label>
|
||||
<input
|
||||
id="gpxFile"
|
||||
@ -105,7 +105,7 @@
|
||||
<div v-if="!withGpx">
|
||||
<div class="workout-date-duration">
|
||||
<div class="form-item">
|
||||
<label>{{ $t('workouts.WORKOUT_DATE') }}:</label>
|
||||
<label>{{ $t('workouts.WORKOUT_DATE') }}*:</label>
|
||||
<div class="workout-date-time">
|
||||
<input
|
||||
id="workout-date"
|
||||
@ -129,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>{{ $t('workouts.DURATION') }}:</label>
|
||||
<label>{{ $t('workouts.DURATION') }}*:</label>
|
||||
<div>
|
||||
<input
|
||||
id="workout-duration-hour"
|
||||
@ -181,23 +181,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>
|
||||
{{ $t('workouts.DISTANCE') }} ({{
|
||||
authUser.imperial_units ? 'mi' : 'km'
|
||||
}}):
|
||||
</label>
|
||||
<input
|
||||
:class="{ errored: isDistanceInvalid() }"
|
||||
name="workout-distance"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
@invalid="invalidateForm"
|
||||
:disabled="loading"
|
||||
v-model="workoutForm.workoutDistance"
|
||||
/>
|
||||
<div class="workout-data">
|
||||
<div class="form-item">
|
||||
<label>
|
||||
{{ $t('workouts.DISTANCE') }} ({{
|
||||
authUser.imperial_units ? 'mi' : 'km'
|
||||
}})*:
|
||||
</label>
|
||||
<input
|
||||
:class="{ errored: isDistanceInvalid() }"
|
||||
name="workout-distance"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
@invalid="invalidateForm"
|
||||
: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 class="form-item">
|
||||
@ -305,6 +341,8 @@
|
||||
workoutDurationMinutes: '',
|
||||
workoutDurationSeconds: '',
|
||||
workoutDistance: '',
|
||||
workoutAscent: '',
|
||||
workoutDescent: '',
|
||||
})
|
||||
const withGpx = ref(
|
||||
props.workout.id ? props.workout.with_gpx : props.isCreation
|
||||
@ -343,14 +381,30 @@
|
||||
const duration = workout.duration.split(':')
|
||||
workoutForm.workoutDistance = `${
|
||||
authUser.value.imperial_units
|
||||
? convertDistance(workout.distance, 'km', 'mi', 2)
|
||||
: parseFloat(workout.distance.toFixed(2))
|
||||
? convertDistance(workout.distance, 'km', 'mi', 3)
|
||||
: parseFloat(workout.distance.toFixed(3))
|
||||
}`
|
||||
workoutForm.workoutDate = workoutDateTime.workout_date
|
||||
workoutForm.workoutTime = workoutDateTime.workout_time
|
||||
workoutForm.workoutDurationHour = duration[0]
|
||||
workoutForm.workoutDurationMinutes = duration[1]
|
||||
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() {
|
||||
@ -359,6 +413,11 @@
|
||||
function isDurationInvalid() {
|
||||
return payloadErrorMessages.value.includes('workouts.INVALID_DURATION')
|
||||
}
|
||||
function isElevationInvalid() {
|
||||
return payloadErrorMessages.value.includes(
|
||||
'workouts.INVALID_ASCENT_OR_DESCENT'
|
||||
)
|
||||
}
|
||||
function formatPayload(payload: IWorkoutForm) {
|
||||
payloadErrorMessages.value = []
|
||||
payload.title = workoutForm.title
|
||||
@ -376,6 +435,24 @@
|
||||
payloadErrorMessages.value.push('workouts.INVALID_DISTANCE')
|
||||
}
|
||||
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() {
|
||||
const payload: IWorkoutForm = {
|
||||
@ -388,10 +465,17 @@
|
||||
} else {
|
||||
formatPayload(payload)
|
||||
}
|
||||
store.dispatch(WORKOUTS_STORE.ACTIONS.EDIT_WORKOUT, {
|
||||
workoutId: props.workout.id,
|
||||
data: payload,
|
||||
})
|
||||
if (payloadErrorMessages.value.length > 0) {
|
||||
store.commit(
|
||||
ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES,
|
||||
payloadErrorMessages.value
|
||||
)
|
||||
} else {
|
||||
store.dispatch(WORKOUTS_STORE.ACTIONS.EDIT_WORKOUT, {
|
||||
workoutId: props.workout.id,
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (withGpx.value) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -134,7 +134,7 @@
|
||||
{{ $t('workouts.ASCENT') }}
|
||||
</span>
|
||||
<Distance
|
||||
v-if="workout.with_gpx"
|
||||
v-if="workout.ascent !== null"
|
||||
:distance="workout.ascent"
|
||||
unitFrom="m"
|
||||
:useImperialUnits="user.imperial_units"
|
||||
@ -145,7 +145,7 @@
|
||||
{{ $t('workouts.DESCENT') }}
|
||||
</span>
|
||||
<Distance
|
||||
v-if="workout.with_gpx"
|
||||
v-if="workout.descent !== null"
|
||||
:distance="workout.descent"
|
||||
unitFrom="m"
|
||||
:useImperialUnits="user.imperial_units"
|
||||
|
@ -16,6 +16,7 @@
|
||||
"FROM": "from",
|
||||
"GPX_FILE": ".gpx file",
|
||||
"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_DURATION": "The duration must be greater than 0 seconds",
|
||||
"LATEST_WORKOUTS": "Latest workouts",
|
||||
|
@ -16,6 +16,7 @@
|
||||
"FROM": "à partir de",
|
||||
"GPX_FILE": "fichier .gpx",
|
||||
"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_DURATION": "La durée doit être supérieure à 0 secondes",
|
||||
"LATEST_WORKOUTS": "Séances récentes",
|
||||
|
@ -116,6 +116,8 @@ export interface IWorkoutForm {
|
||||
distance?: number
|
||||
duration?: number
|
||||
file?: Blob
|
||||
ascent?: number | null
|
||||
descent?: number | null
|
||||
}
|
||||
|
||||
export interface IWorkoutPayload {
|
||||
|
Loading…
Reference in New Issue
Block a user