Notivue started + fixes to location on file parsing

[README] added volume for log
[frontend] started transition of toasts to Notivue
[frontend] added more detailed info on fetch error requests
[backend] added validation to not throw an error if impossible to query location data for the activity
[backend] added validation on location_based_on_coordinates and raise exception when not possible to query Geocodes endpoint
[backend] added additional validations to better handle location missing on parsing .fit and .gpx files
This commit is contained in:
João Vitória Silva
2024-09-10 17:19:24 +01:00
parent 5f4c608787
commit fdcd822be6
13 changed files with 175 additions and 85 deletions

View File

@@ -168,6 +168,7 @@ It is recommended to configure the following volumes for code and data persisten
| /app | <local_path>/endurain/backend/app:/app | Configure volume if you want to edit the code locally by cloning the repo and comment next line |
| /app/user_images | <local_path>/endurain/backend/app/user_images:/app/user_images | Necessary for user image persistence on container image updates |
| /app/bulk_import | <local_path>/endurain/backend/bulk_import:/app/bulk_import | Necessary to enable bulk import of activities. Place here your activities files |
| /app/app.log | <local_path>/endurain/backend/app.log:/app/app.log | Log file for the backend |
## Strava Integration

View File

@@ -425,10 +425,6 @@ async def create_activity_with_uploaded_file(
],
):
try:
# Ensure the 'uploads' directory exists
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
# Return activity
return activies_utils.parse_and_store_activity_from_uploaded_file(token_user_id, file, db)
except Exception as err:
@@ -437,10 +433,7 @@ async def create_activity_with_uploaded_file(
f"Error in create_activity_with_uploaded_file: {err}", exc_info=True
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
raise err
@router.post(

View File

@@ -36,20 +36,21 @@ async def parse_and_store_activity_from_file(
# Parse the file
parsed_info = parse_file(token_user_id, file_extension, file_path)
# Store the activity in the database
created_activity = store_activity(parsed_info, db)
if parsed_info is not None:
# Store the activity in the database
created_activity = store_activity(parsed_info, db)
# Return the created activity
return created_activity
# Return the created activity
return created_activity
else:
return None
except HTTPException:
pass
except Exception as err:
# Log the exception
logger.error(
f"Error in parse_and_store_activity_from_file: {err}", exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def parse_and_store_activity_from_uploaded_file(
@@ -59,38 +60,71 @@ def parse_and_store_activity_from_uploaded_file(
# Get file extension
_, file_extension = os.path.splitext(file.filename)
# Save the uploaded file in the 'uploads' directory
with open(file.filename, "wb") as save_file:
save_file.write(file.file.read())
try:
# Ensure the 'uploads' directory exists
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
# Parse the file
parsed_info = parse_file(token_user_id, file_extension, file.filename)
# Build the full path where the file will be saved
file_path = os.path.join(upload_dir, file.filename)
# Store the activity in the database
created_activity = store_activity(parsed_info, db)
# Save the uploaded file in the 'uploads' directory
with open(file_path, "wb") as save_file:
save_file.write(file.file.read())
# Return the created activity
return created_activity
# Parse the file
parsed_info = parse_file(token_user_id, file_extension, file_path)
if parsed_info is not None:
# Store the activity in the database
created_activity = store_activity(parsed_info, db)
# Return the created activity
return created_activity
else:
return None
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
logger.error(f"Error in parse_and_store_activity_from_uploaded_file: {err}", exc_info=True)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
finally:
# Remove the file after processing
os.remove(file_path)
def parse_file(token_user_id: int, file_extension: str, filename: str) -> dict:
try:
# Choose the appropriate parser based on file extension
if file_extension.lower() == ".gpx":
# Parse the GPX file
parsed_info = gpx_utils.parse_gpx_file(filename, token_user_id)
elif file_extension.lower() == ".fit":
# Parse the FIT file
parsed_info = fit_utils.parse_fit_file(filename, token_user_id)
else:
# file extension not supported raise an HTTPException with a 406 Not Acceptable status code
raise HTTPException(
status_code=status.HTTP_406_NOT_ACCEPTABLE,
detail="File extension not supported. Supported file extensions are .gpx and .fit",
)
if filename.lower() != "bulk_import/__init__.py":
print("Parsing file: ", filename)
logger.info(f"Parsing file: {filename}")
# Choose the appropriate parser based on file extension
if file_extension.lower() == ".gpx":
# Parse the GPX file
parsed_info = gpx_utils.parse_gpx_file(filename, token_user_id)
elif file_extension.lower() == ".fit":
# Parse the FIT file
parsed_info = fit_utils.parse_fit_file(filename, token_user_id)
else:
# file extension not supported raise an HTTPException with a 406 Not Acceptable status code
raise HTTPException(
status_code=status.HTTP_406_NOT_ACCEPTABLE,
detail="File extension not supported. Supported file extensions are .gpx and .fit",
)
# Remove the file after processing
os.remove(filename)
# Return the parsed information
return parsed_info
return parsed_info
else:
return None
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
logger.error(f"Error in parse_file: {err}", exc_info=True)
@@ -99,9 +133,6 @@ def parse_file(token_user_id: int, file_extension: str, filename: str) -> dict:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
finally:
# Remove the file after processing
os.remove(filename)
def store_activity(parsed_info: dict, db: Session):
@@ -110,6 +141,10 @@ def store_activity(parsed_info: dict, db: Session):
# Check if created_activity is None
if created_activity is None:
# Log the error
logger.error(
"Error in store_activity - activity is None, error creating activity"
)
# raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -150,6 +185,9 @@ def calculate_activity_distances(activities: list[activities_schema.Activity]):
def location_based_on_coordinates(latitude, longitude) -> dict | None:
if latitude is None or longitude is None:
return None
if os.environ.get("GEOCODES_MAPS_API") == "changeme1":
return None
# Create a dictionary with the parameters for the request
url_params = {
@@ -176,12 +214,15 @@ def location_based_on_coordinates(latitude, longitude) -> dict | None:
"town": data.get("town"),
"country": data.get("country"),
}
except requests.exceptions.RequestException as err:
# Log the error
logger.error(
f"Error in upload_file querying local from geocode: {err}",
exc_info=True,
"Error in location_based_on_coordinates - Invalid API key provided for the geocoding service"
)
print("Error in location_based_on_coordinates - Invalid API key provided for the geocoding service")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key provided for the geocoding service",
)

View File

View File

@@ -192,11 +192,13 @@ def parse_fit_file(file: str, user_id: int) -> dict:
location_data = activities_utils.location_based_on_coordinates(
latitude, longitude
)
city = location_data["city"]
town = location_data["town"]
country = location_data["country"]
if location_data:
city = location_data["city"]
town = location_data["town"]
country = location_data["country"]
process_one_time_fields = 1
process_one_time_fields = 1
# Check if heart rate, cadence, power are set
if heart_rate != 0:

View File

@@ -76,6 +76,8 @@ def parse_gpx_file(file: str, user_id: int) -> dict:
country = None
process_one_time_fields = 0
pace = 0
calories = 0
visibility = 0
# Arrays to store waypoint data
lat_lon_waypoints = []
@@ -129,11 +131,13 @@ def parse_gpx_file(file: str, user_id: int) -> dict:
location_data = activities_utils.location_based_on_coordinates(
latitude, longitude
)
city = location_data["city"]
town = location_data["town"]
country = location_data["country"]
process_one_time_fields = 1
if location_data:
city = location_data["city"]
town = location_data["town"]
country = location_data["country"]
process_one_time_fields = 1
# Extract heart rate, cadence, and power data from point extensions
heart_rate, cadence, power = 0, 0, 0
@@ -265,6 +269,8 @@ def parse_gpx_file(file: str, user_id: int) -> dict:
pace=pace,
average_speed=average_speed,
average_power=average_power,
calories=calories,
visibility=visibility,
strava_gear_id=None,
strava_activity_id=None,
)

View File

@@ -16,6 +16,7 @@
"bootstrap": "^5.3.2",
"chart.js": "^4.4.1",
"leaflet": "^1.9.4",
"notivue": "^2.4.5",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-i18n": "^9.9.1",
@@ -6292,6 +6293,28 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/notivue": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/notivue/-/notivue-2.4.5.tgz",
"integrity": "sha512-7yBdaKesUZIwdcQP3nv1oWYyisI2bURkZ+D9KfLgeNqguHUzkQ1WdhGcTj59PBZa8mqa1/K5Mh8YsphSToMKcQ==",
"license": "MIT",
"peerDependencies": {
"@nuxt/kit": ">=3.5.0",
"@nuxt/schema": ">=3.5.0",
"defu": ">=6"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"@nuxt/schema": {
"optional": true
},
"defu": {
"optional": true
}
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",

View File

@@ -20,6 +20,7 @@
"bootstrap": "^5.3.2",
"chart.js": "^4.4.1",
"leaflet": "^1.9.4",
"notivue": "^2.4.5",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-i18n": "^9.9.1",

View File

@@ -3,9 +3,15 @@
import ToastContainerComponent from './components/Toasts/ToastContainerComponent.vue'
import NavbarComponent from './components/Navbar/NavbarComponent.vue'
import FooterComponent from './components/FooterComponent.vue'
import { Notivue, Notification, NotificationProgress, pastelTheme } from 'notivue'
</script>
<template>
<Notivue v-slot="item">
<Notification :item="item" :theme="pastelTheme">
<NotificationProgress :item="item" />
</Notification>
</Notivue>
<NavbarComponent />
<ToastContainerComponent />
<main class="container py-4 bg-body">

View File

@@ -78,7 +78,6 @@ export default {
return;
}
try {
// Replace with your actual API call for activities
searchResults.value = await activities.getActivityByName(query);
} catch (error) {
console.error('Error fetching activity results:', error);
@@ -91,7 +90,6 @@ export default {
return;
}
try {
// Replace with your actual API call for gear
searchResults.value = await gears.getGearByNickname(query);
} catch (error) {
console.error('Error fetching gear results:', error);

View File

@@ -1,35 +1,50 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createApp } from "vue";
import { createNotivue } from "notivue";
import { createPinia } from "pinia";
import { useAuthStore } from './stores/authStore';
import { useThemeStore } from './stores/themeStore';
import { useAuthStore } from "./stores/authStore";
import { useThemeStore } from "./stores/themeStore";
import "bootstrap/dist/css/bootstrap.min.css";
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import "bootstrap/dist/js/bootstrap.bundle.min.js";
import 'leaflet/dist/leaflet.css';
import "leaflet/dist/leaflet.css";
import App from './App.vue';
import router from './router';
import i18n from './i18n';
import App from "./App.vue";
import router from "./router";
import i18n from "./i18n";
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core';
import { library } from "@fortawesome/fontawesome-svg-core";
/* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
/* import icons */
import { fas } from '@fortawesome/free-solid-svg-icons';
import { fab } from '@fortawesome/free-brands-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
import { fas } from "@fortawesome/free-solid-svg-icons";
import { fab } from "@fortawesome/free-brands-svg-icons";
import { far } from "@fortawesome/free-regular-svg-icons";
/* add icons to the library */
library.add(fas, fab, far);
/* import notivue components */
import "notivue/notification.css";
import "notivue/animations.css";
import "notivue/notification-progress.css";
const notivue = createNotivue({
notifications: {
global: {
duration: 5000,
},
},
});
const app = createApp(App);
app.use(createPinia());
app.component('font-awesome-icon', FontAwesomeIcon);
app.use(notivue);
app.component("font-awesome-icon", FontAwesomeIcon);
app.use(router);
app.use(i18n);
@@ -40,4 +55,4 @@ authStore.loadUserFromStorage(i18n);
const themeStore = useThemeStore();
themeStore.loadThemeFromStorage();
app.mount('#app');
app.mount("#app");

View File

@@ -9,7 +9,7 @@ async function fetchWithRetry(url, options) {
await refreshAccessToken();
return await attemptFetch(url, options);
} catch (refreshError) {
throw new Error('Failed to refresh access token: ' + refreshError.message);
throw new Error(`Failed to refresh access token: ${refreshError.message}`);
}
} else {
throw error;
@@ -21,7 +21,9 @@ async function attemptFetch(url, options) {
const fullUrl = `${API_URL}${url}`;
const response = await fetch(fullUrl, options);
if (!response.ok) {
throw new Error('' + response.status);
const errorBody = await response.json(); // Parse the response as JSON
const errorMessage = errorBody.detail || 'Unknown error'; // Get the 'detail' field or a default message
throw new Error(`${response.status} - ${errorMessage}`);
}
return response.json();
}
@@ -37,7 +39,9 @@ async function refreshAccessToken() {
},
});
if (!response.ok) {
throw new Error('' + response.status);
const errorBody = await response.json(); // Parse the response as JSON
const errorMessage = errorBody.detail || 'Unknown error'; // Get the 'detail' field or a default message
throw new Error(`${response.status} - ${errorMessage}`);
}
}

View File

@@ -118,8 +118,8 @@ import { useAuthStore } from '@/stores/authStore';
// Import the services
import { activities } from '@/services/activitiesService';
import { followers } from '@/services/followersService';
// Importing the utils
import { addToast } from '@/utils/toastUtils';
// Import Notivue push
import { push } from 'notivue'
// Importing the components
import UserDistanceStatsComponent from '@/components/Activities/UserDistanceStatsComponent.vue';
import NoItemsFoundComponent from '@/components/GeneralComponents/NoItemsFoundComponents.vue';
@@ -160,7 +160,7 @@ export default {
thisMonthDistances.value = await activities.getUserThisMonthStats(authStore.user.id);
} catch (error) {
// Set the error message
addToast(`${t('generalItens.errorFetchingInfo')} - ${error}`, 'danger', true);
push.error(`${t('generalItens.errorFetchingInfo')} - ${error}`)
}
}
@@ -182,7 +182,7 @@ export default {
}
} catch (error) {
// Set the error message
addToast(`${t('generalItens.errorFetchingInfo')} - ${error}`, 'danger', true);
push.error(`${t('generalItens.errorFetchingInfo')} - ${error}`)
}
}
@@ -198,7 +198,7 @@ export default {
const submitUploadFileForm = async () => {
// Set the loading message
addToast(t('homeView.processingActivity'), 'loading', true);
const notification = push.promise(t('homeView.processingActivity'))
// Get the file input
const fileInput = document.querySelector('input[type="file"]');
@@ -218,7 +218,7 @@ export default {
userActivities.value.unshift(createdActivity);
// Set the success message
addToast(t('homeView.successActivityAdded'), 'success', true);
notification.resolve(t('homeView.successActivityAdded'))
// Clear the file input
fileInput.value = '';
@@ -230,7 +230,7 @@ export default {
userNumberOfActivities.value ++;
} catch (error) {
// Set the error message
addToast(`${t('generalItens.errorFetchingInfo')} - ${error}`, 'danger', true);
notification.reject(`${t('generalItens.errorFetchingInfo')} - ${error}`)
}
}
};
@@ -238,13 +238,13 @@ export default {
onMounted(async () => {
if (route.query.activityFound === 'false') {
// Set the activityFound value to false and show the error alert.
addToast(`${t('homeView.errorActivityNotFound')} - ${error}`, 'danger', true);
push.error(`${t('homeView.errorActivityNotFound')} - ${error}`)
}
if (route.query.activityDeleted === 'true') {
userActivities.value = userActivities.value.filter(activity => activity.id !== Number(route.query.activityId));
// Set the activityDeleted value to true and show the success alert.
addToast(t('homeView.successActivityDeleted'), 'success', true);
push.success(t('homeView.successActivityDeleted'))
}
// Add the scroll event listener
@@ -265,7 +265,7 @@ export default {
}
} catch (error) {
// Set the error message
addToast(`${t('generalItens.errorFetchingInfo')} - ${error}`, 'danger', true);
push.error(`${t('generalItens.errorFetchingInfo')} - ${error}`)
}
isLoading.value = false;