API - add typing

This commit is contained in:
Sam
2021-01-02 19:28:03 +01:00
parent 4705393a08
commit 634d06b05a
53 changed files with 1884 additions and 1075 deletions

View File

@ -2,12 +2,14 @@ import json
import os
import shutil
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
from fittrackee import appLog, db
from fittrackee.responses import (
DataInvalidPayloadErrorResponse,
DataNotFoundErrorResponse,
HttpResponse,
InternalServerErrorResponse,
InvalidPayloadErrorResponse,
NotFoundErrorResponse,
@ -46,7 +48,7 @@ ACTIVITIES_PER_PAGE = 5
@activities_blueprint.route('/activities', methods=['GET'])
@authenticate
def get_activities(auth_user_id):
def get_activities(auth_user_id: int) -> Union[Dict, HttpResponse]:
"""
Get activities for the authenticated user.
@ -275,7 +277,9 @@ def get_activities(auth_user_id):
'/activities/<string:activity_short_id>', methods=['GET']
)
@authenticate
def get_activity(auth_user_id, activity_short_id):
def get_activity(
auth_user_id: int, activity_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get an activity
@ -375,8 +379,11 @@ def get_activity(auth_user_id, activity_short_id):
def get_activity_data(
auth_user_id, activity_short_id, data_type, segment_id=None
):
auth_user_id: int,
activity_short_id: str,
data_type: str,
segment_id: Optional[int] = None,
) -> Union[Dict, HttpResponse]:
"""Get data from an activity gpx file"""
activity_uuid = decode_short_id(activity_short_id)
activity = Activity.query.filter_by(uuid=activity_uuid).first()
@ -396,14 +403,17 @@ def get_activity_data(
try:
absolute_gpx_filepath = get_absolute_file_path(activity.gpx)
chart_data_content: Optional[List] = []
if data_type == 'chart_data':
content = get_chart_data(absolute_gpx_filepath, segment_id)
chart_data_content = get_chart_data(
absolute_gpx_filepath, segment_id
)
else: # data_type == 'gpx'
with open(absolute_gpx_filepath, encoding='utf-8') as f:
content = f.read()
gpx_content = f.read()
if segment_id is not None:
content = extract_segment_from_gpx_file(
content, segment_id
gpx_segment_content = extract_segment_from_gpx_file(
gpx_content, segment_id
)
except ActivityGPXException as e:
appLog.error(e.message)
@ -416,7 +426,15 @@ def get_activity_data(
return {
'status': 'success',
'message': '',
'data': ({data_type: content}),
'data': (
{
data_type: chart_data_content
if data_type == 'chart_data'
else gpx_content
if segment_id is None
else gpx_segment_content
}
),
}
@ -424,7 +442,9 @@ def get_activity_data(
'/activities/<string:activity_short_id>/gpx', methods=['GET']
)
@authenticate
def get_activity_gpx(auth_user_id, activity_short_id):
def get_activity_gpx(
auth_user_id: int, activity_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get gpx file for an activity displayed on map with Leaflet
@ -473,7 +493,9 @@ def get_activity_gpx(auth_user_id, activity_short_id):
'/activities/<string:activity_short_id>/chart_data', methods=['GET']
)
@authenticate
def get_activity_chart_data(auth_user_id, activity_short_id):
def get_activity_chart_data(
auth_user_id: int, activity_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Get chart data from an activity gpx file, to display it with Recharts
@ -542,7 +564,9 @@ def get_activity_chart_data(auth_user_id, activity_short_id):
methods=['GET'],
)
@authenticate
def get_segment_gpx(auth_user_id, activity_short_id, segment_id):
def get_segment_gpx(
auth_user_id: int, activity_short_id: str, segment_id: int
) -> Union[Dict, HttpResponse]:
"""
Get gpx file for an activity segment displayed on map with Leaflet
@ -595,7 +619,9 @@ def get_segment_gpx(auth_user_id, activity_short_id, segment_id):
methods=['GET'],
)
@authenticate
def get_segment_chart_data(auth_user_id, activity_short_id, segment_id):
def get_segment_chart_data(
auth_user_id: int, activity_short_id: str, segment_id: int
) -> Union[Dict, HttpResponse]:
"""
Get chart data from an activity gpx file, to display it with Recharts
@ -662,7 +688,7 @@ def get_segment_chart_data(auth_user_id, activity_short_id, segment_id):
@activities_blueprint.route('/activities/map/<map_id>', methods=['GET'])
def get_map(map_id):
def get_map(map_id: int) -> Any:
"""
Get map image for activities with gpx
@ -704,7 +730,7 @@ def get_map(map_id):
@activities_blueprint.route(
'/activities/map_tile/<s>/<z>/<x>/<y>.png', methods=['GET']
)
def get_map_tile(s, z, x, y):
def get_map_tile(s: str, z: str, x: str, y: str) -> Tuple[Response, int]:
"""
Get map tile from tile server.
@ -743,7 +769,7 @@ def get_map_tile(s, z, x, y):
@activities_blueprint.route('/activities', methods=['POST'])
@authenticate
def post_activity(auth_user_id):
def post_activity(auth_user_id: int) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Post an activity with a gpx file
@ -904,7 +930,9 @@ def post_activity(auth_user_id):
@activities_blueprint.route('/activities/no_gpx', methods=['POST'])
@authenticate
def post_activity_no_gpx(auth_user_id):
def post_activity_no_gpx(
auth_user_id: int,
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Post an activity without gpx file
@ -1053,7 +1081,9 @@ def post_activity_no_gpx(auth_user_id):
'/activities/<string:activity_short_id>', methods=['PATCH']
)
@authenticate
def update_activity(auth_user_id, activity_short_id):
def update_activity(
auth_user_id: int, activity_short_id: str
) -> Union[Dict, HttpResponse]:
"""
Update an activity
@ -1199,7 +1229,9 @@ def update_activity(auth_user_id, activity_short_id):
'/activities/<string:activity_short_id>', methods=['DELETE']
)
@authenticate
def delete_activity(auth_user_id, activity_short_id):
def delete_activity(
auth_user_id: int, activity_short_id: str
) -> Union[Tuple[Dict, int], HttpResponse]:
"""
Delete an activity

View File

@ -1,18 +1,23 @@
import datetime
import os
from uuid import uuid4
from typing import Any, Dict, Optional, Union
from uuid import UUID, uuid4
from fittrackee import db
from sqlalchemy.dialects import postgresql
from sqlalchemy.engine.base import Connection
from sqlalchemy.event import listens_for
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.session import object_session
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.session import Session, object_session
from sqlalchemy.types import JSON, Enum
from .utils_files import get_absolute_file_path
from .utils_format import convert_in_duration, convert_value_to_integer
from .utils_id import encode_uuid
BaseModel: DeclarativeMeta = db.Model
record_types = [
'AS', # 'Best Average Speed'
'FD', # 'Farthest Distance'
@ -21,7 +26,9 @@ record_types = [
]
def update_records(user_id, sport_id, connection, session):
def update_records(
user_id: int, sport_id: int, connection: Connection, session: Session
) -> None:
record_table = Record.__table__
new_records = Activity.get_user_activity_records(user_id, sport_id)
for record_type, record_data in new_records.items():
@ -47,7 +54,7 @@ def update_records(user_id, sport_id, connection, session):
new_record = Record(
activity=record_data['activity'], record_type=record_type
)
new_record.value = record_data['record_value']
new_record.value = record_data['record_value'] # type: ignore
session.add(new_record)
else:
connection.execute(
@ -58,7 +65,7 @@ def update_records(user_id, sport_id, connection, session):
)
class Sport(db.Model):
class Sport(BaseModel):
__tablename__ = "sports"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
label = db.Column(db.String(50), unique=True, nullable=False)
@ -71,13 +78,13 @@ class Sport(db.Model):
'Record', lazy=True, backref=db.backref('sports', lazy='joined')
)
def __repr__(self):
def __repr__(self) -> str:
return f'<Sport {self.label!r}>'
def __init__(self, label):
def __init__(self, label: str) -> None:
self.label = label
def serialize(self, is_admin=False):
def serialize(self, is_admin: Optional[bool] = False) -> Dict:
serialized_sport = {
'id': self.id,
'label': self.label,
@ -89,7 +96,7 @@ class Sport(db.Model):
return serialized_sport
class Activity(db.Model):
class Activity(BaseModel):
__tablename__ = "activities"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
uuid = db.Column(
@ -138,10 +145,17 @@ class Activity(db.Model):
backref=db.backref('activities', lazy='joined', single_parent=True),
)
def __str__(self):
def __str__(self) -> str:
return f'<Activity \'{self.sports.label}\' - {self.activity_date}>'
def __init__(self, user_id, sport_id, activity_date, distance, duration):
def __init__(
self,
user_id: int,
sport_id: int,
activity_date: datetime.datetime,
distance: float,
duration: datetime.timedelta,
) -> None:
self.user_id = user_id
self.sport_id = sport_id
self.activity_date = activity_date
@ -149,10 +163,10 @@ class Activity(db.Model):
self.duration = duration
@property
def short_id(self):
def short_id(self) -> str:
return encode_uuid(self.uuid)
def serialize(self, params=None):
def serialize(self, params: Optional[Dict] = None) -> Dict:
date_from = params.get('from') if params else None
date_to = params.get('to') if params else None
distance_from = params.get('distance_from') if params else None
@ -239,41 +253,43 @@ class Activity(db.Model):
.first()
)
return {
"id": self.short_id, # WARNING: client use uuid as id
"user": self.user.username,
"sport_id": self.sport_id,
"title": self.title,
"creation_date": self.creation_date,
"modification_date": self.modification_date,
"activity_date": self.activity_date,
"duration": str(self.duration) if self.duration else None,
"pauses": str(self.pauses) if self.pauses else None,
"moving": str(self.moving) if self.moving else None,
"distance": float(self.distance) if self.distance else None,
"min_alt": float(self.min_alt) if self.min_alt else None,
"max_alt": float(self.max_alt) if self.max_alt else None,
"descent": float(self.descent) if self.descent else None,
"ascent": float(self.ascent) if self.ascent else None,
"max_speed": float(self.max_speed) if self.max_speed else None,
"ave_speed": float(self.ave_speed) if self.ave_speed else None,
"with_gpx": self.gpx is not None,
"bounds": [float(bound) for bound in self.bounds]
'id': self.short_id, # WARNING: client use uuid as id
'user': self.user.username,
'sport_id': self.sport_id,
'title': self.title,
'creation_date': self.creation_date,
'modification_date': self.modification_date,
'activity_date': self.activity_date,
'duration': str(self.duration) if self.duration else None,
'pauses': str(self.pauses) if self.pauses else None,
'moving': str(self.moving) if self.moving else None,
'distance': float(self.distance) if self.distance else None,
'min_alt': float(self.min_alt) if self.min_alt else None,
'max_alt': float(self.max_alt) if self.max_alt else None,
'descent': float(self.descent) if self.descent else None,
'ascent': float(self.ascent) if self.ascent else None,
'max_speed': float(self.max_speed) if self.max_speed else None,
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
'with_gpx': self.gpx is not None,
'bounds': [float(bound) for bound in self.bounds]
if self.bounds
else [], # noqa
"previous_activity": previous_activity.short_id
'previous_activity': previous_activity.short_id
if previous_activity
else None, # noqa
"next_activity": next_activity.short_id if next_activity else None,
"segments": [segment.serialize() for segment in self.segments],
"records": [record.serialize() for record in self.records],
"map": self.map_id if self.map else None,
"weather_start": self.weather_start,
"weather_end": self.weather_end,
"notes": self.notes,
'next_activity': next_activity.short_id if next_activity else None,
'segments': [segment.serialize() for segment in self.segments],
'records': [record.serialize() for record in self.records],
'map': self.map_id if self.map else None,
'weather_start': self.weather_start,
'weather_end': self.weather_end,
'notes': self.notes,
}
@classmethod
def get_user_activity_records(cls, user_id, sport_id, as_integer=False):
def get_user_activity_records(
cls, user_id: int, sport_id: int, as_integer: Optional[bool] = False
) -> Dict:
record_types_columns = {
'AS': 'ave_speed', # 'Average speed'
'FD': 'distance', # 'Farthest Distance'
@ -300,22 +316,26 @@ class Activity(db.Model):
@listens_for(Activity, 'after_insert')
def on_activity_insert(mapper, connection, activity):
def on_activity_insert(
mapper: Mapper, connection: Connection, activity: Activity
) -> None:
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
def receive_after_flush(session: Session, context: Any) -> None:
update_records(
activity.user_id, activity.sport_id, connection, session
) # noqa
@listens_for(Activity, 'after_update')
def on_activity_update(mapper, connection, activity):
def on_activity_update(
mapper: Mapper, connection: Connection, activity: Activity
) -> None:
if object_session(activity).is_modified(
activity, include_collections=True
): # noqa
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
def receive_after_flush(session: Session, context: Any) -> None:
sports_list = [activity.sport_id]
records = Record.query.filter_by(activity_id=activity.id).all()
for rec in records:
@ -326,16 +346,18 @@ def on_activity_update(mapper, connection, activity):
@listens_for(Activity, 'after_delete')
def on_activity_delete(mapper, connection, old_record):
def on_activity_delete(
mapper: Mapper, connection: Connection, old_record: 'Record'
) -> None:
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
def receive_after_flush(session: Session, context: Any) -> None:
if old_record.map:
os.remove(get_absolute_file_path(old_record.map))
if old_record.gpx:
os.remove(get_absolute_file_path(old_record.gpx))
class ActivitySegment(db.Model):
class ActivitySegment(BaseModel):
__tablename__ = "activity_segments"
activity_id = db.Column(
db.Integer, db.ForeignKey('activities.id'), primary_key=True
@ -353,35 +375,37 @@ class ActivitySegment(db.Model):
max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
def __str__(self):
def __str__(self) -> str:
return (
f'<Segment \'{self.segment_id}\' '
f'for activity \'{encode_uuid(self.activity_uuid)}\'>'
)
def __init__(self, segment_id, activity_id, activity_uuid):
def __init__(
self, segment_id: int, activity_id: int, activity_uuid: UUID
) -> None:
self.segment_id = segment_id
self.activity_id = activity_id
self.activity_uuid = activity_uuid
def serialize(self):
def serialize(self) -> Dict:
return {
"activity_id": encode_uuid(self.activity_uuid),
"segment_id": self.segment_id,
"duration": str(self.duration) if self.duration else None,
"pauses": str(self.pauses) if self.pauses else None,
"moving": str(self.moving) if self.moving else None,
"distance": float(self.distance) if self.distance else None,
"min_alt": float(self.min_alt) if self.min_alt else None,
"max_alt": float(self.max_alt) if self.max_alt else None,
"descent": float(self.descent) if self.descent else None,
"ascent": float(self.ascent) if self.ascent else None,
"max_speed": float(self.max_speed) if self.max_speed else None,
"ave_speed": float(self.ave_speed) if self.ave_speed else None,
'activity_id': encode_uuid(self.activity_uuid),
'segment_id': self.segment_id,
'duration': str(self.duration) if self.duration else None,
'pauses': str(self.pauses) if self.pauses else None,
'moving': str(self.moving) if self.moving else None,
'distance': float(self.distance) if self.distance else None,
'min_alt': float(self.min_alt) if self.min_alt else None,
'max_alt': float(self.max_alt) if self.max_alt else None,
'descent': float(self.descent) if self.descent else None,
'ascent': float(self.ascent) if self.ascent else None,
'max_speed': float(self.max_speed) if self.max_speed else None,
'ave_speed': float(self.ave_speed) if self.ave_speed else None,
}
class Record(db.Model):
class Record(BaseModel):
__tablename__ = "records"
__table_args__ = (
db.UniqueConstraint(
@ -401,14 +425,14 @@ class Record(db.Model):
activity_date = db.Column(db.DateTime, nullable=False)
_value = db.Column("value", db.Integer, nullable=True)
def __str__(self):
def __str__(self) -> str:
return (
f'<Record {self.sports.label} - '
f'{self.record_type} - '
f"{self.activity_date.strftime('%Y-%m-%d')}>"
)
def __init__(self, activity, record_type):
def __init__(self, activity: Activity, record_type: str) -> None:
self.user_id = activity.user_id
self.sport_id = activity.sport_id
self.activity_id = activity.id
@ -417,7 +441,7 @@ class Record(db.Model):
self.activity_date = activity.activity_date
@hybrid_property
def value(self):
def value(self) -> Optional[Union[datetime.timedelta, float]]:
if self._value is None:
return None
if self.record_type == 'LD':
@ -427,33 +451,35 @@ class Record(db.Model):
else: # 'FD'
return float(self._value / 1000)
@value.setter
def value(self, val):
@value.setter # type: ignore
def value(self, val: Union[str, float]) -> None:
self._value = convert_value_to_integer(self.record_type, val)
def serialize(self):
def serialize(self) -> Dict:
if self.value is None:
value = None
elif self.record_type in ['AS', 'FD', 'MS']:
value = float(self.value)
value = float(self.value) # type: ignore
else: # 'LD'
value = str(self.value)
value = str(self.value) # type: ignore
return {
"id": self.id,
"user": self.user.username,
"sport_id": self.sport_id,
"activity_id": encode_uuid(self.activity_uuid),
"record_type": self.record_type,
"activity_date": self.activity_date,
"value": value,
'id': self.id,
'user': self.user.username,
'sport_id': self.sport_id,
'activity_id': encode_uuid(self.activity_uuid),
'record_type': self.record_type,
'activity_date': self.activity_date,
'value': value,
}
@listens_for(Record, 'after_delete')
def on_record_delete(mapper, connection, old_record):
def on_record_delete(
mapper: Mapper, connection: Connection, old_record: Record
) -> None:
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
def receive_after_flush(session: Session, context: Any) -> None:
activity = old_record.activities
new_records = Activity.get_user_activity_records(
activity.user_id, activity.sport_id
@ -466,5 +492,5 @@ def on_record_delete(mapper, connection, old_record):
new_record = Record(
activity=record_data['activity'], record_type=record_type
)
new_record.value = record_data['record_value']
new_record.value = record_data['record_value'] # type: ignore
session.add(new_record)

View File

@ -1,3 +1,5 @@
from typing import Dict
from flask import Blueprint
from ..users.utils import authenticate
@ -8,7 +10,7 @@ records_blueprint = Blueprint('records', __name__)
@records_blueprint.route('/records', methods=['GET'])
@authenticate
def get_records(auth_user_id):
def get_records(auth_user_id: int) -> Dict:
"""
Get all records for authenticated user.

View File

@ -1,6 +1,9 @@
from typing import Dict, Union
from fittrackee import db
from fittrackee.responses import (
DataNotFoundErrorResponse,
HttpResponse,
InvalidPayloadErrorResponse,
handle_error_and_return_response,
)
@ -16,7 +19,7 @@ sports_blueprint = Blueprint('sports', __name__)
@sports_blueprint.route('/sports', methods=['GET'])
@authenticate
def get_sports(auth_user_id):
def get_sports(auth_user_id: int) -> Dict:
"""
Get all sports
@ -158,7 +161,7 @@ def get_sports(auth_user_id):
@sports_blueprint.route('/sports/<int:sport_id>', methods=['GET'])
@authenticate
def get_sport(auth_user_id, sport_id):
def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
"""
Get a sport
@ -253,7 +256,9 @@ def get_sport(auth_user_id, sport_id):
@sports_blueprint.route('/sports/<int:sport_id>', methods=['PATCH'])
@authenticate_as_admin
def update_sport(auth_user_id, sport_id):
def update_sport(
auth_user_id: int, sport_id: int
) -> Union[Dict, HttpResponse]:
"""
Update a sport
Authenticated user must be an admin

View File

@ -1,7 +1,9 @@
from datetime import datetime, timedelta
from typing import Dict, Union
from fittrackee import db
from fittrackee.responses import (
HttpResponse,
InvalidPayloadErrorResponse,
NotFoundErrorResponse,
UserNotFoundErrorResponse,
@ -19,7 +21,12 @@ from .utils_format import convert_timedelta_to_integer
stats_blueprint = Blueprint('stats', __name__)
def get_activities(user_name, filter_type):
def get_activities(
user_name: str, filter_type: str
) -> Union[Dict, HttpResponse]:
"""
Return user activities by sport or by time
"""
try:
user = User.query.filter_by(username=user_name).first()
if not user:
@ -40,7 +47,6 @@ def get_activities(user_name, filter_type):
time = params.get('time')
if filter_type == 'by_sport':
sport_id = params.get('sport_id')
if sport_id:
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
@ -59,24 +65,26 @@ def get_activities(user_name, filter_type):
.all()
)
activities_list = {}
activities_list_by_sport = {}
activities_list_by_time = {} # type: ignore
for activity in activities:
if filter_type == 'by_sport':
sport_id = activity.sport_id
if sport_id not in activities_list:
activities_list[sport_id] = {
if sport_id not in activities_list_by_sport:
activities_list_by_sport[sport_id] = {
'nb_activities': 0,
'total_distance': 0.0,
'total_duration': 0,
}
activities_list[sport_id]['nb_activities'] += 1
activities_list[sport_id]['total_distance'] += float(
activities_list_by_sport[sport_id]['nb_activities'] += 1
activities_list_by_sport[sport_id]['total_distance'] += float(
activity.distance
)
activities_list[sport_id][
activities_list_by_sport[sport_id][
'total_duration'
] += convert_timedelta_to_integer(activity.moving)
# filter_type == 'by_time'
else:
if time == 'week':
activity_date = activity.activity_date - timedelta(
@ -105,25 +113,31 @@ def get_activities(user_name, filter_type):
'Invalid time period.', 'fail'
)
sport_id = activity.sport_id
if time_period not in activities_list:
activities_list[time_period] = {}
if sport_id not in activities_list[time_period]:
activities_list[time_period][sport_id] = {
if time_period not in activities_list_by_time:
activities_list_by_time[time_period] = {}
if sport_id not in activities_list_by_time[time_period]:
activities_list_by_time[time_period][sport_id] = {
'nb_activities': 0,
'total_distance': 0.0,
'total_duration': 0,
}
activities_list[time_period][sport_id]['nb_activities'] += 1
activities_list[time_period][sport_id][
activities_list_by_time[time_period][sport_id][
'nb_activities'
] += 1
activities_list_by_time[time_period][sport_id][
'total_distance'
] += float(activity.distance)
activities_list[time_period][sport_id][
activities_list_by_time[time_period][sport_id][
'total_duration'
] += convert_timedelta_to_integer(activity.moving)
return {
'status': 'success',
'data': {'statistics': activities_list},
'data': {
'statistics': activities_list_by_sport
if filter_type == 'by_sport'
else activities_list_by_time
},
}
except Exception as e:
return handle_error_and_return_response(e)
@ -131,7 +145,9 @@ def get_activities(user_name, filter_type):
@stats_blueprint.route('/stats/<user_name>/by_time', methods=['GET'])
@authenticate
def get_activities_by_time(auth_user_id, user_name):
def get_activities_by_time(
auth_user_id: int, user_name: str
) -> Union[Dict, HttpResponse]:
"""
Get activities statistics for a user by time
@ -227,7 +243,9 @@ def get_activities_by_time(auth_user_id, user_name):
@stats_blueprint.route('/stats/<user_name>/by_sport', methods=['GET'])
@authenticate
def get_activities_by_sport(auth_user_id, user_name):
def get_activities_by_sport(
auth_user_id: int, user_name: str
) -> Union[Dict, HttpResponse]:
"""
Get activities statistics for a user by sport
@ -313,7 +331,7 @@ def get_activities_by_sport(auth_user_id, user_name):
@stats_blueprint.route('/stats/all', methods=['GET'])
@authenticate_as_admin
def get_application_stats(auth_user_id):
def get_application_stats(auth_user_id: int) -> Dict:
"""
Get all application statistics

View File

@ -3,6 +3,8 @@ import os
import tempfile
import zipfile
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Union
from uuid import UUID
import gpxpy.gpx
import pytz
@ -10,6 +12,7 @@ from fittrackee import appLog, db
from flask import current_app
from sqlalchemy import exc
from staticmap import Line, StaticMap
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from ..users.models import User
@ -19,13 +22,20 @@ from .utils_gpx import get_gpx_info
class ActivityException(Exception):
def __init__(self, status, message, e):
def __init__(
self, status: str, message: str, e: Optional[Exception] = None
) -> None:
self.status = status
self.message = message
self.e = e
def get_datetime_with_tz(timezone, activity_date, gpx_data=None):
def get_datetime_with_tz(
timezone: str, activity_date: datetime, gpx_data: Optional[Dict] = None
) -> Tuple[Optional[datetime], datetime]:
"""
Return naive datetime and datetime with user timezone
"""
activity_date_tz = None
if timezone:
user_tz = pytz.timezone(timezone)
@ -47,8 +57,12 @@ def get_datetime_with_tz(timezone, activity_date, gpx_data=None):
return activity_date_tz, activity_date
def update_activity_data(activity, gpx_data):
"""activity could be a complete activity or an activity segment"""
def update_activity_data(
activity: Union[Activity, ActivitySegment], gpx_data: Dict
) -> Union[Activity, ActivitySegment]:
"""
Update activity or activity segment with data from gpx file
"""
activity.pauses = gpx_data['stop_time']
activity.moving = gpx_data['moving_time']
activity.min_alt = gpx_data['elevation_min']
@ -60,12 +74,18 @@ def update_activity_data(activity, gpx_data):
return activity
def create_activity(user, activity_data, gpx_data=None):
def create_activity(
user: User, activity_data: Dict, gpx_data: Optional[Dict] = None
) -> Activity:
"""
Create Activity from data entered by user and from gpx if a gpx file is
provided
"""
activity_date = (
gpx_data['start']
if gpx_data
else datetime.strptime(
activity_data.get('activity_date'), '%Y-%m-%d %H:%M'
activity_data['activity_date'], '%Y-%m-%d %H:%M'
)
)
activity_date_tz, activity_date = get_datetime_with_tz(
@ -75,16 +95,14 @@ def create_activity(user, activity_data, gpx_data=None):
duration = (
gpx_data['duration']
if gpx_data
else timedelta(seconds=activity_data.get('duration'))
else timedelta(seconds=activity_data['duration'])
)
distance = (
gpx_data['distance'] if gpx_data else activity_data.get('distance')
)
title = gpx_data['name'] if gpx_data else activity_data.get('title')
distance = gpx_data['distance'] if gpx_data else activity_data['distance']
title = gpx_data['name'] if gpx_data else activity_data.get('title', '')
new_activity = Activity(
user_id=user.id,
sport_id=activity_data.get('sport_id'),
sport_id=activity_data['sport_id'],
activity_date=activity_date,
distance=distance,
duration=duration,
@ -118,7 +136,12 @@ def create_activity(user, activity_data, gpx_data=None):
return new_activity
def create_segment(activity_id, activity_uuid, segment_data):
def create_segment(
activity_id: int, activity_uuid: UUID, segment_data: Dict
) -> ActivitySegment:
"""
Create Activity Segment from gpx data
"""
new_segment = ActivitySegment(
activity_id=activity_id,
activity_uuid=activity_uuid,
@ -130,12 +153,9 @@ def create_segment(activity_id, activity_uuid, segment_data):
return new_segment
def update_activity(activity):
def update_activity(activity: Activity) -> Activity:
"""
Note: only gpx_data is be updated for now (the gpx file is NOT modified)
In a next version, map_data and weather_data will be updated
(case of a modified gpx file, see issue #7)
Update activity data from gpx file
"""
gpx_data, _, _ = get_gpx_info(
get_absolute_file_path(activity.gpx), False, False
@ -155,7 +175,16 @@ def update_activity(activity):
return updated_activity
def edit_activity(activity, activity_data, auth_user_id):
def edit_activity(
activity: Activity, activity_data: Dict, auth_user_id: int
) -> Activity:
"""
Edit an activity
Note: the gpx file is NOT modified
In a next version, map_data and weather_data will be updated
(case of a modified gpx file, see issue #7)
"""
user = User.query.filter_by(id=auth_user_id).first()
if activity_data.get('refresh'):
activity = update_activity(activity)
@ -168,20 +197,18 @@ def edit_activity(activity, activity_data, auth_user_id):
if not activity.gpx:
if activity_data.get('activity_date'):
activity_date = datetime.strptime(
activity_data.get('activity_date'), '%Y-%m-%d %H:%M'
activity_data['activity_date'], '%Y-%m-%d %H:%M'
)
_, activity.activity_date = get_datetime_with_tz(
user.timezone, activity_date
)
if activity_data.get('duration'):
activity.duration = timedelta(
seconds=activity_data.get('duration')
)
activity.duration = timedelta(seconds=activity_data['duration'])
activity.moving = activity.duration
if activity_data.get('distance'):
activity.distance = activity_data.get('distance')
activity.distance = activity_data['distance']
activity.ave_speed = (
None
@ -192,7 +219,10 @@ def edit_activity(activity, activity_data, auth_user_id):
return activity
def get_file_path(dir_path, filename):
def get_file_path(dir_path: str, filename: str) -> str:
"""
Get full path for a file
"""
if not os.path.exists(dir_path):
os.makedirs(dir_path)
file_path = os.path.join(dir_path, filename)
@ -200,9 +230,16 @@ def get_file_path(dir_path, filename):
def get_new_file_path(
auth_user_id, activity_date, sport, old_filename=None, extension=None
):
if not extension:
auth_user_id: int,
activity_date: str,
sport: str,
old_filename: Optional[str] = None,
extension: Optional[str] = None,
) -> str:
"""
Generate a file path from user and activity data
"""
if not extension and old_filename:
extension = f".{old_filename.rsplit('.', 1)[1].lower()}"
_, new_filename = tempfile.mkstemp(
prefix=f'{activity_date}_{sport}_', suffix=extension
@ -214,7 +251,10 @@ def get_new_file_path(
return file_path
def generate_map(map_filepath, map_data):
def generate_map(map_filepath: str, map_data: List) -> None:
"""
Generate and save map image from map data
"""
m = StaticMap(400, 225, 10)
line = Line(map_data, '#3388FF', 4)
m.add_line(line)
@ -222,10 +262,10 @@ def generate_map(map_filepath, map_data):
image.save(map_filepath)
def get_map_hash(map_filepath):
def get_map_hash(map_filepath: str) -> str:
"""
md5 hash is used as id instead of activity id, to retrieve map image
(maps are sensitive data)
Generate a md5 hash used as id instead of activity id, to retrieve map
image (maps are sensitive data)
"""
md5 = hashlib.md5()
absolute_map_filepath = get_absolute_file_path(map_filepath)
@ -235,7 +275,10 @@ def get_map_hash(map_filepath):
return md5.hexdigest()
def process_one_gpx_file(params, filename):
def process_one_gpx_file(params: Dict, filename: str) -> Activity:
"""
Get all data from a gpx file to create an activity with map image
"""
try:
gpx_data, map_data, weather_data = get_gpx_info(params['file_path'])
auth_user_id = params['user'].id
@ -284,23 +327,33 @@ def process_one_gpx_file(params, filename):
raise ActivityException('fail', 'Error during activity save.', e)
def process_zip_archive(common_params, extract_dir):
def process_zip_archive(common_params: Dict, extract_dir: str) -> List:
"""
Get files from a zip archive and create activities, if number of files
does not exceed defined limit.
"""
with zipfile.ZipFile(common_params['file_path'], "r") as zip_ref:
zip_ref.extractall(extract_dir)
new_activities = []
gpx_files_limit = os.getenv('REACT_APP_GPX_LIMIT_IMPORT', '10')
if gpx_files_limit and gpx_files_limit.isdigit():
gpx_files_limit = os.getenv('REACT_APP_GPX_LIMIT_IMPORT', 10)
if (
gpx_files_limit
and isinstance(gpx_files_limit, str)
and gpx_files_limit.isdigit()
):
gpx_files_limit = int(gpx_files_limit)
else:
gpx_files_limit = 10
appLog.error('GPX limit not configured, set to 10.')
appLog.warning('GPX limit not configured, set to 10.')
gpx_files_ok = 0
for gpx_file in os.listdir(extract_dir):
if '.' in gpx_file and gpx_file.rsplit('.', 1)[
1
].lower() in current_app.config.get('ACTIVITY_ALLOWED_EXTENSIONS'):
if (
'.' in gpx_file
and gpx_file.rsplit('.', 1)[1].lower()
in current_app.config['ACTIVITY_ALLOWED_EXTENSIONS']
):
gpx_files_ok += 1
if gpx_files_ok > gpx_files_limit:
break
@ -313,7 +366,17 @@ def process_zip_archive(common_params, extract_dir):
return new_activities
def process_files(auth_user_id, activity_data, activity_file, folders):
def process_files(
auth_user_id: int,
activity_data: Dict,
activity_file: FileStorage,
folders: Dict,
) -> List:
"""
Store gpx file or zip archive and create activities
"""
if activity_file.filename is None:
raise ActivityException('error', 'File has no filename.')
filename = secure_filename(activity_file.filename)
extension = f".{filename.rsplit('.', 1)[1].lower()}"
file_path = get_file_path(folders['tmp_dir'], filename)
@ -322,7 +385,6 @@ def process_files(auth_user_id, activity_data, activity_file, folders):
raise ActivityException(
'error',
f"Sport id: {activity_data.get('sport_id')} does not exist",
None,
)
user = User.query.filter_by(id=auth_user_id).first()
@ -344,7 +406,10 @@ def process_files(auth_user_id, activity_data, activity_file, folders):
return process_zip_archive(common_params, folders['extract_dir'])
def get_upload_dir_size():
def get_upload_dir_size() -> int:
"""
Return upload directory size
"""
upload_path = get_absolute_file_path('')
total_size = 0
for dir_path, _, filenames in os.walk(upload_path):

View File

@ -3,5 +3,5 @@ import os
from flask import current_app
def get_absolute_file_path(relative_path):
def get_absolute_file_path(relative_path: str) -> str:
return os.path.join(current_app.config['UPLOAD_FOLDER'], relative_path)

View File

@ -1,23 +1,26 @@
from datetime import timedelta
from typing import Optional, Union
def convert_in_duration(value):
def convert_in_duration(value: str) -> timedelta:
hours = int(value.split(':')[0])
minutes = int(value.split(':')[1])
return timedelta(seconds=(hours * 3600 + minutes * 60))
def convert_timedelta_to_integer(value):
def convert_timedelta_to_integer(value: str) -> int:
hours, minutes, seconds = str(value).split(':')
return int(hours) * 3600 + int(minutes) * 60 + int(seconds)
def convert_value_to_integer(record_type, val):
def convert_value_to_integer(
record_type: str, val: Union[str, float]
) -> Optional[int]:
if val is None:
return None
if record_type == 'LD':
return convert_timedelta_to_integer(val)
return convert_timedelta_to_integer(str(val))
elif record_type in ['AS', 'MS']:
return int(val * 100)
else: # 'FD'

View File

@ -1,4 +1,5 @@
from datetime import timedelta
from typing import Any, Dict, List, Optional, Tuple
import gpxpy.gpx
@ -6,25 +7,40 @@ from .utils_weather import get_weather
class ActivityGPXException(Exception):
def __init__(self, status, message, e):
def __init__(
self, status: str, message: str, e: Optional[Exception] = None
) -> None:
self.status = status
self.message = message
self.e = e
def open_gpx_file(gpx_file):
gpx_file = open(gpx_file, 'r')
def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
gpx_file = open(gpx_file, 'r') # type: ignore
gpx = gpxpy.parse(gpx_file)
if len(gpx.tracks) == 0:
return None
return gpx
def get_gpx_data(parsed_gpx, max_speed, start, stopped_time_btwn_seg):
gpx_data = {'max_speed': (max_speed / 1000) * 3600, 'start': start}
def get_gpx_data(
parsed_gpx: gpxpy.gpx,
max_speed: float,
start: int,
stopped_time_between_seg: timedelta,
) -> Dict:
"""
Returns data from parsed gpx file
"""
gpx_data: Dict[str, Any] = {
'max_speed': (max_speed / 1000) * 3600,
'start': start,
}
duration = parsed_gpx.get_duration()
gpx_data['duration'] = timedelta(seconds=duration) + stopped_time_btwn_seg
gpx_data['duration'] = (
timedelta(seconds=duration) + stopped_time_between_seg
)
ele = parsed_gpx.get_elevation_extremes()
gpx_data['elevation_max'] = ele.maximum
@ -37,7 +53,7 @@ def get_gpx_data(parsed_gpx, max_speed, start, stopped_time_btwn_seg):
mv = parsed_gpx.get_moving_data()
gpx_data['moving_time'] = timedelta(seconds=mv.moving_time)
gpx_data['stop_time'] = (
timedelta(seconds=mv.stopped_time) + stopped_time_btwn_seg
timedelta(seconds=mv.stopped_time) + stopped_time_between_seg
)
distance = mv.moving_distance + mv.stopped_distance
gpx_data['distance'] = distance / 1000
@ -48,10 +64,17 @@ def get_gpx_data(parsed_gpx, max_speed, start, stopped_time_btwn_seg):
return gpx_data
def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
def get_gpx_info(
gpx_file: str,
update_map_data: Optional[bool] = True,
update_weather_data: Optional[bool] = True,
) -> Tuple:
"""
Parse and return gpx, map and weather data from gpx file
"""
gpx = open_gpx_file(gpx_file)
if gpx is None:
return None
raise ActivityGPXException('not found', 'No gpx file')
gpx_data = {'name': gpx.tracks[0].name, 'segments': []}
max_speed = 0
@ -61,7 +84,7 @@ def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
segments_nb = len(gpx.tracks[0].segments)
prev_seg_last_point = None
no_stopped_time = timedelta(seconds=0)
stopped_time_btwn_seg = no_stopped_time
stopped_time_between_seg = no_stopped_time
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
segment_start = 0
@ -77,7 +100,7 @@ def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
# if a previous segment exists, calculate stopped time between
# the two segments
if prev_seg_last_point:
stopped_time_btwn_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):
@ -104,7 +127,9 @@ def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
segment_data['idx'] = segment_idx
gpx_data['segments'].append(segment_data)
full_gpx_data = get_gpx_data(gpx, max_speed, start, stopped_time_btwn_seg)
full_gpx_data = get_gpx_data(
gpx, max_speed, start, stopped_time_between_seg
)
gpx_data = {**gpx_data, **full_gpx_data}
if update_map_data:
@ -119,7 +144,12 @@ def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
return gpx_data, map_data, weather_data
def get_gpx_segments(track_segments, segment_id=None):
def get_gpx_segments(
track_segments: List, segment_id: Optional[int] = None
) -> List:
"""
Return list of segments, filtered on segment id if provided
"""
if segment_id is not None:
segment_index = segment_id - 1
if segment_index > (len(track_segments) - 1):
@ -135,7 +165,12 @@ def get_gpx_segments(track_segments, segment_id=None):
return segments
def get_chart_data(gpx_file, segment_id=None):
def get_chart_data(
gpx_file: str, segment_id: Optional[int] = None
) -> Optional[List]:
"""
Return data needed to generate chart with speed and elevation
"""
gpx = open_gpx_file(gpx_file)
if gpx is None:
return None
@ -193,7 +228,12 @@ def get_chart_data(gpx_file, segment_id=None):
return chart_data
def extract_segment_from_gpx_file(content, segment_id):
def extract_segment_from_gpx_file(
content: str, segment_id: int
) -> Optional[str]:
"""
Returns segments in xml format from a gpx file content
"""
gpx_content = gpxpy.parse(content)
if len(gpx_content.tracks) == 0:
return None

View File

@ -1,9 +1,17 @@
from uuid import UUID
import shortuuid
def encode_uuid(uuid_value):
def encode_uuid(uuid_value: UUID) -> str:
"""
Return short id string from an UUID
"""
return shortuuid.encode(uuid_value)
def decode_short_id(short_id):
def decode_short_id(short_id: str) -> UUID:
"""
Return UUID from a short id string
"""
return shortuuid.decode(short_id)

View File

@ -1,13 +1,15 @@
import os
from typing import Dict, Optional
import forecastio
import pytz
from fittrackee import appLog
from gpxpy.gpx import GPXRoutePoint
API_KEY = os.getenv('WEATHER_API_KEY')
def get_weather(point):
def get_weather(point: GPXRoutePoint) -> Optional[Dict]:
if not API_KEY or API_KEY == '':
return None
try: