Merged with dev and fixed conflicts

This commit is contained in:
Fmstrat
2022-07-18 09:16:17 -04:00
349 changed files with 17812 additions and 7523 deletions

View File

@ -12,7 +12,7 @@ from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.session import Session, object_session
from sqlalchemy.types import JSON, Enum
from fittrackee import db
from fittrackee import appLog, db
from fittrackee.files import get_absolute_file_path
from .utils.convert import convert_in_duration, convert_value_to_integer
@ -381,9 +381,15 @@ def on_workout_delete(
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session: Session, context: Any) -> None:
if old_record.map:
os.remove(get_absolute_file_path(old_record.map))
try:
os.remove(get_absolute_file_path(old_record.map))
except OSError:
appLog.error('map file not found when deleting workout')
if old_record.gpx:
os.remove(get_absolute_file_path(old_record.gpx))
try:
os.remove(get_absolute_file_path(old_record.gpx))
except OSError:
appLog.error('gpx file not found when deleting workout')
class WorkoutSegment(BaseModel):

View File

@ -1,5 +1,5 @@
from datetime import timedelta
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import gpxpy.gpx
@ -16,9 +16,9 @@ def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
def get_gpx_data(
parsed_gpx: gpxpy.gpx,
parsed_gpx: Union[gpxpy.gpx.GPX, gpxpy.gpx.GPXTrackSegment],
max_speed: float,
start: int,
start: Union[datetime, None],
stopped_time_between_seg: timedelta,
stopped_speed_threshold: float,
) -> Dict:
@ -32,7 +32,8 @@ def get_gpx_data(
duration = parsed_gpx.get_duration()
gpx_data['duration'] = (
timedelta(seconds=duration) + stopped_time_between_seg
timedelta(seconds=duration if duration else 0)
+ stopped_time_between_seg
)
ele = parsed_gpx.get_elevation_extremes()
@ -43,18 +44,24 @@ def get_gpx_data(
gpx_data['uphill'] = hill.uphill
gpx_data['downhill'] = hill.downhill
mv = parsed_gpx.get_moving_data(
moving_data = parsed_gpx.get_moving_data(
stopped_speed_threshold=stopped_speed_threshold
)
gpx_data['moving_time'] = timedelta(seconds=mv.moving_time)
gpx_data['stop_time'] = (
timedelta(seconds=mv.stopped_time) + stopped_time_between_seg
)
distance = mv.moving_distance + mv.stopped_distance
gpx_data['distance'] = distance / 1000
if moving_data:
gpx_data['moving_time'] = timedelta(seconds=moving_data.moving_time)
gpx_data['stop_time'] = (
timedelta(seconds=moving_data.stopped_time)
+ stopped_time_between_seg
)
distance = moving_data.moving_distance + moving_data.stopped_distance
gpx_data['distance'] = distance / 1000
average_speed = distance / mv.moving_time if mv.moving_time > 0 else 0
gpx_data['average_speed'] = (average_speed / 1000) * 3600
average_speed = (
distance / moving_data.moving_time
if moving_data.moving_time > 0
else 0
)
gpx_data['average_speed'] = (average_speed / 1000) * 3600
return gpx_data
@ -72,9 +79,9 @@ def get_gpx_info(
if gpx is None:
raise WorkoutGPXException('not found', 'No gpx file')
gpx_data = {'name': gpx.tracks[0].name, 'segments': []}
max_speed = 0
start = 0
gpx_data: Dict = {'name': gpx.tracks[0].name, 'segments': []}
max_speed = 0.0
start: Optional[datetime] = None
map_data = []
weather_data = []
segments_nb = len(gpx.tracks[0].segments)
@ -83,20 +90,23 @@ def get_gpx_info(
stopped_time_between_seg = no_stopped_time
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
segment_start = 0
segment_start: Optional[datetime] = None
segment_points_nb = len(segment.points)
for point_idx, point in enumerate(segment.points):
if point_idx == 0:
segment_start = point.time
# first gpx point => get weather
if start == 0:
if start is None:
start = point.time
if update_weather_data:
if point.time and update_weather_data:
weather_data.append(get_weather(point))
# if a previous segment exists, calculate stopped time between
# the two segments
if prev_seg_last_point:
stopped_time_between_seg = point.time - prev_seg_last_point
stopped_time_between_seg += (
point.time - prev_seg_last_point
)
# last segment point
if point_idx == (segment_points_nb - 1):
@ -108,13 +118,19 @@ def get_gpx_info(
if update_map_data:
map_data.append([point.longitude, point.latitude])
calculated_max_speed = segment.get_moving_data(
moving_data = segment.get_moving_data(
stopped_speed_threshold=stopped_speed_threshold
).max_speed
segment_max_speed = calculated_max_speed if calculated_max_speed else 0
)
if moving_data:
calculated_max_speed = moving_data.max_speed
segment_max_speed = (
calculated_max_speed if calculated_max_speed else 0
)
if segment_max_speed > max_speed:
max_speed = segment_max_speed
if segment_max_speed > max_speed:
max_speed = segment_max_speed
else:
segment_max_speed = 0.0
segment_data = get_gpx_data(
segment,
@ -137,12 +153,16 @@ def get_gpx_info(
if update_map_data:
bounds = gpx.get_bounds()
gpx_data['bounds'] = [
bounds.min_latitude,
bounds.min_longitude,
bounds.max_latitude,
bounds.max_longitude,
]
gpx_data['bounds'] = (
[
bounds.min_latitude,
bounds.min_longitude,
bounds.max_latitude,
bounds.max_longitude,
]
if bounds
else []
)
return gpx_data, map_data, weather_data
@ -222,7 +242,11 @@ def get_chart_data(
'latitude': point.latitude,
'longitude': point.longitude,
'speed': speed,
'time': point.time,
# workaround
# https://github.com/tkrajina/gpxpy/issues/209
'time': point.time.replace(
tzinfo=timezone(point.time.utcoffset())
),
}
)
previous_point = point

View File

@ -1,20 +1,32 @@
import hashlib
from typing import List
import random
from typing import Dict, List
from flask import current_app
from staticmap import Line, StaticMap
from fittrackee import VERSION
from fittrackee.files import get_absolute_file_path
def get_static_map_tile_server_url(tile_server_config: Dict) -> str:
if tile_server_config['STATICMAP_SUBDOMAINS']:
subdomains = tile_server_config['STATICMAP_SUBDOMAINS'].split(',')
subdomain = f'{random.choice(subdomains)}.' # nosec
else:
subdomain = ''
return tile_server_config['URL'].replace('{s}.', subdomain)
def generate_map(map_filepath: str, map_data: List) -> None:
"""
Generate and save map image from map data
"""
m = StaticMap(400, 225, 10)
m.headers = {'User-Agent': f'FitTrackee v{VERSION}'}
if not current_app.config['TILE_SERVER']['DEFAULT_STATICMAP']:
m.url_template = current_app.config['TILE_SERVER']['URL'].replace(
'{s}.', ''
m.url_template = get_static_map_tile_server_url(
current_app.config['TILE_SERVER']
)
line = Line(map_data, '#3388FF', 4)
m.add_line(line)
@ -27,7 +39,7 @@ def get_map_hash(map_filepath: str) -> str:
Generate a md5 hash used as id instead of workout id, to retrieve map
image (maps are sensitive data)
"""
md5 = hashlib.md5()
md5 = hashlib.md5() # nosec # need 3.9+ to use 'usedforsecurity' flag
absolute_map_filepath = get_absolute_file_path(map_filepath)
with open(absolute_map_filepath, 'rb') as f:
for chunk in iter(lambda: f.read(128 * md5.block_size), b''):

View File

@ -3,18 +3,22 @@ from typing import Dict, Optional
import forecastio
import pytz
from gpxpy.gpx import GPXRoutePoint
from gpxpy.gpx import GPXTrackPoint
from fittrackee import appLog
API_KEY = os.getenv('WEATHER_API_KEY')
def get_weather(point: GPXRoutePoint) -> Optional[Dict]:
if not API_KEY or API_KEY == '':
def get_weather(point: GPXTrackPoint) -> Optional[Dict]:
if not API_KEY or not point.time:
return None
try:
point_time = pytz.utc.localize(point.time)
point_time = (
pytz.utc.localize(point.time)
if point.time.tzinfo is None
else point.time.astimezone(pytz.utc)
)
forecast = forecastio.load_forecast(
API_KEY,
point.latitude,

View File

@ -22,31 +22,42 @@ from .gpx import get_gpx_info
from .maps import generate_map, get_map_hash
def get_datetime_with_tz(
timezone: str, workout_date: datetime, gpx_data: Optional[Dict] = None
) -> Tuple[Optional[datetime], datetime]:
def get_workout_datetime(
workout_date: Union[datetime, str],
user_timezone: Optional[str],
date_str_format: Optional[str] = None,
with_timezone: bool = False,
) -> Tuple[datetime, Optional[datetime]]:
"""
Return naive datetime and datetime with user timezone
Return naive datetime and datetime with user timezone if with_timezone
"""
workout_date_tz = None
if timezone:
user_tz = pytz.timezone(timezone)
utc_tz = pytz.utc
if gpx_data:
# workout date in gpx is in UTC, but in naive datetime
fmt = '%Y-%m-%d %H:%M:%S'
workout_date_string = workout_date.strftime(fmt)
workout_date_tmp = utc_tz.localize(
datetime.strptime(workout_date_string, fmt)
)
workout_date_tz = workout_date_tmp.astimezone(user_tz)
else:
workout_date_tz = user_tz.localize(workout_date)
workout_date = workout_date_tz.astimezone(utc_tz)
# make datetime 'naive' like in gpx file
workout_date = workout_date.replace(tzinfo=None)
workout_date_with_user_tz = None
return workout_date_tz, workout_date
# workout w/o gpx
if isinstance(workout_date, str):
if not date_str_format:
date_str_format = '%Y-%m-%d %H:%M:%S'
workout_date = datetime.strptime(workout_date, date_str_format)
if user_timezone:
workout_date = pytz.timezone(user_timezone).localize(workout_date)
if workout_date.tzinfo is None:
naive_workout_date = workout_date
if user_timezone and with_timezone:
pytz.utc.localize(naive_workout_date)
workout_date_with_user_tz = pytz.utc.localize(
naive_workout_date
).astimezone(pytz.timezone(user_timezone))
else:
naive_workout_date = workout_date.astimezone(pytz.utc).replace(
tzinfo=None
)
if user_timezone and with_timezone:
workout_date_with_user_tz = workout_date.astimezone(
pytz.timezone(user_timezone)
)
return naive_workout_date, workout_date_with_user_tz
def get_datetime_from_request_args(
@ -57,25 +68,32 @@ def get_datetime_from_request_args(
date_from_str = params.get('from')
if date_from_str:
date_from = datetime.strptime(date_from_str, '%Y-%m-%d')
_, date_from = get_datetime_with_tz(user.timezone, date_from)
date_from, _ = get_workout_datetime(
workout_date=date_from_str,
user_timezone=user.timezone,
date_str_format='%Y-%m-%d',
)
date_to_str = params.get('to')
if date_to_str:
date_to = datetime.strptime(
f'{date_to_str} 23:59:59', '%Y-%m-%d %H:%M:%S'
date_to, _ = get_workout_datetime(
workout_date=f'{date_to_str} 23:59:59',
user_timezone=user.timezone,
)
_, date_to = get_datetime_with_tz(user.timezone, date_to)
return date_from, date_to
def _remove_microseconds(delta: timedelta) -> timedelta:
return delta - timedelta(microseconds=delta.microseconds)
def update_workout_data(
workout: Union[Workout, WorkoutSegment], gpx_data: Dict
) -> Union[Workout, WorkoutSegment]:
"""
Update workout or workout segment with data from gpx file
"""
workout.pauses = gpx_data['stop_time']
workout.moving = gpx_data['moving_time']
workout.pauses = _remove_microseconds(gpx_data['stop_time'])
workout.moving = _remove_microseconds(gpx_data['moving_time'])
workout.min_alt = gpx_data['elevation_min']
workout.max_alt = gpx_data['elevation_max']
workout.descent = gpx_data['downhill']
@ -92,17 +110,17 @@ def create_workout(
Create Workout from data entered by user and from gpx if a gpx file is
provided
"""
workout_date = (
gpx_data['start']
workout_date, workout_date_tz = get_workout_datetime(
workout_date=gpx_data['start']
if gpx_data
else datetime.strptime(workout_data['workout_date'], '%Y-%m-%d %H:%M')
)
workout_date_tz, workout_date = get_datetime_with_tz(
user.timezone, workout_date, gpx_data
else workout_data['workout_date'],
date_str_format=None if gpx_data else '%Y-%m-%d %H:%M',
user_timezone=user.timezone,
with_timezone=True,
)
duration = (
gpx_data['duration']
_remove_microseconds(gpx_data['duration'])
if gpx_data
else timedelta(seconds=workout_data['duration'])
)
@ -202,11 +220,10 @@ def edit_workout(
workout.notes = workout_data.get('notes')
if not workout.gpx:
if workout_data.get('workout_date'):
workout_date = datetime.strptime(
workout_data['workout_date'], '%Y-%m-%d %H:%M'
)
_, workout.workout_date = get_datetime_with_tz(
auth_user.timezone, workout_date
workout.workout_date, _ = get_workout_datetime(
workout_date=workout_data.get('workout_date', ''),
date_str_format='%Y-%m-%d %H:%M',
user_timezone=auth_user.timezone,
)
if workout_data.get('duration'):
@ -238,7 +255,7 @@ def get_file_path(dir_path: str, filename: str) -> str:
def get_new_file_path(
auth_user_id: int,
workout_date: str,
sport: str,
sport_id: int,
old_filename: Optional[str] = None,
extension: Optional[str] = None,
) -> str:
@ -248,11 +265,9 @@ def get_new_file_path(
if not extension and old_filename:
extension = f".{old_filename.rsplit('.', 1)[1].lower()}"
_, new_filename = tempfile.mkstemp(
prefix=f'{workout_date}_{sport}_', suffix=extension
prefix=f'{workout_date}_{sport_id}_', suffix=extension
)
dir_path = os.path.join('workouts', str(auth_user_id))
if not os.path.exists(dir_path):
os.makedirs(dir_path)
file_path = os.path.join(dir_path, new_filename.split('/')[-1])
return file_path
@ -268,11 +283,16 @@ def process_one_gpx_file(
params['file_path'], stopped_speed_threshold
)
auth_user = params['auth_user']
workout_date, _ = get_workout_datetime(
workout_date=gpx_data['start'],
date_str_format=None if gpx_data else '%Y-%m-%d %H:%M',
user_timezone=None,
)
new_filepath = get_new_file_path(
auth_user_id=auth_user.id,
workout_date=gpx_data['start'],
workout_date=workout_date.strftime('%Y-%m-%d_%H-%M-%S'),
old_filename=filename,
sport=params['sport_label'],
sport_id=params['sport_id'],
)
absolute_gpx_filepath = get_absolute_file_path(new_filepath)
os.rename(params['file_path'], absolute_gpx_filepath)
@ -280,16 +300,16 @@ def process_one_gpx_file(
map_filepath = get_new_file_path(
auth_user_id=auth_user.id,
workout_date=gpx_data['start'],
workout_date=workout_date.strftime('%Y-%m-%d_%H-%M-%S'),
extension='.png',
sport=params['sport_label'],
sport_id=params['sport_id'],
)
absolute_map_filepath = get_absolute_file_path(map_filepath)
generate_map(absolute_map_filepath, map_data)
except (gpxpy.gpx.GPXXMLSyntaxException, TypeError) as e:
raise WorkoutException('error', 'Error during gpx file parsing.', e)
raise WorkoutException('error', 'error during gpx file parsing', e)
except Exception as e:
raise WorkoutException('error', 'Error during gpx processing.', e)
raise WorkoutException('error', 'error during gpx processing', e)
try:
new_workout = create_workout(
@ -380,7 +400,7 @@ def process_files(
'auth_user': auth_user,
'workout_data': workout_data,
'file_path': file_path,
'sport_label': sport.label,
'sport_id': sport.id,
}
try:

View File

@ -13,7 +13,7 @@ from flask import (
send_from_directory,
)
from sqlalchemy import exc
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.exceptions import NotFound, RequestEntityTooLarge
from werkzeug.utils import secure_filename
from fittrackee import appLog, db
@ -303,7 +303,7 @@ def get_workout(
auth_user: User, workout_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get an workout
Get a workout
**Example request**:
@ -405,7 +405,7 @@ def get_workout_data(
data_type: str,
segment_id: Optional[int] = None,
) -> Union[Dict, HttpResponse]:
"""Get data from an workout gpx file"""
"""Get data from a workout gpx file"""
workout_uuid = decode_short_id(workout_short_id)
workout = Workout.query.filter_by(uuid=workout_uuid).first()
if not workout:
@ -467,7 +467,7 @@ def get_workout_gpx(
auth_user: User, workout_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get gpx file for an workout displayed on map with Leaflet
Get gpx file for a workout displayed on map with Leaflet
**Example request**:
@ -517,7 +517,7 @@ def get_workout_chart_data(
auth_user: User, workout_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get chart data from an workout gpx file, to display it with Recharts
Get chart data from a workout gpx file, to display it with Recharts
**Example request**:
@ -587,7 +587,7 @@ def get_segment_gpx(
auth_user: User, workout_short_id: str, segment_id: int
) -> Union[Dict, HttpResponse]:
"""
Get gpx file for an workout segment displayed on map with Leaflet
Get gpx file for a workout segment displayed on map with Leaflet
**Example request**:
@ -639,7 +639,7 @@ def get_segment_chart_data(
auth_user: User, workout_short_id: str, segment_id: int
) -> Union[Dict, HttpResponse]:
"""
Get chart data from an workout gpx file, to display it with Recharts
Get chart data from a workout gpx file, to display it with Recharts
**Example request**:
@ -798,6 +798,8 @@ def get_map(map_id: int) -> Union[HttpResponse, Response]:
current_app.config['UPLOAD_FOLDER'],
workout.map,
)
except NotFound:
return NotFoundErrorResponse('Map file does not exist.')
except Exception as e:
return handle_error_and_return_response(e)
@ -851,7 +853,7 @@ def get_map_tile(s: str, z: str, x: str, y: str) -> Tuple[Response, int]:
@authenticate
def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Post an workout with a gpx file
Post a workout with a gpx file
**Example request**:
@ -1021,7 +1023,7 @@ def post_workout_no_gpx(
auth_user: User,
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Post an workout without gpx file
Post a workout without gpx file
**Example request**:
@ -1111,7 +1113,8 @@ def post_workout_no_gpx(
"status": "success"
}
:<json string workout_date: workout date (format: ``%Y-%m-%d %H:%M``)
:<json string workout_date: workout date, in user timezone
(format: ``%Y-%m-%d %H:%M``)
:<json float distance: workout distance in km
:<json integer duration: workout duration in seconds
:<json string notes: notes (not mandatory)
@ -1169,7 +1172,7 @@ def update_workout(
auth_user: User, workout_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Update an workout
Update a workout
**Example request**:
@ -1261,7 +1264,8 @@ def update_workout(
:param string workout_short_id: workout short id
:<json string workout_date: workout date (format: ``%Y-%m-%d %H:%M``)
:<json string workout_date: workout date in user timezone
(format: ``%Y-%m-%d %H:%M``)
(only for workout without gpx)
:<json float distance: workout distance in km
(only for workout without gpx)
@ -1316,7 +1320,7 @@ def delete_workout(
auth_user: User, workout_short_id: str
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Delete an workout
Delete a workout
**Example request**: