API - remove intermediate directory and rename api directory

This commit is contained in:
Sam
2020-09-16 15:41:02 +02:00
parent 640385cdb7
commit af301b437e
148 changed files with 1953 additions and 1967 deletions

99
fittrackee/__init__.py Normal file
View File

@ -0,0 +1,99 @@
import logging
import os
from importlib import import_module, reload
from flask import Flask, render_template
from flask_bcrypt import Bcrypt
from flask_dramatiq import Dramatiq
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from .email.email import Email
db = SQLAlchemy()
bcrypt = Bcrypt()
migrate = Migrate()
email_service = Email()
dramatiq = Dramatiq()
appLog = logging.getLogger('fittrackee')
def create_app():
# instantiate the app
app = Flask(__name__, static_folder='dist/static', template_folder='dist')
# set config
with app.app_context():
app_settings = os.getenv('APP_SETTINGS')
if app_settings == 'fittrackee.config.TestingConfig':
# reload config on tests
config = import_module('fittrackee.config')
reload(config)
app.config.from_object(app_settings)
# set up extensions
db.init_app(app)
bcrypt.init_app(app)
migrate.init_app(app, db)
dramatiq.init_app(app)
# set up email
email_service.init_email(app)
# get configuration from database
from .application.models import AppConfig
from .application.utils import init_config, update_app_config_from_database
with app.app_context():
# Note: check if "app_config" table exist to avoid errors when
# dropping tables on dev environments
if db.engine.dialect.has_table(db.engine, 'app_config'):
db_app_config = AppConfig.query.one_or_none()
if not db_app_config:
_, db_app_config = init_config()
update_app_config_from_database(app, db_app_config)
from .activities.activities import activities_blueprint # noqa
from .activities.records import records_blueprint # noqa
from .activities.sports import sports_blueprint # noqa
from .activities.stats import stats_blueprint # noqa
from .application.app_config import config_blueprint # noqa
from .users.auth import auth_blueprint # noqa
from .users.users import users_blueprint # noqa
app.register_blueprint(users_blueprint, url_prefix='/api')
app.register_blueprint(auth_blueprint, url_prefix='/api')
app.register_blueprint(activities_blueprint, url_prefix='/api')
app.register_blueprint(records_blueprint, url_prefix='/api')
app.register_blueprint(sports_blueprint, url_prefix='/api')
app.register_blueprint(stats_blueprint, url_prefix='/api')
app.register_blueprint(config_blueprint, url_prefix='/api')
if app.debug:
logging.getLogger('sqlalchemy').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy').handlers = logging.getLogger(
'werkzeug'
).handlers
logging.getLogger('sqlalchemy.orm').setLevel(logging.WARNING)
logging.getLogger('flake8').propagate = False
appLog.setLevel(logging.DEBUG)
# Enable CORS
@app.after_request
def after_request(response):
response.headers.add('Access-Control-Allow-Origin', '*')
response.headers.add(
'Access-Control-Allow-Headers', 'Content-Type,Authorization'
)
response.headers.add(
'Access-Control-Allow-Methods',
'GET,PUT,POST,DELETE,PATCH,OPTIONS',
)
return response
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch_all(path):
return render_template('index.html')
return app

View File

File diff suppressed because it is too large Load Diff

View 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)

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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()

View 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

View File

View File

@ -0,0 +1,171 @@
from fittrackee import appLog, db
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from ..users.utils import authenticate_as_admin
from .models import AppConfig
from .utils import update_app_config_from_database
config_blueprint = Blueprint('config', __name__)
@config_blueprint.route('/config', methods=['GET'])
def get_application_config():
"""
Get Application config
**Example request**:
.. sourcecode:: http
GET /api/config HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"gpx_limit_import": 10,
"is_registration_enabled": false,
"max_single_file_size": 1048576,
"max_zip_file_size": 10485760,
"max_users": 0
},
"status": "success"
}
:statuscode 200: success
:statuscode 500: Error on getting configuration.
"""
try:
config = AppConfig.query.one()
response_object = {'status': 'success', 'data': config.serialize()}
return jsonify(response_object), 200
except (MultipleResultsFound, NoResultFound) as e:
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error on getting configuration.',
}
return jsonify(response_object), 500
@config_blueprint.route('/config', methods=['PATCH'])
@authenticate_as_admin
def update_application_config(auth_user_id):
"""
Update Application config
Authenticated user must be an admin
**Example request**:
.. sourcecode:: http
GET /api/config HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"gpx_limit_import": 10,
"is_registration_enabled": true,
"max_single_file_size": 1048576,
"max_zip_file_size": 10485760,
"max_users": 10
},
"status": "success"
}
:param integer auth_user_id: authenticate user id (from JSON Web Token)
:<json integrer gpx_limit_import: max number of files in zip archive
:<json boolean is_registration_enabled: is registration enabled ?
:<json integrer max_single_file_size: max size of a single file
:<json integrer max_zip_file_size: max size of a zip archive
:<json integrer max_users: max users allowed to register on instance
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
: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 500: Error on updating configuration.
"""
config_data = request.get_json()
if not config_data:
response_object = {'status': 'error', 'message': 'Invalid payload.'}
return jsonify(response_object), 400
try:
config = AppConfig.query.one()
if 'gpx_limit_import' in config_data:
config.gpx_limit_import = config_data.get('gpx_limit_import')
if 'max_single_file_size' in config_data:
config.max_single_file_size = config_data.get(
'max_single_file_size'
)
if 'max_zip_file_size' in config_data:
config.max_zip_file_size = config_data.get('max_zip_file_size')
if 'max_users' in config_data:
config.max_users = config_data.get('max_users')
db.session.commit()
update_app_config_from_database(current_app, config)
response_object = {'status': 'success', 'data': config.serialize()}
code = 200
except Exception as e:
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error on updating configuration.',
}
code = 500
return jsonify(response_object), code
@config_blueprint.route('/ping', methods=['GET'])
def health_check():
"""health check endpoint
**Example request**:
.. sourcecode:: http
GET /api/ping HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "pong!",
"status": "success"
}
:statuscode 200: success
"""
return jsonify({'status': 'success', 'message': 'pong!'})

View File

@ -0,0 +1,57 @@
from fittrackee import db
from flask import current_app
from sqlalchemy.event import listens_for
from ..users.models import User
class AppConfig(db.Model):
__tablename__ = 'app_config'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
max_users = db.Column(db.Integer, default=0, nullable=False)
gpx_limit_import = db.Column(db.Integer, default=10, nullable=False)
max_single_file_size = db.Column(
db.Integer, default=1048576, nullable=False
)
max_zip_file_size = db.Column(db.Integer, default=10485760, nullable=False)
@property
def is_registration_enabled(self):
nb_users = User.query.count()
return self.max_users == 0 or nb_users < self.max_users
@property
def map_attribution(self):
return current_app.config['TILE_SERVER']['ATTRIBUTION']
def serialize(self):
return {
"gpx_limit_import": self.gpx_limit_import,
"is_registration_enabled": self.is_registration_enabled,
"max_single_file_size": self.max_single_file_size,
"max_zip_file_size": self.max_zip_file_size,
"max_users": self.max_users,
"map_attribution": self.map_attribution,
}
def update_app_config():
config = AppConfig.query.first()
if config:
current_app.config[
'is_registration_enabled'
] = config.is_registration_enabled
@listens_for(User, 'after_insert')
def on_user_insert(mapper, connection, user):
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
update_app_config()
@listens_for(User, 'after_delete')
def on_user_delete(mapper, connection, old_user):
@listens_for(db.Session, 'after_flush', once=True)
def receive_after_flush(session, context):
update_app_config()

View File

@ -0,0 +1,46 @@
import os
from fittrackee import db
from fittrackee.users.models import User
from .models import AppConfig
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB
def init_config():
"""
init application configuration if not existing in database
Note: get some configuration values from env variables
(for FitTrackee versions prior to v0.3.0)
"""
existing_config = AppConfig.query.one_or_none()
nb_users = User.query.count()
if not existing_config:
config = AppConfig()
config.max_users = (
nb_users
if os.getenv('REACT_APP_ALLOW_REGISTRATION') == "false"
else 0
)
config.max_single_file_size = os.environ.get(
'REACT_APP_MAX_SINGLE_FILE_SIZE', MAX_FILE_SIZE
)
config.max_zip_file_size = os.environ.get(
'REACT_APP_MAX_ZIP_FILE_SIZE', MAX_FILE_SIZE * 10
)
db.session.add(config)
db.session.commit()
return True, config
return False, existing_config
def update_app_config_from_database(current_app, db_config):
current_app.config['gpx_limit_import'] = db_config.gpx_limit_import
current_app.config['max_single_file_size'] = db_config.max_single_file_size
current_app.config['MAX_CONTENT_LENGTH'] = db_config.max_zip_file_size
current_app.config['max_users'] = db_config.max_users
current_app.config[
'is_registration_enabled'
] = db_config.is_registration_enabled

70
fittrackee/config.py Normal file
View File

@ -0,0 +1,70 @@
import os
from dramatiq.brokers.redis import RedisBroker
from dramatiq.brokers.stub import StubBroker
from flask import current_app
if os.getenv('APP_SETTINGS') == 'fittrackee.config.TestingConfig':
broker = StubBroker
else:
broker = RedisBroker
class BaseConfig:
"""Base configuration"""
DEBUG = False
TESTING = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
BCRYPT_LOG_ROUNDS = 13
TOKEN_EXPIRATION_DAYS = 30
TOKEN_EXPIRATION_SECONDS = 0
PASSWORD_TOKEN_EXPIRATION_SECONDS = 3600
UPLOAD_FOLDER = os.path.join(current_app.root_path, 'uploads')
PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'}
ACTIVITY_ALLOWED_EXTENSIONS = {'gpx', 'zip'}
TEMPLATES_FOLDER = os.path.join(current_app.root_path, 'email/templates')
UI_URL = os.environ.get('UI_URL')
EMAIL_URL = os.environ.get('EMAIL_URL')
SENDER_EMAIL = os.environ.get('SENDER_EMAIL')
DRAMATIQ_BROKER = broker
TILE_SERVER = {
'URL': os.environ.get(
'TILE_SERVER_URL',
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
),
'ATTRIBUTION': os.environ.get(
'MAP_ATTRIBUTION',
'&copy; <a href="http://www.openstreetmap.org/copyright" '
'target="_blank" rel="noopener noreferrer">OpenStreetMap</a>'
' contributors',
),
}
class DevelopmentConfig(BaseConfig):
"""Development configuration"""
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
SECRET_KEY = 'development key'
USERNAME = 'admin'
PASSWORD = 'default'
BCRYPT_LOG_ROUNDS = 4
DRAMATIQ_BROKER_URL = os.getenv('REDIS_URL', 'redis://')
class TestingConfig(BaseConfig):
"""Testing configuration"""
DEBUG = True
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_TEST_URL')
SECRET_KEY = 'test key'
USERNAME = 'admin'
PASSWORD = 'default'
BCRYPT_LOG_ROUNDS = 4
TOKEN_EXPIRATION_DAYS = 0
TOKEN_EXPIRATION_SECONDS = 3
PASSWORD_TOKEN_EXPIRATION_SECONDS = 3
UPLOAD_FOLDER = '/tmp/fitTrackee/uploads'

26
fittrackee/dist/asset-manifest.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"files": {
"main.css": "/static/css/main.9eb63bc2.chunk.css",
"main.js": "/static/js/main.e589eaf8.chunk.js",
"main.js.map": "/static/js/main.e589eaf8.chunk.js.map",
"runtime-main.js": "/static/js/runtime-main.2d7c76f9.js",
"runtime-main.js.map": "/static/js/runtime-main.2d7c76f9.js.map",
"static/js/2.8ad7236a.chunk.js": "/static/js/2.8ad7236a.chunk.js",
"static/js/2.8ad7236a.chunk.js.map": "/static/js/2.8ad7236a.chunk.js.map",
"index.html": "/index.html",
"precache-manifest.5c6aeed76c2cb8cfc6f5407698470a91.js": "/precache-manifest.5c6aeed76c2cb8cfc6f5407698470a91.js",
"service-worker.js": "/service-worker.js",
"static/css/main.9eb63bc2.chunk.css.map": "/static/css/main.9eb63bc2.chunk.css.map",
"static/js/2.8ad7236a.chunk.js.LICENSE.txt": "/static/js/2.8ad7236a.chunk.js.LICENSE.txt",
"static/media/en.501888db.svg": "/static/media/en.501888db.svg",
"static/media/fr.b75cd962.svg": "/static/media/fr.b75cd962.svg",
"static/media/mail-send.66b8d739.svg": "/static/media/mail-send.66b8d739.svg",
"static/media/password.488f5f4c.svg": "/static/media/password.488f5f4c.svg"
},
"entrypoints": [
"static/js/runtime-main.2d7c76f9.js",
"static/js/2.8ad7236a.chunk.js",
"static/css/main.9eb63bc2.chunk.css",
"static/js/main.e589eaf8.chunk.js"
]
}

BIN
fittrackee/dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

BIN
fittrackee/dist/img/photo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
fittrackee/dist/img/sports/hiking.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
fittrackee/dist/img/sports/running.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
fittrackee/dist/img/sports/walking.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
fittrackee/dist/img/weather/breeze.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
fittrackee/dist/img/weather/cloudy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
fittrackee/dist/img/weather/fog.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
fittrackee/dist/img/weather/rain.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
fittrackee/dist/img/weather/sleet.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
fittrackee/dist/img/weather/snow.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
fittrackee/dist/img/weather/wind.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

1
fittrackee/dist/index.html vendored Normal file
View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"><meta name="theme-color" content="#000000"><link rel="manifest" href="/manifest.json"><link rel="shortcut icon" href="/favicon.ico"><link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fork-awesome@1.1.7/css/fork-awesome.min.css" integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY=" crossorigin="anonymous"><link rel="stylesheet" href="https://cdn.jsdelivr.net/foundation-icons/3.0/foundation-icons.min.css"><link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==" crossorigin=""><title>FitTrackee</title><link href="/static/css/main.9eb63bc2.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script><script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script><script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script><script type="text/javascript">$(document).ready((function(){$("li.nav-item").click((function(){$("button.navbar-toggler").toggleClass("collapsed"),$("#navbarSupportedContent").toggleClass("show")}))}))</script><script>!function(e){function t(t){for(var n,i,l=t[0],f=t[1],a=t[2],p=0,s=[];p<l.length;p++)i=l[p],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(t);s.length;)s.shift()();return u.push.apply(u,a||[]),r()}function r(){for(var e,t=0;t<u.length;t++){for(var r=u[t],n=!0,l=1;l<r.length;l++){var f=r[l];0!==o[f]&&(n=!1)}n&&(u.splice(t--,1),e=i(i.s=r[0]))}return e}var n={},o={1:0},u=[];function i(t){if(n[t])return n[t].exports;var r=n[t]={i:t,l:!1,exports:{}};return e[t].call(r.exports,r,r.exports,i),r.l=!0,r.exports}i.m=e,i.c=n,i.d=function(e,t,r){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(i.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)i.d(r,n,function(t){return e[t]}.bind(null,n));return r},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="/";var l=this.webpackJsonpfittrackee_client=this.webpackJsonpfittrackee_client||[],f=l.push.bind(l);l.push=t,l=l.slice();for(var a=0;a<l.length;a++)t(l[a]);var c=f;r()}([])</script><script src="/static/js/2.8ad7236a.chunk.js"></script><script src="/static/js/main.e589eaf8.chunk.js"></script></body></html>

16
fittrackee/dist/manifest.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"short_name": "FitTrackee",
"name": "Self hosted workout/activity tracker",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"version": "0.2.0-beta"
}

View File

@ -0,0 +1,42 @@
self.__precacheManifest = (self.__precacheManifest || []).concat([
{
"revision": "ce27ec4197f51699274b624e1dd24806",
"url": "/index.html"
},
{
"revision": "18e9a261449a30132350",
"url": "/static/css/main.9eb63bc2.chunk.css"
},
{
"revision": "4fea69143e8c947ba6e0",
"url": "/static/js/2.8ad7236a.chunk.js"
},
{
"revision": "92427c76d8e49274b27c017747c41b82",
"url": "/static/js/2.8ad7236a.chunk.js.LICENSE.txt"
},
{
"revision": "18e9a261449a30132350",
"url": "/static/js/main.e589eaf8.chunk.js"
},
{
"revision": "c25e4b04867fdd1a7bb1",
"url": "/static/js/runtime-main.2d7c76f9.js"
},
{
"revision": "501888db26f05ed93528eae579c09ce9",
"url": "/static/media/en.501888db.svg"
},
{
"revision": "b75cd9624fb6c184c40c24f5bc035610",
"url": "/static/media/fr.b75cd962.svg"
},
{
"revision": "66b8d739b7190f68ea99314d55e1f53d",
"url": "/static/media/mail-send.66b8d739.svg"
},
{
"revision": "488f5f4cd9648db1147b97d8f1357e24",
"url": "/static/media/password.488f5f4c.svg"
}
]);

39
fittrackee/dist/service-worker.js vendored Normal file
View File

@ -0,0 +1,39 @@
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts(
"/precache-manifest.5c6aeed76c2cb8cfc6f5407698470a91.js"
);
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
workbox.core.clientsClaim();
/**
* The workboxSW.precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/index.html"), {
blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/],
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/* @preserve
* Leaflet 1.7.1, a JS library for interactive maps. http://leafletjs.com
* (c) 2010-2019 Vladimir Agafonkin, (c) 2010-2011 CloudMade
*/
/*!
Copyright (c) 2017 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*! decimal.js-light v2.5.0 https://github.com/MikeMcl/decimal.js-light/LICENCE */
/** @license React v0.19.1
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
!function(e){function t(t){for(var n,i,l=t[0],f=t[1],a=t[2],p=0,s=[];p<l.length;p++)i=l[p],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(t);s.length;)s.shift()();return u.push.apply(u,a||[]),r()}function r(){for(var e,t=0;t<u.length;t++){for(var r=u[t],n=!0,l=1;l<r.length;l++){var f=r[l];0!==o[f]&&(n=!1)}n&&(u.splice(t--,1),e=i(i.s=r[0]))}return e}var n={},o={1:0},u=[];function i(t){if(n[t])return n[t].exports;var r=n[t]={i:t,l:!1,exports:{}};return e[t].call(r.exports,r,r.exports,i),r.l=!0,r.exports}i.m=e,i.c=n,i.d=function(e,t,r){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(i.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var n in e)i.d(r,n,function(t){return e[t]}.bind(null,n));return r},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="/";var l=this.webpackJsonpfittrackee_client=this.webpackJsonpfittrackee_client||[],f=l.push.bind(l);l.push=t,l=l.slice();for(var a=0;a<l.length;a++)t(l[a]);var c=f;r()}([]);
//# sourceMappingURL=runtime-main.2d7c76f9.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,27 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512"
viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg">
<path d="m466.916 27.803h-421.832c-24.859 0-45.084 20.225-45.084 45.084v366.226c0 24.859 20.225 45.084 45.084 45.084h421.832c24.859 0 45.084-20.225 45.084-45.084v-366.226c0-24.859-20.225-45.084-45.084-45.084z"
fill="#f0f9ff"/>
<path d="m198.58 188.334-181.344-150.862c-7.75 6.107-13.456 14.691-15.905 24.554l164.142 136.551h33.102z"
fill="#f40055"/>
<path d="m313.425 198.576h33.93l163.447-135.973c-2.325-9.923-7.93-18.592-15.613-24.796l-181.764 151.211z"
fill="#c20044"/>
<path d="m165.472 313.425-164.141 136.549c2.449 9.863 8.155 18.447 15.905 24.553l181.344-150.861-.005-10.241z"
fill="#f40055"/>
<path d="m313.425 313.425v9.557l181.765 151.211c7.683-6.204 13.288-14.874 15.613-24.796l-163.446-135.971z"
fill="#c20044"/>
<path d="m53.273 27.803 145.302 120.879v-120.879z" fill="#406bd4"/>
<path d="m313.425 150.571v-122.768h148.082z" fill="#3257b0"/>
<path d="m394.732 198.575 117.268-97.556v97.556z" fill="#3257b0"/>
<g fill="#406bd4">
<path d="m0 99.317v99.258h119.313z"/>
<path d="m0 313.425v97.699l117.44-97.699z"/>
<path d="m50.49 484.197 148.085-122.676v122.676z"/>
</g>
<path d="m313.425 484.197v-124.139l149.221 124.139z" fill="#3257b0"/>
<path d="m512 409.423-115.395-95.998h115.395z" fill="#3257b0"/>
<path d="m512 222.142h-222.142v-194.339h-67.716v194.339h-222.142v67.716h222.142v194.339h67.716v-194.339h222.142z"
fill="#f40055"/>
<path d="m289.858 222.142v-194.339h-33.858v456.394h33.858v-194.339h222.142v-67.716z"
fill="#c20044"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m173.899 31.804h-8.707l-4.397-4h-115.711c-24.859-.001-45.084 20.224-45.084 45.083v366.226c0 24.859 20.225 45.084 45.084 45.084h115.711l6.348-4h6.755v-448.393z" fill="#406bd4"/><path d="m466.916 27.803h-115.711l-4.523 4h-5.141v448.393h4.141l5.523 4h115.711c24.859 0 45.084-20.225 45.084-45.084v-366.225c0-24.859-20.225-45.084-45.084-45.084z" fill="#c20044"/><path d="m160.795 27.803h190.409v456.394h-190.409z" fill="#f0f9ff"/><path d="m256 27.803h95.205v456.394h-95.205z" fill="#cee5f5"/></svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 17.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 345.834 345.834" style="enable-background:new 0 0 345.834 345.834;" xml:space="preserve">
<g>
<path d="M339.798,260.429c0.13-0.026,0.257-0.061,0.385-0.094c0.109-0.028,0.219-0.051,0.326-0.084
c0.125-0.038,0.247-0.085,0.369-0.129c0.108-0.039,0.217-0.074,0.324-0.119c0.115-0.048,0.226-0.104,0.338-0.157
c0.109-0.052,0.22-0.1,0.327-0.158c0.107-0.057,0.208-0.122,0.312-0.184c0.107-0.064,0.215-0.124,0.319-0.194
c0.111-0.074,0.214-0.156,0.321-0.236c0.09-0.067,0.182-0.13,0.27-0.202c0.162-0.133,0.316-0.275,0.466-0.421
c0.027-0.026,0.056-0.048,0.083-0.075c0.028-0.028,0.052-0.059,0.079-0.088c0.144-0.148,0.284-0.3,0.416-0.46
c0.077-0.094,0.144-0.192,0.216-0.289c0.074-0.1,0.152-0.197,0.221-0.301c0.074-0.111,0.139-0.226,0.207-0.34
c0.057-0.096,0.118-0.19,0.171-0.289c0.062-0.115,0.114-0.234,0.169-0.351c0.049-0.104,0.101-0.207,0.146-0.314
c0.048-0.115,0.086-0.232,0.128-0.349c0.041-0.114,0.085-0.227,0.12-0.343c0.036-0.118,0.062-0.238,0.092-0.358
c0.029-0.118,0.063-0.234,0.086-0.353c0.028-0.141,0.045-0.283,0.065-0.425c0.014-0.1,0.033-0.199,0.043-0.3
c0.025-0.249,0.038-0.498,0.038-0.748V92.76c0-4.143-3.357-7.5-7.5-7.5h-236.25c-0.066,0-0.13,0.008-0.196,0.01
c-0.143,0.004-0.285,0.01-0.427,0.022c-0.113,0.009-0.225,0.022-0.337,0.037c-0.128,0.016-0.255,0.035-0.382,0.058
c-0.119,0.021-0.237,0.046-0.354,0.073c-0.119,0.028-0.238,0.058-0.356,0.092c-0.117,0.033-0.232,0.069-0.346,0.107
c-0.117,0.04-0.234,0.082-0.349,0.128c-0.109,0.043-0.216,0.087-0.322,0.135c-0.118,0.053-0.235,0.11-0.351,0.169
c-0.099,0.051-0.196,0.103-0.292,0.158c-0.116,0.066-0.23,0.136-0.343,0.208c-0.093,0.06-0.184,0.122-0.274,0.185
c-0.106,0.075-0.211,0.153-0.314,0.235c-0.094,0.075-0.186,0.152-0.277,0.231c-0.09,0.079-0.179,0.158-0.266,0.242
c-0.099,0.095-0.194,0.194-0.288,0.294c-0.047,0.05-0.097,0.094-0.142,0.145c-0.027,0.03-0.048,0.063-0.074,0.093
c-0.094,0.109-0.182,0.223-0.27,0.338c-0.064,0.084-0.13,0.168-0.19,0.254c-0.078,0.112-0.15,0.227-0.222,0.343
c-0.059,0.095-0.12,0.189-0.174,0.286c-0.063,0.112-0.118,0.227-0.175,0.342c-0.052,0.105-0.106,0.21-0.153,0.317
c-0.049,0.113-0.092,0.23-0.135,0.345c-0.043,0.113-0.087,0.225-0.124,0.339c-0.037,0.115-0.067,0.232-0.099,0.349
c-0.032,0.12-0.066,0.239-0.093,0.36c-0.025,0.113-0.042,0.228-0.062,0.342c-0.022,0.13-0.044,0.26-0.06,0.39
c-0.013,0.108-0.019,0.218-0.027,0.328c-0.01,0.14-0.019,0.28-0.021,0.421c-0.001,0.041-0.006,0.081-0.006,0.122v46.252
c0,4.143,3.357,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-29.595l66.681,59.037c-0.348,0.245-0.683,0.516-0.995,0.827l-65.687,65.687v-49.288
c0-4.143-3.357-7.5-7.5-7.5s-7.5,3.357-7.5,7.5v9.164h-38.75c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h38.75v43.231
c0,4.143,3.357,7.5,7.5,7.5h236.25c0.247,0,0.494-0.013,0.74-0.037c0.115-0.011,0.226-0.033,0.339-0.049
C339.542,260.469,339.67,260.454,339.798,260.429z M330.834,234.967l-65.688-65.687c-0.042-0.042-0.087-0.077-0.13-0.117
l49.383-41.897c3.158-2.68,3.546-7.412,0.866-10.571c-2.678-3.157-7.41-3.547-10.571-0.866l-84.381,71.59l-98.444-87.158h208.965
V234.967z M185.878,179.888c0.535-0.535,0.969-1.131,1.308-1.765l28.051,24.835c1.418,1.255,3.194,1.885,4.972,1.885
c1.726,0,3.451-0.593,4.853-1.781l28.587-24.254c0.26,0.38,0.553,0.743,0.89,1.08l65.687,65.687H120.191L185.878,179.888z"/>
<path d="M7.5,170.676h126.667c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H7.5c-4.143,0-7.5,3.357-7.5,7.5
S3.357,170.676,7.5,170.676z"/>
<path d="M20.625,129.345H77.5c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H20.625c-4.143,0-7.5,3.357-7.5,7.5
S16.482,129.345,20.625,129.345z"/>
<path d="M62.5,226.51h-55c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h55c4.143,0,7.5-3.357,7.5-7.5S66.643,226.51,62.5,226.51z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512.001 512.001" style="enable-background:new 0 0 512.001 512.001;" xml:space="preserve">
<g>
<g>
<path d="M468.683,287.265h-69.07c-4.147,0-7.508,3.361-7.508,7.508c0,4.147,3.361,7.508,7.508,7.508h69.07
c4.147,0,7.508-3.361,7.508-7.508C476.191,290.626,472.83,287.265,468.683,287.265z"/>
</g>
</g>
<g>
<g>
<path d="M105.012,268.377L85.781,256l19.231-12.376c3.487-2.243,4.495-6.888,2.251-10.376c-2.244-3.486-6.888-4.497-10.376-2.25
l-17.471,11.243v-20.776c0-4.147-3.361-7.508-7.508-7.508c-4.147,0-7.508,3.361-7.508,7.508v20.775l-17.47-11.243
c-3.486-2.245-8.132-1.238-10.376,2.25c-2.245,3.487-1.237,8.133,2.25,10.376L58.034,256l-19.231,12.376
c-3.487,2.244-4.495,6.889-2.25,10.376c1.435,2.23,3.852,3.446,6.32,3.446c1.391,0,2.799-0.386,4.056-1.196l17.47-11.243v20.775
c0,4.147,3.361,7.508,7.508,7.508c4.147,0,7.508-3.361,7.508-7.508V269.76l17.471,11.243c1.257,0.809,2.664,1.196,4.056,1.196
c2.467,0,4.885-1.216,6.32-3.446C109.507,275.266,108.499,270.62,105.012,268.377z"/>
</g>
</g>
<g>
<g>
<path d="M194.441,268.377L175.21,256l19.231-12.376c3.487-2.244,4.495-6.889,2.25-10.376c-2.245-3.486-6.888-4.497-10.376-2.25
l-17.47,11.243v-20.775c0-4.147-3.361-7.508-7.508-7.508c-4.147,0-7.508,3.361-7.508,7.508v20.776l-17.471-11.243
c-3.487-2.245-8.133-1.238-10.376,2.25c-2.245,3.487-1.237,8.133,2.25,10.376L147.463,256l-19.231,12.376
c-3.487,2.244-4.495,6.889-2.25,10.376c1.435,2.23,3.852,3.446,6.32,3.446c1.391,0,2.799-0.386,4.056-1.196l17.471-11.243v20.776
c0,4.147,3.361,7.508,7.508,7.508c4.147,0,7.508-3.361,7.508-7.508V269.76l17.47,11.243c1.257,0.809,2.664,1.196,4.056,1.196
c2.467,0,4.885-1.216,6.32-3.446C198.936,275.266,197.928,270.62,194.441,268.377z"/>
</g>
</g>
<g>
<g>
<path d="M283.871,268.377L264.64,256l19.231-12.376c3.487-2.243,4.495-6.888,2.251-10.376c-2.245-3.486-6.888-4.497-10.376-2.25
l-17.471,11.243v-20.775c0-4.147-3.361-7.508-7.508-7.508c-4.147,0-7.508,3.361-7.508,7.508v20.775l-17.471-11.243
c-3.486-2.245-8.134-1.238-10.376,2.25c-2.245,3.487-1.237,8.133,2.25,10.376L236.892,256l-19.231,12.376
c-3.487,2.244-4.495,6.889-2.25,10.376c1.435,2.23,3.852,3.446,6.32,3.446c1.391,0,2.799-0.386,4.056-1.196l17.471-11.243v20.775
c0,4.147,3.361,7.508,7.508,7.508c4.147,0,7.508-3.361,7.508-7.508V269.76l17.471,11.243c1.257,0.809,2.664,1.196,4.056,1.196
c2.467,0,4.886-1.216,6.32-3.446C288.366,275.266,287.358,270.62,283.871,268.377z"/>
</g>
</g>
<g>
<g>
<path d="M373.3,268.377L354.069,256l19.231-12.376c3.487-2.244,4.495-6.889,2.25-10.376c-2.244-3.486-6.888-4.497-10.376-2.25
l-17.471,11.243v-20.776c0-4.147-3.361-7.508-7.508-7.508c-4.147,0-7.508,3.361-7.508,7.508v20.775l-17.47-11.243
c-3.486-2.245-8.132-1.238-10.376,2.25c-2.245,3.487-1.237,8.133,2.25,10.376L326.322,256l-19.231,12.376
c-3.487,2.244-4.495,6.889-2.25,10.376c1.435,2.23,3.852,3.446,6.32,3.446c1.391,0,2.799-0.386,4.056-1.196l17.47-11.243v20.776
c0,4.147,3.361,7.508,7.508,7.508c4.147,0,7.508-3.361,7.508-7.508V269.76l17.471,11.243c1.257,0.809,2.664,1.196,4.056,1.196
c2.467,0,4.885-1.216,6.32-3.446C377.795,275.266,376.787,270.62,373.3,268.377z"/>
</g>
</g>
<g>
<g>
<path d="M271.792,330.359H15.016V181.642h93.1c4.147,0,7.508-3.361,7.508-7.508c0-4.147-3.361-7.508-7.508-7.508H12.513
C5.613,166.626,0,172.24,0,179.14v153.722c0,6.9,5.613,12.513,12.513,12.513h259.278c4.147,0,7.508-3.361,7.508-7.508
C279.299,333.72,275.939,330.359,271.792,330.359z"/>
</g>
</g>
<g>
<g>
<path d="M499.487,166.626H162.174c-4.147,0-7.508,3.361-7.508,7.508c0,4.147,3.361,7.508,7.508,7.508h334.811v148.716H323.848
c-4.147,0-7.508,3.361-7.508,7.508c0,4.147,3.361,7.508,7.508,7.508h175.64c6.9,0,12.513-5.613,12.513-12.513V179.14
C512.001,172.24,506.387,166.626,499.487,166.626z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

103
fittrackee/email/email.py Normal file
View File

@ -0,0 +1,103 @@
import logging
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from jinja2 import Environment, FileSystemLoader
from .utils_email import parse_email_url
email_log = logging.getLogger('fittrackee_api_email')
email_log.setLevel(logging.DEBUG)
class EmailMessage:
def __init__(self, sender, recipient, subject, html, text):
self.sender = sender
self.recipient = recipient
self.subject = subject
self.html = html
self.text = text
def generate_message(self):
message = MIMEMultipart('alternative')
message['Subject'] = self.subject
message['From'] = self.sender
message['To'] = self.recipient
part1 = MIMEText(self.text, 'plain')
part2 = MIMEText(self.html, 'html')
message.attach(part1)
message.attach(part2)
return message
class EmailTemplate:
def __init__(self, template_directory):
self._env = Environment(loader=FileSystemLoader(template_directory))
def get_content(self, template, lang, part, data):
template = self._env.get_template(f'{template}/{lang}/{part}')
return template.render(data)
def get_all_contents(self, template, lang, data):
output = {}
for part in ['subject.txt', 'body.txt', 'body.html']:
output[part] = self.get_content(template, lang, part, data)
return output
def get_message(self, template, lang, sender, recipient, data):
output = self.get_all_contents(template, lang, data)
message = EmailMessage(
sender,
recipient,
output['subject.txt'],
output['body.html'],
output['body.txt'],
)
return message.generate_message()
class Email:
def __init__(self, app=None):
self.host = 'localhost'
self.port = 1025
self.use_tls = False
self.use_ssl = False
self.username = None
self.password = None
self.sender_email = 'no-reply@example.com'
self.email_template = None
if app is not None:
self.init_email(app)
def init_email(self, app):
parsed_url = parse_email_url(app.config.get('EMAIL_URL'))
self.host = parsed_url['host']
self.port = parsed_url['port']
self.use_tls = parsed_url['use_tls']
self.use_ssl = parsed_url['use_ssl']
self.username = parsed_url['username']
self.password = parsed_url['password']
self.sender_email = app.config.get('SENDER_EMAIL')
self.email_template = EmailTemplate(app.config.get('TEMPLATES_FOLDER'))
@property
def smtp(self):
return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
def send(self, template, lang, recipient, data):
message = self.email_template.get_message(
template, lang, self.sender_email, recipient, data
)
connection_params = {}
if self.use_ssl or self.use_tls:
context = ssl.create_default_context()
if self.use_ssl:
connection_params.update({'context': context})
with self.smtp(self.host, self.port, **connection_params) as smtp:
smtp.login(self.username, self.password)
if self.use_tls:
smtp.starttls(context=context)
smtp.sendmail(self.sender_email, recipient, message.as_string())
smtp.quit()

View File

@ -0,0 +1,268 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Fittrackee - Password reset request</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Use this link to reset your password. The link is only valid for {{ expiration_delay }}.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi {{username}},</h1>
<p>You recently requested to reset your password for your account. Use the button below to reset it.
<strong>This password reset link is only valid for {{ expiration_delay }}.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{password_reset_url}}" class="f-fallback button button--green" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
For security, this request was received from a {{operating_system}} device using {{browser_name}}.
If you did not request a password reset, please ignore this email.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{password_reset_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,10 @@
Hi {{username}},
You recently requested to reset your password for your FitTrackee account. Use the button below to reset it. This password reset link is only valid for {{ expiration_delay }}.
Reset your password ( {{ password_reset_url }} )
For security, this request was received from a {{operating_system}} device using {{browser_name}}. If you did not request a password reset, please ignore this email.
Thanks,
The FitTrackee Team

View File

@ -0,0 +1 @@
FitTrackee - Password reset request

View File

@ -0,0 +1,269 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" content=""/>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>FitTrackee - Réinitialiser le mot de passe</title>
<style type="text/css" rel="stylesheet" media="all">
body {
background-color: #F4F4F7;
color: #51545E;
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
body,
td,
th {
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p {
color: #51545E;
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
color: #6B6E76;
font-size: 13px;
}
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
background-color: #F4F4F7;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
}
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead-name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
.email-body {
width: 100%;
margin: 0;
padding: 0;
background-color: #FFFFFF;
}
.email-body-inner {
width: 570px;
margin: 0 auto;
padding: 0;
background-color: #FFFFFF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
@media only screen and (max-width: 600px) {
.email-body-inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body-inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
h1 {
color: #FFF !important;
}
.email-masthead-name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<span class="preheader">Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant {{ expiration_delay }}.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour {{username}},</h1>
<p>Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le bouton ci-dessous pour le réinitialiser.
<strong>Cette réinitialisation n'est valide que pendant {{ expiration_delay }}.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{password_reset_url}}" class="f-fallback button button--green" target="_blank">Réinitialiser le mot de passe</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
Pour vérification, cette demande a été reçue à partir d'un appareil sous {{operating_system}}, utilisant le navigateur {{browser_name}}.
Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">{{password_reset_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,12 @@
Bonjour {{username}},
Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le lien ci-dessous pour le réinitialiser. Ce lien n'est valide que pendant {{ expiration_delay }}.
Réinitialiser le mot de passe: ( {{ password_reset_url }} )
Pour vérification, cette demande a été reçue à partir d'un appareil sous {{operating_system}}, utilisant le navigateur {{browser_name}}.
Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail.
Merci,
L'équipe FitTrackee

View File

@ -0,0 +1 @@
FitTrackee - Réinitialiser votre mot de passe

View File

@ -0,0 +1,20 @@
from urllib3.util import parse_url
class InvalidEmailUrlScheme(Exception):
...
def parse_email_url(email_url):
parsed_url = parse_url(email_url)
if parsed_url.scheme != 'smtp':
raise InvalidEmailUrlScheme()
credentials = parsed_url.auth.split(':')
return {
'host': parsed_url.host,
'port': parsed_url.port,
'use_tls': True if parsed_url.query == 'tls=True' else False,
'use_ssl': True if parsed_url.query == 'ssl=True' else False,
'username': credentials[0],
'password': credentials[1],
}

1
fittrackee/migrations/README Executable file
View File

@ -0,0 +1 @@
Generic single-database configuration.

View File

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

88
fittrackee/migrations/env.py Executable file
View File

@ -0,0 +1,88 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,44 @@
"""create User table
Revision ID: 9741fc7834da
Revises:
Create Date: 2018-01-20 18:59:49.200032
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9741fc7834da'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=20), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('admin', sa.Boolean(), nullable=False),
sa.Column('first_name', sa.String(length=80), nullable=True),
sa.Column('last_name', sa.String(length=80), nullable=True),
sa.Column('birth_date', sa.DateTime(), nullable=True),
sa.Column('location', sa.String(length=80), nullable=True),
sa.Column('bio', sa.String(length=200), nullable=True),
sa.Column('picture', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

View File

@ -0,0 +1,59 @@
"""create Activity & Sport tables
Revision ID: b7cfe0c17708
Revises: 9741fc7834da
Create Date: 2018-01-21 17:24:52.587814
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b7cfe0c17708'
down_revision = '9741fc7834da'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('label', sa.String(length=50), nullable=False),
sa.Column('img', sa.String(length=255), nullable=True),
sa.Column('is_default', sa.Boolean(), default=False, nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('label')
)
op.create_table('activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sport_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('gpx', sa.String(length=255), nullable=True),
sa.Column('creation_date', sa.DateTime(), nullable=True),
sa.Column('modification_date', sa.DateTime(), nullable=True),
sa.Column('activity_date', sa.DateTime(), nullable=False),
sa.Column('duration', sa.Interval(), nullable=False),
sa.Column('pauses', sa.Interval(), nullable=True),
sa.Column('moving', sa.Interval(), nullable=True),
sa.Column('distance', sa.Numeric(precision=6, scale=3), nullable=True),
sa.Column('min_alt', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('max_alt', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('descent', sa.Numeric(precision=7, scale=2), nullable=True),
sa.Column('ascent', sa.Numeric(precision=7, scale=2), nullable=True),
sa.Column('max_speed', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('ave_speed', sa.Numeric(precision=6, scale=2), nullable=True),
sa.ForeignKeyConstraint(['sport_id'], ['sports.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('activities')
op.drop_table('sports')
# ### end Alembic commands ###

View File

@ -0,0 +1,41 @@
"""create Record table
Revision ID: caf0e0dc621a
Revises: b7cfe0c17708
Create Date: 2018-05-11 22:33:08.267488
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'caf0e0dc621a'
down_revision = 'b7cfe0c17708'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('records',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sport_id', sa.Integer(), nullable=False),
sa.Column('activity_id', sa.Integer(), nullable=False),
sa.Column('record_type', sa.Enum('AS', 'FD', 'LD', 'MS', name='record_types'), nullable=True),
sa.Column('activity_date', sa.DateTime(), nullable=False),
sa.Column('value', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['activity_id'], ['activities.id'], ),
sa.ForeignKeyConstraint(['sport_id'], ['sports.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'sport_id', 'record_type', name='user_sports_records')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('records')
# ### end Alembic commands ###

View File

@ -0,0 +1,43 @@
"""create Activities Segments table
Revision ID: dd73d23a7a3d
Revises: caf0e0dc621a
Create Date: 2018-05-14 12:12:57.299200
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dd73d23a7a3d'
down_revision = 'caf0e0dc621a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('activity_segments',
sa.Column('activity_id', sa.Integer(), nullable=False),
sa.Column('segment_id', sa.Integer(), nullable=False),
sa.Column('duration', sa.Interval(), nullable=False),
sa.Column('pauses', sa.Interval(), nullable=True),
sa.Column('moving', sa.Interval(), nullable=True),
sa.Column('distance', sa.Numeric(precision=6, scale=3), nullable=True),
sa.Column('min_alt', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('max_alt', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('descent', sa.Numeric(precision=7, scale=2), nullable=True),
sa.Column('ascent', sa.Numeric(precision=7, scale=2), nullable=True),
sa.Column('max_speed', sa.Numeric(precision=6, scale=2), nullable=True),
sa.Column('ave_speed', sa.Numeric(precision=6, scale=2), nullable=True),
sa.ForeignKeyConstraint(['activity_id'], ['activities.id'], ),
sa.PrimaryKeyConstraint('activity_id', 'segment_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('activity_segments')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add 'bounds' column to 'Activity' table
Revision ID: 92adde6ac0d0
Revises: dd73d23a7a3d
Create Date: 2018-05-14 14:25:04.889189
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '92adde6ac0d0'
down_revision = 'dd73d23a7a3d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('bounds', postgresql.ARRAY(sa.Float()), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activities', 'bounds')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""add static map url to 'Activity' table
Revision ID: 5a42db64e872
Revises: 92adde6ac0d0
Create Date: 2018-05-30 10:52:33.433687
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5a42db64e872'
down_revision = '92adde6ac0d0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('map', sa.String(length=255), nullable=True))
op.create_unique_constraint(None, 'sports', ['img'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'sports', type_='unique')
op.drop_column('activities', 'map')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add static map id to 'Activity' table
Revision ID: 9f8c9c37da44
Revises: 5a42db64e872
Create Date: 2018-05-30 12:48:11.714627
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9f8c9c37da44'
down_revision = '5a42db64e872'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('map_id', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activities', 'map_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add 'timezone' to 'User' table
Revision ID: e82e5e9447de
Revises: 9f8c9c37da44
Create Date: 2018-06-08 16:01:52.684935
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e82e5e9447de'
down_revision = '9f8c9c37da44'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('timezone', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'timezone')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""add weather infos in 'Activity' table
Revision ID: 71093ac9ca44
Revises: e82e5e9447de
Create Date: 2018-06-13 15:29:11.715377
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '71093ac9ca44'
down_revision = 'e82e5e9447de'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('weather_end', sa.JSON(), nullable=True))
op.add_column('activities', sa.Column('weather_start', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activities', 'weather_start')
op.drop_column('activities', 'weather_end')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Add 'notes' column to 'Activity' table
Revision ID: 096dd0b43beb
Revises: 71093ac9ca44
Create Date: 2018-06-13 17:56:20.359884
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '096dd0b43beb'
down_revision = '71093ac9ca44'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('notes', sa.String(length=500), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activities', 'notes')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add 'weekm' in 'User' table
Revision ID: 27425324c9e3
Revises: 096dd0b43beb
Create Date: 2019-08-31 15:25:09.412426
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '27425324c9e3'
down_revision = '096dd0b43beb'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users', sa.Column('weekm', sa.Boolean(create_constraint=50), nullable=True))
op.execute("UPDATE users SET weekm = false")
op.alter_column('users', 'weekm', nullable=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'weekm')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add 'language' to 'User' table
Revision ID: f69f1e413bde
Revises: 27425324c9e3
Create Date: 2019-09-16 13:18:39.198777
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f69f1e413bde'
down_revision = '27425324c9e3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('language', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'language')
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""replace 'is_default' with 'is_active' in 'Sports' table
Revision ID: 1345afe3b11d
Revises: f69f1e413bde
Create Date: 2019-09-22 17:57:00.595775
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1345afe3b11d'
down_revision = 'f69f1e413bde'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('sports',
sa.Column('is_active',
sa.Boolean(create_constraint=50),
nullable=True))
op.execute("UPDATE sports SET is_active = true")
op.alter_column('sports', 'is_active', nullable=False)
op.drop_column('sports', 'is_default')
def downgrade():
op.add_column('sports',
sa.Column('is_default',
sa.Boolean(create_constraint=50),
nullable=True))
op.execute("UPDATE sports SET is_default = true")
op.alter_column('sports', 'is_default', nullable=False)
op.drop_column('sports', 'is_active')

View File

@ -0,0 +1,35 @@
"""add application config in database
Revision ID: 8a0aad4c838c
Revises: 1345afe3b11d
Create Date: 2019-11-13 13:14:20.147296
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a0aad4c838c'
down_revision = '1345afe3b11d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_config',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('max_users', sa.Integer(), nullable=False),
sa.Column('gpx_limit_import', sa.Integer(), nullable=False),
sa.Column('max_single_file_size', sa.Integer(), nullable=False),
sa.Column('max_zip_file_size', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('app_config')
# ### end Alembic commands ###

101
fittrackee/server.py Normal file
View File

@ -0,0 +1,101 @@
import shutil
from fittrackee import create_app, db
from fittrackee.activities.models import Activity, Sport
from fittrackee.activities.utils import update_activity
from fittrackee.application.utils import (
init_config,
update_app_config_from_database,
)
from fittrackee.users.models import User
from tqdm import tqdm
app = create_app()
@app.cli.command()
def drop_db():
"""Empty database for dev environments."""
db.engine.execute("DROP TABLE IF EXISTS alembic_version;")
db.drop_all()
db.session.commit()
print('Database dropped.')
shutil.rmtree(app.config['UPLOAD_FOLDER'], ignore_errors=True)
print('Uploaded files deleted.')
@app.cli.command()
def initdata():
"""Init the database."""
admin = User(
username='admin', email='admin@example.com', password='mpwoadmin'
)
admin.admin = True
admin.timezone = 'Europe/Paris'
db.session.add(admin)
sport = Sport(label='Cycling (Sport)')
sport.img = '/img/sports/cycling-sport.png'
sport.is_default = True
db.session.add(sport)
sport = Sport(label='Cycling (Transport)')
sport.img = '/img/sports/cycling-transport.png'
sport.is_default = True
db.session.add(sport)
sport = Sport(label='Hiking')
sport.img = '/img/sports/hiking.png'
sport.is_default = True
db.session.add(sport)
sport = Sport(label='Mountain Biking')
sport.img = '/img/sports/mountain-biking.png'
sport.is_default = True
db.session.add(sport)
sport = Sport(label='Running')
sport.img = '/img/sports/running.png'
sport.is_default = True
db.session.add(sport)
sport = Sport(label='Walking')
sport.img = '/img/sports/walking.png'
sport.is_default = True
db.session.add(sport)
db.session.commit()
# update app config
_, db_app_config = init_config()
update_app_config_from_database(app, db_app_config)
print('Initial data stored in database.')
@app.cli.command()
def recalculate():
print("Starting activities data refresh")
activities = (
Activity.query.filter(Activity.gpx != None) # noqa
.order_by(Activity.activity_date.asc()) # noqa
.all()
)
if len(activities) == 0:
print('➡️ no activities to upgrade.')
return None
pbar = tqdm(activities)
for activity in pbar:
update_activity(activity)
pbar.set_postfix(activitiy_id=activity.id)
db.session.commit()
@app.cli.command('init-app-config')
def init_app_config():
"""Init application configuration."""
print("Init application configuration")
config_created, _ = init_config()
if config_created:
print("Creation done!")
else:
print(
"Application configuration already existing in database. "
"Please use web application to update it."
)
if __name__ == '__main__':
app.run()

11
fittrackee/tasks.py Normal file
View File

@ -0,0 +1,11 @@
from fittrackee import dramatiq, email_service
@dramatiq.actor(queue_name='fittrackee_emails')
def reset_password_email(user, email_data):
email_service.send(
template='password_reset_request',
lang=user['language'],
recipient=user['email'],
data=email_data,
)

View File

View File

@ -0,0 +1,683 @@
import datetime
import os
import pytest
from fittrackee import create_app, db
from fittrackee.activities.models import Activity, ActivitySegment, Sport
from fittrackee.application.models import AppConfig
from fittrackee.application.utils import update_app_config_from_database
from fittrackee.users.models import User
os.environ['FLASK_ENV'] = 'testing'
os.environ['APP_SETTINGS'] = 'fittrackee.config.TestingConfig'
# to avoid resetting dev database during tests
os.environ['DATABASE_URL'] = os.getenv('DATABASE_TEST_URL')
def get_app_config(with_config=False):
if with_config:
config = AppConfig()
config.gpx_limit_import = 10
config.max_single_file_size = 1 * 1024 * 1024
config.max_zip_file_size = 1 * 1024 * 1024 * 10
config.max_users = 100
db.session.add(config)
db.session.commit()
return config
else:
return None
def get_app(with_config=False):
app = create_app()
with app.app_context():
db.create_all()
app_db_config = get_app_config(with_config)
if app_db_config:
update_app_config_from_database(app, app_db_config)
yield app
db.session.remove()
db.drop_all()
# close unused idle connections => avoid the following error:
# FATAL: remaining connection slots are reserved for non-replication
# superuser connections
db.engine.dispose()
return app
@pytest.fixture
def app(monkeypatch):
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
if os.getenv('TILE_SERVER_URL'):
monkeypatch.delenv('TILE_SERVER_URL')
if os.getenv('MAP_ATTRIBUTION'):
monkeypatch.delenv('MAP_ATTRIBUTION')
yield from get_app(with_config=True)
@pytest.fixture
def app_no_config():
yield from get_app(with_config=False)
@pytest.fixture
def app_ssl(monkeypatch):
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025?ssl=True')
yield from get_app(with_config=True)
@pytest.fixture
def app_tls(monkeypatch):
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025?tls=True')
yield from get_app(with_config=True)
@pytest.fixture()
def app_config():
config = AppConfig()
config.gpx_limit_import = 10
config.max_single_file_size = 1048576
config.max_zip_file_size = 10485760
config.max_users = 0
db.session.add(config)
db.session.commit()
return config
@pytest.fixture()
def user_1():
user = User(username='test', email='test@test.com', password='12345678')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_1_admin():
admin = User(
username='admin', email='admin@example.com', password='12345678'
)
admin.admin = True
db.session.add(admin)
db.session.commit()
return admin
@pytest.fixture()
def user_1_full():
user = User(username='test', email='test@test.com', password='12345678')
user.first_name = 'John'
user.last_name = 'Doe'
user.bio = 'just a random guy'
user.location = 'somewhere'
user.language = 'en'
user.timezone = 'America/New_York'
user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_1_paris():
user = User(username='test', email='test@test.com', password='12345678')
user.timezone = 'Europe/Paris'
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_2():
user = User(username='toto', email='toto@toto.com', password='87654321')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_2_admin():
user = User(username='toto', email='toto@toto.com', password='87654321')
user.admin = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def user_3():
user = User(username='sam', email='sam@test.com', password='12345678')
user.weekm = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture()
def sport_1_cycling():
sport = Sport(label='Cycling')
db.session.add(sport)
db.session.commit()
return sport
@pytest.fixture()
def sport_1_cycling_inactive():
sport = Sport(label='Cycling')
sport.is_active = False
db.session.add(sport)
db.session.commit()
return sport
@pytest.fixture()
def sport_2_running():
sport = Sport(label='Running')
db.session.add(sport)
db.session.commit()
return sport
@pytest.fixture()
def activity_cycling_user_1():
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('01/01/2018', '%d/%m/%Y'),
distance=10,
duration=datetime.timedelta(seconds=3600),
)
activity.max_speed = 10
activity.ave_speed = 10
activity.moving = activity.duration
db.session.add(activity)
db.session.commit()
return activity
@pytest.fixture()
def activity_cycling_user_1_segment():
activity_segment = ActivitySegment(activity_id=1, segment_id=0)
activity_segment.duration = datetime.timedelta(seconds=6000)
activity_segment.moving = activity_segment.duration
activity_segment.distance = 5
db.session.add(activity_segment)
db.session.commit()
return activity_segment
@pytest.fixture()
def activity_running_user_1():
activity = Activity(
user_id=1,
sport_id=2,
activity_date=datetime.datetime.strptime('01/04/2018', '%d/%m/%Y'),
distance=12,
duration=datetime.timedelta(seconds=6000),
)
activity.moving = activity.duration
db.session.add(activity)
db.session.commit()
return activity
@pytest.fixture()
def seven_activities_user_1():
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('20/03/2017', '%d/%m/%Y'),
distance=5,
duration=datetime.timedelta(seconds=1024),
)
activity.ave_speed = float(activity.distance) / (1024 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('01/06/2017', '%d/%m/%Y'),
distance=10,
duration=datetime.timedelta(seconds=3456),
)
activity.ave_speed = float(activity.distance) / (3456 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('01/01/2018', '%d/%m/%Y'),
distance=10,
duration=datetime.timedelta(seconds=1024),
)
activity.ave_speed = float(activity.distance) / (1024 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('23/02/2018', '%d/%m/%Y'),
distance=1,
duration=datetime.timedelta(seconds=600),
)
activity.ave_speed = float(activity.distance) / (600 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('23/02/2018', '%d/%m/%Y'),
distance=10,
duration=datetime.timedelta(seconds=1000),
)
activity.ave_speed = float(activity.distance) / (1000 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('01/04/2018', '%d/%m/%Y'),
distance=8,
duration=datetime.timedelta(seconds=6000),
)
activity.ave_speed = float(activity.distance) / (6000 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.flush()
activity = Activity(
user_id=1,
sport_id=1,
activity_date=datetime.datetime.strptime('09/05/2018', '%d/%m/%Y'),
distance=10,
duration=datetime.timedelta(seconds=3000),
)
activity.ave_speed = float(activity.distance) / (3000 / 3600)
activity.moving = activity.duration
db.session.add(activity)
db.session.commit()
return activity
@pytest.fixture()
def activity_cycling_user_2():
activity = Activity(
user_id=2,
sport_id=1,
activity_date=datetime.datetime.strptime('23/01/2018', '%d/%m/%Y'),
distance=15,
duration=datetime.timedelta(seconds=3600),
)
activity.moving = activity.duration
db.session.add(activity)
db.session.commit()
return activity
@pytest.fixture()
def gpx_file():
return (
'<?xml version=\'1.0\' encoding=\'UTF-8\'?>'
'<gpx xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxext="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns="http://www.topografix.com/GPX/1/1">' # noqa
' <metadata/>'
' <trk>'
' <name>just an activity</name>'
' <trkseg>'
' <trkpt lat="44.68095" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:45Z</time>'
' </trkpt>'
' <trkpt lat="44.68091" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:50Z</time>'
' </trkpt>'
' <trkpt lat="44.6808" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:00Z</time>'
' </trkpt>'
' <trkpt lat="44.68075" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:05Z</time>'
' </trkpt>'
' <trkpt lat="44.68071" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:10Z</time>'
' </trkpt>'
' <trkpt lat="44.68049" lon="6.07361">'
' <ele>993</ele>'
' <time>2018-03-13T12:45:30Z</time>'
' </trkpt>'
' <trkpt lat="44.68019" lon="6.07356">'
' <ele>992</ele>'
' <time>2018-03-13T12:45:55Z</time>'
' </trkpt>'
' <trkpt lat="44.68014" lon="6.07355">'
' <ele>992</ele>'
' <time>2018-03-13T12:46:00Z</time>'
' </trkpt>'
' <trkpt lat="44.67995" lon="6.07358">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:15Z</time>'
' </trkpt>'
' <trkpt lat="44.67977" lon="6.07364">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:30Z</time>'
' </trkpt>'
' <trkpt lat="44.67972" lon="6.07367">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67966" lon="6.07368">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67961" lon="6.0737">'
' <ele>986</ele>'
' <time>2018-03-13T12:46:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67938" lon="6.07377">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:05Z</time>'
' </trkpt>'
' <trkpt lat="44.67933" lon="6.07381">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:10Z</time>'
' </trkpt>'
' <trkpt lat="44.67922" lon="6.07385">'
' <ele>985</ele>'
' <time>2018-03-13T12:47:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67911" lon="6.0739">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:30Z</time>'
' </trkpt>'
' <trkpt lat="44.679" lon="6.07399">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67896" lon="6.07402">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67884" lon="6.07408">'
' <ele>979</ele>'
' <time>2018-03-13T12:47:55Z</time>'
' </trkpt>'
' <trkpt lat="44.67863" lon="6.07423">'
' <ele>981</ele>'
' <time>2018-03-13T12:48:15Z</time>'
' </trkpt>'
' <trkpt lat="44.67858" lon="6.07425">'
' <ele>980</ele>'
' <time>2018-03-13T12:48:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67842" lon="6.07434">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67837" lon="6.07435">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67822" lon="6.07442">'
' <ele>975</ele>'
' <time>2018-03-13T12:48:55Z</time>'
' </trkpt>'
' </trkseg>'
' </trk>'
'</gpx>'
)
@pytest.fixture()
def gpx_file_wo_name():
return (
'<?xml version=\'1.0\' encoding=\'UTF-8\'?>'
'<gpx xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxext="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns="http://www.topografix.com/GPX/1/1">' # noqa
' <metadata/>'
' <trk>'
' <trkseg>'
' <trkpt lat="44.68095" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:45Z</time>'
' </trkpt>'
' <trkpt lat="44.68091" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:50Z</time>'
' </trkpt>'
' <trkpt lat="44.6808" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:00Z</time>'
' </trkpt>'
' <trkpt lat="44.68075" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:05Z</time>'
' </trkpt>'
' <trkpt lat="44.68071" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:10Z</time>'
' </trkpt>'
' <trkpt lat="44.68049" lon="6.07361">'
' <ele>993</ele>'
' <time>2018-03-13T12:45:30Z</time>'
' </trkpt>'
' <trkpt lat="44.68019" lon="6.07356">'
' <ele>992</ele>'
' <time>2018-03-13T12:45:55Z</time>'
' </trkpt>'
' <trkpt lat="44.68014" lon="6.07355">'
' <ele>992</ele>'
' <time>2018-03-13T12:46:00Z</time>'
' </trkpt>'
' <trkpt lat="44.67995" lon="6.07358">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:15Z</time>'
' </trkpt>'
' <trkpt lat="44.67977" lon="6.07364">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:30Z</time>'
' </trkpt>'
' <trkpt lat="44.67972" lon="6.07367">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67966" lon="6.07368">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67961" lon="6.0737">'
' <ele>986</ele>'
' <time>2018-03-13T12:46:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67938" lon="6.07377">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:05Z</time>'
' </trkpt>'
' <trkpt lat="44.67933" lon="6.07381">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:10Z</time>'
' </trkpt>'
' <trkpt lat="44.67922" lon="6.07385">'
' <ele>985</ele>'
' <time>2018-03-13T12:47:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67911" lon="6.0739">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:30Z</time>'
' </trkpt>'
' <trkpt lat="44.679" lon="6.07399">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67896" lon="6.07402">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67884" lon="6.07408">'
' <ele>979</ele>'
' <time>2018-03-13T12:47:55Z</time>'
' </trkpt>'
' <trkpt lat="44.67863" lon="6.07423">'
' <ele>981</ele>'
' <time>2018-03-13T12:48:15Z</time>'
' </trkpt>'
' <trkpt lat="44.67858" lon="6.07425">'
' <ele>980</ele>'
' <time>2018-03-13T12:48:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67842" lon="6.07434">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67837" lon="6.07435">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67822" lon="6.07442">'
' <ele>975</ele>'
' <time>2018-03-13T12:48:55Z</time>'
' </trkpt>'
' </trkseg>'
' </trk>'
'</gpx>'
)
@pytest.fixture()
def gpx_file_wo_track():
return (
'<?xml version=\'1.0\' encoding=\'UTF-8\'?>'
'<gpx xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxext="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns="http://www.topografix.com/GPX/1/1">' # noqa
' <metadata/>'
'</gpx>'
)
@pytest.fixture()
def gpx_file_invalid_xml():
return (
'<?xml version=\'1.0\' encoding=\'UTF-8\'?>'
'<gpx xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxext="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns="http://www.topografix.com/GPX/1/1">' # noqa
' <metadata/>'
)
@pytest.fixture()
def gpx_file_with_segments():
return (
'<?xml version=\'1.0\' encoding=\'UTF-8\'?>'
'<gpx xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxext="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns="http://www.topografix.com/GPX/1/1">' # noqa
' <metadata/>'
' <trk>'
' <name>just an activity</name>'
' <trkseg>'
' <trkpt lat="44.68095" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:45Z</time>'
' </trkpt>'
' <trkpt lat="44.68091" lon="6.07367">'
' <ele>998</ele>'
' <time>2018-03-13T12:44:50Z</time>'
' </trkpt>'
' <trkpt lat="44.6808" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:00Z</time>'
' </trkpt>'
' <trkpt lat="44.68075" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:05Z</time>'
' </trkpt>'
' <trkpt lat="44.68071" lon="6.07364">'
' <ele>994</ele>'
' <time>2018-03-13T12:45:10Z</time>'
' </trkpt>'
' <trkpt lat="44.68049" lon="6.07361">'
' <ele>993</ele>'
' <time>2018-03-13T12:45:30Z</time>'
' </trkpt>'
' <trkpt lat="44.68019" lon="6.07356">'
' <ele>992</ele>'
' <time>2018-03-13T12:45:55Z</time>'
' </trkpt>'
' <trkpt lat="44.68014" lon="6.07355">'
' <ele>992</ele>'
' <time>2018-03-13T12:46:00Z</time>'
' </trkpt>'
' <trkpt lat="44.67995" lon="6.07358">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:15Z</time>'
' </trkpt>'
' </trkseg>'
' <trkseg>'
' <trkpt lat="44.67977" lon="6.07364">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:30Z</time>'
' </trkpt>'
' <trkpt lat="44.67972" lon="6.07367">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67966" lon="6.07368">'
' <ele>987</ele>'
' <time>2018-03-13T12:46:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67961" lon="6.0737">'
' <ele>986</ele>'
' <time>2018-03-13T12:46:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67938" lon="6.07377">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:05Z</time>'
' </trkpt>'
' <trkpt lat="44.67933" lon="6.07381">'
' <ele>986</ele>'
' <time>2018-03-13T12:47:10Z</time>'
' </trkpt>'
' <trkpt lat="44.67922" lon="6.07385">'
' <ele>985</ele>'
' <time>2018-03-13T12:47:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67911" lon="6.0739">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:30Z</time>'
' </trkpt>'
' <trkpt lat="44.679" lon="6.07399">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67896" lon="6.07402">'
' <ele>980</ele>'
' <time>2018-03-13T12:47:45Z</time>'
' </trkpt>'
' <trkpt lat="44.67884" lon="6.07408">'
' <ele>979</ele>'
' <time>2018-03-13T12:47:55Z</time>'
' </trkpt>'
' <trkpt lat="44.67863" lon="6.07423">'
' <ele>981</ele>'
' <time>2018-03-13T12:48:15Z</time>'
' </trkpt>'
' <trkpt lat="44.67858" lon="6.07425">'
' <ele>980</ele>'
' <time>2018-03-13T12:48:20Z</time>'
' </trkpt>'
' <trkpt lat="44.67842" lon="6.07434">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:35Z</time>'
' </trkpt>'
' <trkpt lat="44.67837" lon="6.07435">'
' <ele>979</ele>'
' <time>2018-03-13T12:48:40Z</time>'
' </trkpt>'
' <trkpt lat="44.67822" lon="6.07442">'
' <ele>975</ele>'
' <time>2018-03-13T12:48:55Z</time>'
' </trkpt>'
' </trkseg>'
' </trk>'
'</gpx>'
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,174 @@
# flake8: noqa
expected_en_text_body = """Hi test,
You recently requested to reset your password for your FitTrackee account. Use the button below to reset it. This password reset link is only valid for 3 seconds.
Reset your password ( http://localhost/password-reset?token=xxx )
For security, this request was received from a Linux device using Firefox. If you did not request a password reset, please ignore this email.
Thanks,
The FitTrackee Team"""
expected_fr_text_body = """Bonjour test,
Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le lien ci-dessous pour le réinitialiser. Ce lien n'est valide que pendant 3 secondes.
Réinitialiser le mot de passe: ( http://localhost/password-reset?token=xxx )
Pour vérification, cette demande a été reçue à partir d'un appareil sous Linux, utilisant le navigateur Firefox.
Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail.
Merci,
L'équipe FitTrackee"""
expected_en_html_body = """ <body>
<span class="preheader">Use this link to reset your password. The link is only valid for 3 seconds.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi test,</h1>
<p>You recently requested to reset your password for your account. Use the button below to reset it.
<strong>This password reset link is only valid for 3 seconds.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/password-reset?token=xxx" class="f-fallback button button--green" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
For security, this request was received from a Linux device using Firefox.
If you did not request a password reset, please ignore this email.
</p>
<p>Thanks,
<br>The FitTrackee Team</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">http://localhost/password-reset?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
expected_fr_html_body = """ <body>
<span class="preheader">Utiliser ce lien pour réinitialiser le mot de passe. Ce lien n'est valide que pendant 3 secondes.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="https://example.com" class="f-fallback email-masthead-name">
FitTrackee
</a>
</td>
</tr>
<tr>
<td class="email-body" width="100%" cellpadding="0" cellspacing="0">
<table class="email-body-inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Bonjour test,</h1>
<p>Vous avez récemment demander la réinitilisation du mot de passe de votre compte sur FitTrackee.
Cliquez sur le bouton ci-dessous pour le réinitialiser.
<strong>Cette réinitialisation n'est valide que pendant 3 secondes.</strong>
</p>
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="http://localhost/password-reset?token=xxx" class="f-fallback button button--green" target="_blank">Réinitialiser le mot de passe</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>
Pour vérification, cette demande a été reçue à partir d'un appareil sous Linux, utilisant le navigateur Firefox.
Si vous n'avez pas demandé de réinitalisation, vous pouvez ignorer cet e-mail.
</p>
<p>Merci,
<br>L'équipe FitTrackee</p>
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">Si vous avez des problèmes avec le bouton, vous pouvez copier et coller le lien suivant dans votre navigateur</p>
<p class="f-fallback sub">http://localhost/password-reset?token=xxx</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; FitTrackee.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""

View File

@ -0,0 +1,924 @@
import json
class TestGetActivities:
def test_it_gets_all_activities_for_authenticated_user(
self,
app,
user_1,
user_2,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
activity_cycling_user_2,
activity_running_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert 'creation_date' in data['data']['activities'][0]
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert 'test' == data['data']['activities'][0]['user']
assert 2 == data['data']['activities'][0]['sport_id']
assert 12.0 == data['data']['activities'][0]['distance']
assert '1:40:00' == data['data']['activities'][0]['duration']
assert 'creation_date' in data['data']['activities'][1]
assert (
'Mon, 01 Jan 2018 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
assert 'test' == data['data']['activities'][1]['user']
assert 1 == data['data']['activities'][1]['sport_id']
assert 10.0 == data['data']['activities'][1]['distance']
assert '1:00:00' == data['data']['activities'][1]['duration']
def test_it_gets_no_activities_for_authenticated_user_with_no_activities(
self,
app,
user_1,
user_2,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
activity_running_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='toto@toto.com', password='87654321')),
content_type='application/json',
)
response = client.get(
'/api/activities',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 0
def test_it_returns_401_if_user_is_not_authenticated(self, app):
client = app.test_client()
response = client.get('/api/activities')
data = json.loads(response.data.decode())
assert response.status_code == 401
assert 'error' in data['status']
assert 'Provide a valid auth token.' in data['message']
class TestGetActivitiesWithPagination:
def test_it_gets_activities_with_default_pagination(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 5
assert 'creation_date' in data['data']['activities'][0]
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert '0:50:00' == data['data']['activities'][0]['duration']
assert 'creation_date' in data['data']['activities'][4]
assert (
'Mon, 01 Jan 2018 00:00:00 GMT'
== data['data']['activities'][4]['activity_date']
)
assert '0:17:04' == data['data']['activities'][4]['duration']
def test_it_gets_first_page(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?page=1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 5
assert 'creation_date' in data['data']['activities'][0]
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert '0:50:00' == data['data']['activities'][0]['duration']
assert 'creation_date' in data['data']['activities'][4]
assert (
'Mon, 01 Jan 2018 00:00:00 GMT'
== data['data']['activities'][4]['activity_date']
)
assert '0:17:04' == data['data']['activities'][4]['duration']
def test_it_gets_second_page(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert 'creation_date' in data['data']['activities'][0]
assert (
'Thu, 01 Jun 2017 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert '0:57:36' == data['data']['activities'][0]['duration']
assert 'creation_date' in data['data']['activities'][1]
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
assert '0:17:04' == data['data']['activities'][1]['duration']
def test_it_gets_empty_third_page(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?page=3',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 0
def test_it_returns_error_on_invalid_page_value(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?page=A',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert (
'Error. Please try again or contact the administrator.'
in data['message']
)
def test_it_gets_5_activities_per_page(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?per_page=10',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 7
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][6]['activity_date']
)
def test_it_gets_3_activities_per_page(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?per_page=3',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 3
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Fri, 23 Feb 2018 00:00:00 GMT'
== data['data']['activities'][2]['activity_date']
)
class TestGetActivitiesWithFilters:
def test_it_gets_activities_with_date_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?from=2018-02-01&to=2018-02-28',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert 'creation_date' in data['data']['activities'][0]
assert (
'Fri, 23 Feb 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert '0:10:00' == data['data']['activities'][0]['duration']
assert 'creation_date' in data['data']['activities'][1]
assert (
'Fri, 23 Feb 2018 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
assert '0:16:40' == data['data']['activities'][1]['duration']
def test_it_gets_no_activities_with_date_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?from=2018-03-01&to=2018-03-30',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 0
def test_if_gets_activities_with_date_filter_from(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?from=2018-04-01',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert 'creation_date' in data['data']['activities'][0]
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
def test_it_gets_activities_with_date_filter_to(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?to=2017-12-31',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert (
'Thu, 01 Jun 2017 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
def test_it_gets_activities_with_ascending_order(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 5
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Fri, 23 Feb 2018 00:00:00 GMT'
== data['data']['activities'][4]['activity_date']
)
def test_it_gets_activities_with_distance_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?distance_from=5&distance_to=8',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
def test_it_gets_activities_with_duration_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?duration_from=00:52&duration_to=01:20',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert (
'Thu, 01 Jun 2017 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
def test_it_gets_activities_with_average_speed_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?ave_speed_from=5&ave_speed_to=10',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert (
'Fri, 23 Feb 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
def test_it_gets_activities_with_max_speed_filter(
self,
app,
user_1,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
activity_running_user_1,
):
activity_cycling_user_1.max_speed = 25
activity_running_user_1.max_speed = 11
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?max_speed_from=10&max_speed_to=20',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
def test_it_gets_activities_with_sport_filter(
self,
app,
user_1,
sport_1_cycling,
seven_activities_user_1,
sport_2_running,
activity_running_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?sport_id=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
class TestGetActivitiesWithFiltersAndPagination:
def test_it_gets_page_2_with_date_filter(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?from=2017-01-01&page=2',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert (
'Thu, 01 Jun 2017 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Mon, 20 Mar 2017 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
def test_it_get_page_2_with_date_filter_and_ascending_order(
self, app, user_1, sport_1_cycling, seven_activities_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities?from=2017-01-01&page=2&order=asc',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 2
assert (
'Sun, 01 Apr 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert (
'Wed, 09 May 2018 00:00:00 GMT'
== data['data']['activities'][1]['activity_date']
)
class TestGetActivity:
def test_it_gets_an_activity(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 'creation_date' in data['data']['activities'][0]
assert (
'Mon, 01 Jan 2018 00:00:00 GMT'
== data['data']['activities'][0]['activity_date']
)
assert 'test' == data['data']['activities'][0]['user']
assert 1 == data['data']['activities'][0]['sport_id']
assert 10.0 == data['data']['activities'][0]['distance']
assert '1:00:00' == data['data']['activities'][0]['duration']
def test_it_returns_403_if_activity_belongs_to_a_different_user(
self, app, user_1, user_2, sport_1_cycling, activity_cycling_user_2
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_returns_404_if_activity_does_not_exist(self, app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/11',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert len(data['data']['activities']) == 0
def test_it_returns_404_on_getting_gpx_if_activity_does_not_exist(
self, app, user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/11/gpx',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert 'Activity not found (id: 11)' in data['message']
assert data['data']['gpx'] == ''
def test_it_returns_404_on_getting_chart_data_if_activity_does_not_exist(
self, app, user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/11/chart_data',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert 'Activity not found (id: 11)' in data['message']
assert data['data']['chart_data'] == ''
def test_it_returns_404_on_getting_gpx_if_activity_have_no_gpx(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1/gpx',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'error' in data['status']
assert 'No gpx file for this activity (id: 1)' in data['message']
def test_it_returns_404_if_activity_have_no_chart_data(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1/chart_data',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'error' in data['status']
assert 'No gpx file for this activity (id: 1)' in data['message']
def test_it_returns_500_on_getting_gpx_if_an_activity_has_invalid_gpx_pathname( # noqa
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
activity_cycling_user_1.gpx = "some path"
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1/gpx',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'internal error' in data['message']
assert 'data' not in data
def test_it_returns_500_on_getting_chart_data_if_an_activity_has_invalid_gpx_pathname( # noqa
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
activity_cycling_user_1.gpx = "some path"
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/1/chart_data',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'internal error' in data['message']
assert 'data' not in data
def test_it_returns_404_if_activity_has_no_map(self, app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/activities/map/123',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'error' in data['status']
assert 'Map does not exist' in data['message']

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,768 @@
import json
from io import BytesIO
from fittrackee.activities.models import Activity
def assert_activity_data_with_gpx(data):
assert 'creation_date' in data['data']['activities'][0]
assert (
'Tue, 13 Mar 2018 12:44:45 GMT'
== data['data']['activities'][0]['activity_date']
)
assert 'test' == data['data']['activities'][0]['user']
assert '0:04:10' == data['data']['activities'][0]['duration']
assert data['data']['activities'][0]['ascent'] == 0.4
assert data['data']['activities'][0]['ave_speed'] == 4.61
assert data['data']['activities'][0]['descent'] == 23.4
assert data['data']['activities'][0]['distance'] == 0.32
assert data['data']['activities'][0]['max_alt'] == 998.0
assert data['data']['activities'][0]['max_speed'] == 5.12
assert data['data']['activities'][0]['min_alt'] == 975.0
assert data['data']['activities'][0]['moving'] == '0:04:10'
assert data['data']['activities'][0]['pauses'] is None
assert data['data']['activities'][0]['with_gpx'] is True
records = data['data']['activities'][0]['records']
assert len(records) == 4
assert records[0]['sport_id'] == 2
assert records[0]['activity_id'] == 1
assert records[0]['record_type'] == 'MS'
assert records[0]['activity_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT'
assert records[0]['value'] == 5.12
assert records[1]['sport_id'] == 2
assert records[1]['activity_id'] == 1
assert records[1]['record_type'] == 'LD'
assert records[1]['activity_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT'
assert records[1]['value'] == '0:04:10'
assert records[2]['sport_id'] == 2
assert records[2]['activity_id'] == 1
assert records[2]['record_type'] == 'FD'
assert records[2]['activity_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT'
assert records[2]['value'] == 0.32
assert records[3]['sport_id'] == 2
assert records[3]['activity_id'] == 1
assert records[3]['record_type'] == 'AS'
assert records[3]['activity_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT'
assert records[3]['value'] == 4.61
class TestEditActivityWithGpx:
def test_it_updates_title_for_an_activity_with_gpx(
self, app, user_1, sport_1_cycling, sport_2_running, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(sport_id=2, title="Activity test")),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 2 == data['data']['activities'][0]['sport_id']
assert data['data']['activities'][0]['title'] == 'Activity test'
assert_activity_data_with_gpx(data)
def test_it_adds_notes_for_an_activity_with_gpx(
self, app, user_1, sport_1_cycling, sport_2_running, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(notes="test notes")),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert data['data']['activities'][0]['title'] == 'just an activity'
assert data['data']['activities'][0]['notes'] == 'test notes'
def test_it_raises_403_when_editing_an_activity_from_different_user(
self, app, user_1, user_2, sport_1_cycling, sport_2_running, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='toto@toto.com', password='87654321')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(sport_id=2, title="Activity test")),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_updates_sport(
self, app, user_1, sport_1_cycling, sport_2_running, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(sport_id=2)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 2 == data['data']['activities'][0]['sport_id']
assert data['data']['activities'][0]['title'] == 'just an activity'
assert_activity_data_with_gpx(data)
def test_it_returns_400_if_payload_is_empty(
self, app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'error' in data['status']
assert 'Invalid payload.' in data['message']
def test_it_raises_500_if_sport_does_not_exists(
self, app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(sport_id=2)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert (
'Error. Please try again or contact the administrator.'
in data['message']
)
class TestEditActivityWithoutGpx:
def test_it_updates_an_activity_wo_gpx(
self,
app,
user_1,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(
dict(
sport_id=2,
duration=3600,
activity_date='2018-05-15 15:05',
distance=8,
title='Activity test',
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 'creation_date' in data['data']['activities'][0]
assert (
data['data']['activities'][0]['activity_date']
== 'Tue, 15 May 2018 15:05:00 GMT'
)
assert data['data']['activities'][0]['user'] == 'test'
assert data['data']['activities'][0]['sport_id'] == 2
assert data['data']['activities'][0]['duration'] == '1:00:00'
assert data['data']['activities'][0]['title'] == 'Activity test'
assert data['data']['activities'][0]['ascent'] is None
assert data['data']['activities'][0]['ave_speed'] == 8.0
assert data['data']['activities'][0]['descent'] is None
assert data['data']['activities'][0]['distance'] == 8.0
assert data['data']['activities'][0]['max_alt'] is None
assert data['data']['activities'][0]['max_speed'] == 8.0
assert data['data']['activities'][0]['min_alt'] is None
assert data['data']['activities'][0]['moving'] == '1:00:00'
assert data['data']['activities'][0]['pauses'] is None
assert data['data']['activities'][0]['with_gpx'] is False
assert data['data']['activities'][0]['map'] is None
assert data['data']['activities'][0]['weather_start'] is None
assert data['data']['activities'][0]['weather_end'] is None
assert data['data']['activities'][0]['notes'] is None
records = data['data']['activities'][0]['records']
assert len(records) == 4
assert records[0]['sport_id'] == 2
assert records[0]['activity_id'] == 1
assert records[0]['record_type'] == 'MS'
assert records[0]['activity_date'] == 'Tue, 15 May 2018 15:05:00 GMT'
assert records[0]['value'] == 8.0
assert records[1]['sport_id'] == 2
assert records[1]['activity_id'] == 1
assert records[1]['record_type'] == 'LD'
assert records[1]['activity_date'] == 'Tue, 15 May 2018 15:05:00 GMT'
assert records[1]['value'] == '1:00:00'
assert records[2]['sport_id'] == 2
assert records[2]['activity_id'] == 1
assert records[2]['record_type'] == 'FD'
assert records[2]['activity_date'] == 'Tue, 15 May 2018 15:05:00 GMT'
assert records[2]['value'] == 8.0
assert records[3]['sport_id'] == 2
assert records[3]['activity_id'] == 1
assert records[3]['record_type'] == 'AS'
assert records[3]['activity_date'] == 'Tue, 15 May 2018 15:05:00 GMT'
assert records[3]['value'] == 8.0
def test_it_adds_notes_to_an_activity_wo_gpx(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(notes='test notes')),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 'creation_date' in data['data']['activities'][0]
assert (
data['data']['activities'][0]['activity_date']
== 'Mon, 01 Jan 2018 00:00:00 GMT'
)
assert data['data']['activities'][0]['user'] == 'test'
assert data['data']['activities'][0]['sport_id'] == 1
assert data['data']['activities'][0]['duration'] == '1:00:00'
assert data['data']['activities'][0]['title'] is None
assert data['data']['activities'][0]['ascent'] is None
assert data['data']['activities'][0]['ave_speed'] == 10.0
assert data['data']['activities'][0]['descent'] is None
assert data['data']['activities'][0]['distance'] == 10.0
assert data['data']['activities'][0]['max_alt'] is None
assert data['data']['activities'][0]['max_speed'] == 10.0
assert data['data']['activities'][0]['min_alt'] is None
assert data['data']['activities'][0]['moving'] == '1:00:00'
assert data['data']['activities'][0]['pauses'] is None
assert data['data']['activities'][0]['with_gpx'] is False
assert data['data']['activities'][0]['map'] is None
assert data['data']['activities'][0]['weather_start'] is None
assert data['data']['activities'][0]['weather_end'] is None
assert data['data']['activities'][0]['notes'] == 'test notes'
records = data['data']['activities'][0]['records']
assert len(records) == 4
assert records[0]['sport_id'] == 1
assert records[0]['activity_id'] == 1
assert records[0]['record_type'] == 'MS'
assert records[0]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[0]['value'] == 10.0
assert records[1]['sport_id'] == 1
assert records[1]['activity_id'] == 1
assert records[1]['record_type'] == 'LD'
assert records[1]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[1]['value'] == '1:00:00'
assert records[2]['sport_id'] == 1
assert records[2]['activity_id'] == 1
assert records[2]['record_type'] == 'FD'
assert records[2]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[2]['value'] == 10.0
assert records[3]['sport_id'] == 1
assert records[3]['activity_id'] == 1
assert records[3]['record_type'] == 'AS'
assert records[3]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[3]['value'] == 10.0
def test_returns_403_when_editing_an_activity_wo_gpx_from_different_user(
self, app, user_1, user_2, sport_1_cycling, activity_cycling_user_2
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(
dict(
sport_id=2,
duration=3600,
activity_date='2018-05-15 15:05',
distance=8,
title='Activity test',
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_updates_an_activity_wo_gpx_with_timezone(
self,
app,
user_1_paris,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(
dict(
sport_id=2,
duration=3600,
activity_date='2018-05-15 15:05',
distance=8,
title='Activity test',
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 'creation_date' in data['data']['activities'][0]
assert (
data['data']['activities'][0]['activity_date']
== 'Tue, 15 May 2018 13:05:00 GMT'
)
assert data['data']['activities'][0]['user'] == 'test'
assert data['data']['activities'][0]['sport_id'] == 2
assert data['data']['activities'][0]['duration'] == '1:00:00'
assert data['data']['activities'][0]['title'] == 'Activity test'
assert data['data']['activities'][0]['ascent'] is None
assert data['data']['activities'][0]['ave_speed'] == 8.0
assert data['data']['activities'][0]['descent'] is None
assert data['data']['activities'][0]['distance'] == 8.0
assert data['data']['activities'][0]['max_alt'] is None
assert data['data']['activities'][0]['max_speed'] == 8.0
assert data['data']['activities'][0]['min_alt'] is None
assert data['data']['activities'][0]['moving'] == '1:00:00'
assert data['data']['activities'][0]['pauses'] is None
assert data['data']['activities'][0]['with_gpx'] is False
records = data['data']['activities'][0]['records']
assert len(records) == 4
assert records[0]['sport_id'] == 2
assert records[0]['activity_id'] == 1
assert records[0]['record_type'] == 'MS'
assert records[0]['activity_date'] == 'Tue, 15 May 2018 13:05:00 GMT'
assert records[0]['value'] == 8.0
assert records[1]['sport_id'] == 2
assert records[1]['activity_id'] == 1
assert records[1]['record_type'] == 'LD'
assert records[1]['activity_date'] == 'Tue, 15 May 2018 13:05:00 GMT'
assert records[1]['value'] == '1:00:00'
assert records[2]['sport_id'] == 2
assert records[2]['activity_id'] == 1
assert records[2]['record_type'] == 'FD'
assert records[2]['activity_date'] == 'Tue, 15 May 2018 13:05:00 GMT'
assert records[2]['value'] == 8.0
assert records[3]['sport_id'] == 2
assert records[3]['activity_id'] == 1
assert records[3]['record_type'] == 'AS'
assert records[3]['activity_date'] == 'Tue, 15 May 2018 13:05:00 GMT'
assert records[3]['value'] == 8.0
def test_it_updates_only_sport_and_distance_an_activity_wo_gpx(
self,
app,
user_1,
sport_1_cycling,
sport_2_running,
activity_cycling_user_1,
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(sport_id=2, distance=20)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 'creation_date' in data['data']['activities'][0]
assert (
data['data']['activities'][0]['activity_date']
== 'Mon, 01 Jan 2018 00:00:00 GMT'
)
assert data['data']['activities'][0]['user'] == 'test'
assert data['data']['activities'][0]['sport_id'] == 2
assert data['data']['activities'][0]['duration'] == '1:00:00'
assert data['data']['activities'][0]['title'] is None
assert data['data']['activities'][0]['ascent'] is None
assert data['data']['activities'][0]['ave_speed'] == 20.0
assert data['data']['activities'][0]['descent'] is None
assert data['data']['activities'][0]['distance'] == 20.0
assert data['data']['activities'][0]['max_alt'] is None
assert data['data']['activities'][0]['max_speed'] == 20.0
assert data['data']['activities'][0]['min_alt'] is None
assert data['data']['activities'][0]['moving'] == '1:00:00'
assert data['data']['activities'][0]['pauses'] is None
assert data['data']['activities'][0]['with_gpx'] is False
records = data['data']['activities'][0]['records']
assert len(records) == 4
assert records[0]['sport_id'] == 2
assert records[0]['activity_id'] == 1
assert records[0]['record_type'] == 'MS'
assert records[0]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[0]['value'] == 20.0
assert records[1]['sport_id'] == 2
assert records[1]['activity_id'] == 1
assert records[1]['record_type'] == 'LD'
assert records[1]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[1]['value'] == '1:00:00'
assert records[2]['sport_id'] == 2
assert records[2]['activity_id'] == 1
assert records[2]['record_type'] == 'FD'
assert records[2]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[2]['value'] == 20.0
assert records[3]['sport_id'] == 2
assert records[3]['activity_id'] == 1
assert records[3]['record_type'] == 'AS'
assert records[3]['activity_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT'
assert records[3]['value'] == 20.0
def test_it_returns_400_if_payload_is_empty(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'error' in data['status']
assert 'Invalid payload.' in data['message']
def test_it_returns_500_if_date_format_is_invalid(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(
dict(
sport_id=1,
duration=3600,
activity_date='15/2018',
distance=10,
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert (
'Error. Please try again or contact the administrator.'
in data['message']
)
def test_it_returns_404_if_edited_activity_doens_not_exists(
self, app, user_1, sport_1_cycling
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(
dict(
sport_id=1,
duration=3600,
activity_date='2018-05-15 14:05',
distance=10,
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
assert len(data['data']['activities']) == 0
class TestRefreshActivityWithGpx:
def test_refresh_an_activity_with_gpx(
self, app, user_1, sport_1_cycling, sport_2_running, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
# Edit some activity data
activity = Activity.query.filter_by(id=1).first()
activity.ascent = 1000
activity.min_alt = -100
response = client.patch(
'/api/activities/1',
content_type='application/json',
data=json.dumps(dict(refresh=True)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['activities']) == 1
assert 1 == data['data']['activities'][0]['sport_id']
assert 0.4 == data['data']['activities'][0]['ascent']
assert 975.0 == data['data']['activities'][0]['min_alt']

View File

@ -0,0 +1,188 @@
import json
import os
from io import BytesIO
from fittrackee.activities.models import Activity
from fittrackee.activities.utils import get_absolute_file_path
def get_gpx_filepath(activity_id):
activity = Activity.query.filter_by(id=activity_id).first()
return activity.gpx
class TestDeleteActivityWithGpx:
def test_it_deletes_an_activity_with_gpx(
self, app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
response = client.delete(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_it_returns_403_when_deleting_an_activity_from_different_user(
self, app, user_1, user_2, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='toto@toto.com', password='87654321')),
content_type='application/json',
)
response = client.delete(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_returns_404_if_activity_does_not_exist(self, app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/activities/9999',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
def test_it_returns_500_when_deleting_an_activity_with_gpx_invalid_file(
self, app, user_1, sport_1_cycling, gpx_file
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
client.post(
'/api/activities',
data=dict(
file=(BytesIO(str.encode(gpx_file)), 'example.gpx'),
data='{"sport_id": 1}',
),
headers=dict(
content_type='multipart/form-data',
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token'],
),
)
gpx_filepath = get_gpx_filepath(1)
gpx_filepath = get_absolute_file_path(gpx_filepath)
os.remove(gpx_filepath)
response = client.delete(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert (
'Error. Please try again or contact the administrator.'
in data['message']
)
class TestDeleteActivityWithoutGpx:
def test_it_deletes_an_activity_wo_gpx(
self, app, user_1, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.delete(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
assert response.status_code == 204
def test_it_returns_403_when_deleting_an_activity_from_different_user(
self, app, user_1, user_2, sport_1_cycling, activity_cycling_user_1
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='toto@toto.com', password='87654321')),
content_type='application/json',
)
response = client.delete(
'/api/activities/1',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']

View File

@ -0,0 +1,60 @@
class TestActivityModel:
def test_activity_model(
self, app, sport_1_cycling, user_1, activity_cycling_user_1
):
activity_cycling_user_1.title = 'Test'
assert 1 == activity_cycling_user_1.id
assert 1 == activity_cycling_user_1.user_id
assert 1 == activity_cycling_user_1.sport_id
assert '2018-01-01 00:00:00' == str(
activity_cycling_user_1.activity_date
)
assert 10.0 == float(activity_cycling_user_1.distance)
assert '1:00:00' == str(activity_cycling_user_1.duration)
assert 'Test' == activity_cycling_user_1.title
assert '<Activity \'Cycling\' - 2018-01-01 00:00:00>' == str(
activity_cycling_user_1
)
serialized_activity = activity_cycling_user_1.serialize()
assert 1 == serialized_activity['id']
assert 'test' == serialized_activity['user']
assert 1 == serialized_activity['sport_id']
assert serialized_activity['title'] == 'Test'
assert 'creation_date' in serialized_activity
assert serialized_activity['modification_date'] is not None
assert (
str(serialized_activity['activity_date']) == '2018-01-01 00:00:00'
)
assert serialized_activity['duration'] == '1:00:00'
assert serialized_activity['pauses'] is None
assert serialized_activity['moving'] == '1:00:00'
assert serialized_activity['distance'] == 10.0
assert serialized_activity['max_alt'] is None
assert serialized_activity['descent'] is None
assert serialized_activity['ascent'] is None
assert serialized_activity['max_speed'] == 10.0
assert serialized_activity['ave_speed'] == 10.0
assert serialized_activity['with_gpx'] is False
assert serialized_activity['bounds'] == []
assert serialized_activity['previous_activity'] is None
assert serialized_activity['next_activity'] is None
assert serialized_activity['segments'] == []
assert serialized_activity['records'] != []
assert serialized_activity['map'] is None
assert serialized_activity['weather_start'] is None
assert serialized_activity['weather_end'] is None
assert serialized_activity['notes'] is None
def test_activity_segment_model(
self,
app,
sport_1_cycling,
user_1,
activity_cycling_user_1,
activity_cycling_user_1_segment,
):
assert '<Segment \'0\' for activity \'1\'>' == str(
activity_cycling_user_1_segment
)

View File

@ -0,0 +1,227 @@
import json
class TestGetConfig:
def test_it_gets_application_config(self, app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/config',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 10
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 1048576
assert data['data']['max_zip_file_size'] == 10485760
assert data['data']['max_users'] == 100
assert data['data']['map_attribution'] == (
'&copy; <a href="http://www.openstreetmap.org/copyright" '
'target="_blank" rel="noopener noreferrer">OpenStreetMap</a> '
'contributors'
)
def test_it_returns_error_if_application_has_no_config(
self, app_no_config, user_1_admin
):
client = app_no_config.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.get(
'/api/config',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on getting configuration.' in data['message']
def test_it_returns_error_if_application_has_several_config(
self, app, app_config, user_1_admin
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.get(
'/api/config',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on getting configuration.' in data['message']
class TestUpdateConfig:
def test_it_updates_config_when_user_is_admin(self, app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(gpx_limit_import=100, max_users=10)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 100
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 1048576
assert data['data']['max_zip_file_size'] == 10485760
assert data['data']['max_users'] == 10
def test_it_updates_all_config(self, app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(
dict(
gpx_limit_import=20,
max_single_file_size=10000,
max_zip_file_size=25000,
max_users=50,
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 20
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 10000
assert data['data']['max_zip_file_size'] == 25000
assert data['data']['max_users'] == 50
def test_it_returns_403_when_user_is_not_an_admin(self, app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(gpx_limit_import=100, max_users=10)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'success' not in data['status']
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_it_returns_400_if_invalid_is_payload(self, app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'error' in data['status']
assert 'Invalid payload.' in data['message']
def test_it_returns_error_on_update_if_application_has_no_config(
self, app_no_config, user_1_admin
):
client = app_no_config.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(
dict(email='admin@example.com', password='12345678')
),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(gpx_limit_import=100, max_users=10)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on updating configuration.' in data['message']

View File

@ -0,0 +1,19 @@
from fittrackee.application.models import AppConfig
class TestConfigModel:
def test_application_config(self, app):
app_config = AppConfig.query.first()
assert 1 == app_config.id
serialized_app_config = app_config.serialize()
assert serialized_app_config['gpx_limit_import'] == 10
assert serialized_app_config['is_registration_enabled'] is True
assert serialized_app_config['max_single_file_size'] == 1048576
assert serialized_app_config['max_zip_file_size'] == 10485760
assert serialized_app_config['max_users'] == 100
assert serialized_app_config['map_attribution'] == (
'&copy; <a href="http://www.openstreetmap.org/copyright" '
'target="_blank" rel="noopener noreferrer">OpenStreetMap</a> '
'contributors'
)

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More