Merge pull request #265 from jat255/alternate_weather_api

Implementing alternative weather API (VisualCrossing.com)
This commit is contained in:
Sam 2022-12-31 16:15:52 +01:00 committed by GitHub
commit 9169a9bf4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 774 additions and 94 deletions

View File

@ -33,4 +33,8 @@ export SENDER_EMAIL=
# export STATICMAP_SUBDOMAINS=
# export MAP_ATTRIBUTION=
# export DEFAULT_STATICMAP=False
# Weather
# available weather API providers: darksky, visualcrossing
# export WEATHER_API_PROVIDER=
# export WEATHER_API_KEY=

View File

@ -43,7 +43,7 @@ Workouts
| Ascent and descent can also be provided (*new in 0.7.10*).
- | A workout with a gpx file can be displayed with map and charts (speed and elevation).
| Controls allow full screen view and position reset (*new in 0.5.5*).
- | If DarkSky API key is provided, weather is displayed in workout detail.
- | If **DarkSky API** or **Visual Crossing** (*new in 0.7.11*) API key is provided, weather is displayed in workout detail.
| Wind is displayed, with arrow indicating direction (a tooltip can be displayed with the direction that the wind is coming **from**) (*new in 0.5.5*).
- Segments can be displayed.
- Workout gpx file can be downloaded (*new in 0.5.1*)

View File

@ -28,7 +28,7 @@ Prerequisites
- optional
- Redis for task queue (if email sending is enabled) and API rate limits
- SMTP provider (if email sending is enabled)
- API key from `Dark Sky <https://darksky.net/dev>`__ (deprecated, DarkSky will stop on March 31st, 2023)
- API key from a `weather data provider <installation.html#weather-data>`__
- `Poetry <https://poetry.eustace.io>`__ (for installation from sources only)
- `Yarn <https://yarnpkg.com>`__ (for development only)
- Docker and Docker Compose (for development or evaluation purposes)
@ -229,7 +229,14 @@ deployment method.
.. versionchanged:: 0.4.0 ⚠️ replaces ``WEATHER_API``
**Dark Sky** API key for weather data (not mandatory).
Weather API key (not mandatory), see ``WEATHER_API_PROVIDER``.
.. envvar:: WEATHER_API_PROVIDER
.. versionadded:: 0.7.11
Provider for weather data (not mandatory), see `Weather data <installation.html#weather-data>`__.
.. envvar:: VUE_APP_API_URL
@ -338,6 +345,21 @@ API rate limits 🆕
limits Enumerate details about all routes with rate limits
Weather data
^^^^^^^^^^^^
.. versionchanged:: 0.7.11
The following weather data providers are supported by **FitTrackee**:
- `Dark Sky <https://darksky.net>`__ (deprecated, will stop on March 31st, 2023)
- `Visual Crossing <https://www.visualcrossing.com>`__ (**note**: historical data are provided on hourly period)
To configure a weather provider, set the following environment variables:
- ``WEATHER_API_PROVIDER``: ``darksky`` for **Dark Sky** or ``visualcrossing`` for **Visual Crossing**
- ``WEATHER_API_KEY``: the key to the corresponding weather provider
Installation
~~~~~~~~~~~~

View File

@ -47,8 +47,9 @@ def get_application_config() -> Union[Dict, HttpResponse]:
"max_single_file_size": 1048576,
"max_users": 0,
"max_zip_file_size": 10485760,
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors"
"version": "0.7.10"
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors",
"version": "0.7.10",
"weather_provider": null
},
"status": "success"
}
@ -99,8 +100,9 @@ def update_application_config(auth_user: User) -> Union[Dict, HttpResponse]:
"max_single_file_size": 1048576,
"max_users": 10,
"max_zip_file_size": 10485760,
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors"
"version": "0.7.10"
"map_attribution": "&copy; <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors",
"version": "0.7.10",
"weather_provider": null
},
"status": "success"
}

View File

@ -1,3 +1,4 @@
import os
from typing import Dict
from flask import current_app
@ -43,6 +44,7 @@ class AppConfig(BaseModel):
return current_app.config['TILE_SERVER']['ATTRIBUTION']
def serialize(self) -> Dict:
weather_provider = os.getenv('WEATHER_API_PROVIDER', '').lower()
return {
'admin_contact': self.admin_contact,
'gpx_limit_import': self.gpx_limit_import,
@ -53,6 +55,11 @@ class AppConfig(BaseModel):
'max_users': self.max_users,
'map_attribution': self.map_attribution,
'version': VERSION,
'weather_provider': (
weather_provider
if weather_provider in ['darksky', 'visualcrossing']
else None
),
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.a82f8875.js"></script><script defer="defer" src="/static/js/app.c4c614bd.js"></script><link href="/static/css/app.2461bfc8.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"/><link rel="stylesheet" href="/static/css/leaflet.css"/><title>FitTrackee</title><script defer="defer" src="/static/js/chunk-vendors.a82f8875.js"></script><script defer="defer" src="/static/js/app.3a2c8310.js"></script><link href="/static/css/app.92a77a8d.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

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

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

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

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
"use strict";(self["webpackChunkfittrackee_client"]=self["webpackChunkfittrackee_client"]||[]).push([[193],{7885:function(e,s,t){t.r(s),t.d(s,{default:function(){return A}});var a=t(6252),r=t(2262),l=t(3577),o=(t(7658),t(9150)),n=t(436);const c={class:"chart-menu"},i={class:"chart-arrow"},u={class:"time-frames custom-checkboxes-group"},d={class:"time-frames-checkboxes custom-checkboxes"},p=["id","name","checked","onInput"],m={class:"chart-arrow"};var v=(0,a.aZ)({__name:"StatsMenu",emits:["arrowClick","timeFrameUpdate"],setup(e,{emit:s}){const t=(0,r.iH)("month"),o=["week","month","year"];function n(e){t.value=e,s("timeFrameUpdate",e)}return(e,r)=>((0,a.wg)(),(0,a.iD)("div",c,[(0,a._)("div",i,[(0,a._)("i",{class:"fa fa-chevron-left","aria-hidden":"true",onClick:r[0]||(r[0]=e=>s("arrowClick",!0))})]),(0,a._)("div",u,[(0,a._)("div",d,[((0,a.wg)(),(0,a.iD)(a.HY,null,(0,a.Ko)(o,(s=>(0,a._)("div",{class:"time-frame custom-checkbox",key:s},[(0,a._)("label",null,[(0,a._)("input",{type:"radio",id:s,name:s,checked:t.value===s,onInput:e=>n(s)},null,40,p),(0,a._)("span",null,(0,l.zw)(e.$t(`statistics.TIME_FRAMES.${s}`)),1)])]))),64))])]),(0,a._)("div",m,[(0,a._)("i",{class:"fa fa-chevron-right","aria-hidden":"true",onClick:r[1]||(r[1]=e=>s("arrowClick",!1))})])]))}}),k=t(3744);const _=(0,k.Z)(v,[["__scopeId","data-v-22d55de2"]]);var S=_,w=t(631);const f={class:"sports-menu"},h=["id","name","checked","onInput"],U={class:"sport-label"};var b=(0,a.aZ)({__name:"StatsSportsMenu",props:{userSports:null,selectedSportIds:{default:()=>[]}},emits:["selectedSportIdsUpdate"],setup(e,{emit:s}){const t=e,{t:n}=(0,o.QT)(),c=(0,a.f3)("sportColors"),{selectedSportIds:i}=(0,r.BK)(t),u=(0,a.Fl)((()=>(0,w.xH)(t.userSports,n)));function d(e){s("selectedSportIdsUpdate",e)}return(e,s)=>{const t=(0,a.up)("SportImage");return(0,a.wg)(),(0,a.iD)("div",f,[((0,a.wg)(!0),(0,a.iD)(a.HY,null,(0,a.Ko)((0,r.SU)(u),(e=>((0,a.wg)(),(0,a.iD)("label",{type:"checkbox",key:e.id,style:(0,l.j5)({color:e.color?e.color:(0,r.SU)(c)[e.label]})},[(0,a._)("input",{type:"checkbox",id:e.id,name:e.label,checked:(0,r.SU)(i).includes(e.id),onInput:s=>d(e.id)},null,40,h),(0,a.Wm)(t,{"sport-label":e.label,color:e.color},null,8,["sport-label","color"]),(0,a._)("span",U,(0,l.zw)(e.translatedLabel),1)],4)))),128))])}}});const I=b;var g=I,T=t(9318);const y={key:0,id:"user-statistics"};var C=(0,a.aZ)({__name:"index",props:{sports:null,user:null},setup(e){const s=e,{t:t}=(0,o.QT)(),{sports:l,user:c}=(0,r.BK)(s),i=(0,r.iH)("month"),u=(0,r.iH)(v(i.value)),d=(0,a.Fl)((()=>(0,w.xH)(s.sports,t))),p=(0,r.iH)(_(s.sports));function m(e){i.value=e,u.value=v(i.value)}function v(e){return(0,T.aZ)(new Date,e,s.user.weekm)}function k(e){u.value=(0,T.FN)(u.value,e,s.user.weekm)}function _(e){return e.map((e=>e.id))}function f(e){p.value.includes(e)?p.value=p.value.filter((s=>s!==e)):p.value.push(e)}return(0,a.YP)((()=>s.sports),(e=>{p.value=_(e)})),(e,s)=>(0,r.SU)(d)?((0,a.wg)(),(0,a.iD)("div",y,[(0,a.Wm)(S,{onTimeFrameUpdate:m,onArrowClick:k}),(0,a.Wm)(n.Z,{sports:(0,r.SU)(l),user:(0,r.SU)(c),chartParams:u.value,"displayed-sport-ids":p.value,fullStats:!0},null,8,["sports","user","chartParams","displayed-sport-ids"]),(0,a.Wm)(g,{"selected-sport-ids":p.value,"user-sports":(0,r.SU)(l),onSelectedSportIdsUpdate:f},null,8,["selected-sport-ids","user-sports"])])):(0,a.kq)("",!0)}});const F=(0,k.Z)(C,[["__scopeId","data-v-30799d13"]]);var Z=F,x=t(5630),D=t(5801),H=t(9917);const E={id:"statistics",class:"view"},R={key:0,class:"container"};var W=(0,a.aZ)({__name:"StatisticsView",setup(e){const s=(0,H.o)(),t=(0,a.Fl)((()=>s.getters[D.YN.GETTERS.AUTH_USER_PROFILE])),o=(0,a.Fl)((()=>s.getters[D.O8.GETTERS.SPORTS].filter((e=>t.value.sports_list.includes(e.id)))));return(e,s)=>{const n=(0,a.up)("Card");return(0,a.wg)(),(0,a.iD)("div",E,[(0,r.SU)(t).username?((0,a.wg)(),(0,a.iD)("div",R,[(0,a.Wm)(n,null,{title:(0,a.w5)((()=>[(0,a.Uk)((0,l.zw)(e.$t("statistics.STATISTICS")),1)])),content:(0,a.w5)((()=>[(0,a.Wm)(Z,{class:(0,l.C_)({"stats-disabled":0===(0,r.SU)(t).nb_workouts}),user:(0,r.SU)(t),sports:(0,r.SU)(o)},null,8,["class","user","sports"])])),_:1}),0===(0,r.SU)(t).nb_workouts?((0,a.wg)(),(0,a.j4)(x.Z,{key:0})):(0,a.kq)("",!0)])):(0,a.kq)("",!0)])}}});const P=(0,k.Z)(W,[["__scopeId","data-v-2e341d4e"]]);var A=P}}]);
//# sourceMappingURL=statistics.31ee4368.js.map
//# sourceMappingURL=statistics.99fc9524.js.map

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

@ -1,3 +1,4 @@
import pytest
from flask import Flask
from fittrackee import VERSION
@ -6,7 +7,10 @@ from fittrackee.users.models import User
class TestConfigModel:
def test_application_config(self, app: Flask) -> None:
def test_application_config(
self, app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv('WEATHER_API_PROVIDER', 'darksky')
app_config = AppConfig.query.first()
app_config.admin_contact = 'admin@example.com'
@ -40,6 +44,7 @@ class TestConfigModel:
== app_config.map_attribution
)
assert serialized_app_config['version'] == VERSION
assert serialized_app_config['weather_provider'] == 'darksky'
def test_it_returns_registration_disabled_when_users_count_exceeds_limit(
self, app: Flask, user_1: User, user_2: User
@ -58,3 +63,28 @@ class TestConfigModel:
serialized_app_config = app_config.serialize()
assert serialized_app_config['is_email_sending_enabled'] is False
@pytest.mark.parametrize(
'input_weather_api_provider, expected_weather_provider',
[
('darksky', 'darksky'),
('Visualcrossing', 'visualcrossing'),
('invalid_provider', None),
('', None),
],
)
def test_it_returns_weather_provider(
self,
app: Flask,
input_weather_api_provider: str,
expected_weather_provider: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv('WEATHER_API_PROVIDER', input_weather_api_provider)
app_config = AppConfig.query.first()
serialized_app_config = app_config.serialize()
assert (
serialized_app_config['weather_provider']
== expected_weather_provider
)

View File

@ -1,6 +1,7 @@
import os
import shutil
from typing import Generator, Optional, Union
from typing import Generator, Iterator, Optional, Union
from unittest.mock import patch
import pytest
from flask import current_app
@ -8,6 +9,13 @@ from flask import current_app
from fittrackee import create_app, db, limiter
from fittrackee.application.models import AppConfig
from fittrackee.application.utils import update_app_config_from_database
from fittrackee.workouts.utils.gpx import weather_service
@pytest.fixture(autouse=True)
def default_weather_service(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
with patch.object(weather_service, 'get_weather', return_value=None):
yield
def get_app_config(
@ -82,7 +90,6 @@ def get_app(
@pytest.fixture
def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
monkeypatch.setenv('WEATHER_API_KEY', '')
if os.getenv('TILE_SERVER_URL'):
monkeypatch.delenv('TILE_SERVER_URL')
if os.getenv('STATICMAP_SUBDOMAINS'):

View File

@ -0,0 +1,344 @@
from datetime import datetime
from typing import Dict, Optional
from unittest.mock import Mock, patch, sentinel
import pytest
import pytz
import requests
from gpxpy.gpx import GPXTrackPoint
from fittrackee.tests.mixins import CallArgsMixin
from fittrackee.tests.utils import random_string
from fittrackee.workouts.utils.weather.dark_sky import DarkSky
from fittrackee.workouts.utils.weather.visual_crossing import VisualCrossing
from fittrackee.workouts.utils.weather.weather_service import WeatherService
VISUAL_CROSSING_RESPONSE = {
"queryCost": 1,
"latitude": 48.866667,
"longitude": 2.333333,
"resolvedAddress": "48.866667,2.333333",
"address": "48.866667,2.333333",
"timezone": "Europe/Paris",
"tzoffset": 1.0,
"days": [
{
"datetime": "2022-11-15",
"datetimeEpoch": 1668466800,
"temp": 10.4,
"humidity": 93.3,
"windspeed": 18.9,
"winddir": 179.4,
"conditions": "Rain, Partially cloudy",
"description": "Partly cloudy throughout the day with rain.",
"icon": "rain",
}
],
"currentConditions": {
"datetime": "13:00:00",
"datetimeEpoch": 1668513600,
"temp": 11.3,
"humidity": 93.1,
"windspeed": 14.0,
"winddir": 161.9,
"conditions": "Rain, Overcast",
"icon": "rain",
},
}
class WeatherTestCase:
api_key = random_string()
@staticmethod
def get_gpx_point(time: Optional[datetime] = None) -> GPXTrackPoint:
return GPXTrackPoint(latitude=48.866667, longitude=2.333333, time=time)
class TestDarksky(WeatherTestCase):
def test_it_calls_forecast_io_with_datetime_in_utc_when_naive_datetime_is_provided( # noqa
self,
) -> None:
naive_point_time = datetime(
year=2022, month=6, day=11, hour=10, minute=23, second=00
)
point = self.get_gpx_point(naive_point_time)
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
darksky.get_weather(point)
forecast_io_mock.load_forecast.assert_called_with(
self.api_key,
point.latitude,
point.longitude,
time=datetime(
year=2022,
month=6,
day=11,
hour=10,
minute=23,
second=00,
tzinfo=pytz.utc,
),
units='si',
)
def test_it_calls_forecast_io_with_provided_datetime_when_with_timezone(
self,
) -> None:
paris_point_time = datetime(
year=2022,
month=6,
day=11,
hour=10,
minute=23,
second=00,
).astimezone(pytz.timezone('Europe/Paris'))
point = self.get_gpx_point(paris_point_time)
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
darksky.get_weather(point)
forecast_io_mock.load_forecast.assert_called_with(
self.api_key,
point.latitude,
point.longitude,
time=paris_point_time,
units='si',
)
def test_it_returns_forecast_currently_data(self) -> None:
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
forecast_io_mock.load_forecast().currently.return_value = sentinel
weather_data = darksky.get_weather(
self.get_gpx_point(datetime.utcnow())
)
assert weather_data == {
'icon': sentinel.icon,
'temperature': sentinel.temperature,
'humidity': sentinel.humidity,
'wind': sentinel.windSpeed,
'windBearing': sentinel.windBearing,
}
class TestVisualCrossingGetTimestamp(WeatherTestCase):
def test_it_returns_expected_timestamp_as_integer(self) -> None:
time = datetime.utcnow()
visual_crossing = VisualCrossing(api_key=self.api_key)
timestamp = visual_crossing._get_timestamp(time)
assert isinstance(timestamp, int)
@pytest.mark.parametrize(
'input_datetime,expected_datetime',
[
('2020-12-15T13:00:00', '2020-12-15T13:00:00'),
('2020-12-15T13:29:59', '2020-12-15T13:00:00'),
('2020-12-15T13:30:00', '2020-12-15T14:00:00'),
('2020-12-15T13:59:59', '2020-12-15T14:00:00'),
],
)
def test_it_returns_rounded_time(
self, input_datetime: str, expected_datetime: str
) -> None:
time = datetime.strptime(input_datetime, '%Y-%m-%dT%H:%M:%S')
visual_crossing = VisualCrossing(api_key=self.api_key)
timestamp = visual_crossing._get_timestamp(time)
assert (
timestamp
== datetime.strptime(
expected_datetime, '%Y-%m-%dT%H:%M:%S'
).timestamp()
)
class TestVisualCrossingGetWeather(WeatherTestCase, CallArgsMixin):
@staticmethod
def get_response() -> Mock:
response_mock = Mock()
response_mock.raise_for_status = Mock()
response_mock.json = Mock()
response_mock.json.return_value = VISUAL_CROSSING_RESPONSE
return response_mock
def test_it_calls_api_with_time_and_point_location(self) -> None:
time = datetime(
year=2022,
month=11,
day=15,
hour=12,
minute=00,
second=00,
tzinfo=pytz.utc,
)
point = self.get_gpx_point(time)
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get') as get_mock:
visual_crossing.get_weather(point)
args = self.get_args(get_mock.call_args)
assert args[0] == (
'https://weather.visualcrossing.com/VisualCrossingWebServices/'
f'rest/services/timeline/{point.latitude},{point.longitude}/'
f'{int(point.time.timestamp())}' # type: ignore
)
def test_it_calls_api_with_expected_params(self) -> None:
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get') as get_mock:
visual_crossing.get_weather(self.get_gpx_point(datetime.utcnow()))
kwargs = self.get_kwargs(get_mock.call_args)
assert kwargs.get('params') == {
'key': self.api_key,
'iconSet': 'icons1',
'unitGroup': 'metric',
'contentType': 'json',
'elements': (
'datetime,datetimeEpoch,temp,humidity,windspeed,'
'winddir,conditions,description,icon'
),
'include': 'current',
}
def test_it_returns_data_from_current_conditions(self) -> None:
point = self.get_gpx_point(
datetime(
year=2022,
month=11,
day=15,
hour=13,
minute=00,
second=00,
tzinfo=pytz.utc,
).astimezone(pytz.timezone('Europe/Paris'))
)
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get', return_value=self.get_response()):
weather_data = visual_crossing.get_weather(point)
current_conditions: Dict = VISUAL_CROSSING_RESPONSE[ # type: ignore
'currentConditions'
]
assert weather_data == {
'icon': current_conditions['icon'],
'temperature': current_conditions['temp'],
'humidity': current_conditions['humidity'] / 100,
'wind': (current_conditions['windspeed'] * 1000) / 3600,
'windBearing': current_conditions['winddir'],
}
class TestWeatherService(WeatherTestCase):
@pytest.mark.parametrize(
'input_api_key,input_provider',
[
('', 'darksky'),
('valid_api_key', ''),
('valid_api_key', 'invalid_provider'),
],
)
def test_weather_api_is_none_when_configuration_is_invalid(
self,
monkeypatch: pytest.MonkeyPatch,
input_api_key: str,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', input_api_key)
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert weather_service.weather_api is None
@pytest.mark.parametrize(
'input_provider',
['darksky', 'DARKSKY'],
)
def test_weather_api_is_darksky_when_configured(
self,
monkeypatch: pytest.MonkeyPatch,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert isinstance(weather_service.weather_api, DarkSky)
@pytest.mark.parametrize(
'input_provider',
['visualcrossing', 'VisualCrossing'],
)
def test_weather_api_is_visualcrossing_when_configured(
self,
monkeypatch: pytest.MonkeyPatch,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert isinstance(weather_service.weather_api, VisualCrossing)
def test_it_returns_none_when_no_weather_api(self) -> None:
weather_service = WeatherService()
weather_service.weather_api = None
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_none_when_point_time_is_none(self) -> None:
weather_service = WeatherService()
weather_service.weather_api = DarkSky('api_key')
point = self.get_gpx_point(None)
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_none_when_weather_api_raises_exception(self) -> None:
weather_api = Mock()
weather_api.get_weather = Mock()
weather_api.get_weather.side_effect = Exception()
weather_service = WeatherService()
weather_service.weather_api = weather_api
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_weather_data(self) -> None:
weather_api = Mock()
weather_api.get_weather = Mock()
weather_api.get_weather.return_value = sentinel
weather_service = WeatherService()
weather_service.weather_api = weather_api
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data == sentinel

View File

@ -4,7 +4,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import gpxpy.gpx
from ..exceptions import WorkoutGPXException
from .weather import get_weather
from .weather import WeatherService
weather_service = WeatherService()
def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
@ -99,7 +101,7 @@ def get_gpx_info(
if start is None:
start = point.time
if point.time and update_weather_data:
weather_data.append(get_weather(point))
weather_data.append(weather_service.get_weather(point))
# if a previous segment exists, calculate stopped time between
# the two segments
@ -114,7 +116,7 @@ def get_gpx_info(
# last gpx point => get weather
if segment_idx == (segments_nb - 1) and update_weather_data:
weather_data.append(get_weather(point))
weather_data.append(weather_service.get_weather(point))
if update_map_data:
map_data.append([point.longitude, point.latitude])

View File

@ -1,40 +0,0 @@
import os
from typing import Dict, Optional
import forecastio
import pytz
from gpxpy.gpx import GPXTrackPoint
from fittrackee import appLog
API_KEY = os.getenv('WEATHER_API_KEY')
def get_weather(point: GPXTrackPoint) -> Optional[Dict]:
if not API_KEY or not point.time:
return None
try:
point_time = (
pytz.utc.localize(point.time)
if point.time.tzinfo is None
else point.time.astimezone(pytz.utc)
)
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,
'windBearing': weather.windBearing,
}
except Exception as e:
appLog.error(e)
return None

View File

@ -0,0 +1,5 @@
from .weather_service import WeatherService
__all__ = [
'WeatherService',
]

View File

@ -0,0 +1,50 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Dict, Optional
from gpxpy.gpx import GPXTrackPoint
class BaseWeather(ABC):
def __init__(self, api_key: str) -> None:
self.api_key: str = api_key
@abstractmethod
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# Expected dict:
# {
# "humidity": 0.69,
# "icon": "partly-cloudy-day",
# "temperature": 12.26,
# "wind": 3.49,
# "windBearing": 315
# }
#
# FitTrackee expects the following units:
# temperature: Celsius,
# humidity: in fraction (rather than percent)
# windSpeed: m/s
# windBearing: direction wind is from in degrees (0 is north)
#
# Expected icon values (for UI):
# - "clear-day",
# - "clear-night",
# - "cloudy",
# - "fog",
# - "partly-cloudy-day",
# - "partly-cloudy-night",
# - "rain",
# - "sleet",
# - "snow",
# - "wind"
pass
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
if not point.time:
# if there's no time associated with the point,
# we cannot get weather
return None
return self._get_data(point.latitude, point.longitude, point.time)

View File

@ -0,0 +1,37 @@
from datetime import datetime
from typing import Dict, Optional
import forecastio
import pytz
from .base_weather import BaseWeather
class DarkSky(BaseWeather):
# Deprecated (API will end on March 31st, 2023)
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# get point time in UTC
point_time = (
pytz.utc.localize(time)
if time.tzinfo is None # naive datetime
else time
)
forecast = forecastio.load_forecast(
self.api_key,
latitude,
longitude,
time=point_time,
units='si',
)
weather = forecast.currently()
return {
'humidity': weather.humidity,
'icon': weather.icon,
'temperature': weather.temperature,
'wind': weather.windSpeed,
'windBearing': weather.windBearing,
}

View File

@ -0,0 +1,84 @@
from datetime import datetime, timedelta
from typing import Dict, Optional
import requests
from fittrackee import appLog
from .base_weather import BaseWeather
class VisualCrossing(BaseWeather):
def __init__(self, api_key: str):
super().__init__(api_key)
self.base_url = (
'https://weather.visualcrossing.com/'
'VisualCrossingWebServices/rest/services'
)
self.params = {
"key": self.api_key,
"iconSet": "icons1", # default value, same as Darksky
"unitGroup": "metric",
"contentType": "json",
"elements": (
"datetime,datetimeEpoch,temp,humidity,windspeed,"
"winddir,conditions,description,icon"
),
"include": "current", # to get only specific time data
}
@staticmethod
def _get_timestamp(time: datetime) -> int:
# The results are returned in the currentConditions field and are
# truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return
# data at 2020-10-19T13:00:00).
# first, round datetime to nearest hour by truncating, and then adding
# an hour if the "real" time's number of minutes is 30 or more (we do
# this since the API only truncates)
trunc_time = time.replace(
second=0, microsecond=0, minute=0, hour=time.hour
) + timedelta(hours=time.minute // 30)
appLog.debug(
f'VC_weather: truncated time {time} ({time.timestamp()})'
f' to {trunc_time} ({trunc_time.timestamp()})'
)
return int(trunc_time.timestamp())
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# All requests to the Timeline Weather API use the following the form:
# https://weather.visualcrossing.com/VisualCrossingWebServices/rest
# /services/timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY
# location (required) is the address, partial address or
# latitude,longitude location for
# which to retrieve weather data. You can also use US ZIP Codes.
# date1 (optional) is the start date for which to retrieve weather
# data. All dates and times are in local time of the **location**
# specified.
url = (
f"{self.base_url}/timeline/{latitude},{longitude}"
f"/{self._get_timestamp(time)}"
)
appLog.debug(
f'VC_weather: getting weather from {url}'.replace(
self.api_key, '*****'
)
)
r = requests.get(url, params=self.params)
r.raise_for_status()
res = r.json()
weather = res['currentConditions']
data = {
'icon': weather['icon'],
'temperature': weather['temp'],
'humidity': weather['humidity'] / 100,
'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s
'windBearing': weather['winddir'],
}
return data

View File

@ -0,0 +1,44 @@
import os
from typing import Dict, Optional, Union
from gpxpy.gpx import GPXTrackPoint
from fittrackee import appLog
from .dark_sky import DarkSky
from .visual_crossing import VisualCrossing
class WeatherService:
"""
Available API:
- DarkSky (deprecated, will end on March 31st, 2023)
- VisualCrossing
"""
def __init__(self) -> None:
self.weather_api = self._get_weather_api()
@staticmethod
def _get_weather_api() -> Union[DarkSky, VisualCrossing, None]:
weather_api_key: str = os.getenv('WEATHER_API_KEY', '')
weather_api_provider: str = os.getenv(
'WEATHER_API_PROVIDER', ''
).lower()
if not weather_api_key:
return None
if weather_api_provider == 'darksky': # deprecated
return DarkSky(weather_api_key)
if weather_api_provider == 'visualcrossing':
return VisualCrossing(weather_api_key)
return None
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
if not self.weather_api:
return None
try:
return self.weather_api.get_weather(point)
except Exception as e:
appLog.error(e)
return None

View File

@ -40,6 +40,12 @@
{{ $t('about.CONTACT_ADMIN') }}
</a>
</div>
<div v-if="weather_provider && weather_provider.name">
{{ $t('about.WEATHER_DATA_FROM') }}
<a :href="weather_provider.url" target="_blank" rel="nofollow noopener">
{{ weather_provider.name }}
</a>
</div>
</div>
</div>
</template>
@ -55,6 +61,22 @@
const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const weather_provider: ComputedRef<Record<string, string>> = computed(() =>
get_weather_provider()
)
function get_weather_provider() {
const weather_provider: Record<string, string> = {}
if (appConfig.value.weather_provider === 'darksky') {
weather_provider['name'] = 'Dark Sky'
weather_provider['url'] = 'https://darksky.net'
}
if (appConfig.value.weather_provider === 'visualcrossing') {
weather_provider['name'] = 'Visual Crossing'
weather_provider['url'] = 'https://www.visualcrossing.com'
}
return weather_provider
}
</script>
<style lang="scss" scoped>

View File

@ -1,13 +1,6 @@
<template>
<div class="wind">
<Distance
:distance="weather.wind"
unitFrom="m"
:digits="1"
:displayUnit="false"
:useImperialUnits="useImperialUnits"
/>
{{ useImperialUnits ? 'ft' : 'm' }}/s
{{ getWindSpeed(weather.wind, useImperialUnits) }}
<div class="wind-bearing">
<i
v-if="weather.windBearing"
@ -27,6 +20,7 @@
import { useI18n } from 'vue-i18n'
import { IWeather } from '@/types/workouts'
import { getWindSpeed } from '@/utils/units'
import { convertDegreeToDirection } from '@/utils/weather'
interface Props {

View File

@ -58,10 +58,20 @@
/>
</td>
<td>
{{ Number(workoutObject.weatherStart.temperature).toFixed(1) }}°C
{{
getTemperature(
workoutObject.weatherStart.temperature,
useImperialUnits
)
}}
</td>
<td>
{{ Number(workoutObject.weatherEnd.temperature).toFixed(1) }}°C
{{
getTemperature(
workoutObject.weatherEnd.temperature,
useImperialUnits
)
}}
</td>
</tr>
<tr>
@ -112,6 +122,7 @@
import WeatherWind from '@/components/Workout/WorkoutDetail/WeatherWind.vue'
import { IWorkoutObject } from '@/types/workouts'
import { getTemperature } from '@/utils/units'
interface Props {
workoutObject: IWorkoutObject

View File

@ -94,9 +94,9 @@
},
"TIMEZONE": "Zeitzone",
"UNITS": {
"IMPERIAL": "Imperiales System (ft, mi)",
"IMPERIAL": "Imperiales System (ft, mi, mph, °F)",
"LABEL": "Einheiten für die Distanz",
"METRIC": "Metrisches System (m, km)"
"METRIC": "Metrisches System (m, km, m/s, °C)"
}
},
"REGISTER": "Registrieren",

View File

@ -2,5 +2,6 @@
"CONTACT_ADMIN": "Contact the administrator",
"FITTRACKEE_DESCRIPTION": "<strong>FitTrackee</strong> is a self-hosted outdoor activity tracker.",
"FITTRACKEE_LICENSE": "under {0} license ",
"SOURCE_CODE": "Source code"
"SOURCE_CODE": "Source code",
"WEATHER_DATA_FROM": "Weather data from:"
}

View File

@ -94,9 +94,9 @@
},
"TIMEZONE": "Timezone",
"UNITS": {
"IMPERIAL": "Imperial system (ft, mi)",
"IMPERIAL": "Imperial system (ft, mi, mph, °F)",
"LABEL": "Units for distance",
"METRIC": "Metric system (m, km)"
"METRIC": "Metric system (m, km, m/s, °C)"
}
},
"REGISTER": "Register",
@ -108,4 +108,4 @@
"USERNAME": "Username",
"USERNAME_INFO": "3 to 30 characters required, only alphanumeric characters and the underscore character \"_\" allowed.",
"USER_PICTURE": "user picture"
}
}

View File

@ -2,5 +2,6 @@
"CONTACT_ADMIN": "Contacter l'administrateur",
"FITTRACKEE_DESCRIPTION": "<strong>FitTrackee</strong> est un <em>tracker</em> d'activités sportives (en extérieur).",
"FITTRACKEE_LICENSE": "sous licence {0} (en) ",
"SOURCE_CODE": "Code source (en)"
"SOURCE_CODE": "Code source (en)",
"WEATHER_DATA_FROM": "Source des données météo :"
}

View File

@ -94,9 +94,9 @@
},
"TIMEZONE": "Fuseau horaire",
"UNITS": {
"IMPERIAL": "Système impérial (ft, mi)",
"IMPERIAL": "Système impérial (ft, mi, mph, °F)",
"LABEL": "Unités pour les distances",
"METRIC": "Système métrique (m, km)"
"METRIC": "Système métrique (m, km, m/s, °C)"
}
},
"REGISTER": "S'inscrire",
@ -108,4 +108,4 @@
"USERNAME": "Nom d'utilisateur",
"USERNAME_INFO": "3 à 30 caractères requis, seuls les caractères alphanumériques et le caractère _ sont autorisés.",
"USER_PICTURE": "photo de l'utilisateur"
}
}

View File

@ -94,9 +94,9 @@
},
"TIMEZONE": "Timezone",
"UNITS": {
"IMPERIAL": "Sistema imperiale (ft, mi)",
"IMPERIAL": "Sistema imperiale (ft, mi, mph, °F)",
"LABEL": "Unità per la distanza",
"METRIC": "Sistema metrico (m, km)"
"METRIC": "Sistema metrico (m, km, m/s, °C)"
}
},
"REGISTER": "Registra",

View File

@ -94,9 +94,9 @@
},
"TIMEZONE": "Tijdzone",
"UNITS": {
"IMPERIAL": "Imperialistisch systeem (ft, mi)",
"IMPERIAL": "Imperialistisch systeem (ft, mi, mph, °F)",
"LABEL": "Eenheid voor afstand",
"METRIC": "Metrisch systeem (m, km)"
"METRIC": "Metrisch systeem (m, km, m/s, °C)"
}
},
"REGISTER": "Registreren",

View File

@ -6,7 +6,7 @@ export interface IAppStatistics {
}
export type TAppConfig = {
[key: string]: number | boolean | string
[key: string]: number | boolean | string | null
admin_contact: string
gpx_limit_import: number
is_email_sending_enabled: boolean
@ -16,6 +16,7 @@ export type TAppConfig = {
max_users: number
max_zip_file_size: number
version: string
weather_provider: string | null
}
export interface IApplication {

View File

@ -63,3 +63,23 @@ export const convertStatsDistance = (
const unitTo = useImperialUnits ? units[unitFrom].defaultTarget : unitFrom
return useImperialUnits ? convertDistance(value, unitFrom, unitTo, 2) : value
}
export const getTemperature = (
temperatureInCelsius: number,
useImperialUnits: boolean
): string => {
const temperature = useImperialUnits
? temperatureInCelsius * 1.8 + 32
: temperatureInCelsius
const unit = useImperialUnits ? ' °F' : '°C'
return `${temperature === 0 ? 0 : Number(temperature).toFixed(1)}${unit}`
}
export const getWindSpeed = (
windSpeedInMS: number,
useImperialUnits: boolean
): string => {
const windSpeed = useImperialUnits ? windSpeedInMS * 2.2369363 : windSpeedInMS
const unit = useImperialUnits ? ' mph' : 'm/s'
return `${windSpeed === 0 ? 0 : Number(windSpeed).toFixed(1)}${unit}`
}

View File

@ -1,7 +1,7 @@
import { assert } from 'chai'
import { TUnit } from '@/types/units'
import { convertDistance } from '@/utils/units'
import { convertDistance, getTemperature, getWindSpeed } from '@/utils/units'
describe('convertDistance', () => {
const testsParams: [number, TUnit, TUnit, number][] = [
@ -56,3 +56,35 @@ describe('convertDistance w/ digits', () => {
})
})
})
describe('getTemperature', () => {
const testsParams: [number, boolean, string][] = [
[0, false, '0°C'],
[10.0, false, '10.0°C'],
[10.3, false, '10.3°C'],
[0, true, '32.0 °F'],
[13.0, true, '55.4 °F'],
]
testsParams.map((testParams) => {
it(`get temperature for input: ${testParams[0]} and imperialUnits: ${testParams[1]}`, () => {
assert.equal(getTemperature(testParams[0], testParams[1]), testParams[2])
})
})
})
describe('getWindSpeed', () => {
const testsParams: [number, boolean, string][] = [
[0, false, '0m/s'],
[6.0, false, '6.0m/s'],
[6.3, false, '6.3m/s'],
[0, true, '0 mph'],
[13.2, true, '29.5 mph'],
]
testsParams.map((testParams) => {
it(`get wind speed for input: ${testParams[0]} and imperialUnits: ${testParams[1]}`, () => {
assert.equal(getWindSpeed(testParams[0], testParams[1]), testParams[2])
})
})
})