API & Client: add weather to Activities - fix #8
@ -1,4 +1,5 @@
|
||||
export REACT_APP_THUNDERFOREST_API_KEY=
|
||||
export WEATHER_API=
|
||||
|
||||
# for dev env
|
||||
export CODACY_PROJECT_TOKEN=
|
||||
|
@ -6,7 +6,7 @@ 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 Enum
|
||||
from sqlalchemy.types import JSON, Enum
|
||||
|
||||
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)
|
||||
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)
|
||||
segments = db.relationship('ActivitySegment',
|
||||
lazy=True,
|
||||
cascade='all, delete',
|
||||
@ -234,7 +236,9 @@ class Activity(db.Model):
|
||||
"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
|
||||
"map": self.map_id if self.map else None,
|
||||
"weather_start": self.weather_start,
|
||||
"weather_end": self.weather_end
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
@ -14,6 +14,7 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
from ..users.models import User
|
||||
from .models import Activity, ActivitySegment, Sport
|
||||
from .utils_weather import get_weather
|
||||
|
||||
|
||||
class ActivityException(Exception):
|
||||
@ -183,12 +184,17 @@ def get_gpx_info(gpx_file):
|
||||
max_speed = 0
|
||||
start = 0
|
||||
map_data = []
|
||||
weather_data = []
|
||||
|
||||
for segment_idx, segment in enumerate(gpx.tracks[0].segments):
|
||||
segment_start = 0
|
||||
for point_idx, point in enumerate(segment.points):
|
||||
if point_idx == 0 and start == 0:
|
||||
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([
|
||||
point.longitude, point.latitude
|
||||
])
|
||||
@ -215,7 +221,7 @@ def get_gpx_info(gpx_file):
|
||||
bounds.max_longitude
|
||||
]
|
||||
|
||||
return gpx_data, map_data
|
||||
return gpx_data, map_data, weather_data
|
||||
|
||||
|
||||
def get_chart_data(gpx_file):
|
||||
@ -305,7 +311,7 @@ def get_map_hash(map_filepath):
|
||||
|
||||
def process_one_gpx_file(params, filename):
|
||||
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
|
||||
new_filepath = get_new_file_path(
|
||||
auth_user_id=auth_user_id,
|
||||
@ -333,6 +339,8 @@ def process_one_gpx_file(params, filename):
|
||||
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()
|
||||
|
||||
|
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 '<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(
|
||||
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
|
||||
click==6.7
|
||||
codacy-coverage==1.3.11
|
||||
cookies==2.2.1
|
||||
coverage==4.5.1
|
||||
execnet==1.5.0
|
||||
flake8==3.5.0
|
||||
@ -43,8 +44,10 @@ pytest-isort==0.2.0
|
||||
pytest-runner==4.2
|
||||
python-dateutil==2.7.3
|
||||
python-editor==1.0.3
|
||||
python-forecastio==1.4.0
|
||||
pytz==2018.4
|
||||
requests==2.18.4
|
||||
responses==0.9.0
|
||||
six==1.11.0
|
||||
SQLAlchemy==1.2.8
|
||||
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 ActivityWeather from './ActivityWeather'
|
||||
|
||||
export default function ActivityDetails(props) {
|
||||
const { activity } = props
|
||||
const withPauses = activity.pauses !== '0:00:00' && activity.pauses !== null
|
||||
const recordLDexists = activity.records.find(r => r.record_type === 'LD')
|
||||
return (
|
||||
<div>
|
||||
<div className="activity-details">
|
||||
<p>
|
||||
<i
|
||||
className="fa fa-clock-o custom-fa"
|
||||
@ -88,6 +90,7 @@ export default function ActivityDetails(props) {
|
||||
Descent: {activity.descent}m
|
||||
</p>
|
||||
)}
|
||||
<ActivityWeather activity={activity} />
|
||||
</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;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.activity-date {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
@ -225,6 +228,25 @@ input, textarea {
|
||||
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 {
|
||||
animation: spin 2s linear infinite;
|
||||
border: 16px solid #f3f3f3;
|
||||
|