API & Client: add weather to Activities - fix #8

This commit is contained in:
Sam 2018-06-13 17:18:12 +02:00
parent dda193fd13
commit aeab7a987b
23 changed files with 222 additions and 5 deletions

View File

@ -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=

View File

@ -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

View File

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

View 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

View File

@ -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,

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

@ -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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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>
) )
} }

View File

@ -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>
)
}

View File

@ -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;