API & Client: add weather to Activities - fix #8
@ -1,4 +1,5 @@
|
|||||||
export REACT_APP_THUNDERFOREST_API_KEY=
|
export REACT_APP_THUNDERFOREST_API_KEY=
|
||||||
|
export WEATHER_API=
|
||||||
|
|
||||||
# for dev env
|
# for dev env
|
||||||
export CODACY_PROJECT_TOKEN=
|
export CODACY_PROJECT_TOKEN=
|
||||||
|
@ -6,7 +6,7 @@ from sqlalchemy.dialects import postgresql
|
|||||||
from sqlalchemy.event import listens_for
|
from sqlalchemy.event import listens_for
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from sqlalchemy.orm.session import object_session
|
from sqlalchemy.orm.session import object_session
|
||||||
from sqlalchemy.types import Enum
|
from sqlalchemy.types import JSON, Enum
|
||||||
|
|
||||||
from .utils_format import convert_in_duration, convert_value_to_integer
|
from .utils_format import convert_in_duration, convert_value_to_integer
|
||||||
|
|
||||||
@ -121,6 +121,8 @@ class Activity(db.Model):
|
|||||||
bounds = db.Column(postgresql.ARRAY(db.Float), nullable=True)
|
bounds = db.Column(postgresql.ARRAY(db.Float), nullable=True)
|
||||||
map = db.Column(db.String(255), nullable=True)
|
map = db.Column(db.String(255), nullable=True)
|
||||||
map_id = db.Column(db.String(50), 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)
|
||||||
segments = db.relationship('ActivitySegment',
|
segments = db.relationship('ActivitySegment',
|
||||||
lazy=True,
|
lazy=True,
|
||||||
cascade='all, delete',
|
cascade='all, delete',
|
||||||
@ -234,7 +236,9 @@ class Activity(db.Model):
|
|||||||
"next_activity": next_activity.id if next_activity else None,
|
"next_activity": next_activity.id if next_activity else None,
|
||||||
"segments": [segment.serialize() for segment in self.segments],
|
"segments": [segment.serialize() for segment in self.segments],
|
||||||
"records": [record.serialize() for record in self.records],
|
"records": [record.serialize() for record in self.records],
|
||||||
"map": self.map_id if self.map else None
|
"map": self.map_id if self.map else None,
|
||||||
|
"weather_start": self.weather_start,
|
||||||
|
"weather_end": self.weather_end
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -14,6 +14,7 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
from ..users.models import User
|
from ..users.models import User
|
||||||
from .models import Activity, ActivitySegment, Sport
|
from .models import Activity, ActivitySegment, Sport
|
||||||
|
from .utils_weather import get_weather
|
||||||
|
|
||||||
|
|
||||||
class ActivityException(Exception):
|
class ActivityException(Exception):
|
||||||
@ -183,12 +184,17 @@ def get_gpx_info(gpx_file):
|
|||||||
max_speed = 0
|
max_speed = 0
|
||||||
start = 0
|
start = 0
|
||||||
map_data = []
|
map_data = []
|
||||||
|
weather_data = []
|
||||||
|
|
||||||
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
|
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
|
||||||
segment_start = 0
|
segment_start = 0
|
||||||
for point_idx, point in enumerate(segment.points):
|
for point_idx, point in enumerate(segment.points):
|
||||||
if point_idx == 0 and start == 0:
|
if point_idx == 0 and start == 0:
|
||||||
start = point.time
|
start = point.time
|
||||||
|
weather_data.append(get_weather(point))
|
||||||
|
if (point_idx == (len(segment.points) - 1) and
|
||||||
|
segment_idx == (len(gpx.tracks[0].segments) - 1)):
|
||||||
|
weather_data.append(get_weather(point))
|
||||||
map_data.append([
|
map_data.append([
|
||||||
point.longitude, point.latitude
|
point.longitude, point.latitude
|
||||||
])
|
])
|
||||||
@ -215,7 +221,7 @@ def get_gpx_info(gpx_file):
|
|||||||
bounds.max_longitude
|
bounds.max_longitude
|
||||||
]
|
]
|
||||||
|
|
||||||
return gpx_data, map_data
|
return gpx_data, map_data, weather_data
|
||||||
|
|
||||||
|
|
||||||
def get_chart_data(gpx_file):
|
def get_chart_data(gpx_file):
|
||||||
@ -305,7 +311,7 @@ def get_map_hash(map_filepath):
|
|||||||
|
|
||||||
def process_one_gpx_file(params, filename):
|
def process_one_gpx_file(params, filename):
|
||||||
try:
|
try:
|
||||||
gpx_data, map_data = get_gpx_info(params['file_path'])
|
gpx_data, map_data, weather_data = get_gpx_info(params['file_path'])
|
||||||
auth_user_id = params['user'].id
|
auth_user_id = params['user'].id
|
||||||
new_filepath = get_new_file_path(
|
new_filepath = get_new_file_path(
|
||||||
auth_user_id=auth_user_id,
|
auth_user_id=auth_user_id,
|
||||||
@ -333,6 +339,8 @@ def process_one_gpx_file(params, filename):
|
|||||||
params['user'], params['activity_data'], gpx_data)
|
params['user'], params['activity_data'], gpx_data)
|
||||||
new_activity.map = map_filepath
|
new_activity.map = map_filepath
|
||||||
new_activity.map_id = get_map_hash(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.add(new_activity)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
32
fittrackee_api/fittrackee_api/activities/utils_weather.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import forecastio
|
||||||
|
import pytz
|
||||||
|
from fittrackee_api 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
|
@ -14,6 +14,33 @@ def test_add_activity(
|
|||||||
assert 'Test' == activity_cycling_user_1.title
|
assert 'Test' == activity_cycling_user_1.title
|
||||||
assert '<Activity \'Cycling\' - 2018-01-01 00:00:00>' == str(activity_cycling_user_1) # noqa
|
assert '<Activity \'Cycling\' - 2018-01-01 00:00:00>' == str(activity_cycling_user_1) # noqa
|
||||||
|
|
||||||
|
serialized_activity = activity_cycling_user_1.serialize()
|
||||||
|
assert 1 == serialized_activity['id']
|
||||||
|
assert 1 == serialized_activity['user_id']
|
||||||
|
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'] == '0:17:04'
|
||||||
|
assert serialized_activity['pauses'] is None
|
||||||
|
assert serialized_activity['moving'] == '0:17:04'
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_add_segment(
|
def test_add_segment(
|
||||||
app, sport_1_cycling, user_1, activity_cycling_user_1,
|
app, sport_1_cycling, user_1, activity_cycling_user_1,
|
||||||
|
30
fittrackee_api/migrations/versions/71093ac9ca44_.py
Normal 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 ###
|
@ -8,6 +8,7 @@ cffi==1.11.5
|
|||||||
chardet==3.0.4
|
chardet==3.0.4
|
||||||
click==6.7
|
click==6.7
|
||||||
codacy-coverage==1.3.11
|
codacy-coverage==1.3.11
|
||||||
|
cookies==2.2.1
|
||||||
coverage==4.5.1
|
coverage==4.5.1
|
||||||
execnet==1.5.0
|
execnet==1.5.0
|
||||||
flake8==3.5.0
|
flake8==3.5.0
|
||||||
@ -43,8 +44,10 @@ pytest-isort==0.2.0
|
|||||||
pytest-runner==4.2
|
pytest-runner==4.2
|
||||||
python-dateutil==2.7.3
|
python-dateutil==2.7.3
|
||||||
python-editor==1.0.3
|
python-editor==1.0.3
|
||||||
|
python-forecastio==1.4.0
|
||||||
pytz==2018.4
|
pytz==2018.4
|
||||||
requests==2.18.4
|
requests==2.18.4
|
||||||
|
responses==0.9.0
|
||||||
six==1.11.0
|
six==1.11.0
|
||||||
SQLAlchemy==1.2.8
|
SQLAlchemy==1.2.8
|
||||||
staticmap==0.5.3
|
staticmap==0.5.3
|
||||||
|
BIN
fittrackee_client/public/img/weather/breeze.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
fittrackee_client/public/img/weather/clear-day.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
fittrackee_client/public/img/weather/clear-night.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
fittrackee_client/public/img/weather/cloudy.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
fittrackee_client/public/img/weather/fog.png
Normal file
After Width: | Height: | Size: 1012 B |
BIN
fittrackee_client/public/img/weather/partly-cloudy-day.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
fittrackee_client/public/img/weather/partly-cloudy-night.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
fittrackee_client/public/img/weather/pour-rain.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
fittrackee_client/public/img/weather/rain.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
fittrackee_client/public/img/weather/sleet.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
fittrackee_client/public/img/weather/snow.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
fittrackee_client/public/img/weather/temperature.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
fittrackee_client/public/img/weather/wind.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
@ -1,11 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
import ActivityWeather from './ActivityWeather'
|
||||||
|
|
||||||
export default function ActivityDetails(props) {
|
export default function ActivityDetails(props) {
|
||||||
const { activity } = props
|
const { activity } = props
|
||||||
const withPauses = activity.pauses !== '0:00:00' && activity.pauses !== null
|
const withPauses = activity.pauses !== '0:00:00' && activity.pauses !== null
|
||||||
const recordLDexists = activity.records.find(r => r.record_type === 'LD')
|
const recordLDexists = activity.records.find(r => r.record_type === 'LD')
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="activity-details">
|
||||||
<p>
|
<p>
|
||||||
<i
|
<i
|
||||||
className="fa fa-clock-o custom-fa"
|
className="fa fa-clock-o custom-fa"
|
||||||
@ -88,6 +90,7 @@ export default function ActivityDetails(props) {
|
|||||||
Descent: {activity.descent}m
|
Descent: {activity.descent}m
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<ActivityWeather activity={activity} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ActivityWeather(props) {
|
||||||
|
const { activity } = props
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
{activity.weather_start && activity.weather_end && (
|
||||||
|
<table
|
||||||
|
className="table table-borderless weather-table text-center"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>
|
||||||
|
Start
|
||||||
|
<br />
|
||||||
|
<img
|
||||||
|
className="weather-img"
|
||||||
|
src={`/img/weather/${activity.weather_start.icon}.png`}
|
||||||
|
alt={`activity weather (${activity.weather_start.icon})`}
|
||||||
|
title={activity.weather_start.summary}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
End
|
||||||
|
<br />
|
||||||
|
<img
|
||||||
|
className="weather-img"
|
||||||
|
src={`/img/weather/${activity.weather_end.icon}.png`}
|
||||||
|
alt={`activity weather (${activity.weather_end.icon})`}
|
||||||
|
title={activity.weather_end.summary}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
className="weather-img-small"
|
||||||
|
src="/img/weather/temperature.png"
|
||||||
|
alt="Temperatures"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_start.temperature}°C
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_end.temperature}°C
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
className="weather-img-small"
|
||||||
|
src="/img/weather/pour-rain.png"
|
||||||
|
alt="Temperatures"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_start.humidity * 100}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_end.humidity * 100}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
className="weather-img-small"
|
||||||
|
src="/img/weather/breeze.png"
|
||||||
|
alt="Temperatures"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_start.wind}m/s
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{activity.weather_end.wind}m/s
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -67,6 +67,9 @@ input, textarea {
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-details {
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
.activity-date {
|
.activity-date {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
@ -225,6 +228,25 @@ input, textarea {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weather-img {
|
||||||
|
max-width: 35px;
|
||||||
|
max-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-img-small {
|
||||||
|
max-width: 20px;
|
||||||
|
max-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-table table, .weather-table th, .weather-table td{
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
animation: spin 2s linear infinite;
|
animation: spin 2s linear infinite;
|
||||||
border: 16px solid #f3f3f3;
|
border: 16px solid #f3f3f3;
|
||||||
|