Merge pull request #265 from jat255/alternate_weather_api
Implementing alternative weather API (VisualCrossing.com)
This commit is contained in:
commit
9169a9bf4c
@ -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=
|
||||
|
@ -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*)
|
||||
|
@ -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
|
||||
~~~~~~~~~~~~
|
||||
|
||||
|
@ -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": "© <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors"
|
||||
"version": "0.7.10"
|
||||
"map_attribution": "© <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": "© <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors"
|
||||
"version": "0.7.10"
|
||||
"map_attribution": "© <a href=http://www.openstreetmap.org/copyright>OpenStreetMap</a> contributors",
|
||||
"version": "0.7.10",
|
||||
"weather_provider": null
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
|
@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
2
fittrackee/dist/index.html
vendored
2
fittrackee/dist/index.html
vendored
@ -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>
|
2
fittrackee/dist/service-worker.js
vendored
2
fittrackee/dist/service-worker.js
vendored
File diff suppressed because one or more lines are too long
2
fittrackee/dist/service-worker.js.map
vendored
2
fittrackee/dist/service-worker.js.map
vendored
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
2
fittrackee/dist/static/js/app.3a2c8310.js
vendored
Normal file
2
fittrackee/dist/static/js/app.3a2c8310.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
fittrackee/dist/static/js/app.3a2c8310.js.map
vendored
Normal file
1
fittrackee/dist/static/js/app.3a2c8310.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
fittrackee/dist/static/js/app.c4c614bd.js
vendored
2
fittrackee/dist/static/js/app.c4c614bd.js
vendored
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
@ -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
1
fittrackee/dist/static/js/workouts.4db67b26.js.map
vendored
Normal file
1
fittrackee/dist/static/js/workouts.4db67b26.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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
|
||||
)
|
||||
|
11
fittrackee/tests/fixtures/fixtures_app.py
vendored
11
fittrackee/tests/fixtures/fixtures_app.py
vendored
@ -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'):
|
||||
|
344
fittrackee/tests/workouts/test_utils/test_weather_service.py
Normal file
344
fittrackee/tests/workouts/test_utils/test_weather_service.py
Normal 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
|
@ -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])
|
||||
|
@ -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
|
5
fittrackee/workouts/utils/weather/__init__.py
Normal file
5
fittrackee/workouts/utils/weather/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .weather_service import WeatherService
|
||||
|
||||
__all__ = [
|
||||
'WeatherService',
|
||||
]
|
50
fittrackee/workouts/utils/weather/base_weather.py
Normal file
50
fittrackee/workouts/utils/weather/base_weather.py
Normal 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)
|
37
fittrackee/workouts/utils/weather/dark_sky.py
Normal file
37
fittrackee/workouts/utils/weather/dark_sky.py
Normal 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,
|
||||
}
|
84
fittrackee/workouts/utils/weather/visual_crossing.py
Normal file
84
fittrackee/workouts/utils/weather/visual_crossing.py
Normal 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
|
44
fittrackee/workouts/utils/weather/weather_service.py
Normal file
44
fittrackee/workouts/utils/weather/weather_service.py
Normal 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
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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:"
|
||||
}
|
@ -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",
|
||||
|
@ -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 :"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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}`
|
||||
}
|
||||
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user