API - remove intermediate directory and rename api directory
This commit is contained in:
0
fittrackee/activities/__init__.py
Normal file
0
fittrackee/activities/__init__.py
Normal file
1299
fittrackee/activities/activities.py
Normal file
1299
fittrackee/activities/activities.py
Normal file
File diff suppressed because it is too large
Load Diff
453
fittrackee/activities/models.py
Normal file
453
fittrackee/activities/models.py
Normal file
@ -0,0 +1,453 @@
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from fittrackee import db
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.event import listens_for
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm.session import 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
|
||||
|
||||
record_types = [
|
||||
'AS', # 'Best Average Speed'
|
||||
'FD', # 'Farthest Distance'
|
||||
'LD', # 'Longest Duration'
|
||||
'MS', # 'Max speed'
|
||||
]
|
||||
|
||||
|
||||
def update_records(user_id, sport_id, connection, session):
|
||||
record_table = Record.__table__
|
||||
new_records = Activity.get_user_activity_records(user_id, sport_id)
|
||||
for record_type, record_data in new_records.items():
|
||||
if record_data['record_value']:
|
||||
record = Record.query.filter_by(
|
||||
user_id=user_id, sport_id=sport_id, record_type=record_type
|
||||
).first()
|
||||
if record:
|
||||
value = convert_value_to_integer(
|
||||
record_type, record_data['record_value']
|
||||
)
|
||||
connection.execute(
|
||||
record_table.update()
|
||||
.where(record_table.c.id == record.id)
|
||||
.values(
|
||||
value=value,
|
||||
activity_id=record_data['activity'].id,
|
||||
activity_date=record_data['activity'].activity_date,
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_record = Record(
|
||||
activity=record_data['activity'], record_type=record_type
|
||||
)
|
||||
new_record.value = record_data['record_value']
|
||||
session.add(new_record)
|
||||
else:
|
||||
connection.execute(
|
||||
record_table.delete()
|
||||
.where(record_table.c.user_id == user_id)
|
||||
.where(record_table.c.sport_id == sport_id)
|
||||
.where(record_table.c.record_type == record_type)
|
||||
)
|
||||
|
||||
|
||||
class Sport(db.Model):
|
||||
__tablename__ = "sports"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
label = db.Column(db.String(50), unique=True, nullable=False)
|
||||
img = db.Column(db.String(255), unique=True, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
activities = db.relationship(
|
||||
'Activity', lazy=True, backref=db.backref('sports', lazy='joined')
|
||||
)
|
||||
records = db.relationship(
|
||||
'Record', lazy=True, backref=db.backref('sports', lazy='joined')
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Sport {self.label!r}>'
|
||||
|
||||
def __init__(self, label):
|
||||
self.label = label
|
||||
|
||||
def serialize(self, is_admin=False):
|
||||
serialized_sport = {
|
||||
'id': self.id,
|
||||
'label': self.label,
|
||||
'img': self.img,
|
||||
'is_active': self.is_active,
|
||||
}
|
||||
if is_admin:
|
||||
serialized_sport['has_activities'] = len(self.activities) > 0
|
||||
return serialized_sport
|
||||
|
||||
|
||||
class Activity(db.Model):
|
||||
__tablename__ = "activities"
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
sport_id = db.Column(
|
||||
db.Integer, db.ForeignKey('sports.id'), nullable=False
|
||||
)
|
||||
title = db.Column(db.String(255), nullable=True)
|
||||
gpx = db.Column(db.String(255), nullable=True)
|
||||
creation_date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||
modification_date = db.Column(
|
||||
db.DateTime, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
activity_date = db.Column(db.DateTime, nullable=False)
|
||||
duration = db.Column(db.Interval, nullable=False)
|
||||
pauses = db.Column(db.Interval, nullable=True)
|
||||
moving = db.Column(db.Interval, nullable=True)
|
||||
distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers
|
||||
min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
|
||||
max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
|
||||
descent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
max_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
|
||||
ave_speed = db.Column(db.Numeric(6, 2), nullable=True) # km/h
|
||||
bounds = db.Column(postgresql.ARRAY(db.Float), nullable=True)
|
||||
map = db.Column(db.String(255), nullable=True)
|
||||
map_id = db.Column(db.String(50), nullable=True)
|
||||
weather_start = db.Column(JSON, nullable=True)
|
||||
weather_end = db.Column(JSON, nullable=True)
|
||||
notes = db.Column(db.String(500), nullable=True)
|
||||
segments = db.relationship(
|
||||
'ActivitySegment',
|
||||
lazy=True,
|
||||
cascade='all, delete',
|
||||
backref=db.backref('activities', lazy='joined', single_parent=True),
|
||||
)
|
||||
records = db.relationship(
|
||||
'Record',
|
||||
lazy=True,
|
||||
cascade='all, delete',
|
||||
backref=db.backref('activities', lazy='joined', single_parent=True),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'<Activity \'{self.sports.label}\' - {self.activity_date}>'
|
||||
|
||||
def __init__(self, user_id, sport_id, activity_date, distance, duration):
|
||||
self.user_id = user_id
|
||||
self.sport_id = sport_id
|
||||
self.activity_date = activity_date
|
||||
self.distance = distance
|
||||
self.duration = duration
|
||||
|
||||
def serialize(self, params=None):
|
||||
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
|
||||
distance_to = params.get('distance_to') if params else None
|
||||
duration_from = params.get('duration_from') if params else None
|
||||
duration_to = params.get('duration_to') if params else None
|
||||
ave_speed_from = params.get('ave_speed_from') if params else None
|
||||
ave_speed_to = params.get('ave_speed_to') if params else None
|
||||
max_speed_from = params.get('max_speed_from') if params else None
|
||||
max_speed_to = params.get('max_speed_to') if params else None
|
||||
sport_id = params.get('sport_id') if params else None
|
||||
previous_activity = (
|
||||
Activity.query.filter(
|
||||
Activity.id != self.id,
|
||||
Activity.user_id == self.user_id,
|
||||
Activity.sport_id == sport_id if sport_id else True,
|
||||
Activity.activity_date <= self.activity_date,
|
||||
Activity.activity_date
|
||||
>= datetime.datetime.strptime(date_from, '%Y-%m-%d')
|
||||
if date_from
|
||||
else True,
|
||||
Activity.activity_date
|
||||
<= datetime.datetime.strptime(date_to, '%Y-%m-%d')
|
||||
if date_to
|
||||
else True,
|
||||
Activity.distance >= int(distance_from)
|
||||
if distance_from
|
||||
else True,
|
||||
Activity.distance <= int(distance_to) if distance_to else True,
|
||||
Activity.duration >= convert_in_duration(duration_from)
|
||||
if duration_from
|
||||
else True,
|
||||
Activity.duration <= convert_in_duration(duration_to)
|
||||
if duration_to
|
||||
else True,
|
||||
Activity.ave_speed >= float(ave_speed_from)
|
||||
if ave_speed_from
|
||||
else True,
|
||||
Activity.ave_speed <= float(ave_speed_to)
|
||||
if ave_speed_to
|
||||
else True,
|
||||
Activity.max_speed >= float(max_speed_from)
|
||||
if max_speed_from
|
||||
else True,
|
||||
Activity.max_speed <= float(max_speed_to)
|
||||
if max_speed_to
|
||||
else True,
|
||||
)
|
||||
.order_by(Activity.activity_date.desc())
|
||||
.first()
|
||||
)
|
||||
next_activity = (
|
||||
Activity.query.filter(
|
||||
Activity.id != self.id,
|
||||
Activity.user_id == self.user_id,
|
||||
Activity.sport_id == sport_id if sport_id else True,
|
||||
Activity.activity_date >= self.activity_date,
|
||||
Activity.activity_date
|
||||
>= datetime.datetime.strptime(date_from, '%Y-%m-%d')
|
||||
if date_from
|
||||
else True,
|
||||
Activity.activity_date
|
||||
<= datetime.datetime.strptime(date_to, '%Y-%m-%d')
|
||||
if date_to
|
||||
else True,
|
||||
Activity.distance >= int(distance_from)
|
||||
if distance_from
|
||||
else True,
|
||||
Activity.distance <= int(distance_to) if distance_to else True,
|
||||
Activity.duration >= convert_in_duration(duration_from)
|
||||
if duration_from
|
||||
else True,
|
||||
Activity.duration <= convert_in_duration(duration_to)
|
||||
if duration_to
|
||||
else True,
|
||||
Activity.ave_speed >= float(ave_speed_from)
|
||||
if ave_speed_from
|
||||
else True,
|
||||
Activity.ave_speed <= float(ave_speed_to)
|
||||
if ave_speed_to
|
||||
else True,
|
||||
)
|
||||
.order_by(Activity.activity_date.asc())
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"id": self.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.id
|
||||
if previous_activity
|
||||
else None, # noqa
|
||||
"next_activity": next_activity.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):
|
||||
record_types_columns = {
|
||||
'AS': 'ave_speed', # 'Average speed'
|
||||
'FD': 'distance', # 'Farthest Distance'
|
||||
'LD': 'moving', # 'Longest Duration'
|
||||
'MS': 'max_speed', # 'Max speed'
|
||||
}
|
||||
records = {}
|
||||
for record_type, column in record_types_columns.items():
|
||||
column_sorted = getattr(getattr(Activity, column), 'desc')()
|
||||
record_activity = (
|
||||
Activity.query.filter_by(user_id=user_id, sport_id=sport_id)
|
||||
.order_by(column_sorted, Activity.activity_date)
|
||||
.first()
|
||||
)
|
||||
records[record_type] = dict(
|
||||
record_value=(
|
||||
getattr(record_activity, column)
|
||||
if record_activity
|
||||
else None
|
||||
),
|
||||
activity=record_activity,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@listens_for(Activity, 'after_insert')
|
||||
def on_activity_insert(mapper, connection, activity):
|
||||
@listens_for(db.Session, 'after_flush', once=True)
|
||||
def receive_after_flush(session, context):
|
||||
update_records(
|
||||
activity.user_id, activity.sport_id, connection, session
|
||||
) # noqa
|
||||
|
||||
|
||||
@listens_for(Activity, 'after_update')
|
||||
def on_activity_update(mapper, connection, activity):
|
||||
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):
|
||||
sports_list = [activity.sport_id]
|
||||
records = Record.query.filter_by(activity_id=activity.id).all()
|
||||
for rec in records:
|
||||
if rec.sport_id not in sports_list:
|
||||
sports_list.append(rec.sport_id)
|
||||
for sport_id in sports_list:
|
||||
update_records(activity.user_id, sport_id, connection, session)
|
||||
|
||||
|
||||
@listens_for(Activity, 'after_delete')
|
||||
def on_activity_delete(mapper, connection, old_record):
|
||||
@listens_for(db.Session, 'after_flush', once=True)
|
||||
def receive_after_flush(session, context):
|
||||
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):
|
||||
__tablename__ = "activity_segments"
|
||||
activity_id = db.Column(
|
||||
db.Integer, db.ForeignKey('activities.id'), primary_key=True
|
||||
)
|
||||
segment_id = db.Column(db.Integer, primary_key=True)
|
||||
duration = db.Column(db.Interval, nullable=False)
|
||||
pauses = db.Column(db.Interval, nullable=True)
|
||||
moving = db.Column(db.Interval, nullable=True)
|
||||
distance = db.Column(db.Numeric(6, 3), nullable=True) # kilometers
|
||||
min_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
|
||||
max_alt = db.Column(db.Numeric(6, 2), nullable=True) # meters
|
||||
descent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
ascent = db.Column(db.Numeric(7, 2), nullable=True) # meters
|
||||
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):
|
||||
return (
|
||||
f'<Segment \'{self.segment_id}\' '
|
||||
f'for activity \'{self.activity_id}\'>'
|
||||
)
|
||||
|
||||
def __init__(self, segment_id, activity_id):
|
||||
self.segment_id = segment_id
|
||||
self.activity_id = activity_id
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
"activity_id": self.activity_id,
|
||||
"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):
|
||||
__tablename__ = "records"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
'user_id', 'sport_id', 'record_type', name='user_sports_records'
|
||||
),
|
||||
)
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
sport_id = db.Column(
|
||||
db.Integer, db.ForeignKey('sports.id'), nullable=False
|
||||
)
|
||||
activity_id = db.Column(
|
||||
db.Integer, db.ForeignKey('activities.id'), nullable=False
|
||||
)
|
||||
record_type = db.Column(Enum(*record_types, name="record_types"))
|
||||
activity_date = db.Column(db.DateTime, nullable=False)
|
||||
_value = db.Column("value", db.Integer, nullable=True)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'<Record {self.sports.label} - '
|
||||
f'{self.record_type} - '
|
||||
f"{self.activity_date.strftime('%Y-%m-%d')}>"
|
||||
)
|
||||
|
||||
def __init__(self, activity, record_type):
|
||||
self.user_id = activity.user_id
|
||||
self.sport_id = activity.sport_id
|
||||
self.activity_id = activity.id
|
||||
self.record_type = record_type
|
||||
self.activity_date = activity.activity_date
|
||||
|
||||
@hybrid_property
|
||||
def value(self):
|
||||
if self._value is None:
|
||||
return None
|
||||
if self.record_type == 'LD':
|
||||
return datetime.timedelta(seconds=self._value)
|
||||
elif self.record_type in ['AS', 'MS']:
|
||||
return float(self._value / 100)
|
||||
else: # 'FD'
|
||||
return float(self._value / 1000)
|
||||
|
||||
@value.setter
|
||||
def value(self, val):
|
||||
self._value = convert_value_to_integer(self.record_type, val)
|
||||
|
||||
def serialize(self):
|
||||
if self.value is None:
|
||||
value = None
|
||||
elif self.record_type in ['AS', 'FD', 'MS']:
|
||||
value = float(self.value)
|
||||
else: # 'LD'
|
||||
value = str(self.value)
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"user": self.user.username,
|
||||
"sport_id": self.sport_id,
|
||||
"activity_id": self.activity_id,
|
||||
"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):
|
||||
@listens_for(db.Session, 'after_flush', once=True)
|
||||
def receive_after_flush(session, context):
|
||||
activity = old_record.activities
|
||||
new_records = Activity.get_user_activity_records(
|
||||
activity.user_id, activity.sport_id
|
||||
)
|
||||
for record_type, record_data in new_records.items():
|
||||
if (
|
||||
record_data['record_value']
|
||||
and record_type == old_record.record_type
|
||||
):
|
||||
new_record = Record(
|
||||
activity=record_data['activity'], record_type=record_type
|
||||
)
|
||||
new_record.value = record_data['record_value']
|
||||
session.add(new_record)
|
116
fittrackee/activities/records.py
Normal file
116
fittrackee/activities/records.py
Normal file
@ -0,0 +1,116 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..users.utils import authenticate
|
||||
from .models import Record
|
||||
|
||||
records_blueprint = Blueprint('records', __name__)
|
||||
|
||||
|
||||
@records_blueprint.route('/records', methods=['GET'])
|
||||
@authenticate
|
||||
def get_records(auth_user_id):
|
||||
"""
|
||||
Get all records for authenticated user.
|
||||
|
||||
Following types of records are available:
|
||||
- average speed (record_type: 'AS')
|
||||
- farest distance (record_type: 'FD')
|
||||
- longest duration (record_type: 'LD')
|
||||
- maximum speed (record_type: 'MS')
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/records HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
**Example responses**:
|
||||
|
||||
- returning records
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"records": [
|
||||
{
|
||||
"activity_date": "Sun, 07 Jul 2019 08:00:00 GMT",
|
||||
"activity_id": 4,
|
||||
"id": 9,
|
||||
"record_type": "AS",
|
||||
"sport_id": 1,
|
||||
"user": "admin",
|
||||
"value": 18
|
||||
},
|
||||
{
|
||||
"activity_date": "Sun, 07 Jul 2019 08:00:00 GMT",
|
||||
"activity_id": 4,
|
||||
"id": 10,
|
||||
"record_type": "FD",
|
||||
"sport_id": 1,
|
||||
"user": "admin",
|
||||
"value": 18
|
||||
},
|
||||
{
|
||||
"activity_date": "Sun, 07 Jul 2019 08:00:00 GMT",
|
||||
"activity_id": 7,
|
||||
"id": 11,
|
||||
"record_type": "LD",
|
||||
"sport_id": 1,
|
||||
"user": "admin",
|
||||
"value": "1:01:00"
|
||||
},
|
||||
{
|
||||
"activity_date": "Sun, 07 Jul 2019 08:00:00 GMT",
|
||||
"activity_id": 4,
|
||||
"id": 12,
|
||||
"record_type": "MS",
|
||||
"sport_id": 1,
|
||||
"user": "admin",
|
||||
"value": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- no records
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"records": []
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
|
||||
"""
|
||||
|
||||
records = (
|
||||
Record.query.filter_by(user_id=auth_user_id)
|
||||
.order_by(Record.sport_id.asc(), Record.record_type.asc())
|
||||
.all()
|
||||
)
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {'records': [record.serialize() for record in records]},
|
||||
}
|
||||
return jsonify(response_object), 200
|
352
fittrackee/activities/sports.py
Normal file
352
fittrackee/activities/sports.py
Normal file
@ -0,0 +1,352 @@
|
||||
from fittrackee import appLog, db
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import exc
|
||||
|
||||
from ..users.models import User
|
||||
from ..users.utils import authenticate, authenticate_as_admin
|
||||
from .models import Sport
|
||||
|
||||
sports_blueprint = Blueprint('sports', __name__)
|
||||
|
||||
|
||||
@sports_blueprint.route('/sports', methods=['GET'])
|
||||
@authenticate
|
||||
def get_sports(auth_user_id):
|
||||
"""
|
||||
Get all sports
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/sports HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
**Example response**:
|
||||
|
||||
- for non admin user :
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": [
|
||||
{
|
||||
"id": 1,
|
||||
"img": "/img/sports/cycling-sport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Sport)"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"img": "/img/sports/cycling-transport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Transport)"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"img": "/img/sports/hiking.png",
|
||||
"is_active": true,
|
||||
"label": "Hiking"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"img": "/img/sports/mountain-biking.png",
|
||||
"is_active": true,
|
||||
"label": "Mountain Biking"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"img": "/img/sports/running.png",
|
||||
"is_active": true,
|
||||
"label": "Running"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"img": "/img/sports/walking.png",
|
||||
"is_active": true,
|
||||
"label": "Walking"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- for admin user :
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": [
|
||||
{
|
||||
"has_activities": true,
|
||||
"id": 1,
|
||||
"img": "/img/sports/cycling-sport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Sport)"
|
||||
},
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 2,
|
||||
"img": "/img/sports/cycling-transport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Transport)"
|
||||
},
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 3,
|
||||
"img": "/img/sports/hiking.png",
|
||||
"is_active": true,
|
||||
"label": "Hiking"
|
||||
},
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 4,
|
||||
"img": "/img/sports/mountain-biking.png",
|
||||
"is_active": true,
|
||||
"label": "Mountain Biking"
|
||||
},
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 5,
|
||||
"img": "/img/sports/running.png",
|
||||
"is_active": true,
|
||||
"label": "Running"
|
||||
},
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 6,
|
||||
"img": "/img/sports/walking.png",
|
||||
"is_active": true,
|
||||
"label": "Walking"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
|
||||
"""
|
||||
|
||||
user = User.query.filter_by(id=int(auth_user_id)).first()
|
||||
sports = Sport.query.order_by(Sport.id).all()
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {'sports': [sport.serialize(user.admin) for sport in sports]},
|
||||
}
|
||||
return jsonify(response_object), 200
|
||||
|
||||
|
||||
@sports_blueprint.route('/sports/<int:sport_id>', methods=['GET'])
|
||||
@authenticate
|
||||
def get_sport(auth_user_id, sport_id):
|
||||
"""
|
||||
Get a sport
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/sports/1 HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
**Example response**:
|
||||
|
||||
- success for non admin user :
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": [
|
||||
{
|
||||
"id": 1,
|
||||
"img": "/img/sports/cycling-sport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Sport)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- success for admin user :
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": [
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 1,
|
||||
"img": "/img/sports/cycling-sport.png",
|
||||
"is_active": true,
|
||||
"label": "Cycling (Sport)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- sport not found
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 404 NOT FOUND
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": []
|
||||
},
|
||||
"status": "not found"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
:param integer sport_id: sport id
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
:statuscode 404: sport not found
|
||||
|
||||
"""
|
||||
|
||||
user = User.query.filter_by(id=int(auth_user_id)).first()
|
||||
sport = Sport.query.filter_by(id=sport_id).first()
|
||||
if sport:
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {'sports': [sport.serialize(user.admin)]},
|
||||
}
|
||||
code = 200
|
||||
else:
|
||||
response_object = {'status': 'not found', 'data': {'sports': []}}
|
||||
code = 404
|
||||
return jsonify(response_object), code
|
||||
|
||||
|
||||
@sports_blueprint.route('/sports/<int:sport_id>', methods=['PATCH'])
|
||||
@authenticate_as_admin
|
||||
def update_sport(auth_user_id, sport_id):
|
||||
"""
|
||||
Update a sport
|
||||
Authenticated user must be an admin
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/sports/1 HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
**Example response**:
|
||||
|
||||
- success
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": [
|
||||
{
|
||||
"has_activities": false,
|
||||
"id": 1,
|
||||
"img": "/img/sports/cycling-sport.png",
|
||||
"is_active": false,
|
||||
"label": "Cycling (Sport)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- sport not found
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 404 NOT FOUND
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"sports": []
|
||||
},
|
||||
"status": "not found"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
:param integer sport_id: sport id
|
||||
|
||||
:<json string is_active: sport active status
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: sport updated
|
||||
:statuscode 400: invalid payload
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
:statuscode 403: You do not have permissions.
|
||||
:statuscode 404: sport not found
|
||||
:statuscode 500:
|
||||
|
||||
"""
|
||||
sport_data = request.get_json()
|
||||
if not sport_data or sport_data.get('is_active') is None:
|
||||
response_object = {'status': 'error', 'message': 'Invalid payload.'}
|
||||
return jsonify(response_object), 400
|
||||
|
||||
try:
|
||||
sport = Sport.query.filter_by(id=sport_id).first()
|
||||
if sport:
|
||||
sport.is_active = sport_data.get('is_active')
|
||||
db.session.commit()
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {'sports': [sport.serialize(True)]},
|
||||
}
|
||||
code = 200
|
||||
else:
|
||||
response_object = {'status': 'not found', 'data': {'sports': []}}
|
||||
code = 404
|
||||
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
|
||||
db.session.rollback()
|
||||
appLog.error(e)
|
||||
response_object = {
|
||||
'status': 'error',
|
||||
'message': 'Error. Please try again or contact the administrator.',
|
||||
}
|
||||
code = 500
|
||||
return jsonify(response_object), code
|
383
fittrackee/activities/stats.py
Normal file
383
fittrackee/activities/stats.py
Normal file
@ -0,0 +1,383 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fittrackee import appLog, db
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..users.models import User
|
||||
from ..users.utils import authenticate, authenticate_as_admin
|
||||
from .models import Activity, Sport
|
||||
from .utils import get_datetime_with_tz, get_upload_dir_size
|
||||
from .utils_format import convert_timedelta_to_integer
|
||||
|
||||
stats_blueprint = Blueprint('stats', __name__)
|
||||
|
||||
|
||||
def get_activities(user_name, filter_type):
|
||||
try:
|
||||
user = User.query.filter_by(username=user_name).first()
|
||||
if not user:
|
||||
response_object = {
|
||||
'status': 'not found',
|
||||
'message': 'User does not exist.',
|
||||
}
|
||||
return jsonify(response_object), 404
|
||||
|
||||
params = request.args.copy()
|
||||
date_from = params.get('from')
|
||||
if date_from:
|
||||
date_from = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
_, date_from = get_datetime_with_tz(user.timezone, date_from)
|
||||
date_to = params.get('to')
|
||||
if date_to:
|
||||
date_to = datetime.strptime(
|
||||
f'{date_to} 23:59:59', '%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
_, date_to = get_datetime_with_tz(user.timezone, date_to)
|
||||
sport_id = params.get('sport_id')
|
||||
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:
|
||||
response_object = {
|
||||
'status': 'not found',
|
||||
'message': 'Sport does not exist.',
|
||||
}
|
||||
return jsonify(response_object), 404
|
||||
|
||||
activities = (
|
||||
Activity.query.filter(
|
||||
Activity.user_id == user.id,
|
||||
Activity.activity_date >= date_from if date_from else True,
|
||||
Activity.activity_date < date_to + timedelta(seconds=1)
|
||||
if date_to
|
||||
else True,
|
||||
Activity.sport_id == sport_id if sport_id else True,
|
||||
)
|
||||
.order_by(Activity.activity_date.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
activities_list = {}
|
||||
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] = {
|
||||
'nb_activities': 0,
|
||||
'total_distance': 0.0,
|
||||
'total_duration': 0,
|
||||
}
|
||||
activities_list[sport_id]['nb_activities'] += 1
|
||||
activities_list[sport_id]['total_distance'] += float(
|
||||
activity.distance
|
||||
)
|
||||
activities_list[sport_id][
|
||||
'total_duration'
|
||||
] += convert_timedelta_to_integer(activity.moving)
|
||||
|
||||
else:
|
||||
if time == 'week':
|
||||
activity_date = activity.activity_date - timedelta(
|
||||
days=(
|
||||
activity.activity_date.isoweekday()
|
||||
if activity.activity_date.isoweekday() < 7
|
||||
else 0
|
||||
)
|
||||
)
|
||||
time_period = datetime.strftime(activity_date, "%Y-%m-%d")
|
||||
elif time == 'weekm': # week start Monday
|
||||
activity_date = activity.activity_date - timedelta(
|
||||
days=activity.activity_date.weekday()
|
||||
)
|
||||
time_period = datetime.strftime(activity_date, "%Y-%m-%d")
|
||||
elif time == 'month':
|
||||
time_period = datetime.strftime(
|
||||
activity.activity_date, "%Y-%m"
|
||||
)
|
||||
elif time == 'year' or not time:
|
||||
time_period = datetime.strftime(
|
||||
activity.activity_date, "%Y"
|
||||
)
|
||||
else:
|
||||
response_object = {
|
||||
'status': 'fail',
|
||||
'message': 'Invalid time period.',
|
||||
}
|
||||
return jsonify(response_object), 400
|
||||
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] = {
|
||||
'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][
|
||||
'total_distance'
|
||||
] += float(activity.distance)
|
||||
activities_list[time_period][sport_id][
|
||||
'total_duration'
|
||||
] += convert_timedelta_to_integer(activity.moving)
|
||||
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {'statistics': activities_list},
|
||||
}
|
||||
code = 200
|
||||
except Exception as e:
|
||||
appLog.error(e)
|
||||
response_object = {
|
||||
'status': 'error',
|
||||
'message': 'Error. Please try again or contact the administrator.',
|
||||
}
|
||||
code = 500
|
||||
return jsonify(response_object), code
|
||||
|
||||
|
||||
@stats_blueprint.route('/stats/<user_name>/by_time', methods=['GET'])
|
||||
@authenticate
|
||||
def get_activities_by_time(auth_user_id, user_name):
|
||||
"""
|
||||
Get activities statistics for a user by time
|
||||
|
||||
**Example requests**:
|
||||
|
||||
- without parameters
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/stats/admin/by_time HTTP/1.1
|
||||
|
||||
- with parameters
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/stats/admin/by_time?from=2018-01-01&to=2018-06-30&time=week HTTP/1.1
|
||||
|
||||
**Example responses**:
|
||||
|
||||
- success
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"statistics": {
|
||||
"2017": {
|
||||
"3": {
|
||||
"nb_activities": 2,
|
||||
"total_distance": 15.282,
|
||||
"total_duration": 12341
|
||||
}
|
||||
},
|
||||
"2019": {
|
||||
"1": {
|
||||
"nb_activities": 3,
|
||||
"total_distance": 47,
|
||||
"total_duration": 9960
|
||||
},
|
||||
"2": {
|
||||
"nb_activities": 1,
|
||||
"total_distance": 5.613,
|
||||
"total_duration": 1267
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- no activities
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"statistics": {}
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
:param integer user_name: user name
|
||||
|
||||
:query string from: start date (format: ``%Y-%m-%d``)
|
||||
:query string to: end date (format: ``%Y-%m-%d``)
|
||||
:query string time: time frame:
|
||||
|
||||
- ``week``: week starting Sunday
|
||||
- ``weekm``: week starting Monday
|
||||
- ``month``: month
|
||||
- ``year``: year (default)
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
:statuscode 404:
|
||||
- User does not exist.
|
||||
|
||||
"""
|
||||
return get_activities(user_name, 'by_time')
|
||||
|
||||
|
||||
@stats_blueprint.route('/stats/<user_name>/by_sport', methods=['GET'])
|
||||
@authenticate
|
||||
def get_activities_by_sport(auth_user_id, user_name):
|
||||
"""
|
||||
Get activities statistics for a user by sport
|
||||
|
||||
**Example requests**:
|
||||
|
||||
- without parameters (get stats for all sports with activities)
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/stats/admin/by_sport HTTP/1.1
|
||||
|
||||
- with sport id
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/stats/admin/by_sport?sport_id=1 HTTP/1.1
|
||||
|
||||
**Example responses**:
|
||||
|
||||
- success
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"statistics": {
|
||||
"1": {
|
||||
"nb_activities": 3,
|
||||
"total_distance": 47,
|
||||
"total_duration": 9960
|
||||
},
|
||||
"2": {
|
||||
"nb_activities": 1,
|
||||
"total_distance": 5.613,
|
||||
"total_duration": 1267
|
||||
},
|
||||
"3": {
|
||||
"nb_activities": 2,
|
||||
"total_distance": 15.282,
|
||||
"total_duration": 12341
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
- no activities
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"statistics": {}
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
:param integer user_name: user name
|
||||
|
||||
:query integer sport_id: sport id
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
:statuscode 404:
|
||||
- User does not exist.
|
||||
- Sport does not exist.
|
||||
|
||||
"""
|
||||
return get_activities(user_name, 'by_sport')
|
||||
|
||||
|
||||
@stats_blueprint.route('/stats/all', methods=['GET'])
|
||||
@authenticate_as_admin
|
||||
def get_application_stats(auth_user_id):
|
||||
"""
|
||||
Get all application statistics
|
||||
|
||||
**Example requests**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/stats/all HTTP/1.1
|
||||
|
||||
|
||||
**Example responses**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"activities": 3,
|
||||
"sports": 3,
|
||||
"users": 2,
|
||||
"uploads_dir_size": 1000
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
:param integer auth_user_id: authenticate user id (from JSON Web Token)
|
||||
|
||||
:reqheader Authorization: OAuth 2.0 Bearer Token
|
||||
|
||||
:statuscode 200: success
|
||||
:statuscode 401:
|
||||
- Provide a valid auth token.
|
||||
- Signature expired. Please log in again.
|
||||
- Invalid token. Please log in again.
|
||||
:statuscode 403: You do not have permissions.
|
||||
"""
|
||||
|
||||
nb_activities = Activity.query.filter().count()
|
||||
nb_users = User.query.filter().count()
|
||||
nb_sports = (
|
||||
db.session.query(func.count(Activity.sport_id))
|
||||
.group_by(Activity.sport_id)
|
||||
.count()
|
||||
)
|
||||
response_object = {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'activities': nb_activities,
|
||||
'sports': nb_sports,
|
||||
'users': nb_users,
|
||||
'uploads_dir_size': get_upload_dir_size(),
|
||||
},
|
||||
}
|
||||
return jsonify(response_object), 200
|
350
fittrackee/activities/utils.py
Normal file
350
fittrackee/activities/utils.py
Normal file
@ -0,0 +1,350 @@
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import gpxpy.gpx
|
||||
import pytz
|
||||
from fittrackee import appLog, db
|
||||
from flask import current_app
|
||||
from sqlalchemy import exc
|
||||
from staticmap import Line, StaticMap
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from ..users.models import User
|
||||
from .models import Activity, ActivitySegment, Sport
|
||||
from .utils_files import get_absolute_file_path
|
||||
from .utils_gpx import get_gpx_info
|
||||
|
||||
|
||||
class ActivityException(Exception):
|
||||
def __init__(self, status, message, e):
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.e = e
|
||||
|
||||
|
||||
def get_datetime_with_tz(timezone, activity_date, gpx_data=None):
|
||||
activity_date_tz = None
|
||||
if timezone:
|
||||
user_tz = pytz.timezone(timezone)
|
||||
utc_tz = pytz.utc
|
||||
if gpx_data:
|
||||
# activity date in gpx is in UTC, but in naive datetime
|
||||
fmt = '%Y-%m-%d %H:%M:%S'
|
||||
activity_date_string = activity_date.strftime(fmt)
|
||||
activity_date_tmp = utc_tz.localize(
|
||||
datetime.strptime(activity_date_string, fmt)
|
||||
)
|
||||
activity_date_tz = activity_date_tmp.astimezone(user_tz)
|
||||
else:
|
||||
activity_date_tz = user_tz.localize(activity_date)
|
||||
activity_date = activity_date_tz.astimezone(utc_tz)
|
||||
# make datetime 'naive' like in gpx file
|
||||
activity_date = activity_date.replace(tzinfo=None)
|
||||
|
||||
return activity_date_tz, activity_date
|
||||
|
||||
|
||||
def update_activity_data(activity, gpx_data):
|
||||
"""activity could be a complete activity or an activity segment"""
|
||||
activity.pauses = gpx_data['stop_time']
|
||||
activity.moving = gpx_data['moving_time']
|
||||
activity.min_alt = gpx_data['elevation_min']
|
||||
activity.max_alt = gpx_data['elevation_max']
|
||||
activity.descent = gpx_data['downhill']
|
||||
activity.ascent = gpx_data['uphill']
|
||||
activity.max_speed = gpx_data['max_speed']
|
||||
activity.ave_speed = gpx_data['average_speed']
|
||||
return activity
|
||||
|
||||
|
||||
def create_activity(user, activity_data, gpx_data=None):
|
||||
activity_date = (
|
||||
gpx_data['start']
|
||||
if gpx_data
|
||||
else datetime.strptime(
|
||||
activity_data.get('activity_date'), '%Y-%m-%d %H:%M'
|
||||
)
|
||||
)
|
||||
activity_date_tz, activity_date = get_datetime_with_tz(
|
||||
user.timezone, activity_date, gpx_data
|
||||
)
|
||||
|
||||
duration = (
|
||||
gpx_data['duration']
|
||||
if gpx_data
|
||||
else timedelta(seconds=activity_data.get('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')
|
||||
|
||||
new_activity = Activity(
|
||||
user_id=user.id,
|
||||
sport_id=activity_data.get('sport_id'),
|
||||
activity_date=activity_date,
|
||||
distance=distance,
|
||||
duration=duration,
|
||||
)
|
||||
new_activity.notes = activity_data.get('notes')
|
||||
|
||||
if title is not None and title != '':
|
||||
new_activity.title = title
|
||||
else:
|
||||
sport = Sport.query.filter_by(id=new_activity.sport_id).first()
|
||||
fmt = "%Y-%m-%d %H:%M:%S"
|
||||
activity_datetime = (
|
||||
activity_date_tz.strftime(fmt)
|
||||
if activity_date_tz
|
||||
else new_activity.activity_date.strftime(fmt)
|
||||
)
|
||||
new_activity.title = f'{sport.label} - {activity_datetime}'
|
||||
|
||||
if gpx_data:
|
||||
new_activity.gpx = gpx_data['filename']
|
||||
new_activity.bounds = gpx_data['bounds']
|
||||
update_activity_data(new_activity, gpx_data)
|
||||
else:
|
||||
new_activity.moving = duration
|
||||
new_activity.ave_speed = (
|
||||
None
|
||||
if duration.seconds == 0
|
||||
else float(new_activity.distance) / (duration.seconds / 3600)
|
||||
)
|
||||
new_activity.max_speed = new_activity.ave_speed
|
||||
return new_activity
|
||||
|
||||
|
||||
def create_segment(activity_id, segment_data):
|
||||
new_segment = ActivitySegment(
|
||||
activity_id=activity_id, segment_id=segment_data['idx']
|
||||
)
|
||||
new_segment.duration = segment_data['duration']
|
||||
new_segment.distance = segment_data['distance']
|
||||
update_activity_data(new_segment, segment_data)
|
||||
return new_segment
|
||||
|
||||
|
||||
def update_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)
|
||||
"""
|
||||
gpx_data, _, _ = get_gpx_info(
|
||||
get_absolute_file_path(activity.gpx), False, False
|
||||
)
|
||||
updated_activity = update_activity_data(activity, gpx_data)
|
||||
updated_activity.duration = gpx_data['duration']
|
||||
updated_activity.distance = gpx_data['distance']
|
||||
db.session.flush()
|
||||
|
||||
for segment_idx, segment in enumerate(updated_activity.segments):
|
||||
segment_data = gpx_data['segments'][segment_idx]
|
||||
updated_segment = update_activity_data(segment, segment_data)
|
||||
updated_segment.duration = segment_data['duration']
|
||||
updated_segment.distance = segment_data['distance']
|
||||
db.session.flush()
|
||||
|
||||
return updated_activity
|
||||
|
||||
|
||||
def edit_activity(activity, activity_data, auth_user_id):
|
||||
user = User.query.filter_by(id=auth_user_id).first()
|
||||
if activity_data.get('refresh'):
|
||||
activity = update_activity(activity)
|
||||
if activity_data.get('sport_id'):
|
||||
activity.sport_id = activity_data.get('sport_id')
|
||||
if activity_data.get('title'):
|
||||
activity.title = activity_data.get('title')
|
||||
if activity_data.get('notes'):
|
||||
activity.notes = activity_data.get('notes')
|
||||
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.activity_date = get_datetime_with_tz(
|
||||
user.timezone, activity_date
|
||||
)
|
||||
|
||||
if activity_data.get('duration'):
|
||||
activity.duration = timedelta(
|
||||
seconds=activity_data.get('duration')
|
||||
)
|
||||
activity.moving = activity.duration
|
||||
|
||||
if activity_data.get('distance'):
|
||||
activity.distance = activity_data.get('distance')
|
||||
|
||||
activity.ave_speed = (
|
||||
None
|
||||
if activity.duration.seconds == 0
|
||||
else float(activity.distance) / (activity.duration.seconds / 3600)
|
||||
)
|
||||
activity.max_speed = activity.ave_speed
|
||||
return activity
|
||||
|
||||
|
||||
def get_file_path(dir_path, filename):
|
||||
if not os.path.exists(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
return file_path
|
||||
|
||||
|
||||
def get_new_file_path(
|
||||
auth_user_id, activity_date, sport, old_filename=None, extension=None
|
||||
):
|
||||
if not extension:
|
||||
extension = f".{old_filename.rsplit('.', 1)[1].lower()}"
|
||||
_, new_filename = tempfile.mkstemp(
|
||||
prefix=f'{activity_date}_{sport}_', suffix=extension
|
||||
)
|
||||
dir_path = os.path.join('activities', 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
|
||||
|
||||
|
||||
def generate_map(map_filepath, map_data):
|
||||
m = StaticMap(400, 225, 10)
|
||||
line = Line(map_data, '#3388FF', 4)
|
||||
m.add_line(line)
|
||||
image = m.render()
|
||||
image.save(map_filepath)
|
||||
|
||||
|
||||
def get_map_hash(map_filepath):
|
||||
"""
|
||||
md5 hash is 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)
|
||||
with open(absolute_map_filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(128 * md5.block_size), b''):
|
||||
md5.update(chunk)
|
||||
return md5.hexdigest()
|
||||
|
||||
|
||||
def process_one_gpx_file(params, filename):
|
||||
try:
|
||||
gpx_data, map_data, weather_data = get_gpx_info(params['file_path'])
|
||||
auth_user_id = params['user'].id
|
||||
new_filepath = get_new_file_path(
|
||||
auth_user_id=auth_user_id,
|
||||
activity_date=gpx_data['start'],
|
||||
old_filename=filename,
|
||||
sport=params['sport_label'],
|
||||
)
|
||||
absolute_gpx_filepath = get_absolute_file_path(new_filepath)
|
||||
os.rename(params['file_path'], absolute_gpx_filepath)
|
||||
gpx_data['filename'] = new_filepath
|
||||
|
||||
map_filepath = get_new_file_path(
|
||||
auth_user_id=auth_user_id,
|
||||
activity_date=gpx_data['start'],
|
||||
extension='.png',
|
||||
sport=params['sport_label'],
|
||||
)
|
||||
absolute_map_filepath = get_absolute_file_path(map_filepath)
|
||||
generate_map(absolute_map_filepath, map_data)
|
||||
except (gpxpy.gpx.GPXXMLSyntaxException, TypeError) as e:
|
||||
raise ActivityException('error', 'Error during gpx file parsing.', e)
|
||||
except Exception as e:
|
||||
raise ActivityException('error', 'Error during gpx processing.', e)
|
||||
|
||||
try:
|
||||
new_activity = create_activity(
|
||||
params['user'], params['activity_data'], gpx_data
|
||||
)
|
||||
new_activity.map = map_filepath
|
||||
new_activity.map_id = get_map_hash(map_filepath)
|
||||
new_activity.weather_start = weather_data[0]
|
||||
new_activity.weather_end = weather_data[1]
|
||||
db.session.add(new_activity)
|
||||
db.session.flush()
|
||||
|
||||
for segment_data in gpx_data['segments']:
|
||||
new_segment = create_segment(new_activity.id, segment_data)
|
||||
db.session.add(new_segment)
|
||||
db.session.commit()
|
||||
return new_activity
|
||||
except (exc.IntegrityError, ValueError) as e:
|
||||
raise ActivityException('fail', 'Error during activity save.', e)
|
||||
|
||||
|
||||
def process_zip_archive(common_params, extract_dir):
|
||||
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 = int(gpx_files_limit)
|
||||
else:
|
||||
gpx_files_limit = 10
|
||||
appLog.error('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'):
|
||||
gpx_files_ok += 1
|
||||
if gpx_files_ok > gpx_files_limit:
|
||||
break
|
||||
file_path = os.path.join(extract_dir, gpx_file)
|
||||
params = common_params
|
||||
params['file_path'] = file_path
|
||||
new_activity = process_one_gpx_file(params, gpx_file)
|
||||
new_activities.append(new_activity)
|
||||
|
||||
return new_activities
|
||||
|
||||
|
||||
def process_files(auth_user_id, activity_data, activity_file, folders):
|
||||
filename = secure_filename(activity_file.filename)
|
||||
extension = f".{filename.rsplit('.', 1)[1].lower()}"
|
||||
file_path = get_file_path(folders['tmp_dir'], filename)
|
||||
sport = Sport.query.filter_by(id=activity_data.get('sport_id')).first()
|
||||
if not sport:
|
||||
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()
|
||||
|
||||
common_params = {
|
||||
'user': user,
|
||||
'activity_data': activity_data,
|
||||
'file_path': file_path,
|
||||
'sport_label': sport.label,
|
||||
}
|
||||
|
||||
try:
|
||||
activity_file.save(file_path)
|
||||
except Exception as e:
|
||||
raise ActivityException('error', 'Error during activity file save.', e)
|
||||
|
||||
if extension == ".gpx":
|
||||
return [process_one_gpx_file(common_params, filename)]
|
||||
else:
|
||||
return process_zip_archive(common_params, folders['extract_dir'])
|
||||
|
||||
|
||||
def get_upload_dir_size():
|
||||
upload_path = get_absolute_file_path('')
|
||||
total_size = 0
|
||||
for dir_path, _, filenames in os.walk(upload_path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dir_path, f)
|
||||
total_size += os.path.getsize(fp)
|
||||
return total_size
|
7
fittrackee/activities/utils_files.py
Normal file
7
fittrackee/activities/utils_files.py
Normal file
@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def get_absolute_file_path(relative_path):
|
||||
return os.path.join(current_app.config['UPLOAD_FOLDER'], relative_path)
|
24
fittrackee/activities/utils_format.py
Normal file
24
fittrackee/activities/utils_format.py
Normal file
@ -0,0 +1,24 @@
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def convert_in_duration(value):
|
||||
hours = int(value.split(':')[0])
|
||||
minutes = int(value.split(':')[1])
|
||||
return timedelta(seconds=(hours * 3600 + minutes * 60))
|
||||
|
||||
|
||||
def convert_timedelta_to_integer(value):
|
||||
hours, minutes, seconds = str(value).split(':')
|
||||
return int(hours) * 3600 + int(minutes) * 60 + int(seconds)
|
||||
|
||||
|
||||
def convert_value_to_integer(record_type, val):
|
||||
if val is None:
|
||||
return None
|
||||
|
||||
if record_type == 'LD':
|
||||
return convert_timedelta_to_integer(val)
|
||||
elif record_type in ['AS', 'MS']:
|
||||
return int(val * 100)
|
||||
else: # 'FD'
|
||||
return int(val * 1000)
|
218
fittrackee/activities/utils_gpx.py
Normal file
218
fittrackee/activities/utils_gpx.py
Normal file
@ -0,0 +1,218 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import gpxpy.gpx
|
||||
|
||||
from .utils_weather import get_weather
|
||||
|
||||
|
||||
class ActivityGPXException(Exception):
|
||||
def __init__(self, status, message, e):
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.e = e
|
||||
|
||||
|
||||
def open_gpx_file(gpx_file):
|
||||
gpx_file = open(gpx_file, 'r')
|
||||
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}
|
||||
|
||||
duration = parsed_gpx.get_duration()
|
||||
gpx_data['duration'] = timedelta(seconds=duration) + stopped_time_btwn_seg
|
||||
|
||||
ele = parsed_gpx.get_elevation_extremes()
|
||||
gpx_data['elevation_max'] = ele.maximum
|
||||
gpx_data['elevation_min'] = ele.minimum
|
||||
|
||||
hill = parsed_gpx.get_uphill_downhill()
|
||||
gpx_data['uphill'] = hill.uphill
|
||||
gpx_data['downhill'] = hill.downhill
|
||||
|
||||
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
|
||||
)
|
||||
distance = mv.moving_distance + mv.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
|
||||
|
||||
return gpx_data
|
||||
|
||||
|
||||
def get_gpx_info(gpx_file, update_map_data=True, update_weather_data=True):
|
||||
gpx = open_gpx_file(gpx_file)
|
||||
if gpx is None:
|
||||
return None
|
||||
|
||||
gpx_data = {'name': gpx.tracks[0].name, 'segments': []}
|
||||
max_speed = 0
|
||||
start = 0
|
||||
map_data = []
|
||||
weather_data = []
|
||||
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
|
||||
|
||||
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
|
||||
segment_start = 0
|
||||
segment_points_nb = len(segment.points)
|
||||
for point_idx, point in enumerate(segment.points):
|
||||
if point_idx == 0:
|
||||
# first gpx point => get weather
|
||||
if start == 0:
|
||||
start = point.time
|
||||
if 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_btwn_seg = point.time - prev_seg_last_point
|
||||
|
||||
# last segment point
|
||||
if point_idx == (segment_points_nb - 1):
|
||||
prev_seg_last_point = point.time
|
||||
|
||||
# last gpx point => get weather
|
||||
if segment_idx == (segments_nb - 1) and update_weather_data:
|
||||
weather_data.append(get_weather(point))
|
||||
|
||||
if update_map_data:
|
||||
map_data.append([point.longitude, point.latitude])
|
||||
segment_max_speed = (
|
||||
segment.get_moving_data().max_speed
|
||||
if segment.get_moving_data().max_speed
|
||||
else 0
|
||||
)
|
||||
|
||||
if segment_max_speed > max_speed:
|
||||
max_speed = segment_max_speed
|
||||
|
||||
segment_data = get_gpx_data(
|
||||
segment, segment_max_speed, segment_start, no_stopped_time
|
||||
)
|
||||
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)
|
||||
gpx_data = {**gpx_data, **full_gpx_data}
|
||||
|
||||
if update_map_data:
|
||||
bounds = gpx.get_bounds()
|
||||
gpx_data['bounds'] = [
|
||||
bounds.min_latitude,
|
||||
bounds.min_longitude,
|
||||
bounds.max_latitude,
|
||||
bounds.max_longitude,
|
||||
]
|
||||
|
||||
return gpx_data, map_data, weather_data
|
||||
|
||||
|
||||
def get_gpx_segments(track_segments, segment_id=None):
|
||||
if segment_id is not None:
|
||||
segment_index = segment_id - 1
|
||||
if segment_index > (len(track_segments) - 1):
|
||||
raise ActivityGPXException(
|
||||
'not found', f'No segment with id \'{segment_id}\'', None
|
||||
)
|
||||
if segment_index < 0:
|
||||
raise ActivityGPXException('error', 'Incorrect segment id', None)
|
||||
segments = [track_segments[segment_index]]
|
||||
else:
|
||||
segments = track_segments
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def get_chart_data(gpx_file, segment_id=None):
|
||||
gpx = open_gpx_file(gpx_file)
|
||||
if gpx is None:
|
||||
return None
|
||||
|
||||
chart_data = []
|
||||
first_point = None
|
||||
previous_point = None
|
||||
previous_distance = 0
|
||||
|
||||
track_segments = gpx.tracks[0].segments
|
||||
segments = get_gpx_segments(track_segments, segment_id)
|
||||
|
||||
for segment_idx, segment in enumerate(segments):
|
||||
for point_idx, point in enumerate(segment.points):
|
||||
if segment_idx == 0 and point_idx == 0:
|
||||
first_point = point
|
||||
distance = (
|
||||
point.distance_3d(previous_point)
|
||||
if (
|
||||
point.elevation
|
||||
and previous_point
|
||||
and previous_point.elevation
|
||||
)
|
||||
else point.distance_2d(previous_point)
|
||||
)
|
||||
distance = 0 if distance is None else distance
|
||||
distance += previous_distance
|
||||
speed = (
|
||||
round((segment.get_speed(point_idx) / 1000) * 3600, 2)
|
||||
if segment.get_speed(point_idx) is not None
|
||||
else 0
|
||||
)
|
||||
chart_data.append(
|
||||
{
|
||||
'distance': (
|
||||
round(distance / 1000, 2)
|
||||
if distance is not None
|
||||
else 0
|
||||
),
|
||||
'duration': point.time_difference(first_point),
|
||||
'elevation': (
|
||||
round(point.elevation, 1)
|
||||
if point.elevation is not None
|
||||
else 0
|
||||
),
|
||||
'latitude': point.latitude,
|
||||
'longitude': point.longitude,
|
||||
'speed': speed,
|
||||
'time': point.time,
|
||||
}
|
||||
)
|
||||
previous_point = point
|
||||
previous_distance = distance
|
||||
|
||||
return chart_data
|
||||
|
||||
|
||||
def extract_segment_from_gpx_file(content, segment_id):
|
||||
gpx_content = gpxpy.parse(content)
|
||||
if len(gpx_content.tracks) == 0:
|
||||
return None
|
||||
|
||||
track_segment = get_gpx_segments(
|
||||
gpx_content.tracks[0].segments, segment_id
|
||||
)
|
||||
|
||||
gpx = gpxpy.gpx.GPX()
|
||||
gpx_track = gpxpy.gpx.GPXTrack()
|
||||
gpx.tracks.append(gpx_track)
|
||||
gpx_segment = gpxpy.gpx.GPXTrackSegment()
|
||||
gpx_track.segments.append(gpx_segment)
|
||||
|
||||
for point_idx, point in enumerate(track_segment[0].points):
|
||||
gpx_segment.points.append(
|
||||
gpxpy.gpx.GPXTrackPoint(
|
||||
point.latitude, point.longitude, elevation=point.elevation
|
||||
)
|
||||
)
|
||||
|
||||
return gpx.to_xml()
|
32
fittrackee/activities/utils_weather.py
Normal file
32
fittrackee/activities/utils_weather.py
Normal file
@ -0,0 +1,32 @@
|
||||
import os
|
||||
|
||||
import forecastio
|
||||
import pytz
|
||||
from fittrackee import appLog
|
||||
|
||||
API_KEY = os.getenv('WEATHER_API')
|
||||
|
||||
|
||||
def get_weather(point):
|
||||
if not API_KEY or API_KEY == '':
|
||||
return None
|
||||
try:
|
||||
point_time = pytz.utc.localize(point.time)
|
||||
forecast = forecastio.load_forecast(
|
||||
API_KEY,
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
time=point_time,
|
||||
units='si',
|
||||
)
|
||||
weather = forecast.currently()
|
||||
return {
|
||||
'summary': weather.summary,
|
||||
'icon': weather.icon,
|
||||
'temperature': weather.temperature,
|
||||
'humidity': weather.humidity,
|
||||
'wind': weather.windSpeed,
|
||||
}
|
||||
except Exception as e:
|
||||
appLog.error(e)
|
||||
return None
|
Reference in New Issue
Block a user