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
from datetime import datetime
from io import BytesIO
from typing import Dict, Optional
from typing import Any, Dict, Optional
from unittest.mock import Mock
import pytest
@ -446,12 +446,37 @@ class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin):
assert len(data['data']['workouts']) == 1
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(
'input_description,input_notes',
[
('empty notes', ''),
('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(
@ -861,8 +886,46 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
assert len(data['data']['workouts']) == 1
assert_workout_data_wo_gpx(data)
def test_it_returns_400_if_workout_date_is_missing(
self, app: Flask, user_1: User, sport_1_cycling: Sport
@pytest.mark.parametrize(
'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:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
@ -871,13 +934,13 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
response = client.post(
'/api/workouts/no_gpx',
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}'),
)
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
) -> None:
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')
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,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
sport_2_running: Sport,
input_distance: Any,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, user_1.email
@ -917,41 +981,43 @@ class TestPostWorkoutWithoutGpx(ApiTestCaseMixin):
data=json.dumps(
dict(
sport_id=1,
duration=0,
workout_date='2018-05-14 14:05',
distance=0,
title='Workout test',
duration=3600,
workout_date='2018-05-15 14:05',
distance=input_distance,
)
),
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 '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
self.assert_400(response)
assert len(data['data']['workouts'][0]['segments']) == 0
assert len(data['data']['workouts'][0]['records']) == 0
@pytest.mark.parametrize('input_duration', [0, '', None])
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(
'client_scope, can_access',

View File

@ -988,7 +988,11 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
if 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:
return InvalidPayloadErrorResponse()
@ -1149,10 +1153,10 @@ def post_workout_no_gpx(
workout_data = request.get_json()
if (
not workout_data
or workout_data.get('sport_id') is None
or workout_data.get('duration') is None
or workout_data.get('distance') is None
or workout_data.get('workout_date') is None
or not workout_data.get('sport_id')
or not workout_data.get('duration')
or not workout_data.get('distance')
or not workout_data.get('workout_date')
):
return InvalidPayloadErrorResponse()

View File

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

View File

@ -16,6 +16,8 @@
"FROM": "from",
"GPX_FILE": ".gpx file",
"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",
"LOAD_MORE_WORKOUT": "Load more workouts",
"MAX_ALTITUDE": "max. altitude",

View File

@ -16,6 +16,8 @@
"FROM": "à partir de",
"GPX_FILE": "fichier .gpx",
"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",
"LOAD_MORE_WORKOUT": "Charger les séances suivantes",
"MAX_ALTITUDE": "altitude max",

View File

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

View File

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