API - refacto weather utils
This commit is contained in:
		
							
								
								
									
										10
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								.env.example
									
									
									
									
									
								
							@@ -34,11 +34,7 @@ export SENDER_EMAIL=
 | 
				
			|||||||
# export MAP_ATTRIBUTION=
 | 
					# export MAP_ATTRIBUTION=
 | 
				
			||||||
# export DEFAULT_STATICMAP=False
 | 
					# export DEFAULT_STATICMAP=False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Define one of the following API key values to lookup weather conditions
 | 
					# Weather
 | 
				
			||||||
# for the start and end of each workout:
 | 
					# available weather API providers: darksky, visualcrossing
 | 
				
			||||||
 | 
					# export WEATHER_API_PROVIDER=
 | 
				
			||||||
# DarkSky weather API key (deprecated):
 | 
					 | 
				
			||||||
# export WEATHER_API_KEY=
 | 
					# export WEATHER_API_KEY=
 | 
				
			||||||
 | 
					 | 
				
			||||||
# VisualCrossing.com weather API key:
 | 
					 | 
				
			||||||
# export VC_WEATHER_API_KEY=
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								fittrackee/tests/fixtures/fixtures_app.py
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								fittrackee/tests/fixtures/fixtures_app.py
									
									
									
									
										vendored
									
									
								
							@@ -1,6 +1,7 @@
 | 
				
			|||||||
import os
 | 
					import os
 | 
				
			||||||
import shutil
 | 
					import shutil
 | 
				
			||||||
from typing import Generator, Optional, Union
 | 
					from typing import Generator, Iterator, Optional, Union
 | 
				
			||||||
 | 
					from unittest.mock import patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import pytest
 | 
					import pytest
 | 
				
			||||||
from flask import current_app
 | 
					from flask import current_app
 | 
				
			||||||
@@ -8,6 +9,13 @@ from flask import current_app
 | 
				
			|||||||
from fittrackee import create_app, db, limiter
 | 
					from fittrackee import create_app, db, limiter
 | 
				
			||||||
from fittrackee.application.models import AppConfig
 | 
					from fittrackee.application.models import AppConfig
 | 
				
			||||||
from fittrackee.application.utils import update_app_config_from_database
 | 
					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(
 | 
					def get_app_config(
 | 
				
			||||||
@@ -79,7 +87,6 @@ def get_app(
 | 
				
			|||||||
@pytest.fixture
 | 
					@pytest.fixture
 | 
				
			||||||
def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
 | 
					def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
 | 
				
			||||||
    monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
 | 
					    monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
 | 
				
			||||||
    monkeypatch.setenv('WEATHER_API_KEY', '')
 | 
					 | 
				
			||||||
    if os.getenv('TILE_SERVER_URL'):
 | 
					    if os.getenv('TILE_SERVER_URL'):
 | 
				
			||||||
        monkeypatch.delenv('TILE_SERVER_URL')
 | 
					        monkeypatch.delenv('TILE_SERVER_URL')
 | 
				
			||||||
    if os.getenv('STATICMAP_SUBDOMAINS'):
 | 
					    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
 | 
					import gpxpy.gpx
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from ..exceptions import WorkoutGPXException
 | 
					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]:
 | 
					def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
 | 
				
			||||||
@@ -99,7 +101,7 @@ def get_gpx_info(
 | 
				
			|||||||
                if start is None:
 | 
					                if start is None:
 | 
				
			||||||
                    start = point.time
 | 
					                    start = point.time
 | 
				
			||||||
                    if point.time and update_weather_data:
 | 
					                    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
 | 
					                # if a previous segment exists, calculate stopped time between
 | 
				
			||||||
                # the two segments
 | 
					                # the two segments
 | 
				
			||||||
@@ -114,7 +116,7 @@ def get_gpx_info(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                # last gpx point => get weather
 | 
					                # last gpx point => get weather
 | 
				
			||||||
                if segment_idx == (segments_nb - 1) and update_weather_data:
 | 
					                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:
 | 
					            if update_map_data:
 | 
				
			||||||
                map_data.append([point.longitude, point.latitude])
 | 
					                map_data.append([point.longitude, point.latitude])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,129 +0,0 @@
 | 
				
			|||||||
import os
 | 
					 | 
				
			||||||
from datetime import datetime, timedelta
 | 
					 | 
				
			||||||
from typing import Dict, Optional, Union
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import forecastio
 | 
					 | 
				
			||||||
import pytz
 | 
					 | 
				
			||||||
import requests
 | 
					 | 
				
			||||||
from gpxpy.gpx import GPXTrackPoint
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from fittrackee import appLog
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
API_KEY = os.getenv('WEATHER_API_KEY')
 | 
					 | 
				
			||||||
VC_API_KEY = os.getenv('VC_WEATHER_API_KEY')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_weather(point: GPXTrackPoint) -> Optional[Dict]:
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        if not point.time:
 | 
					 | 
				
			||||||
            # if there's no time associated with the point;
 | 
					 | 
				
			||||||
            # we cannot get weather
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            point_time = (
 | 
					 | 
				
			||||||
                pytz.utc.localize(point.time)
 | 
					 | 
				
			||||||
                if point.time.tzinfo is None
 | 
					 | 
				
			||||||
                else point.time.astimezone(pytz.utc)
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            if API_KEY:
 | 
					 | 
				
			||||||
                # if darksky api key is present, use that
 | 
					 | 
				
			||||||
                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,
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            elif VC_API_KEY:
 | 
					 | 
				
			||||||
                # if visualcrossing API key is present, use that
 | 
					 | 
				
			||||||
                return get_visual_crossing_weather(
 | 
					 | 
				
			||||||
                    VC_API_KEY, point.latitude, point.longitude, point.time
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    except Exception as e:
 | 
					 | 
				
			||||||
        appLog.error(e)
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_visual_crossing_weather(
 | 
					 | 
				
			||||||
    api_key: str, latitude: float, longitude: float, time: datetime
 | 
					 | 
				
			||||||
) -> Dict[str, Union[str, float]]:
 | 
					 | 
				
			||||||
    # 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.
 | 
					 | 
				
			||||||
    # You can also request the information for a specific time for a single
 | 
					 | 
				
			||||||
    # date by including time into the date1 field using the format
 | 
					 | 
				
			||||||
    # yyyy-MM-ddTHH:mm:ss. For example 2020-10-19T13:00:00.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # 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()}) to '
 | 
					 | 
				
			||||||
        f'{trunc_time} ({trunc_time.timestamp()})'
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    base_url = (
 | 
					 | 
				
			||||||
        'https://weather.visualcrossing.com/'
 | 
					 | 
				
			||||||
        + 'VisualCrossingWebServices/rest/services'
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    url = (
 | 
					 | 
				
			||||||
        f"{base_url}/timeline/{latitude},{longitude}"
 | 
					 | 
				
			||||||
        f"/{int(trunc_time.timestamp())}?key={api_key}"
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    params = {
 | 
					 | 
				
			||||||
        "unitGroup": "metric",
 | 
					 | 
				
			||||||
        "contentType": "json",
 | 
					 | 
				
			||||||
        "elements": (
 | 
					 | 
				
			||||||
            "datetime,datetimeEpoch,temp,humidity,windspeed,"
 | 
					 | 
				
			||||||
            "winddir,conditions,description,icon"
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        "include": "current",
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    appLog.debug(
 | 
					 | 
				
			||||||
        f'VC_weather: getting weather from {url}'.replace(api_key, '*****')
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    r = requests.get(url, params=params)
 | 
					 | 
				
			||||||
    r.raise_for_status()
 | 
					 | 
				
			||||||
    res = r.json()
 | 
					 | 
				
			||||||
    weather = res['currentConditions']
 | 
					 | 
				
			||||||
    # FitTrackee expects the following units:
 | 
					 | 
				
			||||||
    #   temp: Celsius,
 | 
					 | 
				
			||||||
    #   humidity: in fraction (rather than percent)
 | 
					 | 
				
			||||||
    #   windSpeed: m/s
 | 
					 | 
				
			||||||
    #   windBearing: direction wind is from in degrees (0 is north)
 | 
					 | 
				
			||||||
    # VC provides humidity in percent, wind in km/h
 | 
					 | 
				
			||||||
    data = {
 | 
					 | 
				
			||||||
        'summary': weather['conditions'],
 | 
					 | 
				
			||||||
        'icon': f"vc-{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
 | 
					 | 
				
			||||||
							
								
								
									
										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
 | 
				
			||||||
		Reference in New Issue
	
	Block a user