Merge pull request #271 from SamR1/fix-workout-creation
Fix workouts creation
This commit is contained in:
commit
c62a1b2cfe
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.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>
|
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
2
fittrackee/dist/static/js/app.3b81d4ee.js
vendored
Normal file
2
fittrackee/dist/static/js/app.3b81d4ee.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/app.3b81d4ee.js.map
vendored
Normal file
1
fittrackee/dist/static/js/app.3b81d4ee.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/app.81fed7e5.js
vendored
2
fittrackee/dist/static/js/app.81fed7e5.js
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
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/workouts.4c5396bb.js
vendored
Normal file
2
fittrackee/dist/static/js/workouts.4c5396bb.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/workouts.4c5396bb.js.map
vendored
Normal file
1
fittrackee/dist/static/js/workouts.4c5396bb.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
@ -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',
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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, {
|
||||
|
Loading…
Reference in New Issue
Block a user