Merge pull request #271 from SamR1/fix-workout-creation

Fix workouts creation
This commit is contained in:
Sam 2022-11-16 10:31:14 +01:00 committed by GitHub
commit c62a1b2cfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 169 additions and 61 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.d575ea61.js"></script><script defer="defer" src="/static/js/app.81fed7e5.js"></script><link href="/static/css/app.b6bd588e.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.d575ea61.js"></script><script defer="defer" src="/static/js/app.3b81d4ee.js"></script><link href="/static/css/app.b6bd588e.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

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ import os
import re import re
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
from typing import Dict, Optional from typing import Any, Dict, Optional
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
@ -446,12 +446,37 @@ class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin):
assert len(data['data']['workouts']) == 1 assert len(data['data']['workouts']) == 1
assert_workout_data_with_gpx(data) assert_workout_data_with_gpx(data)
def test_it_returns_400_when_quotes_are_not_escaped_in_notes(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
gpx_file: str,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
)
response = client.post(
'/api/workouts',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{{"sport_id": 1, "notes": "test "workout""}}',
),
headers=dict(
content_type='multipart/form-data',
Authorization=f'Bearer {auth_token}',
),
)
self.assert_400(response)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'input_description,input_notes', 'input_description,input_notes',
[ [
('empty notes', ''), ('empty notes', ''),
('short notes', 'test workout'), ('short notes', 'test workout'),
('notes with special characters', 'test \nworkout'), ('notes with special characters', "test \n'workout'"),
], ],
) )
def test_it_adds_a_workout_with_gpx_notes( def test_it_adds_a_workout_with_gpx_notes(
@ -861,8 +886,46 @@ 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)
def test_it_returns_400_if_workout_date_is_missing( @pytest.mark.parametrize(
self, app: Flask, user_1: User, sport_1_cycling: Sport 'description,input_data',
[
(
"'sport_id' is missing",
{
"duration": 3600,
"workout_date": '2018-05-15 14:05',
"distance": 10,
},
),
(
"'duration' is missing",
{
"sport_id": 1,
"workout_date": '2018-05-15 14:05',
"distance": 10,
},
),
(
"'workout_date' is missing",
{"sport_id": 1, "duration": 3600, "distance": 10},
),
(
"'distance' is missing",
{
"sport_id": 1,
"duration": 3600,
"workout_date": '2018-05-15 14:05',
},
),
],
)
def test_it_returns_400_if_key_is_missing(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
description: str,
input_data: Dict,
) -> None: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -871,13 +934,13 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
response = client.post( response = client.post(
'/api/workouts/no_gpx', '/api/workouts/no_gpx',
content_type='application/json', content_type='application/json',
data=json.dumps(dict(sport_id=1, duration=3600, distance=10)), data=json.dumps(input_data),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
self.assert_400(response) self.assert_400(response)
def test_it_returns_500_if_workout_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:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
@ -900,12 +963,13 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
self.assert_500(response, 'Error during workout save.', status='fail') self.assert_500(response, 'Error during workout save.', status='fail')
def test_it_adds_workout_with_zero_value( @pytest.mark.parametrize('input_distance', [0, '', None])
def test_it_returns_400_when_distance_is_invalid(
self, self,
app: Flask, app: Flask,
user_1: User, user_1: User,
sport_1_cycling: Sport, sport_1_cycling: Sport,
sport_2_running: Sport, input_distance: Any,
) -> None: ) -> None:
client, auth_token = self.get_test_client_and_auth_token( client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email app, user_1.email
@ -917,41 +981,43 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
data=json.dumps( data=json.dumps(
dict( dict(
sport_id=1, sport_id=1,
duration=0, duration=3600,
workout_date='2018-05-14 14:05', workout_date='2018-05-15 14:05',
distance=0, distance=input_distance,
title='Workout test',
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
) )
data = json.loads(response.data.decode()) self.assert_400(response)
assert response.status_code == 201
assert 'created' in data['status']
assert len(data['data']['workouts']) == 1
assert 'creation_date' in data['data']['workouts'][0]
assert (
data['data']['workouts'][0]['workout_date']
== 'Mon, 14 May 2018 14:05:00 GMT'
)
assert data['data']['workouts'][0]['user'] == 'test'
assert data['data']['workouts'][0]['sport_id'] == 1
assert data['data']['workouts'][0]['duration'] is None
assert data['data']['workouts'][0]['title'] == 'Workout test'
assert data['data']['workouts'][0]['ascent'] is None
assert data['data']['workouts'][0]['ave_speed'] is None
assert data['data']['workouts'][0]['descent'] is None
assert data['data']['workouts'][0]['distance'] is None
assert data['data']['workouts'][0]['max_alt'] is None
assert data['data']['workouts'][0]['max_speed'] is None
assert data['data']['workouts'][0]['min_alt'] is None
assert data['data']['workouts'][0]['moving'] is None
assert data['data']['workouts'][0]['pauses'] is None
assert data['data']['workouts'][0]['with_gpx'] is False
assert len(data['data']['workouts'][0]['segments']) == 0 @pytest.mark.parametrize('input_duration', [0, '', None])
assert len(data['data']['workouts'][0]['records']) == 0 def test_it_returns_400_when_duration_is_invalid(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
input_duration: Any,
) -> 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=input_duration,
workout_date='2018-05-15 14:05',
distance=10,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
self.assert_400(response)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'client_scope, can_access', 'client_scope, can_access',

View File

@ -988,7 +988,11 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
if error_response: if error_response:
return error_response return error_response
workout_data = json.loads(request.form['data'], strict=False) try:
workout_data = json.loads(request.form['data'], strict=False)
except json.decoder.JSONDecodeError:
return InvalidPayloadErrorResponse()
if not workout_data or workout_data.get('sport_id') is None: if not workout_data or workout_data.get('sport_id') is None:
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()
@ -1149,10 +1153,10 @@ def post_workout_no_gpx(
workout_data = request.get_json() workout_data = request.get_json()
if ( if (
not workout_data not workout_data
or workout_data.get('sport_id') is None or not workout_data.get('sport_id')
or workout_data.get('duration') is None or not workout_data.get('duration')
or workout_data.get('distance') is None or not workout_data.get('distance')
or workout_data.get('workout_date') is None or not workout_data.get('workout_date')
): ):
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()

View File

@ -135,6 +135,7 @@
id="workout-duration-hour" id="workout-duration-hour"
name="workout-duration-hour" name="workout-duration-hour"
class="workout-duration" class="workout-duration"
:class="{ errored: isDurationInvalid() }"
type="text" type="text"
placeholder="HH" placeholder="HH"
minlength="1" minlength="1"
@ -150,6 +151,7 @@
id="workout-duration-minutes" id="workout-duration-minutes"
name="workout-duration-minutes" name="workout-duration-minutes"
class="workout-duration" class="workout-duration"
:class="{ errored: isDurationInvalid() }"
type="text" type="text"
pattern="^([0-5][0-9])$" pattern="^([0-5][0-9])$"
minlength="2" minlength="2"
@ -165,6 +167,7 @@
id="workout-duration-seconds" id="workout-duration-seconds"
name="workout-duration-seconds" name="workout-duration-seconds"
class="workout-duration" class="workout-duration"
:class="{ errored: isDurationInvalid() }"
type="text" type="text"
pattern="^([0-5][0-9])$" pattern="^([0-5][0-9])$"
minlength="2" minlength="2"
@ -185,6 +188,7 @@
}}): }}):
</label> </label>
<input <input
:class="{ errored: isDistanceInvalid() }"
name="workout-distance" name="workout-distance"
type="number" type="number"
min="0" min="0"
@ -228,6 +232,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ComputedRef, ComputedRef,
Ref,
computed, computed,
reactive, reactive,
ref, ref,
@ -306,6 +311,7 @@
) )
let gpxFile: File | null = null let gpxFile: File | null = null
const formErrors = ref(false) const formErrors = ref(false)
const payloadErrorMessages: Ref<string[]> = ref([])
onMounted(() => { onMounted(() => {
if (props.workout.id) { if (props.workout.id) {
@ -347,15 +353,28 @@
workoutForm.workoutDurationSeconds = duration[2] workoutForm.workoutDurationSeconds = duration[2]
} }
} }
function isDistanceInvalid() {
return payloadErrorMessages.value.includes('workouts.INVALID_DISTANCE')
}
function isDurationInvalid() {
return payloadErrorMessages.value.includes('workouts.INVALID_DURATION')
}
function formatPayload(payload: IWorkoutForm) { function formatPayload(payload: IWorkoutForm) {
payloadErrorMessages.value = []
payload.title = workoutForm.title payload.title = workoutForm.title
payload.distance = authUser.value.imperial_units
? convertDistance(+workoutForm.workoutDistance, 'mi', 'km', 3)
: +workoutForm.workoutDistance
payload.duration = payload.duration =
+workoutForm.workoutDurationHour * 3600 + +workoutForm.workoutDurationHour * 3600 +
+workoutForm.workoutDurationMinutes * 60 + +workoutForm.workoutDurationMinutes * 60 +
+workoutForm.workoutDurationSeconds +workoutForm.workoutDurationSeconds
if (payload.duration <= 0) {
payloadErrorMessages.value.push('workouts.INVALID_DURATION')
}
payload.distance = authUser.value.imperial_units
? convertDistance(+workoutForm.workoutDistance, 'mi', 'km', 3)
: +workoutForm.workoutDistance
if (payload.distance <= 0) {
payloadErrorMessages.value.push('workouts.INVALID_DISTANCE')
}
payload.workout_date = `${workoutForm.workoutDate} ${workoutForm.workoutTime}` payload.workout_date = `${workoutForm.workoutDate} ${workoutForm.workoutTime}`
} }
function updateWorkout() { function updateWorkout() {
@ -384,7 +403,17 @@
store.dispatch(WORKOUTS_STORE.ACTIONS.ADD_WORKOUT, payload) store.dispatch(WORKOUTS_STORE.ACTIONS.ADD_WORKOUT, payload)
} else { } else {
formatPayload(payload) formatPayload(payload)
store.dispatch(WORKOUTS_STORE.ACTIONS.ADD_WORKOUT_WITHOUT_GPX, payload) if (payloadErrorMessages.value.length > 0) {
store.commit(
ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES,
payloadErrorMessages.value
)
} else {
store.dispatch(
WORKOUTS_STORE.ACTIONS.ADD_WORKOUT_WITHOUT_GPX,
payload
)
}
} }
} }
} }
@ -521,5 +550,9 @@
margin-top: 0; margin-top: 0;
} }
} }
.errored {
outline: 2px solid var(--input-error-color);
}
} }
</style> </style>

View File

@ -16,6 +16,8 @@
"FROM": "from", "FROM": "from",
"GPX_FILE": ".gpx file", "GPX_FILE": ".gpx file",
"HIDE_FILTERS": "hide filters", "HIDE_FILTERS": "hide filters",
"INVALID_DISTANCE": "The distance must be greater than 0",
"INVALID_DURATION": "The duration must be greater than 0 seconds",
"LATEST_WORKOUTS": "Latest workouts", "LATEST_WORKOUTS": "Latest workouts",
"LOAD_MORE_WORKOUT": "Load more workouts", "LOAD_MORE_WORKOUT": "Load more workouts",
"MAX_ALTITUDE": "max. altitude", "MAX_ALTITUDE": "max. altitude",

View File

@ -16,6 +16,8 @@
"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_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", "LATEST_WORKOUTS": "Séances récentes",
"LOAD_MORE_WORKOUT": "Charger les séances suivantes", "LOAD_MORE_WORKOUT": "Charger les séances suivantes",
"MAX_ALTITUDE": "altitude max", "MAX_ALTITUDE": "altitude max",

View File

@ -11,7 +11,7 @@ export const mutations: MutationTree<IRootState> & TRootMutations = {
}, },
[ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES]( [ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES](
state: IRootState, state: IRootState,
errorMessages: string errorMessages: string | string[]
) { ) {
state.errorMessages = errorMessages state.errorMessages = errorMessages
}, },

View File

@ -60,7 +60,7 @@ export type TRootMutations<S = IRootState> = {
[ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES](state: S): void [ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES](state: S): void
[ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES]( [ROOT_STORE.MUTATIONS.SET_ERROR_MESSAGES](
state: S, state: S,
errorMessages: string errorMessages: string | string[]
): void ): void
[ROOT_STORE.MUTATIONS.UPDATE_APPLICATION_CONFIG]( [ROOT_STORE.MUTATIONS.UPDATE_APPLICATION_CONFIG](
state: S, state: S,

View File

@ -185,11 +185,12 @@ export const actions: ActionTree<IWorkoutsState, IRootState> &
if (!payload.file) { if (!payload.file) {
throw new Error('No file part') throw new Error('No file part')
} }
const notes = payload.notes.replace(/"/g, '\\"')
const form = new FormData() const form = new FormData()
form.append('file', payload.file) form.append('file', payload.file)
form.append( form.append(
'data', 'data',
`{"sport_id": ${payload.sport_id}, "notes": "${payload.notes}"}` `{"sport_id": ${payload.sport_id}, "notes": "${notes}"}`
) )
authApi authApi
.post('workouts', form, { .post('workouts', form, {